// --- 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 items-center justify-between rounded-xl bg-white/80 px-4 py-3 text-sm shadow-sm backdrop-blur transition hover:-translate-y-0.5 hover:shadow-lg dark:bg-slate-800/70 dark:text-slate-100'; 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 items-center justify-between rounded-xl bg-white/80 px-4 py-3 text-sm shadow-sm backdrop-blur transition hover:-translate-y-0.5 hover:shadow-lg dark:bg-slate-800/70 dark:text-slate-100'; 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 items-center justify-between gap-3 rounded-xl bg-white/80 px-4 py-3 text-sm shadow-sm backdrop-blur transition hover:-translate-y-0.5 hover:shadow-lg dark:bg-slate-800/70 dark:text-slate-100'; 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 mobileList = document.getElementById('transactionsListMobile'); const filterEl = document.getElementById('transactionFilter'); const searchEl = document.getElementById('transactionSearch'); const mobileSortSelect = document.getElementById('mobileSortSelect'); if (!tbody && !mobileList) return; const filter = filterEl ? filterEl.value : 'all'; const search = searchEl ? searchEl.value.toLowerCase() : ''; 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; }); // 3. Filter by Category Status if (filter === 'uncategorized') { txsToRender = txsToRender.filter(tx => tx.reconciliation_status === 'uncategorized'); } // 4. Filter by Search if (search) { txsToRender = txsToRender.filter(tx => tx.description.toLowerCase().includes(search)); } // 5. Update Header styles + mobile select 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'); } }); if (mobileSortSelect) { const desiredValue = `${transactionSort.column}-${transactionSort.direction}`; if (Array.from(mobileSortSelect.options).some(opt => opt.value === desiredValue)) { mobileSortSelect.value = desiredValue; } } // 6. Render if (tbody) tbody.innerHTML = ''; if (mobileList) mobileList.innerHTML = ''; if (txsToRender.length === 0) { if (tbody) { tbody.innerHTML = 'No transactions found.'; } if (mobileList) { const emptyCard = document.createElement('div'); emptyCard.className = 'card text-center text-sm text-slate-500 dark:text-slate-300'; emptyCard.textContent = 'No transactions found.'; mobileList.appendChild(emptyCard); } return; } txsToRender.forEach(tx => { const txDate = new Date(tx.date); let categoryDisplay = ''; let categoryLabel = ''; let vendorDisplay = ''; let vendorLabel = ''; if (tx.reconciliation_status === 'uncategorized') { categoryDisplay = 'Uncategorized'; categoryLabel = 'Uncategorized'; vendorDisplay = 'N/A'; vendorLabel = 'N/A'; } else if (tx.split_count === 1) { categoryDisplay = tx.category || 'N/A'; categoryLabel = tx.category || 'N/A'; vendorDisplay = tx.vendorName || 'N/A'; vendorLabel = tx.vendorName || 'N/A'; } else if (tx.split_count > 1) { categoryDisplay = '— Split —'; categoryLabel = 'Split'; vendorDisplay = '— Split —'; vendorLabel = 'Split'; } else { categoryDisplay = 'Needs Attention'; categoryLabel = 'Needs attention'; vendorDisplay = 'N/A'; vendorLabel = 'N/A'; } if (tbody) { const tr = document.createElement('tr'); tr.className = 'border-b border-slate-100 transition dark:border-slate-800 hover:bg-slate-50/80 dark:hover:bg-slate-800/60'; tr.innerHTML = ` ${txDate.toLocaleDateString()} ${tx.description} ${categoryDisplay} ${vendorDisplay} ${tx.amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })} `; tbody.appendChild(tr); } if (mobileList) { const card = document.createElement('div'); card.className = 'card space-y-3'; const amountColor = tx.amount >= 0 ? 'text-emerald-500' : 'text-rose-500'; card.innerHTML = `

${txDate.toLocaleDateString()}

${tx.description}

${tx.amount >= 0 ? 'Incoming' : 'Outgoing'}

${tx.amount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}

${categoryLabel} Vendor: ${vendorLabel} ${tx.reconciliation_status}
`; mobileList.appendChild(card); } }); if (tbody) { 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); }); } if (mobileList) { mobileList.querySelectorAll('.mobile-edit-tx-btn').forEach(btn => { btn.onclick = (e) => showSplitTransactionModal(e.currentTarget.dataset.id); }); mobileList.querySelectorAll('.mobile-delete-tx-btn').forEach(btn => { btn.onclick = (e) => deleteTransaction(e.currentTarget.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('#sidebar nav a').forEach(a => { a.classList.remove('active-nav'); a.removeAttribute('aria-current'); }); const activeView = document.getElementById(viewId); const activeLink = document.querySelector(`#sidebar nav a[data-view="${viewId}"]`); if (activeView) { activeView.classList.remove('hidden'); } if (activeLink) { activeLink.classList.add('active-nav'); activeLink.setAttribute('aria-current', 'page'); } // 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', () => { const body = document.body; const root = document.documentElement; const darkModeToggle = document.getElementById('darkModeToggle'); const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('mobileOverlay'); const menuToggle = document.getElementById('menuToggle'); const closeSidebarBtn = document.getElementById('closeSidebar'); const applyTheme = (mode) => { const isDark = mode === 'dark'; body.classList.toggle('dark-mode', isDark); root.classList.toggle('dark', isDark); }; const savedTheme = localStorage.getItem('simpleLedgerTheme'); if (savedTheme) { applyTheme(savedTheme); } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { applyTheme('dark'); } if (menuToggle) { menuToggle.setAttribute('aria-expanded', 'false'); } if (darkModeToggle) { darkModeToggle.addEventListener('click', () => { const isDark = !body.classList.contains('dark-mode'); applyTheme(isDark ? 'dark' : 'light'); localStorage.setItem('simpleLedgerTheme', isDark ? 'dark' : 'light'); }); } const openSidebar = () => { if (!sidebar) return; sidebar.classList.remove('-translate-x-full'); sidebar.classList.add('translate-x-0'); overlay?.classList.remove('hidden'); menuToggle?.setAttribute('aria-expanded', 'true'); }; const hideSidebar = () => { if (!sidebar) return; sidebar.classList.add('-translate-x-full'); sidebar.classList.remove('translate-x-0'); overlay?.classList.add('hidden'); menuToggle?.setAttribute('aria-expanded', 'false'); }; const maybeHideSidebar = () => { if (window.innerWidth < 1024) { hideSidebar(); } }; menuToggle?.addEventListener('click', openSidebar); closeSidebarBtn?.addEventListener('click', hideSidebar); overlay?.addEventListener('click', hideSidebar); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && window.innerWidth < 1024) { hideSidebar(); } }); window.addEventListener('resize', () => { if (window.innerWidth >= 1024) { sidebar?.classList.remove('-translate-x-full'); sidebar?.classList.remove('translate-x-0'); overlay?.classList.add('hidden'); menuToggle?.setAttribute('aria-expanded', 'false'); } }); // 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('#sidebar nav a').forEach(link => { link.onclick = (e) => { e.preventDefault(); const targetView = e.currentTarget.dataset.view; if (targetView) { showView(targetView); maybeHideSidebar(); } }; }); const logoutButton = document.getElementById('logoutButton'); if (logoutButton) { logoutButton.onclick = (e) => { e.preventDefault(); logout(); maybeHideSidebar(); }; } const quickTransactions = document.getElementById('quickTransactions'); if (quickTransactions) { quickTransactions.onclick = () => { showView('transactions'); maybeHideSidebar(); }; } const quickUpload = document.getElementById('quickUpload'); if (quickUpload) { quickUpload.onclick = () => { showView('upload'); maybeHideSidebar(); }; } // --- Event Listeners --- const uploadForm = document.getElementById('uploadForm'); if (uploadForm) uploadForm.onsubmit = handleFiles; const categoryForm = document.getElementById('categoryForm'); if (categoryForm) categoryForm.onsubmit = addCategory; const vendorForm = document.getElementById('vendorForm'); if (vendorForm) vendorForm.onsubmit = addVendor; const ruleForm = document.getElementById('ruleForm'); if (ruleForm) ruleForm.onsubmit = addRule; const transactionFilterEl = document.getElementById('transactionFilter'); if (transactionFilterEl) transactionFilterEl.onchange = renderTransactions; const transactionSearchEl = document.getElementById('transactionSearch'); if (transactionSearchEl) transactionSearchEl.oninput = renderTransactions; const generateReportBtn = document.getElementById('generateReportBtn'); if (generateReportBtn) generateReportBtn.onclick = generateReport; const exportReportBtn = document.getElementById('exportReportBtn'); if (exportReportBtn) exportReportBtn.onclick = exportReport; const applyAllRulesBtn = document.getElementById('applyAllRulesBtn'); if (applyAllRulesBtn) applyAllRulesBtn.onclick = applyAllRules; const dateFilterSelect = document.getElementById('dateFilterSelect'); if (dateFilterSelect) { dateFilterSelect.onchange = (e) => { const customPicker = document.getElementById('customDateRangePicker'); if (!customPicker) return; transactionDateFilter = e.target.value; if (e.target.value === 'custom') { customPicker.classList.remove('hidden'); customPicker.classList.add('flex'); handleDateChange(); } else { customPicker.classList.add('hidden'); customPicker.classList.remove('flex'); customDateRange = { start: null, end: null }; renderTransactions(); } }; } renderTransactions(); const handleDateChange = () => { if (transactionDateFilter !== 'custom') return; const startDateVal = document.getElementById('startDate')?.value; const endDateVal = document.getElementById('endDate')?.value; if (startDateVal && endDateVal) { customDateRange = { start: new Date(startDateVal), end: new Date(endDateVal) }; renderTransactions(); } }; const startDateInput = document.getElementById('startDate'); const endDateInput = document.getElementById('endDate'); if (startDateInput) startDateInput.onchange = handleDateChange; if (endDateInput) endDateInput.onchange = handleDateChange; const mobileSortSelectEl = document.getElementById('mobileSortSelect'); if (mobileSortSelectEl) { mobileSortSelectEl.onchange = (e) => { const [column, direction] = e.target.value.split('-'); transactionSort.column = column; transactionSort.direction = direction; renderTransactions(); }; } // 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'); } });