Change account_number from INTEGER to VARCHAR(50) to support segmented codes like 30-3001-0000 used by real HOA accounting systems. Budget CSV import now: - Auto-creates income/expense accounts from CSV when they don't exist - Infers account_type and fund_type from account number prefix conventions - Parses currency-formatted values ($48,065.21, $(13,000.00), $-, etc.) - Reports created accounts back to the user Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
92 lines
3.5 KiB
TypeScript
92 lines
3.5 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Title, Table, Group, Stack, Text, Card, Loader, Center, Divider, Badge,
|
|
} from '@mantine/core';
|
|
import { DateInput } from '@mantine/dates';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import api from '../../services/api';
|
|
|
|
interface AccountLine { account_number: string; name: string; amount: string; fund_type: string; }
|
|
interface IncomeStatementData {
|
|
from: string; to: string;
|
|
income: AccountLine[]; expenses: AccountLine[];
|
|
total_income: string; total_expenses: string; net_income: string;
|
|
}
|
|
|
|
export function IncomeStatementPage() {
|
|
const [from, setFrom] = useState(new Date(new Date().getFullYear(), 0, 1));
|
|
const [to, setTo] = useState(new Date());
|
|
|
|
const { data, isLoading } = useQuery<IncomeStatementData>({
|
|
queryKey: ['income-statement', from.toISOString().split('T')[0], to.toISOString().split('T')[0]],
|
|
queryFn: async () => {
|
|
const { data } = await api.get(`/reports/income-statement?from=${from.toISOString().split('T')[0]}&to=${to.toISOString().split('T')[0]}`);
|
|
return data;
|
|
},
|
|
});
|
|
|
|
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
|
|
|
if (isLoading) return <Center h={300}><Loader /></Center>;
|
|
|
|
const netIncome = parseFloat(data?.net_income || '0');
|
|
|
|
return (
|
|
<Stack>
|
|
<Group justify="space-between">
|
|
<Title order={2}>Income Statement</Title>
|
|
<Group>
|
|
<DateInput label="From" value={from} onChange={(v) => v && setFrom(v)} w={160} />
|
|
<DateInput label="To" value={to} onChange={(v) => v && setTo(v)} w={160} />
|
|
</Group>
|
|
</Group>
|
|
|
|
<Card withBorder>
|
|
<Title order={4} mb="md" c="green">Income</Title>
|
|
<Table>
|
|
<Table.Tbody>
|
|
{(data?.income || []).map((a) => (
|
|
<Table.Tr key={a.account_number}>
|
|
<Table.Td w={80}>{a.account_number}</Table.Td>
|
|
<Table.Td>{a.name} <Badge size="xs" variant="light">{a.fund_type}</Badge></Table.Td>
|
|
<Table.Td ta="right" ff="monospace" w={140}>{fmt(a.amount)}</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
<Table.Tfoot>
|
|
<Table.Tr><Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
|
|
<Table.Td ta="right" fw={700} ff="monospace" c="green">{fmt(data?.total_income || '0')}</Table.Td></Table.Tr>
|
|
</Table.Tfoot>
|
|
</Table>
|
|
|
|
<Divider my="md" />
|
|
|
|
<Title order={4} mb="md" c="red">Expenses</Title>
|
|
<Table>
|
|
<Table.Tbody>
|
|
{(data?.expenses || []).map((a) => (
|
|
<Table.Tr key={a.account_number}>
|
|
<Table.Td w={80}>{a.account_number}</Table.Td>
|
|
<Table.Td>{a.name} <Badge size="xs" variant="light">{a.fund_type}</Badge></Table.Td>
|
|
<Table.Td ta="right" ff="monospace" w={140}>{fmt(a.amount)}</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
<Table.Tfoot>
|
|
<Table.Tr><Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
|
|
<Table.Td ta="right" fw={700} ff="monospace" c="red">{fmt(data?.total_expenses || '0')}</Table.Td></Table.Tr>
|
|
</Table.Tfoot>
|
|
</Table>
|
|
|
|
<Divider my="md" />
|
|
<Group justify="space-between" px="sm">
|
|
<Text fw={700} size="xl">Net Income</Text>
|
|
<Text fw={700} size="xl" ff="monospace" c={netIncome >= 0 ? 'green' : 'red'}>
|
|
{fmt(data?.net_income || '0')}
|
|
</Text>
|
|
</Group>
|
|
</Card>
|
|
</Stack>
|
|
);
|
|
}
|