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 @@
-
-