BillTracker/client/pages/SpendingPage.jsx

684 lines
29 KiB
JavaScript

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, DollarSign } from 'lucide-react';
import { api } from '@/api';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
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 pctBar(amount, budget) {
if (!budget) return null;
const pct = Math.min(100, Math.round((amount / budget) * 100));
const over = amount > budget;
return { pct, over };
}
// ── 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 (
<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>
: <CategoryPicker categories={categories} current={tx.spending_category_id} onSelect={handleSelect} />
}
</div>
{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>
)}
</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>
);
}
// ── 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>
);
}
// ── 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 [copying, setCopying] = useState(false);
// loadCategories is stable — categories don't vary by month
const loadCategories = useCallback(async () => {
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) {
toast.error(err.message || 'Failed to load categories');
}
}, []);
// loadTransactions is exposed so pagination buttons can call it with a page arg
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]);
// Load categories once on mount
useEffect(() => { loadCategories(); }, [loadCategories]);
// Load summary and transactions whenever month/category filter changes.
// Depends on primitive values directly — avoids the double-fetch that
// happened when useCallback references were used as deps (both effects
// would fire whenever year/month changed).
useEffect(() => {
let cancelled = false;
const run = async () => {
setLoading(true);
try {
const d = await api.spendingSummary({ year, month });
if (cancelled) return;
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) {
if (!cancelled) toast.error(err.message || 'Failed to load spending summary');
} finally {
if (!cancelled) setLoading(false);
}
};
run();
loadTransactions(1);
return () => { cancelled = true; };
}, [year, month, activeCat]); // eslint-disable-line react-hooks/exhaustive-deps
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 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>
{/* 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>
)}
{/* 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>
<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'}
</button>
</div>
</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>
{/* Income & deposits */}
<IncomeSection year={year} month={month} totalIncome={summary?.income} />
{/* Merchant rules manager */}
<RulesManager categories={categories} />
</div>
</div>
);
}