From bc8998d57e07ed868063916b80e1df0ec4ead26b Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 5 Nov 2025 20:04:43 -0500 Subject: [PATCH] refactor: Move inline JavaScript to a separate file - Move all JavaScript code from index.html to app.js. - Update index.html to include a script tag for app.js. --- app.js | 1165 +++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 9 +- index.html | 1180 +------------------------------------------- server.js | 321 ++++++------ simpleledger.db | Bin 57344 -> 159744 bytes 5 files changed, 1359 insertions(+), 1316 deletions(-) create mode 100644 app.js diff --git a/app.js b/app.js new file mode 100644 index 0000000..fc9ec6a --- /dev/null +++ b/app.js @@ -0,0 +1,1165 @@ +// --- App State --- + let categories = [], rules = [], transactions = [], vendors = []; + let expensePieChart = null; + let jwtToken = localStorage.getItem('simpleLedgerToken'); + let transactionSort = { column: 'date', direction: 'desc' }; + let transactionDateFilter = 'lastMonth'; + let customDateRange = { start: null, end: null }; + + // --- API Base --- + const API_URL = '/api'; + + // --- API Fetch Helper --- + async function apiFetch(endpoint, options = {}) { + const headers = { + 'Authorization': `Bearer ${jwtToken}`, + ...options.headers + }; + + if (!(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json'; + } + + const res = await fetch(`${API_URL}${endpoint}`, { ...options, headers }); + + if (res.status === 401 || res.status === 403) { + // Token is invalid or expired + logout(); + throw new Error('Authentication error. Please log in again.'); + } + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(errorText || 'API request failed'); + } + + const contentType = res.headers.get("content-type"); + if (contentType && contentType.indexOf("application/json") !== -1) { + return res.json(); + } else { + return res.text(); + } + } + + // --- Auth Functions --- + function showAuthError(message) { + const el = document.getElementById('authError'); + el.textContent = message; + el.classList.remove('hidden'); + } + + function hideAuthError() { + document.getElementById('authError').classList.add('hidden'); + } + + function toggleAuthForm(showLogin) { + hideAuthError(); + if (showLogin) { + document.getElementById('authTitle').textContent = 'Login'; + document.getElementById('loginForm').classList.remove('hidden'); + document.getElementById('registerForm').classList.add('hidden'); + document.getElementById('toggleAuth').textContent = 'Need an account? Register'; + } else { + document.getElementById('authTitle').textContent = 'Register'; + document.getElementById('loginForm').classList.add('hidden'); + document.getElementById('registerForm').classList.remove('hidden'); + document.getElementById('toggleAuth').textContent = 'Have an account? Login'; + } + } + + async function handleLogin(e) { + e.preventDefault(); + const form = e.target; + const username = form.username.value; + const password = form.password.value; + + try { + const data = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + + if (!data.ok) { + const errorText = await data.text(); + throw new Error(errorText); + } + + const { accessToken } = await data.json(); + jwtToken = accessToken; + localStorage.setItem('simpleLedgerToken', jwtToken); + + document.getElementById('authView').classList.add('hidden'); + document.getElementById('appContainer').classList.remove('hidden'); + showView('dashboard'); + await loadAllData(); + + } catch (err) { + showAuthError(err.message); + } + } + + async function handleRegister(e) { + e.preventDefault(); + const form = e.target; + const username = form.username.value; + const password = form.password.value; + + try { + const data = await fetch('/api/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + + if (!data.ok) { + const errorText = await data.text(); + throw new Error(errorText); + } + + swal('Success', 'Registration successful! Please log in.', 'success'); + toggleAuthForm(true); // Show login form + + } catch (err) { + showAuthError(err.message); + } + } + + function logout() { + jwtToken = null; + localStorage.removeItem('simpleLedgerToken'); + document.getElementById('authView').classList.remove('hidden'); + document.getElementById('appContainer').classList.add('hidden'); + } + + // --- Data Loading --- + async function loadAllData() { + showLoadingModal(true, "Loading your data..."); + try { + // Must load categories and vendors *first* + await loadCategories(); + await loadVendors(); + // Then load rules and transactions + await Promise.all([ + loadRules(), + loadTransactions() + ]); + updateDashboard(); // Update dashboard after all data is loaded + } catch (error) { + swal('Error loading data', error.message, 'error'); + } finally { + showLoadingModal(false); + } + } + + async function loadCategories() { + categories = await apiFetch('/categories'); + renderCategories(); + renderCategoryDropdowns(); // Renders for all existing selects + } + + async function loadVendors() { + vendors = await apiFetch('/vendors'); + renderVendors(); + renderVendorDropdowns(); // Renders for all existing selects + } + + async function loadRules() { + rules = await apiFetch('/rules'); + renderRules(); + } + + async function loadTransactions() { + transactions = await apiFetch(`/transactions?v=${new Date().getTime()}`); + // Dates are already strings from server, convert to Date objects + transactions.forEach(tx => tx.date = new Date(tx.date)); + renderTransactions(); // Will sort and render + } + + // --- UI Rendering --- + + function renderCategories() { + const list = document.getElementById('categoryList'); + if (!list) return; + list.innerHTML = ''; + categories.sort((a,b) => a.name.localeCompare(b.name)).forEach(cat => { + const el = document.createElement('div'); + el.className = 'flex justify-between items-center p-2 bg-white rounded-lg shadow-sm'; + el.innerHTML = ` + ${cat.name} (${cat.type}) + + `; + el.querySelector('button').onclick = () => deleteCategory(cat.id); + list.appendChild(el); + }); + } + + function renderCategoryDropdowns(selector = '.category-select') { + const selects = document.querySelectorAll(selector); + const options = categories + .sort((a,b) => a.name.localeCompare(b.name)) + .map(c => ``) + .join(''); + selects.forEach(sel => { + const currentVal = sel.value; + sel.innerHTML = options; + if (currentVal) sel.value = currentVal; + }); + } + + function renderVendors() { + const list = document.getElementById('vendorList'); + if (!list) return; + list.innerHTML = ''; + vendors.forEach(v => { + const el = document.createElement('div'); + el.className = 'flex justify-between items-center p-2 bg-white rounded-lg shadow-sm'; + el.innerHTML = ` + ${v.name} + + `; + el.querySelector('button').onclick = () => deleteVendor(v.id); + list.appendChild(el); + }); + } + + function renderVendorDropdowns(selector = '.vendor-select') { + const selects = document.querySelectorAll(selector); + const options = vendors + .sort((a,b) => a.name.localeCompare(b.name)) + .map(c => ``) + .join(''); + selects.forEach(sel => { + const currentVal = sel.value; + sel.innerHTML = '' + options; + sel.value = currentVal || ""; + }); + } + + function renderRules() { + const list = document.getElementById('rulesList'); + if (!list) return; + list.innerHTML = ''; + rules.forEach(rule => { + const el = document.createElement('div'); + el.className = 'flex justify-between items-center p-2 bg-white rounded-lg shadow-sm'; + el.innerHTML = ` + If description contains "${rule.keyword}", set category to ${rule.category} + + `; + el.querySelector('button').onclick = () => deleteRule(rule.id); + list.appendChild(el); + }); + } + + function renderTransactions() { + const tbody = document.getElementById('transactionsTableBody'); + const filter = document.getElementById('transactionFilter').value; + const search = document.getElementById('transactionSearch').value.toLowerCase(); + if (!tbody) return; + + let txsToRender = [...transactions]; + + // 1. Filter by Date + if (transactionDateFilter === 'lastMonth') { + const oneMonthAgo = new Date(); + oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); + txsToRender = txsToRender.filter(tx => new Date(tx.date) >= oneMonthAgo); + } else if (transactionDateFilter === 'custom' && customDateRange.start && customDateRange.end) { + // Adjust end date to include the whole day + const endDate = new Date(customDateRange.end); + endDate.setHours(23, 59, 59, 999); + txsToRender = txsToRender.filter(tx => { + const txDate = new Date(tx.date); + return txDate >= customDateRange.start && txDate <= endDate; + }); + } + + // 2. Sort + txsToRender.sort((a, b) => { + let valA = a[transactionSort.column]; + let valB = b[transactionSort.column]; + + if (transactionSort.column === 'date') { + valA = valA.getTime(); + valB = valB.getTime(); + } else if (transactionSort.column === 'amount') { + valA = valA; + valB = valB; + } else { + valA = (valA || '').toLowerCase(); + valB = (valB || '').toLowerCase(); + } + + if (valA < valB) return transactionSort.direction === 'asc' ? -1 : 1; + if (valA > valB) return transactionSort.direction === 'asc' ? 1 : -1; + return 0; + }); + + // 2. Filter by Category Status + if (filter === 'uncategorized') { + txsToRender = txsToRender.filter(tx => tx.reconciliation_status === 'uncategorized'); + } + + // 3. Filter by Search + if (search) { + txsToRender = txsToRender.filter(tx => tx.description.toLowerCase().includes(search)); + } + + // 4. Update Header styles + document.querySelectorAll('.sortable-header').forEach(th => { + th.classList.remove('sort-asc', 'sort-desc'); + if (th.dataset.sort === transactionSort.column) { + th.classList.add(transactionSort.direction === 'asc' ? 'sort-asc' : 'sort-desc'); + } + }); + + // 5. Render + tbody.innerHTML = ''; + if (txsToRender.length === 0) { + tbody.innerHTML = 'No transactions found.'; + return; + } + + txsToRender.forEach(tx => { + const txDate = new Date(tx.date); + const tr = document.createElement('tr'); + tr.className = 'border-b hover:bg-gray-50'; + + // Determine Category/Vendor display + let categoryDisplay = ''; + let vendorDisplay = ''; + if (tx.reconciliation_status === 'uncategorized') { + categoryDisplay = 'Uncategorized'; + vendorDisplay = 'N/A'; + } else if (tx.split_count === 1) { + categoryDisplay = tx.category || 'N/A'; + vendorDisplay = tx.vendorName || 'N/A'; + } else if (tx.split_count > 1) { + categoryDisplay = '— Split —'; + vendorDisplay = '— Split —'; + } else { // split_count is 0 or null + categoryDisplay = 'Needs Attention'; + vendorDisplay = 'N/A'; + } + + tr.innerHTML = ` + ${txDate.toLocaleDateString()} + ${tx.description} + ${categoryDisplay} + ${vendorDisplay} + + ${tx.amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} + + + + + + `; + tbody.appendChild(tr); + }); + + tbody.querySelectorAll('.edit-tx-btn').forEach(btn => { + btn.onclick = (e) => showSplitTransactionModal(e.target.dataset.id); + }); + tbody.querySelectorAll('.delete-tx-btn').forEach(btn => { + btn.onclick = (e) => deleteTransaction(e.target.dataset.id); + }); + } + + function handleSort(e) { + const column = e.target.dataset.sort; + if (!column) return; + + if (transactionSort.column === column) { + transactionSort.direction = transactionSort.direction === 'asc' ? 'desc' : 'asc'; + } else { + transactionSort.column = column; + transactionSort.direction = 'desc'; // Default to desc for new columns + } + renderTransactions(); + } + + async function updateDashboard() { + try { + const summary = await apiFetch('/dashboard-summary'); + const { totalIncome, totalReturns, totalExpenses } = summary.stats; + + const netIncome = totalIncome + totalReturns; // Returns are negative + const netProfit = netIncome + totalExpenses; // Expenses are negative + + document.getElementById('statGrossIncome').textContent = totalIncome.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + document.getElementById('statReturns').textContent = totalReturns.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + document.getElementById('statNetIncome').textContent = netIncome.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + document.getElementById('statTotalExpenses').textContent = totalExpenses.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + document.getElementById('statNetProfit').textContent = netProfit.toLocaleString('en-US', { style: 'currency', currency: 'USD' }); + + if (netProfit >= 0) { + document.getElementById('statNetProfit').classList.remove('text-red-600'); + document.getElementById('statNetProfit').classList.add('text-green-600'); + } else { + document.getElementById('statNetProfit').classList.remove('green-red-600'); + document.getElementById('statNetProfit').classList.add('text-red-600'); + } + + // Render Pie Chart + const ctx = document.getElementById('expensePieChart').getContext('2d'); + if (expensePieChart) { + expensePieChart.destroy(); + } + expensePieChart = new Chart(ctx, { + type: 'pie', + data: { + labels: summary.pieData.labels, + datasets: [{ + label: 'Expenses', + data: summary.pieData.data, + backgroundColor: [ + 'rgba(239, 68, 68, 0.7)', 'rgba(59, 130, 246, 0.7)', 'rgba(234, 179, 8, 0.7)', + 'rgba(34, 197, 94, 0.7)', 'rgba(168, 85, 247, 0.7)', 'rgba(236, 72, 153, 0.7)', + 'rgba(249, 115, 22, 0.7)', 'rgba(13, 148, 136, 0.7)', 'rgba(107, 114, 128, 0.7)', + 'rgba(217, 119, 6, 0.7)' + ], + borderColor: '#fff', + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { position: 'right' } + } + } + }); + } catch (error) { + console.error("Error updating dashboard:", error); + swal('Error', "Could not load dashboard summary.", 'error'); + } + } + + function generateReport() { + // This function is still flawed and needs a server-side endpoint for 100% accuracy. + // For now, it will only report on transactions with a single, categorized split. + const startVal = document.getElementById('reportStartDate').value; + const endVal = document.getElementById('reportEndDate').value; + + const start = new Date(startVal + 'T00:00:00'); + const end = new Date(endVal + 'T23:59:59'); + + if (!startVal || !endVal) { + swal('Error', "Please select valid start and end dates.", 'error'); + return; + } + + let incomeByCat = {}; + let expensesByCat = {}; + let totalIncome = 0; + let totalExpenses = 0; + + const txs = transactions.filter(tx => { + const txDate = new Date(tx.date); + return txDate >= start && txDate <= end; + }); + + txs.forEach(tx => { + if (tx.split_count === 1 && tx.reconciliation_status === 'categorized') { + const category = categories.find(c => c.name === tx.category); + if (!category) return; // Skip if category not found + + const type = category.type; + + if (type === 'income') { + totalIncome += tx.amount; + incomeByCat[tx.category] = (incomeByCat[tx.category] || 0) + tx.amount; + } else if (type === 'expense') { + totalExpenses += tx.amount; + expensesByCat[tx.category] = (expensesByCat[tx.category] || 0) + tx.amount; + } + } + // We are intentionally skipping uncategorized and multi-split txs + // because we don't have their split data on the client. + }); + + if (txs.length > 0 && (totalIncome === 0 && totalExpenses === 0)) { + swal('Warning', "This report is a client-side estimate and may be incomplete. It does not include multi-split or uncategorized transactions. A server-side report is needed for full accuracy.", 'warning'); + } + + const netProfit = totalIncome + totalExpenses; + const reportBody = document.getElementById('plReportBody'); + reportBody.innerHTML = ''; + + let incomeHtml = 'Income'; + const incomeCategories = Object.keys(incomeByCat).sort(); + for (const cat of incomeCategories) { + const amt = incomeByCat[cat]; + incomeHtml += `${cat}${amt.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`; + } + incomeHtml += `Total Income (Net Sales)${totalIncome.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`; + + let expensesHtml = 'Expenses'; + const expenseCategories = Object.keys(expensesByCat).sort(); + for (const cat of expenseCategories) { + const amt = expensesByCat[cat]; + expensesHtml += `${cat}${amt.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`; + } + expensesHtml += `Total Expenses${totalExpenses.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`; + + let netHtml = ` + Net Profit / (Loss) + ${netProfit.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} + `; + + reportBody.innerHTML = incomeHtml + expensesHtml + netHtml; + document.getElementById('exportReportBtn').classList.remove('hidden'); + } + + function exportReport() { + let csvContent = "data:text/csv;charset=utf-8,"; + csvContent += "Item,Category,Amount\n"; + + const reportBody = document.getElementById('plReportBody'); + reportBody.querySelectorAll('tr').forEach(tr => { + const cells = tr.querySelectorAll('td'); + if (cells.length === 2) { + let col1 = cells[0].textContent.replace(/,/g, ''); + let col2 = cells[1].textContent.replace(/[$(),]/g, ''); + if (cells[0].classList.contains('pl-6')) { + csvContent += ` ,${col1},${col2}\n`; + } else { + csvContent += `${col1}, ,${col2}\n`; + } + } + }); + + const encodedUri = encodeURI(csvContent); + const link = document.createElement("a"); + link.setAttribute("href", encodedUri); + link.setAttribute("download", "profit_loss_report.csv"); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + + // --- Data Actions --- + + async function addCategory(e) { + e.preventDefault(); + const form = e.target; + const name = form.categoryName.value; + const type = form.categoryType.value; + if (!name) return; + + try { + await apiFetch('/categories', { + method: 'POST', + body: JSON.stringify({ name, type }) + }); + form.reset(); + await loadCategories(); // Refresh + } catch (error) { + console.error("Error adding category: ", error); + swal('Error', error.message, 'error'); + } + } + + async function deleteCategory(id) { + swal({ + title: "Are you sure?", + text: "Are you sure you want to delete this category?", + icon: "warning", + buttons: true, + dangerMode: true, + }) + .then(async (willDelete) => { + if (willDelete) { + try { + await apiFetch(`/categories/${id}`, { method: 'DELETE' }); + await loadCategories(); // Refresh + swal("Poof! The category has been deleted!", { + icon: "success", + }); + } catch (error) { + console.error("Error deleting category: ", error); + swal('Error', error.message, 'error'); + } + } + }); + } + + async function addVendor(e) { + e.preventDefault(); + const form = e.target; + const name = form.vendorName.value; + if (!name) return; + + try { + await apiFetch('/vendors', { + method: 'POST', + body: JSON.stringify({ name }) + }); + form.reset(); + await loadVendors(); // Refresh + } catch (error) { + console.error("Error adding vendor: ", error); + swal('Error', error.message, 'error'); + } + } + + async function deleteVendor(id) { + + swal({ + + title: "Are you sure?", + + text: "Are you sure you want to delete this vendor?", + + icon: "warning", + + buttons: true, + + dangerMode: true, + + }) + + .then(async (willDelete) => { + + if (willDelete) { + + try { + + await apiFetch(`/vendors/${id}`, { method: 'DELETE' }); + + await loadVendors(); // Refresh + + swal("Poof! The vendor has been deleted!", { + + icon: "success", + + }); + + } catch (error) { + + console.error("Error deleting vendor: ", error); + + swal('Error', error.message, 'error'); + + } + + } + + }); + + } + + async function addRule(e) { + e.preventDefault(); + const form = e.target; + const keyword = form.ruleKeyword.value; + const category = form.ruleCategory.value; + if (!keyword || !category) return; + + try { + await apiFetch('/rules', { + method: 'POST', + body: JSON.stringify({ keyword, category }) + }); + form.reset(); + await loadRules(); // Refresh + + swal({ + title: "Rule added!", + text: "Apply all rules to uncategorized transactions now?", + icon: "success", + buttons: true, + }) + .then((willApply) => { + if (willApply) { + applyAllRules(); + } + }); + + } catch (error) { + console.error("Error adding rule: ", error); + swal('Error', error.message, 'error'); + } + } + + async function deleteRule(id) { + swal({ + title: "Are you sure?", + text: "Are you sure you want to delete this rule?", + icon: "warning", + buttons: true, + dangerMode: true, + }) + .then(async (willDelete) => { + if (willDelete) { + try { + await apiFetch(`/rules/${id}`, { method: 'DELETE' }); + await loadRules(); // Refresh + swal("Poof! The rule has been deleted!", { + icon: "success", + }); + } catch (error) { + console.error("Error deleting rule: ", error); + swal('Error', error.message, 'error'); + } + } + }); + } + + async function applyAllRules() { + showLoadingModal(true, "Applying rules..."); + try { + const message = await apiFetch('/rules/apply-all', { method: 'POST' }); + swal('Success', message, 'success'); + await loadTransactions(); // Refresh transactions + } catch (error) { + console.error("Error applying rules:", error); + alert(error.message); + } finally { + showLoadingModal(false); + } + } + + // --- NEW: Split Modal Functions --- + + /** + * Creates a new 'split' row element for the modal. + * This is the FIXED function. + */ + function createSplitRow(split = {}) { + const row = document.createElement('div'); + row.className = 'split-row grid grid-cols-12 gap-2 items-center'; + + // 1. Build Category Options + const categoryOptions = categories + .sort((a,b) => a.name.localeCompare(b.name)) + .map(c => ``) + .join(''); + + // 2. Build Vendor Options + const vendorOptions = vendors + .sort((a,b) => a.name.localeCompare(b.name)) + .map(c => ``) + .join(''); + + // 3. Set innerHTML + row.innerHTML = ` +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+ `; + + // 4. Set values from the split object + const categorySelect = row.querySelector('.split-category'); + const vendorSelect = row.querySelector('.split-vendor'); + const amountInput = row.querySelector('.split-amount'); + const descriptionInput = row.querySelector('.split-description'); + + if (split.category) categorySelect.value = split.category; + if (split.vendorId) vendorSelect.value = split.vendorId; + if (split.amount) amountInput.value = split.amount.toFixed(2); + if (split.description) descriptionInput.value = split.description; + + // Add listeners + amountInput.addEventListener('input', validateSplitAmounts); + row.querySelector('.delete-split-btn').addEventListener('click', () => { + row.remove(); + validateSplitAmounts(); + }); + + return row; + } + + function validateSplitAmounts() { + const total = parseFloat(document.getElementById('splitTxTotal').value); + const allSplitRows = document.querySelectorAll('#splitsContainer .split-row'); + let currentSplitSum = 0; + + allSplitRows.forEach(row => { + currentSplitSum += parseFloat(row.querySelector('.split-amount').value) || 0; + }); + + const remaining = total - currentSplitSum; + const remainingEl = document.getElementById('splitModalRemaining'); + const saveBtn = document.getElementById('saveSplitsBtn'); + + remainingEl.textContent = `Remaining: ${remaining.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`; + + // Use a small tolerance for floating point errors + if (Math.abs(remaining) < 0.001) { + remainingEl.classList.remove('text-red-600'); + remainingEl.classList.add('text-green-600'); + saveBtn.disabled = false; + } else { + remainingEl.classList.remove('text-green-600'); + remainingEl.classList.add('text-red-600'); + saveBtn.disabled = true; + } + } + + async function showSplitTransactionModal(txId) { + const tx = transactions.find(t => t.id == txId); + if (!tx) return; + + // Store transaction data + document.getElementById('splitTxId').value = tx.id; + document.getElementById('splitTxTotal').value = tx.amount; + + // Set modal header + document.getElementById('splitModalTitle').textContent = `Edit / Split Transaction`; + document.getElementById('splitModalDescription').textContent = `${new Date(tx.date).toLocaleDateString()} - ${tx.description}`; + document.getElementById('splitModalTotal').textContent = `Total: ${tx.amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`; + + // Fetch and render splits + const splitsContainer = document.getElementById('splitsContainer'); + splitsContainer.innerHTML = '
'; // Show loading + + try { + // --- FIX: Ensure categories and vendors are loaded before opening modal --- + if (categories.length === 0 || vendors.length === 0) { + console.log("Categories or vendors empty, re-fetching..."); + await loadCategories(); + await loadVendors(); + } + + const splits = await apiFetch(`/transactions/${tx.id}/splits`); + splitsContainer.innerHTML = ''; // Clear spinner + + if (splits.length === 0) { + // This shouldn't happen, but as a fallback, create one default split + splitsContainer.appendChild(createSplitRow({ + description: tx.description, + amount: tx.amount, + category: 'Expense - Uncategorized' + })); + } else { + splits.forEach(split => { + splitsContainer.appendChild(createSplitRow(split)); + }); + } + + validateSplitAmounts(); + document.getElementById('editTxModal').classList.remove('hidden'); + } catch (error) { + // This catch block will now *correctly* catch API errors + console.error("Error fetching splits:", error); + swal('Error', "Could not load transaction splits. API error: " + error.message, 'error'); + } + } + + function closeEditTransactionModal() { + document.getElementById('editTxModal').classList.add('hidden'); + } + + async function handleSaveSplits(e) { + e.preventDefault(); + const transactionId = parseInt(document.getElementById('splitTxId').value, 10); + const allSplitRows = document.querySelectorAll('#splitsContainer .split-row'); + + if (isNaN(transactionId)) { + alert('Error: Invalid transaction ID.'); + return; + } + + let splitsPayload = []; + allSplitRows.forEach(row => { + const vendorIdValue = row.querySelector('.split-vendor').value; + splitsPayload.push({ + category: row.querySelector('.split-category').value, + vendorId: vendorIdValue ? parseInt(vendorIdValue, 10) : null, + description: row.querySelector('.split-description').value, + amount: parseFloat(row.querySelector('.split-amount').value) + }); + }); + + try { + await apiFetch(`/transactions/${transactionId}/splits`, { + method: 'PUT', + body: JSON.stringify(splitsPayload) + }); + closeEditTransactionModal(); + + swal("Success", "Transaction saved!", "success"); + await loadTransactions(); // Refresh transactions + + await updateDashboard(); + + } catch (error) { + swal('Error', error.message, 'error'); + } + } + + async function handleCreateRuleFromSplit() { + // Use the first split row to create a rule + const firstSplitRow = document.querySelector('#splitsContainer .split-row'); + if (!firstSplitRow) { + swal('Error', "No split to create rule from.", 'error'); + return; + } + + // --- FIX 2: Get description robustly --- + const txId = document.getElementById('splitTxId').value; + const tx = transactions.find(t => t.id == txId); + if (!tx) { + swal('Error', "Error: Could not find parent transaction.", 'error'); + return; + } + const description = tx.description; + const category = firstSplitRow.querySelector('.split-category').value; + + swal({ + title: "Create a new rule", + text: `Create rule for "${description}"?\n\nEnter the part of the description to use as a keyword:`, + content: { + element: "input", + attributes: { + placeholder: "Type your keyword here", + value: description, + }, + }, + }) + .then(async (keyword) => { + if (keyword) { + try { + await apiFetch('/rules', { + method: 'POST', + body: JSON.stringify({ keyword, category }) + }); + await loadRules(); + swal('Success', `Rule created: If description contains "${keyword}", set to "${category}".`, 'success'); + } catch (error) { + console.error("Error creating rule:", error); + swal('Error', error.message, 'error'); + } + } + }); + } + + async function deleteTransaction(txId) { + swal({ + title: "Are you sure?", + text: "Are you sure you want to delete this transaction (and all its splits) permanently?", + icon: "warning", + buttons: true, + dangerMode: true, + }) + .then(async (willDelete) => { + if (willDelete) { + try { + await apiFetch(`/transactions/${txId}`, { method: 'DELETE' }); + await loadTransactions(); // Full refresh + await updateDashboard(); + swal("Poof! The transaction has been deleted!", { + icon: "success", + }); + } catch (error) { + console.error("Error deleting transaction: ", error); + swal('Error', error.message, 'error'); + } + } + }); + } + + // --- File Processing --- + + async function handleFiles(e) { + e.preventDefault(); + const bankFile = document.getElementById('bankFile').files[0]; + const salesFile = document.getElementById('salesFile').files[0]; + + if (!bankFile && !salesFile) { + swal('Error', "Please select at least one file to upload.", 'error'); + return; + } + + const formData = new FormData(); + if (bankFile) formData.append('bankFile', bankFile); + if (salesFile) formData.append('salesFile', salesFile); + + showLoadingModal(true, "Uploading and processing files..."); + + try { + const result = await apiFetch('/upload', { + method: 'POST', + body: formData + }); + + swal('Success', result, 'success'); + document.getElementById('uploadForm').reset(); + await loadTransactions(); // Refresh data + await updateDashboard(); // Refresh dashboard + showView('transactions'); + + } catch (error) { + console.error("Error uploading files:", error); + alert(error.message); + } finally { + showLoadingModal(false); + } + } + + + // --- UI Navigation --- + function showView(viewId) { + document.querySelectorAll('main > section').forEach(section => { + section.classList.add('hidden'); + }); + document.querySelectorAll('nav a').forEach(a => { + a.classList.remove('bg-blue-700', 'text-white'); + a.classList.add('text-blue-100', 'hover:bg-blue-600'); + }); + + const activeView = document.getElementById(viewId); + const activeLink = document.querySelector(`nav a[data-view="${viewId}"]`); + + if (activeView) activeView.classList.remove('hidden'); + if (activeLink) { + activeLink.classList.add('bg-blue-700', 'text-white'); + activeLink.classList.remove('text-blue-100', 'hover:bg-blue-600'); + } + + // Refresh dashboard data only when switching to it + if (viewId === 'dashboard') { + updateDashboard(); + } + } + + function showLoadingModal(show, text = 'Loading...') { + const modal = document.getElementById('loadingModal'); + const textEl = document.getElementById('loadingModalText'); + if (show) { + textEl.textContent = text; + modal.classList.remove('hidden'); + } else { + modal.classList.add('hidden'); + } + } + + // --- Initial App Load --- + document.addEventListener('DOMContentLoaded', () => { + // Auth form toggling + document.getElementById('toggleAuth').onclick = (e) => { + e.preventDefault(); + const isLogin = document.getElementById('loginForm').classList.contains('hidden'); + toggleAuthForm(isLogin); + }; + + // Auth form submission + document.getElementById('loginForm').onsubmit = handleLogin; + document.getElementById('registerForm').onsubmit = handleRegister; + + // App Navigation + document.querySelectorAll('nav a').forEach(a => { + a.onclick = (e) => { + e.preventDefault(); + if(e.target.id === 'logoutButton') return logout(); + showView(e.target.dataset.view); + } + }); + + // --- Event Listeners --- + document.getElementById('uploadForm').onsubmit = handleFiles; + document.getElementById('categoryForm').onsubmit = addCategory; + document.getElementById('vendorForm').onsubmit = addVendor; + document.getElementById('ruleForm').onsubmit = addRule; + document.getElementById('transactionFilter').onchange = renderTransactions; + document.getElementById('transactionSearch').oninput = renderTransactions; + document.getElementById('generateReportBtn').onclick = generateReport; + document.getElementById('exportReportBtn').onclick = exportReport; + document.getElementById('applyAllRulesBtn').onclick = applyAllRules; + + document.getElementById('dateFilterSelect').onchange = (e) => { + const customPicker = document.getElementById('customDateRangePicker'); + if (e.target.value === 'custom') { + customPicker.classList.remove('hidden'); + customPicker.classList.add('flex'); + } else { + customPicker.classList.add('hidden'); + customPicker.classList.remove('flex'); + } + }; + + document.getElementById('filterByDateBtn').onclick = () => { + const filterType = document.getElementById('dateFilterSelect').value; + transactionDateFilter = filterType; + + if (filterType === 'custom') { + const startDate = document.getElementById('startDate').value; + const endDate = document.getElementById('endDate').value; + if (startDate && endDate) { + customDateRange = { start: new Date(startDate), end: new Date(endDate) }; + } else { + swal('Error', 'Please select a start and end date for the custom range.', 'error'); + return; + } + } + renderTransactions(); + }; + + // Mobile menu + const sidebar = document.getElementById('sidebar'); + const mobileMenuBtn = document.getElementById('mobileMenuBtn'); + + if (mobileMenuBtn) { + mobileMenuBtn.onclick = () => { + sidebar.classList.toggle('-translate-x-full'); + sidebar.classList.toggle('hidden'); + }; + } + + // Close sidebar when a nav link is clicked on mobile + sidebar.querySelectorAll('a').forEach(link => { + link.addEventListener('click', () => { + if (window.innerWidth < 768) { // md breakpoint + sidebar.classList.add('-translate-x-full'); + sidebar.classList.add('hidden'); + } + }); + }); + + // Transaction table sorting + document.querySelectorAll('.sortable-header').forEach(th => { + th.onclick = handleSort; + }); + + // Edit/Split Tx Modal + document.getElementById('closeTxModalBtn').onclick = closeEditTransactionModal; + document.getElementById('saveSplitsBtn').onclick = handleSaveSplits; + document.getElementById('createRuleFromSplit').onclick = handleCreateRuleFromSplit; + document.getElementById('addSplitBtn').onclick = () => { + document.getElementById('splitsContainer').appendChild(createSplitRow()); + }; + + // Check if already logged in + if (jwtToken) { + document.getElementById('authView').classList.add('hidden'); + document.getElementById('appContainer').classList.remove('hidden'); + showView('dashboard'); + loadAllData(); + } else { + document.getElementById('authView').classList.remove('hidden'); + document.getElementById('appContainer').classList.add('hidden'); + } + }); diff --git a/docker-compose.yml b/docker-compose.yml index 214b0d2..25fad25 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,9 @@ +version: '3.8' + services: app: build: . ports: - - "3001:3000" + - "3000:3000" volumes: - - ./ledger_db:/usr/src/app/data - -volumes: - ledger_db: + - ./simpleledger.db:/usr/src/app/data/simpleledger.db diff --git a/index.html b/index.html index 185496d..b93d5eb 100644 --- a/index.html +++ b/index.html @@ -135,27 +135,35 @@ -