fix: compound inflation, budget planner CSV import, simplify budget manager

- Fix compound inflation: use Math.pow(1 + rate/100, yearsGap) instead of
  flat rate so multi-year gaps (e.g., 2026→2029) compound annually
- Budget Planner: add CSV import flow + Download Template button; show proper
  empty state when no base budget exists with Create/Import options
- Budget Manager: remove CSV import, Download Template, and Save buttons;
  redirect users to Budget Planner when no budget exists for selected year
- Fix getAvailableYears to return null latestBudgetYear when no budgets exist
  and include current year in year selector for fresh tenants

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 14:39:31 -04:00
parent f2b0b57535
commit f20f54b128
4 changed files with 421 additions and 220 deletions

View File

@@ -1,11 +1,12 @@
import { useState, useRef } from 'react';
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Badge, Card, Alert,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconDeviceFloppy, IconUpload, IconDownload, IconInfoCircle, IconPencil, IconX } from '@tabler/icons-react';
import { IconDeviceFloppy, IconInfoCircle, IconPencil, IconX, IconArrowRight } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
@@ -25,23 +26,6 @@ interface BudgetLine {
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/**
* Parse a currency-formatted value: "$48,065.21", "$(13,000.00)", " $- "
*/
function parseCurrencyValue(val: string): number {
if (!val) return 0;
let s = val.trim();
if (!s || s === '-' || s === '$-' || s === '$ -') return 0;
const isNegative = s.includes('(') && s.includes(')');
s = s.replace(/[$,\s()]/g, '');
if (!s || s === '-') return 0;
const num = parseFloat(s);
if (isNaN(num)) return 0;
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.
@@ -55,50 +39,12 @@ function hydrateBudgetLine(row: any): BudgetLine {
return line as BudgetLine;
}
function parseCSV(text: string): Record<string, string>[] {
const lines = text.trim().split('\n');
if (lines.length < 2) return [];
const headers = lines[0].split(',').map((h) => h.trim().toLowerCase());
const rows: Record<string, string>[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Handle quoted fields containing commas
const values: string[] = [];
let current = '';
let inQuotes = false;
for (let j = 0; j < line.length; j++) {
const ch = line[j];
if (ch === '"') {
inQuotes = !inQuotes;
} else if (ch === ',' && !inQuotes) {
values.push(current.trim());
current = '';
} else {
current += ch;
}
}
values.push(current.trim());
const row: Record<string, string> = {};
headers.forEach((h, idx) => {
row[h] = values[idx] || '';
});
rows.push(row);
}
return rows;
}
export function BudgetsPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
const [isEditing, setIsEditing] = useState(false);
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
@@ -106,19 +52,16 @@ export function BudgetsPage() {
const incomeSectionBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseSectionBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
// Budget exists when there is data loaded for the selected year
const hasBudget = budgetData.length > 0;
// Cells are editable only when editing an existing budget or creating a new one (no data yet)
const cellsEditable = !isReadOnly && (isEditing || !hasBudget);
const cellsEditable = !isReadOnly && isEditing;
const { isLoading } = useQuery<BudgetLine[]>({
queryKey: ['budgets', year],
queryFn: async () => {
const { data } = await api.get(`/budgets/${year}`);
// Hydrate each line: ensure numbers and compute annual_total
const hydrated = (data as any[]).map(hydrateBudgetLine);
setBudgetData(hydrated);
setIsEditing(false); // Reset to view mode when year changes or data reloads
setIsEditing(false);
return hydrated;
},
});
@@ -146,98 +89,8 @@ export function BudgetsPage() {
},
});
const importMutation = useMutation({
mutationFn: async (lines: Record<string, string>[]) => {
const parsed = lines.map((row) => ({
account_number: row.account_number || row.accountnumber || '',
account_name: row.account_name || row.accountname || '',
jan: parseCurrencyValue(row.jan),
feb: parseCurrencyValue(row.feb),
mar: parseCurrencyValue(row.mar),
apr: parseCurrencyValue(row.apr),
may: parseCurrencyValue(row.may),
jun: parseCurrencyValue(row.jun),
jul: parseCurrencyValue(row.jul),
aug: parseCurrencyValue(row.aug),
sep: parseCurrencyValue(row.sep),
oct: parseCurrencyValue(row.oct),
nov: parseCurrencyValue(row.nov),
dec_amt: parseCurrencyValue(row.dec_amt || row.dec || ''),
}));
const { data } = await api.post(`/budgets/${year}/import`, parsed);
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
let msg = `Imported ${data.imported} budget line(s)`;
if (data.created?.length) {
msg += `. Created ${data.created.length} new account(s)`;
}
if (data.errors?.length) {
msg += `. ${data.errors.length} error(s): ${data.errors.join('; ')}`;
}
notifications.show({
message: msg,
color: data.errors?.length ? 'yellow' : 'green',
autoClose: 10000,
});
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Import failed', color: 'red' });
},
});
const handleDownloadTemplate = async () => {
try {
const response = await api.get(`/budgets/${year}/template`, {
responseType: 'blob',
});
const blob = new Blob([response.data], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `budget_template_${year}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err: any) {
notifications.show({ message: 'Failed to download template', color: 'red' });
}
};
const handleImportCSV = () => {
fileInputRef.current?.click();
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
if (!text) {
notifications.show({ message: 'Could not read file', color: 'red' });
return;
}
const rows = parseCSV(text);
if (rows.length === 0) {
notifications.show({ message: 'No data rows found in CSV', color: 'red' });
return;
}
importMutation.mutate(rows);
};
reader.readAsText(file);
// Reset input so the same file can be re-selected
event.target.value = '';
};
const handleCancelEdit = () => {
setIsEditing(false);
// Re-fetch to discard unsaved changes
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
};
@@ -263,7 +116,6 @@ export function BudgetsPage() {
const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
const totalOperatingIncome = operatingIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
const totalReserveIncome = reserveIncomeLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
const totalIncome = totalOperatingIncome + totalReserveIncome;
const totalExpense = expenseLines.reduce((sum, line) => sum + (line.annual_total || 0), 0);
return (
@@ -272,40 +124,18 @@ export function BudgetsPage() {
<Title order={2}>Budget Manager</Title>
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
<Button
variant="outline"
leftSection={<IconDownload size={16} />}
onClick={handleDownloadTemplate}
>
Download Template
</Button>
{!isReadOnly && (<>
<Button
variant="outline"
leftSection={<IconUpload size={16} />}
onClick={handleImportCSV}
loading={importMutation.isPending}
>
Import CSV
</Button>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".csv,.txt"
onChange={handleFileChange}
/>
{hasBudget && !isEditing ? (
<Button
variant="outline"
leftSection={<IconPencil size={16} />}
onClick={() => setIsEditing(true)}
>
Edit Budget
</Button>
) : (
<>
{isEditing && (
{!isReadOnly && hasBudget && (
<>
{!isEditing ? (
<Button
variant="outline"
leftSection={<IconPencil size={16} />}
onClick={() => setIsEditing(true)}
>
Edit Budget
</Button>
) : (
<>
<Button
variant="outline"
color="gray"
@@ -314,25 +144,37 @@ export function BudgetsPage() {
>
Cancel
</Button>
)}
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
>
Save Budget
</Button>
</>
)}
</>)}
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
>
Save Budget
</Button>
</>
)}
</>
)}
</Group>
</Group>
{budgetData.length === 0 && !isLoading && (
{!hasBudget && !isLoading && (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
No budget data for {year}. Import a CSV to get started. Your CSV should have columns:{' '}
<Text span ff="monospace" size="xs">account_number, account_name, jan, feb, ..., dec</Text>.
Accounts will be auto-created if they don&apos;t exist yet.
<Stack gap="sm">
<Text>No budget data for {year}.</Text>
<Text size="sm">
To create or import a budget, use the <Text span fw={600}>Budget Planner</Text> to build,
review, and ratify a budget for this year. Once ratified, it will appear here.
</Text>
<Button
variant="light"
leftSection={<IconArrowRight size={16} />}
w="fit-content"
onClick={() => navigate('/board-planning/budgets')}
>
Go to Budget Planner
</Button>
</Stack>
</Alert>
)}
@@ -375,7 +217,7 @@ export function BudgetsPage() {
{budgetData.length === 0 && (
<Table.Tr>
<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 for this year.</Text>
</Table.Td>
</Table.Tr>
)}