2026-06-04 04:31:25 -05:00
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
|
|
|
import { toast } from 'sonner';
|
2026-06-14 19:21:34 -05:00
|
|
|
import {
|
|
|
|
|
ChevronLeft, ChevronRight, ChevronDown, Tag, ReceiptText, TrendingDown, Pencil, Check, X,
|
|
|
|
|
Trash2, BookmarkPlus, Settings2, Copy, AlertCircle, RefreshCw, ArrowRightLeft,
|
|
|
|
|
Layers, Wallet,
|
|
|
|
|
} from 'lucide-react';
|
2026-06-04 04:31:25 -05:00
|
|
|
import { api } from '@/api';
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Badge } from '@/components/ui/badge';
|
2026-06-14 19:21:34 -05:00
|
|
|
import { Skeleton } from '@/components/ui/Skeleton';
|
|
|
|
|
import {
|
|
|
|
|
DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuLabel,
|
|
|
|
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
|
|
|
|
} from '@/components/ui/dropdown-menu';
|
2026-06-14 15:15:31 -05:00
|
|
|
import { CategoryPicker } from '@/components/transactions/CategoryPicker';
|
2026-06-04 04:31:25 -05:00
|
|
|
|
|
|
|
|
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' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
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
|
2026-06-04 04:31:25 -05:00
|
|
|
function pctBar(amount, budget) {
|
2026-06-14 19:21:34 -05:00
|
|
|
if (budget == null) return null;
|
2026-06-04 04:31:25 -05:00
|
|
|
const pct = Math.min(100, Math.round((amount / budget) * 100));
|
2026-06-14 19:21:34 -05:00
|
|
|
const level = amount > budget ? 'over' : (budget > 0 && amount / budget >= 0.8) ? 'warn' : 'ok';
|
|
|
|
|
return { pct, level, over: level === 'over' };
|
2026-06-04 04:31:25 -05:00
|
|
|
}
|
|
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
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' };
|
|
|
|
|
|
2026-06-04 04:31:25 -05:00
|
|
|
// ── Transaction row ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function TxRow({ tx, categories, onCategorize }) {
|
2026-06-04 19:53:38 -05:00
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
const [rememberPrompt, setRememberPrompt] = useState(null); // { categoryId, categoryName }
|
|
|
|
|
const dismissTimer = useRef(null);
|
2026-06-04 04:31:25 -05:00
|
|
|
|
2026-06-04 19:53:38 -05:00
|
|
|
// 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) => {
|
2026-06-04 04:31:25 -05:00
|
|
|
setSaving(true);
|
2026-06-04 19:53:38 -05:00
|
|
|
setRememberPrompt(null);
|
2026-06-04 04:31:25 -05:00
|
|
|
try {
|
2026-06-04 19:53:38 -05:00
|
|
|
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 });
|
2026-06-04 04:31:25 -05:00
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Failed to categorize');
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-04 19:53:38 -05:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-04 04:31:25 -05:00
|
|
|
return (
|
2026-06-04 19:53:38 -05:00
|
|
|
<div className="border-b border-border/30 last:border-0">
|
|
|
|
|
<div className="flex items-center gap-3 px-4 py-2.5 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>
|
2026-06-14 19:21:34 -05:00
|
|
|
: <CategoryPicker categories={categories} current={tx.spending_category_id} currentLabel={tx.spending_category_name} onSelect={handleSelect} />
|
2026-06-04 19:53:38 -05:00
|
|
|
}
|
2026-06-04 04:31:25 -05:00
|
|
|
</div>
|
2026-06-04 19:53:38 -05:00
|
|
|
{rememberPrompt && (
|
|
|
|
|
<div className="mx-4 mb-2 flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-1.5">
|
|
|
|
|
<BookmarkPlus className="h-3.5 w-3.5 text-primary shrink-0" />
|
|
|
|
|
<span className="text-xs text-muted-foreground flex-1">
|
|
|
|
|
Always categorize <span className="font-medium text-foreground">{tx.payee}</span> as{' '}
|
|
|
|
|
<span className="font-medium text-foreground">{rememberPrompt.categoryName}</span>?
|
|
|
|
|
</span>
|
|
|
|
|
<button type="button" onClick={saveRule}
|
|
|
|
|
className="text-xs font-medium text-primary hover:text-primary/80 transition-colors">
|
|
|
|
|
Save rule
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" onClick={() => { clearTimeout(dismissTimer.current); setRememberPrompt(null); }}
|
|
|
|
|
className="text-muted-foreground hover:text-foreground">
|
|
|
|
|
<X className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-04 04:31:25 -05:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 19:53:38 -05:00
|
|
|
// ── 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 (
|
|
|
|
|
<div className="rounded-xl border border-emerald-500/25 bg-card/80 overflow-hidden">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setOpen(v => !v)}
|
|
|
|
|
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/20 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<TrendingDown className="h-4 w-4 text-emerald-500 rotate-180" />
|
|
|
|
|
<span className="text-sm font-medium flex-1">Income & Deposits</span>
|
|
|
|
|
<span className="text-sm font-mono font-semibold text-emerald-500">{fmt(totalIncome)}</span>
|
|
|
|
|
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ml-2 ${open ? 'rotate-180' : ''}`} />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{open && (
|
|
|
|
|
<div className="border-t border-emerald-500/20">
|
|
|
|
|
{loading ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground text-center py-6">Loading…</p>
|
|
|
|
|
) : rows.length === 0 ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground text-center py-6">No income transactions found.</p>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="divide-y divide-border/30 max-h-72 overflow-y-auto">
|
|
|
|
|
{rows.map(tx => (
|
|
|
|
|
<div key={tx.id} className="flex items-center gap-3 px-4 py-2.5 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-emerald-500 shrink-0">
|
|
|
|
|
+{fmt(tx.amount)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{pages > 1 && (
|
|
|
|
|
<div className="flex items-center justify-center gap-2 px-4 py-2.5 border-t border-border/30">
|
|
|
|
|
<Button variant="ghost" size="sm" disabled={page <= 1} onClick={() => load(page - 1)}>
|
|
|
|
|
<ChevronLeft className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
<span className="text-xs text-muted-foreground">{page} / {pages}</span>
|
|
|
|
|
<Button variant="ghost" size="sm" disabled={page >= pages} onClick={() => load(page + 1)}>
|
|
|
|
|
<ChevronRight className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
<p className="px-4 py-2 text-[11px] text-muted-foreground border-t border-border/30">
|
|
|
|
|
Positive unmatched transactions — deposits, refunds, transfers in. Bill-matched payments are excluded.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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 (
|
|
|
|
|
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setOpen(v => !v)}
|
|
|
|
|
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/20 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<span className="text-sm font-medium flex-1">Merchant Rules</span>
|
|
|
|
|
{rules.length > 0 && !open && (
|
|
|
|
|
<Badge variant="secondary" className="text-xs">{rules.length}</Badge>
|
|
|
|
|
)}
|
|
|
|
|
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{open && (
|
|
|
|
|
<div className="border-t border-border/40">
|
|
|
|
|
{/* Add new rule */}
|
|
|
|
|
<form onSubmit={addRule} className="flex items-center gap-2 px-4 py-3 border-b border-border/30">
|
|
|
|
|
<input
|
|
|
|
|
value={newMerchant}
|
|
|
|
|
onChange={e => 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"
|
|
|
|
|
/>
|
|
|
|
|
<select
|
|
|
|
|
value={newCategory}
|
|
|
|
|
onChange={e => setNewCategory(e.target.value)}
|
|
|
|
|
className="rounded-md border border-border/60 bg-background px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
|
|
|
|
>
|
|
|
|
|
<option value="">Category…</option>
|
|
|
|
|
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
<Button type="submit" size="sm" disabled={adding || !newMerchant.trim() || !newCategory}>
|
|
|
|
|
{adding ? 'Saving…' : 'Add'}
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
{/* Existing rules */}
|
|
|
|
|
{loading ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground text-center py-6">Loading…</p>
|
|
|
|
|
) : rules.length === 0 ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground text-center py-6">
|
|
|
|
|
No rules saved yet. Categorize a transaction and click "Save rule" to create one.
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="divide-y divide-border/30 max-h-64 overflow-y-auto">
|
|
|
|
|
{rules.map(r => (
|
|
|
|
|
<div key={r.id} className="flex items-center gap-3 px-4 py-2.5">
|
|
|
|
|
<span className="font-mono text-xs text-muted-foreground flex-1 truncate">{r.merchant}</span>
|
|
|
|
|
<span className="text-xs shrink-0">→</span>
|
|
|
|
|
<span className="text-xs font-medium shrink-0">{r.category_name}</span>
|
|
|
|
|
<button type="button" onClick={() => deleteRule(r.id)}
|
|
|
|
|
className="ml-1 text-muted-foreground hover:text-destructive transition-colors shrink-0">
|
|
|
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
// ── Spending settings menu ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function SpendingSettingsMenu({ settings, onToggle, saving }) {
|
|
|
|
|
return (
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<Button type="button" size="sm" variant="outline" className="h-8 gap-1.5 px-2.5 text-xs" disabled={saving}>
|
|
|
|
|
<Settings2 className="h-3.5 w-3.5" />
|
|
|
|
|
<span className="hidden sm:inline">View</span>
|
|
|
|
|
</Button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end" className="w-60">
|
|
|
|
|
<DropdownMenuLabel>Spending page options</DropdownMenuLabel>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
<DropdownMenuCheckboxItem
|
|
|
|
|
checked={settingEnabled(settings.spending_show_ready_to_assign)}
|
|
|
|
|
onSelect={e => e.preventDefault()}
|
|
|
|
|
onCheckedChange={checked => onToggle('spending_show_ready_to_assign', checked)}
|
|
|
|
|
>
|
|
|
|
|
Ready to Assign
|
|
|
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
|
<DropdownMenuCheckboxItem
|
|
|
|
|
checked={settingEnabled(settings.spending_show_avg)}
|
|
|
|
|
onSelect={e => e.preventDefault()}
|
|
|
|
|
onCheckedChange={checked => onToggle('spending_show_avg', checked)}
|
|
|
|
|
>
|
|
|
|
|
3-month averages
|
|
|
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
|
<DropdownMenuCheckboxItem
|
|
|
|
|
checked={settingEnabled(settings.spending_show_cover_overspend)}
|
|
|
|
|
onSelect={e => e.preventDefault()}
|
|
|
|
|
onCheckedChange={checked => onToggle('spending_show_cover_overspend', checked)}
|
|
|
|
|
>
|
|
|
|
|
Cover overspending
|
|
|
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
|
<DropdownMenuCheckboxItem
|
|
|
|
|
checked={settingEnabled(settings.spending_group_categories, false)}
|
|
|
|
|
onSelect={e => e.preventDefault()}
|
|
|
|
|
onCheckedChange={checked => onToggle('spending_group_categories', checked)}
|
|
|
|
|
>
|
|
|
|
|
Group categories
|
|
|
|
|
</DropdownMenuCheckboxItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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 <span className="text-[11px] text-destructive/70">No category has budget left to cover this</span>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setOpen(v => !v)}
|
|
|
|
|
disabled={saving}
|
|
|
|
|
className="flex items-center gap-1 text-[11px] font-medium text-destructive hover:text-destructive/80 transition-colors disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<ArrowRightLeft className="h-3 w-3" /> Cover from…
|
|
|
|
|
</button>
|
|
|
|
|
{open && (
|
|
|
|
|
<div className="absolute z-10 right-0 mt-1 w-52 rounded-md border border-border/60 bg-popover shadow-lg py-1">
|
|
|
|
|
{options.map(s => (
|
|
|
|
|
<button
|
|
|
|
|
key={s.category_id}
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => cover(s)}
|
|
|
|
|
className="w-full flex items-center justify-between gap-2 px-2.5 py-1.5 text-xs text-left hover:bg-muted/40 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<span className="truncate">{s.category_name}</span>
|
|
|
|
|
<span className="text-muted-foreground font-mono shrink-0">{fmt(s.available)} avail</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 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 (
|
|
|
|
|
<div className="rounded-xl border border-border/60 bg-card/80 overflow-hidden">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setOpen(v => !v)}
|
|
|
|
|
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/20 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Layers className="h-4 w-4 text-muted-foreground" />
|
|
|
|
|
<span className="text-sm font-medium flex-1">Category Groups</span>
|
|
|
|
|
{groups.length > 0 && !open && (
|
|
|
|
|
<Badge variant="secondary" className="text-xs">{groups.length}</Badge>
|
|
|
|
|
)}
|
|
|
|
|
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{open && (
|
|
|
|
|
<div className="border-t border-border/40">
|
|
|
|
|
<form onSubmit={addGroup} className="flex items-center gap-2 px-4 py-3 border-b border-border/30">
|
|
|
|
|
<input
|
|
|
|
|
value={newGroupName}
|
|
|
|
|
onChange={e => 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"
|
|
|
|
|
/>
|
|
|
|
|
<Button type="submit" size="sm" disabled={adding || !newGroupName.trim()}>
|
|
|
|
|
{adding ? 'Adding…' : 'Add group'}
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
{groups.length === 0 ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground text-center py-6">
|
|
|
|
|
No groups yet. Create one above, then assign categories to it below.
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="divide-y divide-border/30">
|
|
|
|
|
{groups.map(g => (
|
|
|
|
|
<div key={g.id} className="flex items-center gap-2 px-4 py-2">
|
|
|
|
|
{editingId === g.id ? (
|
|
|
|
|
<>
|
|
|
|
|
<input
|
|
|
|
|
value={editName}
|
|
|
|
|
onChange={e => 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"
|
|
|
|
|
/>
|
|
|
|
|
<button type="button" onClick={() => renameGroup(g.id)} className="text-emerald-500 hover:text-emerald-400"><Check className="h-3.5 w-3.5" /></button>
|
|
|
|
|
<button type="button" onClick={() => setEditingId(null)} className="text-muted-foreground hover:text-foreground"><X className="h-3.5 w-3.5" /></button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<span className="text-sm font-medium flex-1 truncate">{g.name}</span>
|
|
|
|
|
<button type="button" onClick={() => { setEditingId(g.id); setEditName(g.name); }} className="text-muted-foreground hover:text-foreground"><Pencil className="h-3.5 w-3.5" /></button>
|
|
|
|
|
<button type="button" onClick={() => deleteGroup(g.id)} className="text-muted-foreground hover:text-destructive"><Trash2 className="h-3.5 w-3.5" /></button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{categories.length > 0 && (
|
|
|
|
|
<div className="border-t border-border/30 px-4 py-3 space-y-2">
|
|
|
|
|
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Assign categories</p>
|
|
|
|
|
{categories.map(c => (
|
|
|
|
|
<div key={c.id} className="flex items-center justify-between gap-2">
|
|
|
|
|
<span className="text-sm truncate">{c.name}</span>
|
|
|
|
|
<select
|
|
|
|
|
value={c.group_id ?? ''}
|
|
|
|
|
onChange={e => setCategoryGroup(c, e.target.value)}
|
|
|
|
|
className="rounded-md border border-border/60 bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
|
|
|
|
>
|
|
|
|
|
<option value="">No group</option>
|
|
|
|
|
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 04:31:25 -05:00
|
|
|
// ── 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([]);
|
2026-06-14 19:21:34 -05:00
|
|
|
const [categoryGroups, setCategoryGroups] = useState([]);
|
2026-06-04 04:31:25 -05:00
|
|
|
const [activeCat, setActiveCat] = useState(undefined); // undefined = all
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [txLoading, setTxLoading] = useState(false);
|
|
|
|
|
const [budgets, setBudgets] = useState({}); // categoryId → amount
|
2026-06-04 21:57:42 -05:00
|
|
|
const [copying, setCopying] = useState(false);
|
2026-06-14 19:21:34 -05:00
|
|
|
const [spendingSettings, setSpendingSettings] = useState({});
|
|
|
|
|
const [savingSpendingSetting, setSavingSpendingSetting] = useState(false);
|
|
|
|
|
const [summaryError, setSummaryError] = useState(null);
|
|
|
|
|
const [txError, setTxError] = useState(null);
|
|
|
|
|
const [catError, setCatError] = useState(null);
|
2026-06-04 04:31:25 -05:00
|
|
|
|
2026-06-04 21:00:59 -05:00
|
|
|
// loadCategories is stable — categories don't vary by month
|
2026-06-04 04:31:25 -05:00
|
|
|
const loadCategories = useCallback(async () => {
|
2026-06-14 19:21:34 -05:00
|
|
|
setCatError(null);
|
2026-06-04 04:31:25 -05:00
|
|
|
try {
|
|
|
|
|
const d = await api.categories();
|
2026-06-04 20:01:51 -05:00
|
|
|
// Only show spending-enabled categories in the spending UI
|
|
|
|
|
setCategories((d.categories || d || []).filter(c => !c.deleted_at && c.spending_enabled));
|
2026-06-04 19:53:38 -05:00
|
|
|
} catch (err) {
|
2026-06-14 19:21:34 -05:00
|
|
|
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
|
2026-06-04 19:53:38 -05:00
|
|
|
}
|
2026-06-04 04:31:25 -05:00
|
|
|
}, []);
|
|
|
|
|
|
2026-06-04 21:00:59 -05:00
|
|
|
// loadTransactions is exposed so pagination buttons can call it with a page arg
|
2026-06-04 04:31:25 -05:00
|
|
|
const loadTransactions = useCallback(async (page = 1) => {
|
|
|
|
|
setTxLoading(true);
|
2026-06-14 19:21:34 -05:00
|
|
|
setTxError(null);
|
2026-06-04 04:31:25 -05:00
|
|
|
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) {
|
2026-06-14 19:21:34 -05:00
|
|
|
setTxError(err.message || 'Failed to load transactions');
|
2026-06-04 04:31:25 -05:00
|
|
|
} finally {
|
|
|
|
|
setTxLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [year, month, activeCat]);
|
|
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
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(() => {});
|
|
|
|
|
}, []);
|
2026-06-04 21:00:59 -05:00
|
|
|
|
|
|
|
|
// Load summary and transactions whenever month/category filter changes.
|
|
|
|
|
useEffect(() => {
|
2026-06-14 19:21:34 -05:00
|
|
|
loadSummary();
|
2026-06-04 21:00:59 -05:00
|
|
|
loadTransactions(1);
|
|
|
|
|
}, [year, month, activeCat]); // eslint-disable-line react-hooks/exhaustive-deps
|
2026-06-04 04:31:25 -05:00
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 21:57:42 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-04 04:31:25 -05:00
|
|
|
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
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
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();
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-04 04:31:25 -05:00
|
|
|
const selectCat = (catId) => {
|
|
|
|
|
setActiveCat(prev => prev === catId ? undefined : catId);
|
|
|
|
|
setTxPage(1);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
if (loading && !summary) {
|
2026-06-04 04:31:25 -05:00
|
|
|
return (
|
2026-06-14 19:21:34 -05:00
|
|
|
<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">
|
|
|
|
|
<div className="flex items-center justify-between gap-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Skeleton variant="line" className="h-6 w-28" />
|
|
|
|
|
<Skeleton variant="line" className="h-4 w-56" />
|
|
|
|
|
</div>
|
|
|
|
|
<Skeleton variant="button" className="w-32" />
|
|
|
|
|
</div>
|
|
|
|
|
<Skeleton variant="card" className="h-28" />
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
|
|
|
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} variant="card" className="h-20" />)}
|
|
|
|
|
</div>
|
|
|
|
|
<Skeleton variant="card" className="h-72" />
|
|
|
|
|
<Skeleton variant="card" className="h-64" />
|
|
|
|
|
</div>
|
2026-06-04 04:31:25 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
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 (
|
|
|
|
|
<div key={cat.category_id} className={`transition-colors ${isActive ? 'bg-primary/8' : 'hover:bg-muted/30'}`}>
|
|
|
|
|
<button type="button" onClick={() => selectCat(cat.category_id)} className="w-full text-left px-4 pt-3">
|
|
|
|
|
<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 ${LEVEL_BAR_CLASS[level]}`} style={{ width: `${bar.pct}%` }} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex items-center justify-between mt-1 gap-2">
|
|
|
|
|
<span className="text-[11px] text-muted-foreground">
|
|
|
|
|
{cat.tx_count} transaction{cat.tx_count !== 1 ? 's' : ''}
|
|
|
|
|
{showAvg && cat.avg_3mo > 0 && (
|
|
|
|
|
<span className="ml-2 text-muted-foreground/70">avg {fmt(cat.avg_3mo)}/mo</span>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
{available != null && (
|
|
|
|
|
<span className={`text-[11px] font-medium ${LEVEL_TEXT_CLASS[level]}`}>
|
|
|
|
|
{available < 0 ? `${fmt(Math.abs(available))} over` : `${fmt(available)} left`}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
<div className="flex items-center justify-between gap-2 px-4 pb-3 pt-1.5">
|
|
|
|
|
<BudgetEditor
|
|
|
|
|
categoryId={cat.category_id}
|
|
|
|
|
year={year} month={month}
|
|
|
|
|
initial={budgets[cat.category_id] ?? null}
|
|
|
|
|
onSaved={handleBudgetSaved}
|
|
|
|
|
/>
|
|
|
|
|
{showCover && level === 'over' && (
|
|
|
|
|
<CoverOverspendPicker cat={cat} siblings={realCatEntries} budgets={budgets} year={year} month={month} onCovered={handleBudgetsCovered} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderUncatRow = () => (
|
|
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const renderOtherRow = () => (
|
|
|
|
|
<div
|
|
|
|
|
title="Categorized outside Spending — enable Spending on this category to manage it here."
|
|
|
|
|
className="px-4 py-3"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<span className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
|
|
|
|
<Layers className="h-3.5 w-3.5" /> Other categories
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-sm font-mono font-semibold text-muted-foreground">{fmt(otherEntry.amount)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-[11px] text-muted-foreground mt-0.5">{otherEntry.tx_count} transaction{otherEntry.tx_count !== 1 ? 's' : ''}</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const activeCatLabel = activeCat === null
|
|
|
|
|
? 'Uncategorized'
|
|
|
|
|
: activeCat !== undefined
|
|
|
|
|
? realCatEntries.find(c => c.category_id === activeCat)?.category_name
|
|
|
|
|
: null;
|
2026-06-04 04:31:25 -05:00
|
|
|
|
|
|
|
|
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>
|
2026-06-14 19:21:34 -05:00
|
|
|
<SpendingSettingsMenu settings={spendingSettings} onToggle={(key, checked) => saveSpendingSetting({ [key]: checked ? 'true' : 'false' })} saving={savingSpendingSetting} />
|
2026-06-04 04:31:25 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
{/* Summary load error */}
|
|
|
|
|
{summaryError && (
|
|
|
|
|
<div className="flex items-center gap-3 rounded-xl border border-destructive/25 bg-destructive/10 px-4 py-3">
|
|
|
|
|
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
|
|
|
|
<span className="text-sm text-destructive flex-1">{summaryError}</span>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={loadSummary} className="h-7 gap-1.5 text-xs">
|
|
|
|
|
<RefreshCw className="h-3.5 w-3.5" /> Retry
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Ready to Assign */}
|
|
|
|
|
{showReadyToAssign && (
|
|
|
|
|
<div className={`rounded-xl border px-4 py-4 ${readyToAssign >= 0 ? 'border-emerald-500/25 bg-emerald-500/5' : 'border-amber-500/25 bg-amber-500/10'}`}>
|
|
|
|
|
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Ready to Assign</p>
|
|
|
|
|
<p className={`text-3xl font-bold mt-1 ${readyToAssign >= 0 ? 'text-emerald-500' : 'text-amber-500'}`}>
|
|
|
|
|
{fmt(Math.abs(readyToAssign))}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
|
|
|
{readyToAssign >= 0 ? 'left to budget this month' : 'over-assigned — budgets exceed income'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-04 04:31:25 -05:00
|
|
|
{/* 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">
|
2026-06-14 19:21:34 -05:00
|
|
|
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Budgeted</p>
|
|
|
|
|
<p className="text-2xl font-bold mt-1">{fmt(totalBudgeted)}</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">Spent</p>
|
|
|
|
|
<p className="text-2xl font-bold mt-1">{fmt(totalSpending)}</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">Remaining</p>
|
|
|
|
|
<p className={`text-2xl font-bold mt-1 ${remaining < 0 ? 'text-destructive' : 'text-emerald-500'}`}>{fmt(remaining)}</p>
|
2026-06-04 04:31:25 -05:00
|
|
|
</div>
|
|
|
|
|
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3">
|
2026-06-14 19:21:34 -05:00
|
|
|
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wide">Income</p>
|
|
|
|
|
<p className="text-2xl font-bold mt-1 text-emerald-500">{fmt(income)}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3 col-span-2 sm:col-span-1">
|
2026-06-04 04:31:25 -05:00
|
|
|
<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>
|
|
|
|
|
|
2026-06-04 20:01:51 -05:00
|
|
|
{/* No spending categories notice */}
|
|
|
|
|
{categories.length === 0 && (
|
|
|
|
|
<div className="flex items-start gap-3 rounded-xl border border-amber-500/25 bg-amber-500/10 px-4 py-3">
|
|
|
|
|
<Tag className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
|
|
|
|
<div className="text-sm text-amber-600 dark:text-amber-400">
|
|
|
|
|
No spending categories are enabled yet. Go to{' '}
|
|
|
|
|
<a href="/categories" className="underline underline-offset-2 font-medium">Categories</a>
|
|
|
|
|
{' '}and enable "Spending" on the categories you want to use here.
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-14 19:21:34 -05:00
|
|
|
{catError && (
|
|
|
|
|
<div className="flex items-center gap-3 rounded-xl border border-destructive/25 bg-destructive/10 px-4 py-3">
|
|
|
|
|
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
|
|
|
|
<span className="text-sm text-destructive flex-1">{catError}</span>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={loadCategories} className="h-7 gap-1.5 text-xs">
|
|
|
|
|
<RefreshCw className="h-3.5 w-3.5" /> Retry
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-06-04 20:01:51 -05:00
|
|
|
|
2026-06-04 04:31:25 -05:00
|
|
|
{/* 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>
|
2026-06-04 21:57:42 -05:00
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
|
|
|
{activeCat !== undefined && (
|
|
|
|
|
<button type="button" onClick={() => setActiveCat(undefined)}
|
|
|
|
|
className="text-xs text-muted-foreground hover:text-foreground underline-offset-4 hover:underline">
|
|
|
|
|
Show all
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handleCopyBudgets}
|
|
|
|
|
disabled={copying}
|
|
|
|
|
title="Copy budgets from last month"
|
|
|
|
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-40"
|
|
|
|
|
>
|
|
|
|
|
<Copy className="h-3.5 w-3.5" />
|
|
|
|
|
{copying ? 'Copying…' : 'Copy last month'}
|
2026-06-04 04:31:25 -05:00
|
|
|
</button>
|
2026-06-04 21:57:42 -05:00
|
|
|
</div>
|
2026-06-04 04:31:25 -05:00
|
|
|
</div>
|
|
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
{realCatEntries.length === 0 && !uncatEntry && !otherEntry ? (
|
|
|
|
|
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
|
|
|
|
<Wallet className="h-8 w-8 text-muted-foreground/50" />
|
|
|
|
|
<p className="text-sm text-muted-foreground">No spending found for this month yet.</p>
|
|
|
|
|
<p className="text-xs text-muted-foreground/70">Set a budget or categorize transactions below to see them here.</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : groupedEntries ? (
|
2026-06-04 04:31:25 -05:00
|
|
|
<div className="divide-y divide-border/30">
|
2026-06-14 19:21:34 -05:00
|
|
|
{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);
|
2026-06-04 04:31:25 -05:00
|
|
|
return (
|
2026-06-14 19:21:34 -05:00
|
|
|
<div key={group.id}>
|
|
|
|
|
<div className="px-4 py-2 bg-muted/20 flex items-center justify-between">
|
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{group.name}</span>
|
|
|
|
|
<span className="text-xs font-mono text-muted-foreground">{fmt(subtotalAmount)} / {fmt(subtotalBudget)}</span>
|
2026-06-04 04:31:25 -05:00
|
|
|
</div>
|
2026-06-14 19:21:34 -05:00
|
|
|
<div className="divide-y divide-border/30">
|
|
|
|
|
{entries.map(renderCategoryRow)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-06-04 04:31:25 -05:00
|
|
|
);
|
|
|
|
|
})}
|
2026-06-14 19:21:34 -05:00
|
|
|
{(uncatEntry || otherEntry) && (
|
|
|
|
|
<div className="divide-y divide-border/30">
|
|
|
|
|
{uncatEntry && renderUncatRow()}
|
|
|
|
|
{otherEntry && renderOtherRow()}
|
|
|
|
|
</div>
|
2026-06-04 04:31:25 -05:00
|
|
|
)}
|
|
|
|
|
</div>
|
2026-06-14 19:21:34 -05:00
|
|
|
) : (
|
|
|
|
|
<div className="divide-y divide-border/30">
|
|
|
|
|
{realCatEntries.map(renderCategoryRow)}
|
|
|
|
|
{uncatEntry && renderUncatRow()}
|
|
|
|
|
{otherEntry && renderOtherRow()}
|
|
|
|
|
</div>
|
2026-06-04 04:31:25 -05:00
|
|
|
)}
|
|
|
|
|
</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
|
2026-06-14 19:21:34 -05:00
|
|
|
{activeCatLabel && (
|
2026-06-04 04:31:25 -05:00
|
|
|
<span className="ml-1.5 text-muted-foreground font-normal">
|
2026-06-14 19:21:34 -05:00
|
|
|
— {activeCatLabel}
|
2026-06-04 04:31:25 -05:00
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
<Badge variant="secondary" className="ml-auto text-xs">{txTotal}</Badge>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
{txError ? (
|
|
|
|
|
<div className="flex items-center gap-3 px-4 py-6">
|
|
|
|
|
<AlertCircle className="h-4 w-4 text-destructive shrink-0" />
|
|
|
|
|
<span className="text-sm text-destructive flex-1">{txError}</span>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={() => loadTransactions(txPage)} className="h-7 gap-1.5 text-xs">
|
|
|
|
|
<RefreshCw className="h-3.5 w-3.5" /> Retry
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : txLoading ? (
|
|
|
|
|
<div className="divide-y divide-border/30">
|
|
|
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
|
|
|
<div key={i} className="flex items-center gap-3 px-4 py-2.5">
|
|
|
|
|
<div className="min-w-0 flex-1 space-y-1.5">
|
|
|
|
|
<Skeleton variant="line" className="h-3.5 w-2/3" />
|
|
|
|
|
<Skeleton variant="line" className="h-3 w-1/4" />
|
|
|
|
|
</div>
|
|
|
|
|
<Skeleton variant="line" className="h-3.5 w-16" />
|
|
|
|
|
<Skeleton variant="line" className="h-7 w-28" />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-06-04 04:31:25 -05:00
|
|
|
) : transactions.length === 0 ? (
|
2026-06-14 19:21:34 -05:00
|
|
|
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
|
|
|
|
<ReceiptText className="h-8 w-8 text-muted-foreground/50" />
|
|
|
|
|
<p className="text-sm text-muted-foreground">No transactions found{activeCatLabel ? ` for ${activeCatLabel}` : ''}.</p>
|
|
|
|
|
</div>
|
2026-06-04 04:31:25 -05:00
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<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>
|
|
|
|
|
|
2026-06-04 19:53:38 -05:00
|
|
|
{/* Income & deposits */}
|
|
|
|
|
<IncomeSection year={year} month={month} totalIncome={summary?.income} />
|
|
|
|
|
|
2026-06-14 19:21:34 -05:00
|
|
|
{/* Category groups management */}
|
|
|
|
|
{showGroups && (
|
|
|
|
|
<CategoryGroupManager
|
|
|
|
|
categories={categories}
|
|
|
|
|
groups={categoryGroups}
|
|
|
|
|
onGroupsChanged={refreshAfterGroupChange}
|
|
|
|
|
onCategoriesChanged={refreshAfterCategoryChange}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-06-04 19:53:38 -05:00
|
|
|
{/* Merchant rules manager */}
|
|
|
|
|
<RulesManager categories={categories} />
|
|
|
|
|
|
2026-06-04 04:31:25 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|