Fix budget page UI: annual totals, summary cards, sticky headers, column layout

- Compute annual_total on data load via hydrateBudgetLine() (was only computed on edit)
- Ensure monthly values are cast to numbers from PostgreSQL response
- Fix summary cards to use pre-computed annual_total instead of re-summing months
- Make Income/Expense section headers sticky so labels stay visible on horizontal scroll
- Split account number and name into separate columns for better readability
- Add section total in annual column for each income/expense group
- Add subtle border between frozen columns and scrollable month data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 18:44:19 -05:00
parent 61e43212b9
commit 017421a85a

View File

@@ -40,6 +40,19 @@ function parseCurrencyValue(val: string): number {
return isNegative ? -num : num; return isNegative ? -num : num;
} }
/**
* Ensure all monthly values are numbers (PostgreSQL can return strings for NUMERIC columns)
* and compute annual_total as the sum of all monthly values.
*/
function hydrateBudgetLine(row: any): BudgetLine {
const line: any = { ...row };
for (const m of months) {
line[m] = Number(line[m]) || 0;
}
line.annual_total = months.reduce((sum, m) => sum + (line[m] || 0), 0);
return line as BudgetLine;
}
function parseCSV(text: string): Record<string, string>[] { function parseCSV(text: string): Record<string, string>[] {
const lines = text.trim().split('\n'); const lines = text.trim().split('\n');
if (lines.length < 2) return []; if (lines.length < 2) return [];
@@ -88,8 +101,10 @@ export function BudgetsPage() {
queryKey: ['budgets', year], queryKey: ['budgets', year],
queryFn: async () => { queryFn: async () => {
const { data } = await api.get(`/budgets/${year}`); const { data } = await api.get(`/budgets/${year}`);
setBudgetData(data); // Hydrate each line: ensure numbers and compute annual_total
return data; const hydrated = (data as any[]).map(hydrateBudgetLine);
setBudgetData(hydrated);
return hydrated;
}, },
}); });
@@ -222,8 +237,8 @@ export function BudgetsPage() {
const incomeLines = budgetData.filter((b) => b.account_type === 'income'); const incomeLines = budgetData.filter((b) => b.account_type === 'income');
const expenseLines = budgetData.filter((b) => b.account_type === 'expense'); const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
const totalIncome = months.reduce((s, m) => s + incomeLines.reduce((a, b) => a + ((b as any)[m] || 0), 0), 0); const totalIncome = incomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
const totalExpense = months.reduce((s, m) => s + expenseLines.reduce((a, b) => a + ((b as any)[m] || 0), 0), 0); const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
return ( return (
<Stack> <Stack>
@@ -285,20 +300,21 @@ export function BudgetsPage() {
</Group> </Group>
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover style={{ minWidth: 1400 }}> <Table striped highlightOnHover style={{ minWidth: 1600 }}>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 1, minWidth: 280 }}>Account</Table.Th> <Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 2, minWidth: 120 }}>Acct #</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', zIndex: 2, minWidth: 220 }}>Account Name</Table.Th>
{monthLabels.map((m) => ( {monthLabels.map((m) => (
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th> <Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
))} ))}
<Table.Th ta="right" style={{ minWidth: 100 }}>Annual</Table.Th> <Table.Th ta="right" style={{ minWidth: 110 }}>Annual</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{budgetData.length === 0 && ( {budgetData.length === 0 && (
<Table.Tr> <Table.Tr>
<Table.Td colSpan={14}> <Table.Td colSpan={15}>
<Text ta="center" c="dimmed" py="lg">No budget data. Import a CSV or add income/expense accounts to get started.</Text> <Text ta="center" c="dimmed" py="lg">No budget data. Import a CSV or add income/expense accounts to get started.</Text>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -306,18 +322,56 @@ export function BudgetsPage() {
{['income', 'expense'].map((type) => { {['income', 'expense'].map((type) => {
const lines = budgetData.filter((b) => b.account_type === type); const lines = budgetData.filter((b) => b.account_type === type);
if (lines.length === 0) return null; if (lines.length === 0) return null;
const sectionBg = type === 'income' ? '#e6f9e6' : '#fde8e8';
const sectionTotal = lines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
return [ return [
<Table.Tr key={`header-${type}`} style={{ background: type === 'income' ? '#e6f9e6' : '#fde8e8' }}> <Table.Tr key={`header-${type}`} style={{ background: sectionBg }}>
<Table.Td colSpan={14} fw={700} tt="capitalize">{type}</Table.Td> <Table.Td
colSpan={2}
fw={700}
tt="capitalize"
style={{
position: 'sticky',
left: 0,
background: sectionBg,
zIndex: 2,
}}
>
{type}
</Table.Td>
{monthLabels.map((m) => (
<Table.Td key={m} />
))}
<Table.Td ta="right" fw={700} ff="monospace">{fmt(sectionTotal)}</Table.Td>
</Table.Tr>, </Table.Tr>,
...lines.map((line) => { ...lines.map((line) => {
const idx = budgetData.indexOf(line); const idx = budgetData.indexOf(line);
return ( return (
<Table.Tr key={line.account_id}> <Table.Tr key={line.account_id}>
<Table.Td style={{ position: 'sticky', left: 0, background: 'white', zIndex: 1 }}> <Table.Td
<Group gap="xs"> style={{
<Text size="sm" c="dimmed">{line.account_number}</Text> position: 'sticky',
<Text size="sm">{line.account_name}</Text> left: 0,
background: 'white',
zIndex: 1,
borderRight: '1px solid #e9ecef',
}}
>
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
</Table.Td>
<Table.Td
style={{
position: 'sticky',
left: 120,
background: 'white',
zIndex: 1,
borderRight: '1px solid #e9ecef',
}}
>
<Group gap={6} wrap="nowrap">
<Text size="sm" style={{ whiteSpace: 'nowrap' }}>{line.account_name}</Text>
{line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>} {line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>}
</Group> </Group>
</Table.Td> </Table.Td>