Initial commit: HOA Financial Intelligence Platform MVP
Multi-tenant financial management platform for homeowner associations featuring: - NestJS backend with 16 modules (auth, accounts, transactions, budgets, units, invoices, payments, vendors, reserves, investments, capital projects, reports) - React + Mantine frontend with dashboard, CRUD pages, and financial reports - Schema-per-tenant PostgreSQL isolation with JWT-based tenant resolution - Docker Compose infrastructure (nginx, backend, frontend, postgres, redis) - Comprehensive seed data for Sunrise Valley HOA demo - 39 API endpoints with Swagger documentation - Double-entry bookkeeping with journal entries - Budget vs actual reporting and Sankey cash flow visualization Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
187
frontend/src/pages/reports/BudgetVsActualPage.tsx
Normal file
187
frontend/src/pages/reports/BudgetVsActualPage.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Stack, Text, Card, Loader, Center,
|
||||
Select, Badge, Progress, SimpleGrid,
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface BudgetVsActualLine {
|
||||
account_id: string;
|
||||
account_number: number;
|
||||
account_name: string;
|
||||
account_type: string;
|
||||
fund_type: string;
|
||||
budget_amount: number;
|
||||
actual_amount: number;
|
||||
variance: number;
|
||||
variance_pct: number;
|
||||
}
|
||||
|
||||
interface BudgetVsActualData {
|
||||
year: number;
|
||||
lines: BudgetVsActualLine[];
|
||||
total_income_budget: number;
|
||||
total_income_actual: number;
|
||||
total_expense_budget: number;
|
||||
total_expense_actual: number;
|
||||
}
|
||||
|
||||
export function BudgetVsActualPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear().toString());
|
||||
|
||||
const yearOptions = Array.from({ length: 5 }, (_, i) => {
|
||||
const y = new Date().getFullYear() - 2 + i;
|
||||
return { value: String(y), label: String(y) };
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery<BudgetVsActualData>({
|
||||
queryKey: ['budget-vs-actual', year],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/budgets/${year}/vs-actual`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const fmt = (v: number) =>
|
||||
(v || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
|
||||
|
||||
const pctFmt = (v: number) => `${(v || 0).toFixed(1)}%`;
|
||||
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
const lines = data?.lines || [];
|
||||
const incomeLines = lines.filter((l) => l.account_type === 'income');
|
||||
const expenseLines = lines.filter((l) => l.account_type === 'expense');
|
||||
|
||||
const totalIncomeBudget = data?.total_income_budget || incomeLines.reduce((s, l) => s + l.budget_amount, 0);
|
||||
const totalIncomeActual = data?.total_income_actual || incomeLines.reduce((s, l) => s + l.actual_amount, 0);
|
||||
const totalExpenseBudget = data?.total_expense_budget || expenseLines.reduce((s, l) => s + l.budget_amount, 0);
|
||||
const totalExpenseActual = data?.total_expense_actual || expenseLines.reduce((s, l) => s + l.actual_amount, 0);
|
||||
|
||||
const incomeVariance = totalIncomeActual - totalIncomeBudget;
|
||||
const expenseVariance = totalExpenseActual - totalExpenseBudget;
|
||||
const netBudget = totalIncomeBudget - totalExpenseBudget;
|
||||
const netActual = totalIncomeActual - totalExpenseActual;
|
||||
|
||||
const varianceColor = (variance: number, isExpense: boolean) => {
|
||||
if (variance === 0) return 'gray';
|
||||
// For income: positive variance (actual > budget) is good
|
||||
// For expenses: negative variance (actual < budget) is good
|
||||
if (isExpense) return variance < 0 ? 'green' : 'red';
|
||||
return variance > 0 ? 'green' : 'red';
|
||||
};
|
||||
|
||||
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
|
||||
<>
|
||||
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
|
||||
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
|
||||
</Table.Tr>
|
||||
{sectionLines.map((line) => {
|
||||
const usagePct = line.budget_amount > 0 ? (line.actual_amount / line.budget_amount) * 100 : 0;
|
||||
return (
|
||||
<Table.Tr key={line.account_id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" c="dimmed">{line.account_number}</Text>
|
||||
<Text size="sm">{line.account_name}</Text>
|
||||
{line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(line.budget_amount)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(line.actual_amount)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={varianceColor(line.variance, isExpense)} fw={500}>
|
||||
{line.variance > 0 ? '+' : ''}{fmt(line.variance)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" c={varianceColor(line.variance, isExpense)}>
|
||||
{line.budget_amount > 0 ? pctFmt(line.variance_pct) : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td w={120}>
|
||||
{line.budget_amount > 0 && (
|
||||
<Progress
|
||||
value={Math.min(usagePct, 100)}
|
||||
size="sm"
|
||||
color={usagePct > 100 ? (isExpense ? 'red' : 'green') : (isExpense ? 'green' : 'yellow')}
|
||||
/>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
<Table.Tr style={{ fontWeight: 700 }}>
|
||||
<Table.Td>Total {title}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(totalBudget)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(totalActual)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={varianceColor(totalActual - totalBudget, isExpense)}>
|
||||
{(totalActual - totalBudget) > 0 ? '+' : ''}{fmt(totalActual - totalBudget)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" c={varianceColor(totalActual - totalBudget, isExpense)}>
|
||||
{totalBudget > 0 ? pctFmt(((totalActual - totalBudget) / totalBudget) * 100) : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td></Table.Td>
|
||||
</Table.Tr>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Budget vs. Actual</Title>
|
||||
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 4 }}>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Income Variance</Text>
|
||||
<Text fw={700} size="xl" c={incomeVariance >= 0 ? 'green' : 'red'}>
|
||||
{incomeVariance >= 0 ? '+' : ''}{fmt(incomeVariance)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">{fmt(totalIncomeActual)} of {fmt(totalIncomeBudget)} budgeted</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Expense Variance</Text>
|
||||
<Text fw={700} size="xl" c={expenseVariance <= 0 ? 'green' : 'red'}>
|
||||
{expenseVariance >= 0 ? '+' : ''}{fmt(expenseVariance)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">{fmt(totalExpenseActual)} of {fmt(totalExpenseBudget)} budgeted</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Budget</Text>
|
||||
<Text fw={700} size="xl" c={netBudget >= 0 ? 'green' : 'red'}>{fmt(netBudget)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Actual</Text>
|
||||
<Text fw={700} size="xl" c={netActual >= 0 ? 'green' : 'red'}>{fmt(netActual)}</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Card withBorder>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th style={{ minWidth: 250 }}>Account</Table.Th>
|
||||
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
|
||||
<Table.Th ta="right" style={{ minWidth: 110 }}>Actual</Table.Th>
|
||||
<Table.Th ta="right" style={{ minWidth: 110 }}>Variance ($)</Table.Th>
|
||||
<Table.Th ta="right" style={{ minWidth: 80 }}>Variance %</Table.Th>
|
||||
<Table.Th style={{ minWidth: 120 }}>Progress</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{lines.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No budget vs actual data available. Create a budget first.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{incomeLines.length > 0 && renderSection('Income', incomeLines, false, totalIncomeBudget, totalIncomeActual)}
|
||||
{expenseLines.length > 0 && renderSection('Expenses', expenseLines, true, totalExpenseBudget, totalExpenseActual)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user