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:
@@ -40,6 +40,19 @@ function parseCurrencyValue(val: string): number {
|
||||
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>[] {
|
||||
const lines = text.trim().split('\n');
|
||||
if (lines.length < 2) return [];
|
||||
@@ -88,8 +101,10 @@ export function BudgetsPage() {
|
||||
queryKey: ['budgets', year],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/budgets/${year}`);
|
||||
setBudgetData(data);
|
||||
return data;
|
||||
// Hydrate each line: ensure numbers and compute annual_total
|
||||
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 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 totalExpense = months.reduce((s, m) => s + expenseLines.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 = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -285,20 +300,21 @@ export function BudgetsPage() {
|
||||
</Group>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<Table striped highlightOnHover style={{ minWidth: 1400 }}>
|
||||
<Table striped highlightOnHover style={{ minWidth: 1600 }}>
|
||||
<Table.Thead>
|
||||
<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) => (
|
||||
<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.Thead>
|
||||
<Table.Tbody>
|
||||
{budgetData.length === 0 && (
|
||||
<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>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -306,18 +322,56 @@ export function BudgetsPage() {
|
||||
{['income', 'expense'].map((type) => {
|
||||
const lines = budgetData.filter((b) => b.account_type === type);
|
||||
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 [
|
||||
<Table.Tr key={`header-${type}`} style={{ background: type === 'income' ? '#e6f9e6' : '#fde8e8' }}>
|
||||
<Table.Td colSpan={14} fw={700} tt="capitalize">{type}</Table.Td>
|
||||
<Table.Tr key={`header-${type}`} style={{ background: sectionBg }}>
|
||||
<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>,
|
||||
...lines.map((line) => {
|
||||
const idx = budgetData.indexOf(line);
|
||||
return (
|
||||
<Table.Tr key={line.account_id}>
|
||||
<Table.Td style={{ position: 'sticky', left: 0, background: 'white', zIndex: 1 }}>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" c="dimmed">{line.account_number}</Text>
|
||||
<Text size="sm">{line.account_name}</Text>
|
||||
<Table.Td
|
||||
style={{
|
||||
position: 'sticky',
|
||||
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>}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
Reference in New Issue
Block a user