// --- 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');
}
});