424 lines
18 KiB
JavaScript
424 lines
18 KiB
JavaScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { ChevronLeft, ChevronRight, Tag, ReceiptText, TrendingDown, CircleDollarSign, Pencil, Check, X } from 'lucide-react';
|
|
import { api } from '@/api';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
|
|
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 pctBar(amount, budget) {
|
|
if (!budget) return null;
|
|
const pct = Math.min(100, Math.round((amount / budget) * 100));
|
|
const over = amount > budget;
|
|
return { pct, over };
|
|
}
|
|
|
|
// ── Category picker dropdown ─────────────────────────────────────────────────
|
|
|
|
function CategoryPicker({ categories, current, onSelect }) {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const close = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
|
|
document.addEventListener('mousedown', close);
|
|
return () => document.removeEventListener('mousedown', close);
|
|
}, [open]);
|
|
|
|
const currentCat = categories.find(c => c.id === current);
|
|
|
|
return (
|
|
<div ref={ref} className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(v => !v)}
|
|
className="flex items-center gap-1.5 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground transition-colors"
|
|
>
|
|
<Tag className="h-3 w-3 shrink-0" />
|
|
<span className="max-w-[100px] truncate">{currentCat?.name ?? 'Uncategorized'}</span>
|
|
</button>
|
|
|
|
{open && (
|
|
<div className="absolute right-0 top-full z-50 mt-1 w-44 rounded-lg border border-border/60 bg-popover shadow-lg overflow-hidden">
|
|
<button
|
|
type="button"
|
|
onMouseDown={e => { e.preventDefault(); onSelect(null, false); setOpen(false); }}
|
|
className="w-full px-3 py-2 text-left text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
|
|
>
|
|
— Uncategorized
|
|
</button>
|
|
{categories.map(cat => (
|
|
<button
|
|
key={cat.id}
|
|
type="button"
|
|
onMouseDown={e => { e.preventDefault(); onSelect(cat.id, false); setOpen(false); }}
|
|
className={`w-full px-3 py-2 text-left text-xs hover:bg-muted/50 transition-colors ${cat.id === current ? 'text-primary font-medium' : ''}`}
|
|
>
|
|
{cat.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Transaction row ──────────────────────────────────────────────────────────
|
|
|
|
function TxRow({ tx, categories, onCategorize }) {
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const handleSelect = async (categoryId, saveRule) => {
|
|
setSaving(true);
|
|
try {
|
|
await api.categorizeTransaction(tx.id, { category_id: categoryId, save_rule: saveRule });
|
|
onCategorize(tx.id, categoryId, categories.find(c => c.id === categoryId)?.name ?? null);
|
|
} catch (err) {
|
|
toast.error(err.message || 'Failed to categorize');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border/30 last:border-0 hover:bg-muted/20 transition-colors">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="text-sm truncate font-medium">{tx.payee}</p>
|
|
<p className="text-xs text-muted-foreground">{tx.date}</p>
|
|
</div>
|
|
<span className="text-sm font-mono font-semibold text-destructive shrink-0">
|
|
-{fmt(tx.amount)}
|
|
</span>
|
|
{saving
|
|
? <span className="text-xs text-muted-foreground w-28 text-center">Saving…</span>
|
|
: <CategoryPicker categories={categories} current={tx.spending_category_id} onSelect={handleSelect} />
|
|
}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(true)}
|
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
{initial != null ? `Budget: ${fmt(initial)}` : 'Set budget'}
|
|
<Pencil className="h-3 w-3" />
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
type="number"
|
|
value={val}
|
|
onChange={e => 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"
|
|
/>
|
|
<button type="button" onClick={save} className="text-emerald-500 hover:text-emerald-400"><Check className="h-3.5 w-3.5" /></button>
|
|
<button type="button" onClick={() => setEditing(false)} className="text-muted-foreground hover:text-foreground"><X className="h-3.5 w-3.5" /></button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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 [activeCat, setActiveCat] = useState(undefined); // undefined = all
|
|
const [loading, setLoading] = useState(true);
|
|
const [txLoading, setTxLoading] = useState(false);
|
|
const [budgets, setBudgets] = useState({}); // categoryId → amount
|
|
|
|
const loadCategories = useCallback(async () => {
|
|
try {
|
|
const d = await api.categories();
|
|
setCategories((d.categories || d || []).filter(c => !c.deleted_at));
|
|
} catch {}
|
|
}, []);
|
|
|
|
const loadSummary = useCallback(async () => {
|
|
setLoading(true);
|
|
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);
|
|
} catch (err) {
|
|
toast.error(err.message || 'Failed to load spending summary');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [year, month]);
|
|
|
|
const loadTransactions = useCallback(async (page = 1) => {
|
|
setTxLoading(true);
|
|
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) {
|
|
toast.error(err.message || 'Failed to load transactions');
|
|
} finally {
|
|
setTxLoading(false);
|
|
}
|
|
}, [year, month, activeCat]);
|
|
|
|
useEffect(() => { loadCategories(); }, [loadCategories]);
|
|
useEffect(() => { loadSummary(); loadTransactions(1); }, [loadSummary, loadTransactions]);
|
|
|
|
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 selectCat = (catId) => {
|
|
setActiveCat(prev => prev === catId ? undefined : catId);
|
|
setTxPage(1);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-[60vh] text-muted-foreground text-sm">
|
|
Loading spending…
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const uncatEntry = summary?.by_category?.find(c => !c.category_id);
|
|
const catEntries = summary?.by_category?.filter(c => !!c.category_id) || [];
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[radial-gradient(circle_at_top_right,oklch(var(--primary)/0.05),transparent_30rem)] pb-16">
|
|
<div className="mx-auto max-w-4xl px-4 py-6 sm:px-6 space-y-6">
|
|
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-xl font-semibold">Spending</h1>
|
|
<p className="text-sm text-muted-foreground">Unmatched bank transactions by category</p>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(-1)}>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<span className="text-sm font-medium w-24 text-center">
|
|
{MONTH_NAMES[month - 1]} {year}
|
|
</span>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => navMonth(1)}>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Overview strip */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3">
|
|
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Total Spending</p>
|
|
<p className="text-2xl font-bold mt-1">{fmt(summary?.total_spending)}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3">
|
|
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Uncategorized</p>
|
|
<p className="text-2xl font-bold mt-1">{fmt(summary?.uncategorized_amount)}</p>
|
|
{summary?.uncategorized_count > 0 && (
|
|
<p className="text-xs text-muted-foreground">{summary.uncategorized_count} transaction{summary.uncategorized_count !== 1 ? 's' : ''}</p>
|
|
)}
|
|
</div>
|
|
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3 col-span-2 sm:col-span-1">
|
|
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Income Received</p>
|
|
<p className="text-2xl font-bold mt-1 text-emerald-500">{fmt(summary?.income)}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category breakdown */}
|
|
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
|
|
<div className="px-4 py-3 border-b border-border/40 flex items-center gap-2">
|
|
<TrendingDown className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">By Category</span>
|
|
{activeCat !== undefined && (
|
|
<button type="button" onClick={() => setActiveCat(undefined)}
|
|
className="ml-auto text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline">
|
|
Show all
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{catEntries.length === 0 && !uncatEntry ? (
|
|
<p className="text-sm text-muted-foreground text-center py-8">No spending transactions found for this month.</p>
|
|
) : (
|
|
<div className="divide-y divide-border/30">
|
|
{catEntries.map(cat => {
|
|
const bar = pctBar(cat.amount, cat.budget ?? budgets[cat.category_id]);
|
|
const isActive = activeCat === cat.category_id;
|
|
return (
|
|
<button
|
|
key={cat.category_id}
|
|
type="button"
|
|
onClick={() => selectCat(cat.category_id)}
|
|
className={`w-full text-left px-4 py-3 transition-colors ${isActive ? 'bg-primary/8' : 'hover:bg-muted/30'}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium truncate">{cat.category_name}</span>
|
|
<span className="text-sm font-mono font-semibold shrink-0">{fmt(cat.amount)}</span>
|
|
</div>
|
|
{bar && (
|
|
<div className="mt-1.5 h-1.5 w-full rounded-full bg-muted/50">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${bar.over ? 'bg-destructive' : 'bg-primary'}`}
|
|
style={{ width: `${bar.pct}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between mt-1">
|
|
<span className="text-[11px] text-muted-foreground">{cat.tx_count} transaction{cat.tx_count !== 1 ? 's' : ''}</span>
|
|
<span onClick={e => e.stopPropagation()}>
|
|
<BudgetEditor
|
|
categoryId={cat.category_id}
|
|
year={year} month={month}
|
|
initial={budgets[cat.category_id] ?? null}
|
|
onSaved={handleBudgetSaved}
|
|
/>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
|
|
{/* Uncategorized row */}
|
|
{uncatEntry && (
|
|
<button
|
|
type="button"
|
|
onClick={() => selectCat(null)}
|
|
className={`w-full text-left px-4 py-3 transition-colors ${activeCat === null ? 'bg-primary/8' : 'hover:bg-muted/30'}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
<Tag className="h-3.5 w-3.5" /> Uncategorized
|
|
</span>
|
|
<span className="text-sm font-mono font-semibold text-muted-foreground">{fmt(uncatEntry.amount)}</span>
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground mt-0.5">{uncatEntry.tx_count} transaction{uncatEntry.tx_count !== 1 ? 's' : ''}</p>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Transaction list */}
|
|
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
|
|
<div className="px-4 py-3 border-b border-border/40 flex items-center gap-2">
|
|
<ReceiptText className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">
|
|
Transactions
|
|
{activeCat !== undefined && (
|
|
<span className="ml-1.5 text-muted-foreground font-normal">
|
|
— {activeCat === null ? 'Uncategorized' : catEntries.find(c => c.category_id === activeCat)?.category_name}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<Badge variant="secondary" className="ml-auto text-xs">{txTotal}</Badge>
|
|
</div>
|
|
|
|
{txLoading ? (
|
|
<div className="py-10 text-center text-sm text-muted-foreground">Loading…</div>
|
|
) : transactions.length === 0 ? (
|
|
<div className="py-10 text-center text-sm text-muted-foreground">No transactions found.</div>
|
|
) : (
|
|
<>
|
|
<div>
|
|
{transactions.map(tx => (
|
|
<TxRow
|
|
key={tx.id}
|
|
tx={tx}
|
|
categories={categories}
|
|
onCategorize={handleCategorize}
|
|
/>
|
|
))}
|
|
</div>
|
|
{txPages > 1 && (
|
|
<div className="flex items-center justify-center gap-2 px-4 py-3 border-t border-border/30">
|
|
<Button variant="ghost" size="sm" disabled={txPage <= 1} onClick={() => loadTransactions(txPage - 1)}>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<span className="text-xs text-muted-foreground">Page {txPage} of {txPages}</span>
|
|
<Button variant="ghost" size="sm" disabled={txPage >= txPages} onClick={() => loadTransactions(txPage + 1)}>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|