refactor(ts): SpendingPage → TypeScript

YNAB-style spending view typed: CatEntry (category_id number|'other'|
null) with a RealCatEntry narrowing for the budget-indexed paths,
SpendingSummary/SpendingTx/IncomeTx/SpendingRule/CategoryGroup local
types, budgets as Record<string,number|null>, React Query
setQueryData<T> for optimistic budget/categorize edits, cast-at-boundary
for api.* results. Dropped a pre-existing dead 'total' state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-04 23:29:39 -05:00
parent d49a2ae4dc
commit 3b1e7e4da9
1 changed files with 177 additions and 99 deletions

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef, type FormEvent } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { import {
useSpendingSummary, useSpendingTransactions, useSpendingCategories, useCategoryGroups, useSpendingSummary, useSpendingTransactions, useSpendingCategories, useCategoryGroups,
@ -18,39 +18,86 @@ import {
DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { CategoryPicker } from '@/components/transactions/CategoryPicker'; import { CategoryPicker } from '@/components/transactions/CategoryPicker';
import { formatUSD } from '@/lib/money'; import { formatUSD, asDollars } from '@/lib/money';
import { errMessage } from '@/lib/utils';
import type { Category } from '@/types';
const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const MONTH_NAMES = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmt(n) { function fmt(v: number | null | undefined): string {
return formatUSD(n); return formatUSD(asDollars(Number(v) || 0));
} }
function settingEnabled(value, fallback = true) { function settingEnabled(value: unknown, fallback = true): boolean {
if (value === undefined || value === null || value === '') return fallback; if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true'; return value === true || value === 'true';
} }
type Level = 'over' | 'warn' | 'ok';
interface CatEntry {
category_id: number | 'other' | null;
category_name?: string;
amount: number;
budget?: number | null;
tx_count?: number;
avg_3mo?: number;
group_id?: number | null;
}
type RealCatEntry = CatEntry & { category_id: number };
interface SpendingSummary {
by_category?: CatEntry[];
total_spending?: number;
income?: number;
uncategorized_amount?: number;
uncategorized_count?: number;
}
interface SpendingTx {
id: number;
payee?: string | null;
date?: string | null;
amount: number;
spending_category_id?: number | string | null;
spending_category_name?: string | null;
[key: string]: unknown;
}
interface SpendingTxResponse { transactions?: SpendingTx[]; total?: number; pages?: number }
interface IncomeTx { id: number; payee?: string | null; date?: string | null; amount: number }
interface IncomeResponse { transactions?: IncomeTx[]; total?: number; pages?: number }
interface SpendingRule { id: number; merchant: string; category_name?: string; category_id?: number }
interface CategoryGroup { id: number; name: string }
interface CopyBudgetsResponse { copied: number; budgets?: { category_id: number; amount: number }[] }
type Budgets = Record<string, number | null>;
// pctBar() returns a progress-bar percent plus a YNAB-style status level: // pctBar() returns a progress-bar percent plus a YNAB-style status level:
// - 'over': spending has exceeded the budget (Available < 0) // - 'over': spending has exceeded the budget (Available < 0)
// - 'warn': spending has crossed ~80% of the budget // - 'warn': spending has crossed ~80% of the budget
// - 'ok': on track // - 'ok': on track
function pctBar(amount, budget) { function pctBar(amount: number, budget: number | null | undefined): { pct: number; level: Level; over: boolean } | null {
if (budget == null) return null; if (budget == null) return null;
const pct = Math.min(100, Math.round((amount / budget) * 100)); const pct = Math.min(100, Math.round((amount / budget) * 100));
const level = amount > budget ? 'over' : (budget > 0 && amount / budget >= 0.8) ? 'warn' : 'ok'; const level: Level = amount > budget ? 'over' : (budget > 0 && amount / budget >= 0.8) ? 'warn' : 'ok';
return { pct, level, over: level === 'over' }; return { pct, level, over: level === 'over' };
} }
const LEVEL_BAR_CLASS = { over: 'bg-destructive', warn: 'bg-amber-500', ok: 'bg-primary' }; const LEVEL_BAR_CLASS: Record<Level, string> = { 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' }; const LEVEL_TEXT_CLASS: Record<Level, string> = { over: 'text-destructive', warn: 'text-amber-500', ok: 'text-emerald-500' };
// ── Transaction row ────────────────────────────────────────────────────────── // ── Transaction row ──────────────────────────────────────────────────────────
function TxRow({ tx, categories, onCategorize }) { function TxRow({ tx, categories, onCategorize }: {
tx: SpendingTx;
categories: Category[];
onCategorize: (txId: number, categoryId: number | string | null, categoryName: string | null) => void;
}) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [rememberPrompt, setRememberPrompt] = useState(null); // { categoryId, categoryName } const [rememberPrompt, setRememberPrompt] = useState<{ categoryId: number | string | null; categoryName: string | null } | null>(null);
const dismissTimer = useRef(null); const dismissTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Auto-dismiss the "remember" prompt after 7 seconds // Auto-dismiss the "remember" prompt after 7 seconds
useEffect(() => { useEffect(() => {
@ -59,7 +106,7 @@ function TxRow({ tx, categories, onCategorize }) {
return () => clearTimeout(dismissTimer.current); return () => clearTimeout(dismissTimer.current);
}, [rememberPrompt]); }, [rememberPrompt]);
const handleSelect = async (categoryId) => { const handleSelect = async (categoryId: number | string | null) => {
setSaving(true); setSaving(true);
setRememberPrompt(null); setRememberPrompt(null);
try { try {
@ -69,7 +116,7 @@ function TxRow({ tx, categories, onCategorize }) {
// Offer to remember the merchant rule (only when assigning a real category) // Offer to remember the merchant rule (only when assigning a real category)
if (categoryId) setRememberPrompt({ categoryId, categoryName: catName }); if (categoryId) setRememberPrompt({ categoryId, categoryName: catName });
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to categorize'); toast.error(errMessage(err, 'Failed to categorize'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
@ -83,7 +130,7 @@ function TxRow({ tx, categories, onCategorize }) {
await api.categorizeTransaction(tx.id, { category_id: rememberPrompt.categoryId, save_rule: true }); await api.categorizeTransaction(tx.id, { category_id: rememberPrompt.categoryId, save_rule: true });
toast.success(`Rule saved — future ${tx.payee} transactions will be auto-categorized.`); toast.success(`Rule saved — future ${tx.payee} transactions will be auto-categorized.`);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to save rule'); toast.error(errMessage(err, 'Failed to save rule'));
} }
}; };
@ -125,13 +172,19 @@ function TxRow({ tx, categories, onCategorize }) {
// ── Budget edit inline ─────────────────────────────────────────────────────── // ── Budget edit inline ───────────────────────────────────────────────────────
function BudgetEditor({ categoryId, year, month, initial, onSaved }) { function BudgetEditor({ categoryId, year, month, initial, onSaved }: {
categoryId: number;
year: number;
month: number;
initial: number | null;
onSaved: (categoryId: number, amount: number | null) => void;
}) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [val, setVal] = useState(initial ?? ''); const [val, setVal] = useState<string>(initial != null ? String(initial) : '');
const save = async () => { const save = async () => {
const amount = val === '' ? null : parseFloat(val); const amount = val === '' ? null : parseFloat(val);
if (val !== '' && (isNaN(amount) || amount < 0)) { toast.error('Enter a valid amount'); return; } if (val !== '' && (amount == null || isNaN(amount) || amount < 0)) { toast.error('Enter a valid amount'); return; }
try { try {
await api.setSpendingBudget({ category_id: categoryId, year, month, amount }); await api.setSpendingBudget({ category_id: categoryId, year, month, amount });
onSaved(categoryId, amount); onSaved(categoryId, amount);
@ -169,10 +222,9 @@ function BudgetEditor({ categoryId, year, month, initial, onSaved }) {
// ── Income section ─────────────────────────────────────────────────────────── // ── Income section ───────────────────────────────────────────────────────────
function IncomeSection({ year, month, totalIncome }) { function IncomeSection({ year, month, totalIncome }: { year: number; month: number; totalIncome?: number }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [rows, setRows] = useState([]); const [rows, setRows] = useState<IncomeTx[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pages, setPages] = useState(1); const [pages, setPages] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -180,13 +232,12 @@ function IncomeSection({ year, month, totalIncome }) {
const load = useCallback(async (p = 1) => { const load = useCallback(async (p = 1) => {
setLoading(true); setLoading(true);
try { try {
const d = await api.spendingIncome({ year, month, page: p }); const d = await api.spendingIncome({ year, month, page: p }) as IncomeResponse;
setRows(d.transactions || []); setRows(d.transactions || []);
setTotal(d.total || 0);
setPages(d.pages || 1); setPages(d.pages || 1);
setPage(p); setPage(p);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to load income transactions'); toast.error(errMessage(err, 'Failed to load income transactions'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -254,9 +305,9 @@ function IncomeSection({ year, month, totalIncome }) {
// ── Rules manager ──────────────────────────────────────────────────────────── // ── Rules manager ────────────────────────────────────────────────────────────
function RulesManager({ categories }) { function RulesManager({ categories }: { categories: Category[] }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [rules, setRules] = useState([]); const [rules, setRules] = useState<SpendingRule[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [newMerchant, setNewMerchant] = useState(''); const [newMerchant, setNewMerchant] = useState('');
const [newCategory, setNewCategory] = useState(''); const [newCategory, setNewCategory] = useState('');
@ -264,21 +315,21 @@ function RulesManager({ categories }) {
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { setRules((await api.spendingCategoryRules()).rules || []); } try { setRules(((await api.spendingCategoryRules()) as { rules?: SpendingRule[] }).rules || []); }
catch { toast.error('Failed to load rules'); } catch { toast.error('Failed to load rules'); }
finally { setLoading(false); } finally { setLoading(false); }
}, []); }, []);
useEffect(() => { if (open) load(); }, [open, load]); useEffect(() => { if (open) load(); }, [open, load]);
const deleteRule = async (id) => { const deleteRule = async (id: number) => {
try { try {
await api.deleteSpendingRule(id); await api.deleteSpendingRule(id);
setRules(prev => prev.filter(r => r.id !== id)); setRules(prev => prev.filter(r => r.id !== id));
} catch { toast.error('Failed to delete rule'); } } catch { toast.error('Failed to delete rule'); }
}; };
const addRule = async (e) => { const addRule = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newMerchant.trim() || !newCategory) return; if (!newMerchant.trim() || !newCategory) return;
setAdding(true); setAdding(true);
@ -288,7 +339,7 @@ function RulesManager({ categories }) {
await load(); await load();
toast.success('Rule saved.'); toast.success('Rule saved.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to save rule'); toast.error(errMessage(err, 'Failed to save rule'));
} finally { setAdding(false); } } finally { setAdding(false); }
}; };
@ -360,7 +411,11 @@ function RulesManager({ categories }) {
// ── Spending settings menu ─────────────────────────────────────────────────── // ── Spending settings menu ───────────────────────────────────────────────────
function SpendingSettingsMenu({ settings, onToggle, saving }) { function SpendingSettingsMenu({ settings, onToggle, saving }: {
settings: Record<string, unknown>;
onToggle: (key: string, checked: boolean) => void;
saving: boolean;
}) {
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -407,20 +462,26 @@ function SpendingSettingsMenu({ settings, onToggle, saving }) {
// ── Cover overspending ─────────────────────────────────────────────────────── // ── Cover overspending ───────────────────────────────────────────────────────
function CoverOverspendPicker({ cat, siblings, budgets, year, month, onCovered }) { function CoverOverspendPicker({ cat, siblings, budgets, year, month, onCovered }: {
cat: RealCatEntry;
siblings: RealCatEntry[];
budgets: Budgets;
year: number;
month: number;
onCovered: (updates: Record<string, number>) => void;
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const catBudget = budgets[cat.category_id] ?? cat.budget ?? 0; const catBudget = budgets[cat.category_id] ?? cat.budget ?? 0;
const overspend = Math.max(0, cat.amount - catBudget); const overspend = Math.max(0, cat.amount - catBudget);
if (overspend <= 0) return null;
const options = siblings const options = siblings
.filter(s => s.category_id !== cat.category_id) .filter(s => s.category_id !== cat.category_id)
.map(s => ({ ...s, available: (budgets[s.category_id] ?? s.budget ?? 0) - s.amount })) .map(s => ({ ...s, available: (budgets[s.category_id] ?? s.budget ?? 0) - s.amount }))
.filter(s => s.available > 0); .filter(s => s.available > 0);
const cover = async (source) => { const cover = async (source: RealCatEntry & { available: number }) => {
const sourceBudget = budgets[source.category_id] ?? source.budget ?? 0; const sourceBudget = budgets[source.category_id] ?? source.budget ?? 0;
const amount = Math.min(overspend, source.available); const amount = Math.min(overspend, source.available);
setSaving(true); setSaving(true);
@ -433,12 +494,14 @@ function CoverOverspendPicker({ cat, siblings, budgets, year, month, onCovered }
toast.success(`Covered ${fmt(amount)} from ${source.category_name}.`); toast.success(`Covered ${fmt(amount)} from ${source.category_name}.`);
setOpen(false); setOpen(false);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to cover overspending'); toast.error(errMessage(err, 'Failed to cover overspending'));
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
if (overspend <= 0) return null;
if (options.length === 0) { if (options.length === 0) {
return <span className="text-[11px] text-destructive/70">No category has budget left to cover this</span>; return <span className="text-[11px] text-destructive/70">No category has budget left to cover this</span>;
} }
@ -474,14 +537,19 @@ function CoverOverspendPicker({ cat, siblings, budgets, year, month, onCovered }
// ── Category groups manager ────────────────────────────────────────────────── // ── Category groups manager ──────────────────────────────────────────────────
function CategoryGroupManager({ categories, groups, onGroupsChanged, onCategoriesChanged }) { function CategoryGroupManager({ categories, groups, onGroupsChanged, onCategoriesChanged }: {
categories: Category[];
groups: CategoryGroup[];
onGroupsChanged: () => Promise<void> | void;
onCategoriesChanged: () => Promise<void> | void;
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [newGroupName, setNewGroupName] = useState(''); const [newGroupName, setNewGroupName] = useState('');
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState(null); const [editingId, setEditingId] = useState<number | null>(null);
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const addGroup = async (e) => { const addGroup = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!newGroupName.trim()) return; if (!newGroupName.trim()) return;
setAdding(true); setAdding(true);
@ -491,39 +559,39 @@ function CategoryGroupManager({ categories, groups, onGroupsChanged, onCategorie
await onGroupsChanged(); await onGroupsChanged();
toast.success('Group created.'); toast.success('Group created.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to create group'); toast.error(errMessage(err, 'Failed to create group'));
} finally { } finally {
setAdding(false); setAdding(false);
} }
}; };
const renameGroup = async (id) => { const renameGroup = async (id: number) => {
if (!editName.trim()) return; if (!editName.trim()) return;
try { try {
await api.updateCategoryGroup(id, { name: editName.trim() }); await api.updateCategoryGroup(id, { name: editName.trim() });
setEditingId(null); setEditingId(null);
await onGroupsChanged(); await onGroupsChanged();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to rename group'); toast.error(errMessage(err, 'Failed to rename group'));
} }
}; };
const deleteGroup = async (id) => { const deleteGroup = async (id: number) => {
try { try {
await api.deleteCategoryGroup(id); await api.deleteCategoryGroup(id);
await onGroupsChanged(); await onGroupsChanged();
toast.success('Group deleted.'); toast.success('Group deleted.');
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to delete group'); toast.error(errMessage(err, 'Failed to delete group'));
} }
}; };
const setCategoryGroup = async (cat, groupId) => { const setCategoryGroup = async (cat: Category, groupId: string) => {
try { try {
await api.updateCategory(cat.id, { name: cat.name, spending_enabled: true, group_id: groupId === '' ? null : Number(groupId) }); await api.updateCategory(cat.id, { name: cat.name, spending_enabled: true, group_id: groupId === '' ? null : Number(groupId) });
await onCategoriesChanged(); await onCategoriesChanged();
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to update category group'); toast.error(errMessage(err, 'Failed to update category group'));
} }
}; };
@ -614,6 +682,8 @@ function CategoryGroupManager({ categories, groups, onGroupsChanged, onCategorie
// ── Main page ──────────────────────────────────────────────────────────────── // ── Main page ────────────────────────────────────────────────────────────────
interface GroupBucket { group: { id: number | string; name: string }; entries: RealCatEntry[] }
export default function SpendingPage() { export default function SpendingPage() {
const now = new Date(); const now = new Date();
const [year, setYear] = useState(now.getFullYear()); const [year, setYear] = useState(now.getFullYear());
@ -621,29 +691,34 @@ export default function SpendingPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [txPage, setTxPage] = useState(1); const [txPage, setTxPage] = useState(1);
const [activeCat, setActiveCat] = useState(undefined); // undefined = all const [activeCat, setActiveCat] = useState<number | null | undefined>(undefined); // undefined = all
const { data: summary = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month); const { data: summaryRaw = null, isPending: loading, error: summaryErrObj } = useSpendingSummary(year, month);
const { data: txData, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage }); const summary = summaryRaw as SpendingSummary | null;
const { data: txDataRaw, isFetching: txLoading, error: txErrObj } = useSpendingTransactions({ year, month, activeCat, page: txPage });
const txData = txDataRaw as SpendingTxResponse | undefined;
const transactions = useMemo(() => txData?.transactions || [], [txData]); const transactions = useMemo(() => txData?.transactions || [], [txData]);
const txTotal = txData?.total || 0; const txTotal = txData?.total || 0;
const txPages = txData?.pages || 1; const txPages = txData?.pages || 1;
const { data: categories = [], error: catErrObj } = useSpendingCategories(); const { data: categories = [], error: catErrObj } = useSpendingCategories();
const { data: categoryGroups = [] } = useCategoryGroups(); const { data: categoryGroupsRaw = [] } = useCategoryGroups();
const categoryGroups = categoryGroupsRaw as CategoryGroup[];
const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null; const summaryError = summaryErrObj ? (summaryErrObj.message || 'Failed to load spending summary') : null;
const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null; const txError = txErrObj ? (txErrObj.message || 'Failed to load transactions') : null;
const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null; const catError = catErrObj ? (catErrObj.message || 'Failed to load categories') : null;
// Optimistic edits write through the query cache so existing call sites work. // Optimistic edits write through the query cache so existing call sites work.
const setSummary = useCallback((u) => queryClient.setQueryData(['spending-summary', year, month], const setSummary = useCallback(
prev => (typeof u === 'function' ? u(prev) : u)), [queryClient, year, month]); (u: (prev: SpendingSummary | null | undefined) => SpendingSummary | null | undefined) =>
const setTransactions = useCallback((u) => queryClient.setQueryData( queryClient.setQueryData<SpendingSummary | null>(['spending-summary', year, month], prev => u(prev)),
[queryClient, year, month]);
const setTransactions = useCallback(
(u: (prev: SpendingTx[]) => SpendingTx[]) =>
queryClient.setQueryData<SpendingTxResponse>(
['spending-transactions', year, month, activeCat ?? 'all', txPage], ['spending-transactions', year, month, activeCat ?? 'all', txPage],
prev => { prev => (prev ? { ...prev, transactions: u(prev.transactions || []) } : prev)),
const nextList = typeof u === 'function' ? u(prev?.transactions || []) : u; [queryClient, year, month, activeCat, txPage]);
return prev ? { ...prev, transactions: nextList } : prev; const [budgets, setBudgets] = useState<Budgets>({}); // categoryId → amount (seeded from summary)
}), [queryClient, year, month, activeCat, txPage]);
const [budgets, setBudgets] = useState({}); // categoryId → amount (seeded from summary)
const [copying, setCopying] = useState(false); const [copying, setCopying] = useState(false);
const [spendingSettings, setSpendingSettings] = useState({}); const [spendingSettings, setSpendingSettings] = useState<Record<string, unknown>>({});
const [savingSpendingSetting, setSavingSpendingSetting] = useState(false); const [savingSpendingSetting, setSavingSpendingSetting] = useState(false);
// loadCategories is stable — categories don't vary by month // loadCategories is stable — categories don't vary by month
@ -661,7 +736,7 @@ export default function SpendingPage() {
}, [txPage, queryClient]); }, [txPage, queryClient]);
useEffect(() => { useEffect(() => {
api.settings().then(setSpendingSettings).catch(() => {}); api.settings().then(s => setSpendingSettings((s as Record<string, unknown>) || {})).catch(() => {});
}, []); }, []);
// Reset to page 1 whenever the month or category filter changes. // Reset to page 1 whenever the month or category filter changes.
@ -671,21 +746,21 @@ export default function SpendingPage() {
// (this is what loadSummary used to do inline). // (this is what loadSummary used to do inline).
useEffect(() => { useEffect(() => {
if (!summary) return; if (!summary) return;
const bmap = {}; const bmap: Budgets = {};
(summary.by_category || []).forEach(c => { if (c.category_id && c.budget != null) bmap[c.category_id] = c.budget; }); (summary.by_category || []).forEach(c => { if (c.category_id != null && c.budget != null) bmap[c.category_id] = c.budget; });
setBudgets(bmap); setBudgets(bmap);
}, [summary]); }, [summary]);
async function saveSpendingSetting(patch, successMessage) { async function saveSpendingSetting(patch: Record<string, string>, successMessage?: string) {
setSavingSpendingSetting(true); setSavingSpendingSetting(true);
setSpendingSettings(prev => ({ ...prev, ...patch })); setSpendingSettings(prev => ({ ...prev, ...patch }));
try { try {
const next = await api.saveSettings(patch); const next = await api.saveSettings(patch);
setSpendingSettings(prev => ({ ...prev, ...next })); setSpendingSettings(prev => ({ ...prev, ...(next as Record<string, unknown>) }));
if (successMessage) toast.success(successMessage); if (successMessage) toast.success(successMessage);
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to update spending setting'); toast.error(errMessage(err, 'Failed to update spending setting'));
api.settings().then(s => setSpendingSettings(prev => ({ ...prev, ...s }))).catch(() => {}); api.settings().then(s => setSpendingSettings(prev => ({ ...prev, ...(s as Record<string, unknown>) }))).catch(() => {});
} finally { } finally {
setSavingSpendingSetting(false); setSavingSpendingSetting(false);
} }
@ -694,67 +769,69 @@ export default function SpendingPage() {
const handleCopyBudgets = async () => { const handleCopyBudgets = async () => {
setCopying(true); setCopying(true);
try { try {
const d = await api.copySpendingBudgets({ year, month }); const d = await api.copySpendingBudgets({ year, month }) as CopyBudgetsResponse;
if (d.copied === 0) { if (d.copied === 0) {
toast.info('No budgets found in the previous month to copy.'); toast.info('No budgets found in the previous month to copy.');
} else { } else {
// Update local budget state from server response // Update local budget state from server response
const bmap = {}; const bmap: Budgets = {};
(d.budgets || []).forEach(b => { bmap[b.category_id] = b.amount; }); (d.budgets || []).forEach(b => { bmap[b.category_id] = b.amount; });
setBudgets(bmap); setBudgets(bmap);
setSummary(prev => prev ? { setSummary(prev => prev ? {
...prev, ...prev,
by_category: prev.by_category.map(c => by_category: (prev.by_category || []).map(c => {
c.category_id && bmap[c.category_id] != null const key = c.category_id;
? { ...c, budget: bmap[c.category_id] } const val = key == null ? undefined : bmap[key];
: c return val != null ? { ...c, budget: val } : c;
), }),
} : prev); } : prev);
toast.success(`${d.copied} budget${d.copied !== 1 ? 's' : ''} copied from last month.`); toast.success(`${d.copied} budget${d.copied !== 1 ? 's' : ''} copied from last month.`);
} }
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to copy budgets'); toast.error(errMessage(err, 'Failed to copy budgets'));
} finally { } finally {
setCopying(false); setCopying(false);
} }
}; };
const navMonth = (dir) => { const navMonth = (dir: number) => {
let m = month + dir, y = year; let m = month + dir, y = year;
if (m > 12) { m = 1; y++; } if (m > 12) { m = 1; y++; }
if (m < 1) { m = 12; y--; } if (m < 1) { m = 12; y--; }
setMonth(m); setYear(y); setActiveCat(undefined); setTxPage(1); setMonth(m); setYear(y); setActiveCat(undefined); setTxPage(1);
}; };
const handleCategorize = (txId, categoryId, categoryName) => { const handleCategorize = (txId: number, categoryId: number | string | null, categoryName: string | null) => {
setTransactions(prev => prev.map(t => setTransactions(prev => prev.map(t =>
t.id === txId ? { ...t, spending_category_id: categoryId, spending_category_name: categoryName } : t t.id === txId ? { ...t, spending_category_id: categoryId, spending_category_name: categoryName } : t
)); ));
loadSummary(); loadSummary();
}; };
const handleBudgetSaved = (categoryId, amount) => { const handleBudgetSaved = (categoryId: number, amount: number | null) => {
setBudgets(prev => ({ ...prev, [categoryId]: amount })); setBudgets(prev => ({ ...prev, [categoryId]: amount }));
setSummary(prev => { setSummary(prev => {
if (!prev) return prev; if (!prev) return prev;
return { return {
...prev, ...prev,
by_category: prev.by_category.map(c => by_category: (prev.by_category || []).map(c =>
c.category_id === categoryId ? { ...c, budget: amount } : c c.category_id === categoryId ? { ...c, budget: amount } : c
), ),
}; };
}); });
}; };
const handleBudgetsCovered = (updates) => { const handleBudgetsCovered = (updates: Record<string, number>) => {
setBudgets(prev => ({ ...prev, ...updates })); setBudgets(prev => ({ ...prev, ...updates }));
setSummary(prev => { setSummary(prev => {
if (!prev) return prev; if (!prev) return prev;
return { return {
...prev, ...prev,
by_category: prev.by_category.map(c => by_category: (prev.by_category || []).map(c => {
c.category_id in updates ? { ...c, budget: updates[c.category_id] } : c const key = c.category_id;
), const val = key == null ? undefined : updates[key];
return val != null ? { ...c, budget: val } : c;
}),
}; };
}); });
}; };
@ -769,7 +846,7 @@ export default function SpendingPage() {
await loadSummary(); await loadSummary();
}; };
const selectCat = (catId) => { const selectCat = (catId: number | null) => {
setActiveCat(prev => prev === catId ? undefined : catId); setActiveCat(prev => prev === catId ? undefined : catId);
setTxPage(1); setTxPage(1);
}; };
@ -796,11 +873,12 @@ export default function SpendingPage() {
); );
} }
const realCatEntries = summary?.by_category?.filter(c => typeof c.category_id === 'number') || []; const realCatEntries = (summary?.by_category || []).filter(
(c): c is RealCatEntry => typeof c.category_id === 'number');
const uncatEntry = summary?.by_category?.find(c => c.category_id === null) || null; 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 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 totalBudgeted = Object.values(budgets).reduce<number>((sum, b) => sum + (Number(b) || 0), 0);
const totalSpending = summary?.total_spending || 0; const totalSpending = summary?.total_spending || 0;
const income = summary?.income || 0; const income = summary?.income || 0;
const readyToAssign = income - totalBudgeted; const readyToAssign = income - totalBudgeted;
@ -811,12 +889,12 @@ export default function SpendingPage() {
const showCover = settingEnabled(spendingSettings.spending_show_cover_overspend); const showCover = settingEnabled(spendingSettings.spending_show_cover_overspend);
const showGroups = settingEnabled(spendingSettings.spending_group_categories, false); const showGroups = settingEnabled(spendingSettings.spending_group_categories, false);
const groupedEntries = (() => { const groupedEntries: GroupBucket[] | null = (() => {
if (!showGroups || categoryGroups.length === 0) return null; if (!showGroups || categoryGroups.length === 0) return null;
const byGroup = new Map(categoryGroups.map(g => [g.id, { group: g, entries: [] }])); const byGroup = new Map<number, GroupBucket>(categoryGroups.map(g => [g.id, { group: g, entries: [] }]));
const ungrouped = []; const ungrouped: RealCatEntry[] = [];
realCatEntries.forEach(c => { realCatEntries.forEach(c => {
if (c.group_id && byGroup.has(c.group_id)) byGroup.get(c.group_id).entries.push(c); if (c.group_id && byGroup.has(c.group_id)) byGroup.get(c.group_id)!.entries.push(c);
else ungrouped.push(c); else ungrouped.push(c);
}); });
const groups = [...byGroup.values()].filter(g => g.entries.length > 0); const groups = [...byGroup.values()].filter(g => g.entries.length > 0);
@ -824,7 +902,7 @@ export default function SpendingPage() {
return groups; return groups;
})(); })();
const renderCategoryRow = (cat) => { const renderCategoryRow = (cat: RealCatEntry) => {
const budget = budgets[cat.category_id] ?? cat.budget ?? null; const budget = budgets[cat.category_id] ?? cat.budget ?? null;
const bar = pctBar(cat.amount, budget); const bar = pctBar(cat.amount, budget);
const isActive = activeCat === cat.category_id; const isActive = activeCat === cat.category_id;
@ -846,7 +924,7 @@ export default function SpendingPage() {
<div className="flex items-center justify-between mt-1 gap-2"> <div className="flex items-center justify-between mt-1 gap-2">
<span className="text-[11px] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{cat.tx_count} transaction{cat.tx_count !== 1 ? 's' : ''} {cat.tx_count} transaction{cat.tx_count !== 1 ? 's' : ''}
{showAvg && cat.avg_3mo > 0 && ( {showAvg && (cat.avg_3mo ?? 0) > 0 && (
<span className="ml-2 text-muted-foreground/70">avg {fmt(cat.avg_3mo)}/mo</span> <span className="ml-2 text-muted-foreground/70">avg {fmt(cat.avg_3mo)}/mo</span>
)} )}
</span> </span>
@ -882,9 +960,9 @@ export default function SpendingPage() {
<span className="text-sm font-medium text-muted-foreground flex items-center 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 <Tag className="h-3.5 w-3.5" /> Uncategorized
</span> </span>
<span className="text-sm font-mono font-semibold text-muted-foreground">{fmt(uncatEntry.amount)}</span> <span className="text-sm font-mono font-semibold text-muted-foreground">{fmt(uncatEntry?.amount)}</span>
</div> </div>
<p className="text-[11px] text-muted-foreground mt-0.5">{uncatEntry.tx_count} transaction{uncatEntry.tx_count !== 1 ? 's' : ''}</p> <p className="text-[11px] text-muted-foreground mt-0.5">{uncatEntry?.tx_count} transaction{uncatEntry?.tx_count !== 1 ? 's' : ''}</p>
</button> </button>
); );
@ -897,9 +975,9 @@ export default function SpendingPage() {
<span className="text-sm font-medium text-muted-foreground flex items-center 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 <Layers className="h-3.5 w-3.5" /> Other categories
</span> </span>
<span className="text-sm font-mono font-semibold text-muted-foreground">{fmt(otherEntry.amount)}</span> <span className="text-sm font-mono font-semibold text-muted-foreground">{fmt(otherEntry?.amount)}</span>
</div> </div>
<p className="text-[11px] text-muted-foreground mt-0.5">{otherEntry.tx_count} transaction{otherEntry.tx_count !== 1 ? 's' : ''}</p> <p className="text-[11px] text-muted-foreground mt-0.5">{otherEntry?.tx_count} transaction{otherEntry?.tx_count !== 1 ? 's' : ''}</p>
</div> </div>
); );
@ -938,7 +1016,7 @@ export default function SpendingPage() {
<div className="flex items-center gap-3 rounded-xl border border-destructive/25 bg-destructive/10 px-4 py-3"> <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" /> <AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive flex-1">{summaryError}</span> <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"> <Button variant="outline" size="sm" onClick={() => loadSummary()} className="h-7 gap-1.5 text-xs">
<RefreshCw className="h-3.5 w-3.5" /> Retry <RefreshCw className="h-3.5 w-3.5" /> Retry
</Button> </Button>
</div> </div>
@ -978,8 +1056,8 @@ export default function SpendingPage() {
<div className="rounded-xl border border-border/60 bg-card/80 px-4 py-3 col-span-2 sm:col-span-1"> <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">Uncategorized</p> <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> <p className="text-2xl font-bold mt-1">{fmt(summary?.uncategorized_amount)}</p>
{summary?.uncategorized_count > 0 && ( {(summary?.uncategorized_count ?? 0) > 0 && (
<p className="text-xs text-muted-foreground">{summary.uncategorized_count} transaction{summary.uncategorized_count !== 1 ? 's' : ''}</p> <p className="text-xs text-muted-foreground">{summary?.uncategorized_count} transaction{summary?.uncategorized_count !== 1 ? 's' : ''}</p>
)} )}
</div> </div>
</div> </div>
@ -999,7 +1077,7 @@ export default function SpendingPage() {
<div className="flex items-center gap-3 rounded-xl border border-destructive/25 bg-destructive/10 px-4 py-3"> <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" /> <AlertCircle className="h-4 w-4 text-destructive shrink-0" />
<span className="text-sm text-destructive flex-1">{catError}</span> <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"> <Button variant="outline" size="sm" onClick={() => loadCategories()} className="h-7 gap-1.5 text-xs">
<RefreshCw className="h-3.5 w-3.5" /> Retry <RefreshCw className="h-3.5 w-3.5" /> Retry
</Button> </Button>
</div> </div>