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 (
-{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.
) : (
<>
{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 */}
{/* 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 && (
{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 && (
)}
{/* 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)}
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 && (
)}
{/* 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 */}
);
}