import React, { useState, useEffect, useCallback, useRef } from 'react'; import { toast } from 'sonner'; import { ChevronLeft, ChevronRight, ChevronDown, Tag, ReceiptText, TrendingDown, Pencil, Check, X, Trash2, BookmarkPlus, Settings2, Copy, AlertCircle, RefreshCw, ArrowRightLeft, Layers, Wallet, } from 'lucide-react'; import { api } from '@/api'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/Skeleton'; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { CategoryPicker } from '@/components/transactions/CategoryPicker'; const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; function fmt(n) { return Number(n || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); } function settingEnabled(value, fallback = true) { if (value === undefined || value === null || value === '') return fallback; return value === true || value === 'true'; } // pctBar() returns a progress-bar percent plus a YNAB-style status level: // - 'over': spending has exceeded the budget (Available < 0) // - 'warn': spending has crossed ~80% of the budget // - 'ok': on track function pctBar(amount, budget) { if (budget == null) return null; const pct = Math.min(100, Math.round((amount / budget) * 100)); const level = amount > budget ? 'over' : (budget > 0 && amount / budget >= 0.8) ? 'warn' : 'ok'; return { pct, level, over: level === 'over' }; } const LEVEL_BAR_CLASS = { over: 'bg-destructive', warn: 'bg-amber-500', ok: 'bg-primary' }; const LEVEL_TEXT_CLASS = { over: 'text-destructive', warn: 'text-amber-500', ok: 'text-emerald-500' }; // ── Transaction row ────────────────────────────────────────────────────────── function TxRow({ tx, categories, onCategorize }) { const [saving, setSaving] = useState(false); const [rememberPrompt, setRememberPrompt] = useState(null); // { categoryId, categoryName } const dismissTimer = useRef(null); // Auto-dismiss the "remember" prompt after 7 seconds useEffect(() => { if (!rememberPrompt) return; dismissTimer.current = setTimeout(() => setRememberPrompt(null), 7000); return () => clearTimeout(dismissTimer.current); }, [rememberPrompt]); const handleSelect = async (categoryId) => { setSaving(true); setRememberPrompt(null); try { await api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: false }); const catName = categories.find(c => c.id === categoryId)?.name ?? null; onCategorize(tx.id, categoryId, catName); // Offer to remember the merchant rule (only when assigning a real category) if (categoryId) setRememberPrompt({ categoryId, categoryName: catName }); } catch (err) { toast.error(err.message || 'Failed to categorize'); } finally { setSaving(false); } }; const saveRule = async () => { if (!rememberPrompt) return; clearTimeout(dismissTimer.current); setRememberPrompt(null); try { await api.categorizeTransaction(tx.id, { category_id: rememberPrompt.categoryId, save_rule: true }); toast.success(`Rule saved — future ${tx.payee} transactions will be auto-categorized.`); } catch (err) { toast.error(err.message || 'Failed to save rule'); } }; return (

{tx.payee}

{tx.date}

-{fmt(tx.amount)} {saving ? Saving… : }
{rememberPrompt && (
Always categorize {tx.payee} as{' '} {rememberPrompt.categoryName}?
)}
); } // ── Budget edit inline ─────────────────────────────────────────────────────── function BudgetEditor({ categoryId, year, month, initial, onSaved }) { const [editing, setEditing] = useState(false); const [val, setVal] = useState(initial ?? ''); const save = async () => { const amount = val === '' ? null : parseFloat(val); if (val !== '' && (isNaN(amount) || amount < 0)) { toast.error('Enter a valid amount'); return; } try { await api.setSpendingBudget({ category_id: categoryId, year, month, amount }); onSaved(categoryId, amount); setEditing(false); } catch { toast.error('Failed to save budget'); } }; if (!editing) return ( ); return (
setVal(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') setEditing(false); }} placeholder="0.00" autoFocus className="w-20 rounded border border-border/60 bg-background px-1.5 py-0.5 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring" />
); } // ── Income section ─────────────────────────────────────────────────────────── function IncomeSection({ year, month, totalIncome }) { const [open, setOpen] = useState(false); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [page, setPage] = useState(1); const [pages, setPages] = useState(1); const [loading, setLoading] = useState(false); const load = useCallback(async (p = 1) => { setLoading(true); try { const d = await api.spendingIncome({ year, month, page: p }); setRows(d.transactions || []); setTotal(d.total || 0); setPages(d.pages || 1); setPage(p); } catch (err) { toast.error(err.message || 'Failed to load income transactions'); } finally { setLoading(false); } }, [year, month]); useEffect(() => { if (open) load(1); }, [open, load]); if (!totalIncome) return null; return (
{open && (
{loading ? (

Loading…

) : rows.length === 0 ? (

No income transactions found.

) : ( <>
{rows.map(tx => (

{tx.payee}

{tx.date}

+{fmt(tx.amount)}
))}
{pages > 1 && (
{page} / {pages}
)} )}

Positive unmatched transactions — deposits, refunds, transfers in. Bill-matched payments are excluded.

)}
); } // ── Rules manager ──────────────────────────────────────────────────────────── function RulesManager({ categories }) { const [open, setOpen] = useState(false); const [rules, setRules] = useState([]); const [loading, setLoading] = useState(false); const [newMerchant, setNewMerchant] = useState(''); const [newCategory, setNewCategory] = useState(''); const [adding, setAdding] = useState(false); const load = useCallback(async () => { setLoading(true); try { setRules((await api.spendingCategoryRules()).rules || []); } catch { toast.error('Failed to load rules'); } finally { setLoading(false); } }, []); useEffect(() => { if (open) load(); }, [open, load]); const deleteRule = async (id) => { try { await api.deleteSpendingRule(id); setRules(prev => prev.filter(r => r.id !== id)); } catch { toast.error('Failed to delete rule'); } }; const addRule = async (e) => { e.preventDefault(); if (!newMerchant.trim() || !newCategory) return; setAdding(true); try { await api.addSpendingRule({ merchant: newMerchant.trim(), category_id: parseInt(newCategory, 10) }); setNewMerchant(''); setNewCategory(''); await load(); toast.success('Rule saved.'); } catch (err) { toast.error(err.message || 'Failed to save rule'); } finally { setAdding(false); } }; return (
{open && (
{/* Add new rule */}
setNewMerchant(e.target.value)} placeholder="Merchant name (e.g. walmart)" className="flex-1 min-w-0 rounded-md border border-border/60 bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring" />
{/* Existing rules */} {loading ? (

Loading…

) : rules.length === 0 ? (

No rules saved yet. Categorize a transaction and click "Save rule" to create one.

) : (
{rules.map(r => (
{r.merchant} {r.category_name}
))}
)}
)}
); } // ── Spending settings menu ─────────────────────────────────────────────────── function SpendingSettingsMenu({ settings, onToggle, saving }) { return ( Spending page options e.preventDefault()} onCheckedChange={checked => onToggle('spending_show_ready_to_assign', checked)} > Ready to Assign e.preventDefault()} onCheckedChange={checked => onToggle('spending_show_avg', checked)} > 3-month averages e.preventDefault()} onCheckedChange={checked => onToggle('spending_show_cover_overspend', checked)} > Cover overspending e.preventDefault()} onCheckedChange={checked => onToggle('spending_group_categories', checked)} > Group categories ); } // ── Cover overspending ─────────────────────────────────────────────────────── function CoverOverspendPicker({ cat, siblings, budgets, year, month, onCovered }) { const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const catBudget = budgets[cat.category_id] ?? cat.budget ?? 0; const overspend = Math.max(0, cat.amount - catBudget); if (overspend <= 0) return null; const options = siblings .filter(s => s.category_id !== cat.category_id) .map(s => ({ ...s, available: (budgets[s.category_id] ?? s.budget ?? 0) - s.amount })) .filter(s => s.available > 0); const cover = async (source) => { const sourceBudget = budgets[source.category_id] ?? source.budget ?? 0; const amount = Math.min(overspend, source.available); setSaving(true); try { const newSourceBudget = sourceBudget - amount; const newCatBudget = catBudget + amount; await api.setSpendingBudget({ category_id: source.category_id, year, month, amount: newSourceBudget }); await api.setSpendingBudget({ category_id: cat.category_id, year, month, amount: newCatBudget }); onCovered({ [source.category_id]: newSourceBudget, [cat.category_id]: newCatBudget }); toast.success(`Covered ${fmt(amount)} from ${source.category_name}.`); setOpen(false); } catch (err) { toast.error(err.message || 'Failed to cover overspending'); } finally { setSaving(false); } }; if (options.length === 0) { return No category has budget left to cover this; } return (
{open && (
{options.map(s => ( ))}
)}
); } // ── Category groups manager ────────────────────────────────────────────────── function CategoryGroupManager({ categories, groups, onGroupsChanged, onCategoriesChanged }) { const [open, setOpen] = useState(false); const [newGroupName, setNewGroupName] = useState(''); const [adding, setAdding] = useState(false); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const addGroup = async (e) => { e.preventDefault(); if (!newGroupName.trim()) return; setAdding(true); try { await api.createCategoryGroup({ name: newGroupName.trim() }); setNewGroupName(''); await onGroupsChanged(); toast.success('Group created.'); } catch (err) { toast.error(err.message || 'Failed to create group'); } finally { setAdding(false); } }; const renameGroup = async (id) => { if (!editName.trim()) return; try { await api.updateCategoryGroup(id, { name: editName.trim() }); setEditingId(null); await onGroupsChanged(); } catch (err) { toast.error(err.message || 'Failed to rename group'); } }; const deleteGroup = async (id) => { try { await api.deleteCategoryGroup(id); await onGroupsChanged(); toast.success('Group deleted.'); } catch (err) { toast.error(err.message || 'Failed to delete group'); } }; const setCategoryGroup = async (cat, groupId) => { try { await api.updateCategory(cat.id, { name: cat.name, spending_enabled: true, group_id: groupId === '' ? null : Number(groupId) }); await onCategoriesChanged(); } catch (err) { toast.error(err.message || 'Failed to update category group'); } }; return (
{open && (
setNewGroupName(e.target.value)} placeholder="New group name (e.g. Bills, Everyday)" className="flex-1 min-w-0 rounded-md border border-border/60 bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring" />
{groups.length === 0 ? (

No groups yet. Create one above, then assign categories to it below.

) : (
{groups.map(g => (
{editingId === g.id ? ( <> setEditName(e.target.value)} autoFocus onKeyDown={e => { if (e.key === 'Enter') renameGroup(g.id); if (e.key === 'Escape') setEditingId(null); }} className="flex-1 rounded-md border border-border/60 bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring" /> ) : ( <> {g.name} )}
))}
)} {categories.length > 0 && (

Assign categories

{categories.map(c => (
{c.name}
))}
)}
)}
); } // ── Main page ──────────────────────────────────────────────────────────────── export default function SpendingPage() { const now = new Date(); const [year, setYear] = useState(now.getFullYear()); const [month, setMonth] = useState(now.getMonth() + 1); const [summary, setSummary] = useState(null); const [transactions, setTransactions] = useState([]); const [txTotal, setTxTotal] = useState(0); const [txPage, setTxPage] = useState(1); const [txPages, setTxPages] = useState(1); const [categories, setCategories] = useState([]); const [categoryGroups, setCategoryGroups] = useState([]); const [activeCat, setActiveCat] = useState(undefined); // undefined = all const [loading, setLoading] = useState(true); const [txLoading, setTxLoading] = useState(false); const [budgets, setBudgets] = useState({}); // categoryId → amount const [copying, setCopying] = useState(false); const [spendingSettings, setSpendingSettings] = useState({}); const [savingSpendingSetting, setSavingSpendingSetting] = useState(false); const [summaryError, setSummaryError] = useState(null); const [txError, setTxError] = useState(null); const [catError, setCatError] = useState(null); // loadCategories is stable — categories don't vary by month const loadCategories = useCallback(async () => { setCatError(null); try { const d = await api.categories(); // Only show spending-enabled categories in the spending UI setCategories((d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled)); } catch (err) { setCatError(err.message || 'Failed to load categories'); } }, []); const loadCategoryGroups = useCallback(async () => { try { setCategoryGroups((await api.categoryGroups()) || []); } catch { // non-fatal — group headers simply won't render } }, []); // loadTransactions is exposed so pagination buttons can call it with a page arg const loadTransactions = useCallback(async (page = 1) => { setTxLoading(true); setTxError(null); try { const params = { year, month, page, limit: 50 }; if (activeCat === null) params.category_id = 'null'; else if (activeCat !== undefined) params.category_id = activeCat; const d = await api.spendingTransactions(params); setTransactions(d.transactions || []); setTxTotal(d.total || 0); setTxPages(d.pages || 1); setTxPage(page); } catch (err) { setTxError(err.message || 'Failed to load transactions'); } finally { setTxLoading(false); } }, [year, month, activeCat]); const loadSummary = useCallback(async () => { setLoading(true); setSummaryError(null); try { const d = await api.spendingSummary({ year, month }); setSummary(d); const bmap = {}; (d.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; }); setBudgets(bmap); if (d.category_groups) setCategoryGroups(d.category_groups); } catch (err) { setSummaryError(err.message || 'Failed to load spending summary'); } finally { setLoading(false); } }, [year, month]); // Load categories, groups, and settings once on mount useEffect(() => { loadCategories(); loadCategoryGroups(); }, [loadCategories, loadCategoryGroups]); useEffect(() => { api.settings().then(setSpendingSettings).catch(() => {}); }, []); // Load summary and transactions whenever month/category filter changes. useEffect(() => { loadSummary(); loadTransactions(1); }, [year, month, activeCat]); // eslint-disable-line react-hooks/exhaustive-deps async function saveSpendingSetting(patch, successMessage) { setSavingSpendingSetting(true); setSpendingSettings(prev => ({ ...prev, ...patch })); try { const next = await api.saveSettings(patch); setSpendingSettings(prev => ({ ...prev, ...next })); if (successMessage) toast.success(successMessage); } catch (err) { toast.error(err.message || 'Failed to update spending setting'); api.settings().then(s => setSpendingSettings(prev => ({ ...prev, ...s }))).catch(() => {}); } finally { setSavingSpendingSetting(false); } } const handleCopyBudgets = async () => { setCopying(true); try { const d = await api.copySpendingBudgets({ year, month }); if (d.copied === 0) { toast.info('No budgets found in the previous month to copy.'); } else { // Update local budget state from server response const bmap = {}; (d.budgets || []).forEach(b => { bmap[b.category_id] = b.amount; }); setBudgets(bmap); setSummary(prev => prev ? { ...prev, by_category: prev.by_category.map(c => c.category_id && bmap[c.category_id] != null ? { ...c, budget: bmap[c.category_id] } : c ), } : prev); toast.success(`${d.copied} budget${d.copied !== 1 ? 's' : ''} copied from last month.`); } } catch (err) { toast.error(err.message || 'Failed to copy budgets'); } finally { setCopying(false); } }; const navMonth = (dir) => { let m = month + dir, y = year; if (m > 12) { m = 1; y++; } if (m < 1) { m = 12; y--; } setMonth(m); setYear(y); setActiveCat(undefined); setTxPage(1); }; const handleCategorize = (txId, categoryId, categoryName) => { setTransactions(prev => prev.map(t => t.id === txId ? { ...t, spending_category_id: categoryId, spending_category_name: categoryName } : t )); loadSummary(); }; const handleBudgetSaved = (categoryId, amount) => { setBudgets(prev => ({ ...prev, [categoryId]: amount })); setSummary(prev => { if (!prev) return prev; return { ...prev, by_category: prev.by_category.map(c => c.category_id === categoryId ? { ...c, budget: amount } : c ), }; }); }; const handleBudgetsCovered = (updates) => { setBudgets(prev => ({ ...prev, ...updates })); setSummary(prev => { if (!prev) return prev; return { ...prev, by_category: prev.by_category.map(c => c.category_id in updates ? { ...c, budget: updates[c.category_id] } : c ), }; }); }; const refreshAfterGroupChange = async () => { await loadCategoryGroups(); await loadSummary(); }; const refreshAfterCategoryChange = async () => { await loadCategories(); await loadSummary(); }; const selectCat = (catId) => { setActiveCat(prev => prev === catId ? undefined : catId); setTxPage(1); }; if (loading && !summary) { return (
{Array.from({ length: 5 }).map((_, i) => )}
); } const realCatEntries = summary?.by_category?.filter(c => typeof c.category_id === 'number') || []; const uncatEntry = summary?.by_category?.find(c => c.category_id === null) || null; const otherEntry = summary?.by_category?.find(c => c.category_id === 'other') || null; const totalBudgeted = Object.values(budgets).reduce((sum, b) => sum + (Number(b) || 0), 0); const totalSpending = summary?.total_spending || 0; const income = summary?.income || 0; const readyToAssign = income - totalBudgeted; const remaining = totalBudgeted - totalSpending; const showReadyToAssign = settingEnabled(spendingSettings.spending_show_ready_to_assign); const showAvg = settingEnabled(spendingSettings.spending_show_avg); const showCover = settingEnabled(spendingSettings.spending_show_cover_overspend); const showGroups = settingEnabled(spendingSettings.spending_group_categories, false); const groupedEntries = (() => { if (!showGroups || categoryGroups.length === 0) return null; const byGroup = new Map(categoryGroups.map(g => [g.id, { group: g, entries: [] }])); const ungrouped = []; realCatEntries.forEach(c => { if (c.group_id && byGroup.has(c.group_id)) byGroup.get(c.group_id).entries.push(c); else ungrouped.push(c); }); const groups = [...byGroup.values()].filter(g => g.entries.length > 0); if (ungrouped.length > 0) groups.push({ group: { id: 'ungrouped', name: 'Ungrouped' }, entries: ungrouped }); return groups; })(); const renderCategoryRow = (cat) => { const budget = budgets[cat.category_id] ?? cat.budget ?? null; const bar = pctBar(cat.amount, budget); const isActive = activeCat === cat.category_id; const available = budget != null ? budget - cat.amount : null; const level = bar?.level ?? 'ok'; return (
{showCover && level === 'over' && ( )}
); }; const renderUncatRow = () => ( ); const renderOtherRow = () => (
Other categories {fmt(otherEntry.amount)}

{otherEntry.tx_count} transaction{otherEntry.tx_count !== 1 ? 's' : ''}

); const activeCatLabel = activeCat === null ? 'Uncategorized' : activeCat !== undefined ? realCatEntries.find(c => c.category_id === activeCat)?.category_name : null; return (
{/* Header */}

Spending

Unmatched bank transactions by category

{MONTH_NAMES[month - 1]} {year} saveSpendingSetting({ [key]: checked ? 'true' : 'false' })} saving={savingSpendingSetting} />
{/* Summary load error */} {summaryError && (
{summaryError}
)} {/* Ready to Assign */} {showReadyToAssign && (
= 0 ? 'border-emerald-500/25 bg-emerald-500/5' : 'border-amber-500/25 bg-amber-500/10'}`}>

Ready to Assign

= 0 ? 'text-emerald-500' : 'text-amber-500'}`}> {fmt(Math.abs(readyToAssign))}

{readyToAssign >= 0 ? 'left to budget this month' : 'over-assigned — budgets exceed income'}

)} {/* Overview strip */}

Budgeted

{fmt(totalBudgeted)}

Spent

{fmt(totalSpending)}

Remaining

{fmt(remaining)}

Income

{fmt(income)}

Uncategorized

{fmt(summary?.uncategorized_amount)}

{summary?.uncategorized_count > 0 && (

{summary.uncategorized_count} transaction{summary.uncategorized_count !== 1 ? 's' : ''}

)}
{/* No spending categories notice */} {categories.length === 0 && (
No spending categories are enabled yet. Go to{' '} Categories {' '}and enable "Spending" on the categories you want to use here.
)} {catError && (
{catError}
)} {/* Category breakdown */}
By Category
{activeCat !== undefined && ( )}
{realCatEntries.length === 0 && !uncatEntry && !otherEntry ? (

No spending found for this month yet.

Set a budget or categorize transactions below to see them here.

) : groupedEntries ? (
{groupedEntries.map(({ group, entries }) => { const subtotalAmount = entries.reduce((s, c) => s + c.amount, 0); const subtotalBudget = entries.reduce((s, c) => s + (budgets[c.category_id] ?? c.budget ?? 0), 0); return (
{group.name} {fmt(subtotalAmount)} / {fmt(subtotalBudget)}
{entries.map(renderCategoryRow)}
); })} {(uncatEntry || otherEntry) && (
{uncatEntry && renderUncatRow()} {otherEntry && renderOtherRow()}
)}
) : (
{realCatEntries.map(renderCategoryRow)} {uncatEntry && renderUncatRow()} {otherEntry && renderOtherRow()}
)}
{/* Transaction list */}
Transactions {activeCatLabel && ( — {activeCatLabel} )} {txTotal}
{txError ? (
{txError}
) : txLoading ? (
{Array.from({ length: 5 }).map((_, i) => (
))}
) : transactions.length === 0 ? (

No transactions found{activeCatLabel ? ` for ${activeCatLabel}` : ''}.

) : ( <>
{transactions.map(tx => ( ))}
{txPages > 1 && (
Page {txPage} of {txPages}
)} )}
{/* Income & deposits */} {/* Category groups management */} {showGroups && ( )} {/* Merchant rules manager */}
); }