Rename to HOA LedgerIQ and implement remaining report pages
- Rename app from "HOA Financial Platform" to "HOA LedgerIQ" across all frontend pages, backend API docs, package.json files, and seed data - Add Cash Flow Statement report (GET /reports/cash-flow) with operating and reserve fund activity breakdown, beginning/ending cash balances - Add Aging Report (GET /reports/aging) with per-unit aging buckets (current, 1-30, 31-60, 61-90, 90+ days), expandable invoice details - Add Year-End Package (GET /reports/year-end) with income statement summary, collection stats, 1099-NEC vendor report, reserve fund status - Add Settings page showing org info, user profile, and system details - Replace all PlaceholderPage references with real implementations - Bump auth store version to 3 for localStorage migration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,7 +53,7 @@ export function LoginPage() {
|
||||
return (
|
||||
<Container size={420} my={80}>
|
||||
<Title ta="center" order={2}>
|
||||
HOA Financial Platform
|
||||
HOA LedgerIQ
|
||||
</Title>
|
||||
<Text c="dimmed" size="sm" ta="center" mt={5}>
|
||||
Don't have an account?{' '}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function DashboardPage() {
|
||||
|
||||
{!currentOrg ? (
|
||||
<Card withBorder p="xl" ta="center">
|
||||
<Text size="lg" fw={500}>Welcome to the HOA Financial Platform</Text>
|
||||
<Text size="lg" fw={500}>Welcome to HOA LedgerIQ</Text>
|
||||
<Text c="dimmed" mt="sm">
|
||||
Create or select an organization to get started.
|
||||
</Text>
|
||||
|
||||
254
frontend/src/pages/reports/AgingReportPage.tsx
Normal file
254
frontend/src/pages/reports/AgingReportPage.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
||||
ThemeIcon, ActionIcon, Collapse,
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconChevronDown, IconChevronRight, IconAlertTriangle, IconCash,
|
||||
IconClock, IconClockHour4, IconClockHour8, IconClockHour12, IconReportMoney,
|
||||
} from '@tabler/icons-react';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface Invoice {
|
||||
invoice_id: string;
|
||||
invoice_number: string;
|
||||
description: string;
|
||||
due_date: string;
|
||||
amount: number;
|
||||
amount_paid: number;
|
||||
balance_due: number;
|
||||
aging_bucket: string;
|
||||
}
|
||||
|
||||
interface AgingUnit {
|
||||
unit_id: string;
|
||||
unit_number: string;
|
||||
owner_name: string;
|
||||
current: number;
|
||||
days_1_30: number;
|
||||
days_31_60: number;
|
||||
days_61_90: number;
|
||||
days_90_plus: number;
|
||||
total: number;
|
||||
invoices: Invoice[];
|
||||
}
|
||||
|
||||
interface AgingSummary {
|
||||
current: number;
|
||||
days_1_30: number;
|
||||
days_31_60: number;
|
||||
days_61_90: number;
|
||||
days_90_plus: number;
|
||||
total: number;
|
||||
unit_count: number;
|
||||
}
|
||||
|
||||
interface AgingData {
|
||||
units: AgingUnit[];
|
||||
summary: AgingSummary;
|
||||
}
|
||||
|
||||
const fmt = (v: number | string) =>
|
||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
const BUCKET_COLORS: Record<string, string> = {
|
||||
current: 'green',
|
||||
'1-30': 'yellow',
|
||||
'31-60': 'orange',
|
||||
'61-90': 'red',
|
||||
'90+': 'red',
|
||||
};
|
||||
|
||||
function bucketColor(bucket: string): string {
|
||||
return BUCKET_COLORS[bucket] || 'gray';
|
||||
}
|
||||
|
||||
function AgingBadge({ bucket }: { bucket: string }) {
|
||||
return (
|
||||
<Badge size="xs" variant="light" color={bucketColor(bucket)}>
|
||||
{bucket}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceRows({ invoices }: { invoices: Invoice[] }) {
|
||||
return (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={9} p={0} style={{ background: 'var(--mantine-color-gray-0)' }}>
|
||||
<Table fontSize="xs" horizontalSpacing="sm" verticalSpacing={4}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Invoice #</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Due Date</Table.Th>
|
||||
<Table.Th ta="right">Amount</Table.Th>
|
||||
<Table.Th ta="right">Paid</Table.Th>
|
||||
<Table.Th ta="right">Balance</Table.Th>
|
||||
<Table.Th>Bucket</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{invoices.map((inv) => (
|
||||
<Table.Tr key={inv.invoice_id}>
|
||||
<Table.Td />
|
||||
<Table.Td ff="monospace">{inv.invoice_number}</Table.Td>
|
||||
<Table.Td>{inv.description}</Table.Td>
|
||||
<Table.Td>{new Date(inv.due_date).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(inv.amount)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(inv.amount_paid)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={600}>{fmt(inv.balance_due)}</Table.Td>
|
||||
<Table.Td><AgingBadge bucket={inv.aging_bucket} /></Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgingReportPage() {
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data, isLoading } = useQuery<AgingData>({
|
||||
queryKey: ['aging-report'],
|
||||
queryFn: async () => { const { data } = await api.get('/reports/aging'); return data; },
|
||||
});
|
||||
|
||||
const toggleRow = (unitId: string) => {
|
||||
setExpandedRows((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(unitId)) {
|
||||
next.delete(unitId);
|
||||
} else {
|
||||
next.add(unitId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
const summary = data?.summary;
|
||||
const units = data?.units || [];
|
||||
|
||||
const summaryCards: { label: string; value: number; color: string; icon: React.ReactNode }[] = [
|
||||
{ label: 'Current', value: summary?.current || 0, color: 'green', icon: <IconCash size={20} /> },
|
||||
{ label: '1-30 Days', value: summary?.days_1_30 || 0, color: 'yellow', icon: <IconClock size={20} /> },
|
||||
{ label: '31-60 Days', value: summary?.days_31_60 || 0, color: 'orange', icon: <IconClockHour4 size={20} /> },
|
||||
{ label: '61-90 Days', value: summary?.days_61_90 || 0, color: 'red', icon: <IconClockHour8 size={20} /> },
|
||||
{ label: '90+ Days', value: summary?.days_90_plus || 0, color: 'red', icon: <IconClockHour12 size={20} /> },
|
||||
{ label: 'Total Outstanding', value: summary?.total || 0, color: 'blue', icon: <IconReportMoney size={20} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Aging Report</Title>
|
||||
{summary && (
|
||||
<Badge size="lg" variant="light" color="blue">
|
||||
{summary.unit_count} {summary.unit_count === 1 ? 'unit' : 'units'} with outstanding balances
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, lg: 6 }}>
|
||||
{summaryCards.map((card) => (
|
||||
<Card key={card.label} withBorder padding="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color={card.color} size="sm">
|
||||
{card.icon}
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" fw={500}>{card.label}</Text>
|
||||
</Group>
|
||||
<Text size="xl" fw={700} ff="monospace" c={card.value > 0 ? card.color : undefined}>
|
||||
{fmt(card.value)}
|
||||
</Text>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
<Card withBorder>
|
||||
<Group mb="md" gap="xs">
|
||||
<ThemeIcon variant="light" color="orange" size="sm">
|
||||
<IconAlertTriangle size={16} />
|
||||
</ThemeIcon>
|
||||
<Title order={4}>Outstanding Balances by Unit</Title>
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40} />
|
||||
<Table.Th>Unit #</Table.Th>
|
||||
<Table.Th>Owner</Table.Th>
|
||||
<Table.Th ta="right"><Text c="green" span fw={600}>Current</Text></Table.Th>
|
||||
<Table.Th ta="right"><Text c="yellow.7" span fw={600}>1-30</Text></Table.Th>
|
||||
<Table.Th ta="right"><Text c="orange" span fw={600}>31-60</Text></Table.Th>
|
||||
<Table.Th ta="right"><Text c="red" span fw={600}>61-90</Text></Table.Th>
|
||||
<Table.Th ta="right"><Text c="red.8" span fw={600}>90+</Text></Table.Th>
|
||||
<Table.Th ta="right">Total</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{units.map((unit) => {
|
||||
const isExpanded = expandedRows.has(unit.unit_id);
|
||||
return (
|
||||
<>
|
||||
<Table.Tr
|
||||
key={unit.unit_id}
|
||||
onClick={() => toggleRow(unit.unit_id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Table.Td>
|
||||
<ActionIcon variant="subtle" size="sm" color="gray">
|
||||
{isExpanded ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
|
||||
</ActionIcon>
|
||||
</Table.Td>
|
||||
<Table.Td fw={600}>{unit.unit_number}</Table.Td>
|
||||
<Table.Td>{unit.owner_name}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={unit.current > 0 ? 'green' : 'dimmed'}>
|
||||
{fmt(unit.current)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={unit.days_1_30 > 0 ? 'yellow.7' : 'dimmed'}>
|
||||
{fmt(unit.days_1_30)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={unit.days_31_60 > 0 ? 'orange' : 'dimmed'}>
|
||||
{fmt(unit.days_31_60)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={unit.days_61_90 > 0 ? 'red' : 'dimmed'}>
|
||||
{fmt(unit.days_61_90)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={unit.days_90_plus > 0 ? 'red.8' : 'dimmed'}>
|
||||
{fmt(unit.days_90_plus)}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={700}>
|
||||
{fmt(unit.total)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{isExpanded && (
|
||||
<InvoiceRows key={`${unit.unit_id}-invoices`} invoices={unit.invoices} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td />
|
||||
<Table.Td colSpan={2} fw={700}>Totals</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={700} c="green">{fmt(summary?.current || 0)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={700} c="yellow.7">{fmt(summary?.days_1_30 || 0)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={700} c="orange">{fmt(summary?.days_31_60 || 0)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={700} c="red">{fmt(summary?.days_61_90 || 0)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={700} c="red.8">{fmt(summary?.days_90_plus || 0)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={700}>{fmt(summary?.total || 0)}</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
257
frontend/src/pages/reports/CashFlowPage.tsx
Normal file
257
frontend/src/pages/reports/CashFlowPage.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Table, Group, Stack, Text, Card, Loader, Center, Divider,
|
||||
Badge, SimpleGrid, TextInput, Button, ThemeIcon,
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
IconCash, IconArrowUpRight, IconArrowDownRight,
|
||||
IconWallet, IconReportMoney, IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface OperatingActivity {
|
||||
name: string;
|
||||
type: 'income' | 'expense';
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface ReserveActivity {
|
||||
name: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface CashFlowData {
|
||||
from: string;
|
||||
to: string;
|
||||
operating_activities: OperatingActivity[];
|
||||
reserve_activities: ReserveActivity[];
|
||||
total_operating: string;
|
||||
total_reserve: string;
|
||||
net_cash_change: string;
|
||||
beginning_cash: string;
|
||||
ending_cash: string;
|
||||
}
|
||||
|
||||
export function CashFlowPage() {
|
||||
const today = new Date();
|
||||
const yearStart = `${today.getFullYear()}-01-01`;
|
||||
const todayStr = today.toISOString().split('T')[0];
|
||||
|
||||
const [fromDate, setFromDate] = useState(yearStart);
|
||||
const [toDate, setToDate] = useState(todayStr);
|
||||
const [queryFrom, setQueryFrom] = useState(yearStart);
|
||||
const [queryTo, setQueryTo] = useState(todayStr);
|
||||
|
||||
const { data, isLoading } = useQuery<CashFlowData>({
|
||||
queryKey: ['cash-flow', queryFrom, queryTo],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/reports/cash-flow?from=${queryFrom}&to=${queryTo}`);
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const handleApply = () => {
|
||||
setQueryFrom(fromDate);
|
||||
setQueryTo(toDate);
|
||||
};
|
||||
|
||||
const fmt = (v: string | number) =>
|
||||
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
|
||||
const totalOperating = parseFloat(data?.total_operating || '0');
|
||||
const totalReserve = parseFloat(data?.total_reserve || '0');
|
||||
const beginningCash = parseFloat(data?.beginning_cash || '0');
|
||||
const endingCash = parseFloat(data?.ending_cash || '0');
|
||||
|
||||
if (isLoading) return <Center h={300}><Loader /></Center>;
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Cash Flow Statement</Title>
|
||||
<Group>
|
||||
<TextInput
|
||||
type="date"
|
||||
label="From"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.currentTarget.value)}
|
||||
w={160}
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
label="To"
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.currentTarget.value)}
|
||||
w={160}
|
||||
/>
|
||||
<Button
|
||||
leftSection={<IconSearch size={16} />}
|
||||
onClick={handleApply}
|
||||
mt={24}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, md: 4 }}>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="blue" size="sm">
|
||||
<IconWallet size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Beginning Cash</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">{fmt(beginningCash)}</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color={totalOperating >= 0 ? 'green' : 'red'} size="sm">
|
||||
{totalOperating >= 0 ? <IconArrowUpRight size={14} /> : <IconArrowDownRight size={14} />}
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Operating</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
|
||||
{fmt(totalOperating)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color={totalReserve >= 0 ? 'green' : 'red'} size="sm">
|
||||
<IconReportMoney size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Reserve</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
|
||||
{fmt(totalReserve)}
|
||||
</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Group gap="xs" mb={4}>
|
||||
<ThemeIcon variant="light" color="teal" size="sm">
|
||||
<IconCash size={14} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Ending Cash</Text>
|
||||
</Group>
|
||||
<Text fw={700} size="xl" ff="monospace">{fmt(endingCash)}</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Operating Activities */}
|
||||
<Card withBorder>
|
||||
<Title order={4} mb="md">Operating Activities</Title>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Activity</Table.Th>
|
||||
<Table.Th ta="center" w={100}>Type</Table.Th>
|
||||
<Table.Th ta="right" w={160}>Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(data?.operating_activities || []).length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={3}>
|
||||
<Text ta="center" c="dimmed" py="lg">No operating activities for this period.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{(data?.operating_activities || []).map((a, idx) => (
|
||||
<Table.Tr key={`${a.name}-${idx}`}>
|
||||
<Table.Td>{a.name}</Table.Td>
|
||||
<Table.Td ta="center">
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={a.type === 'income' ? 'green' : 'red'}
|
||||
>
|
||||
{a.type}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={a.amount >= 0 ? 'green' : 'red'}>
|
||||
{fmt(a.amount)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={2} fw={700}>Total Operating Activities</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace" c={totalOperating >= 0 ? 'green' : 'red'}>
|
||||
{fmt(data?.total_operating || '0')}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Reserve Fund Activities */}
|
||||
<Card withBorder>
|
||||
<Title order={4} mb="md">Reserve Fund Activities</Title>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Activity</Table.Th>
|
||||
<Table.Th ta="right" w={160}>Amount</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(data?.reserve_activities || []).length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={2}>
|
||||
<Text ta="center" c="dimmed" py="lg">No reserve fund activities for this period.</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{(data?.reserve_activities || []).map((a, idx) => (
|
||||
<Table.Tr key={`${a.name}-${idx}`}>
|
||||
<Table.Td>{a.name}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" c={a.amount >= 0 ? 'green' : 'red'}>
|
||||
{fmt(a.amount)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={700}>Total Reserve Fund Activities</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace" c={totalReserve >= 0 ? 'green' : 'red'}>
|
||||
{fmt(data?.total_reserve || '0')}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{/* Net Cash Change Summary */}
|
||||
<Card withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" px="sm">
|
||||
<Text fw={700} size="lg">Net Cash Change</Text>
|
||||
<Text
|
||||
fw={700}
|
||||
size="lg"
|
||||
ff="monospace"
|
||||
c={parseFloat(data?.net_cash_change || '0') >= 0 ? 'green' : 'red'}
|
||||
>
|
||||
{fmt(data?.net_cash_change || '0')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Group justify="space-between" px="sm">
|
||||
<Text fw={700} size="lg">Beginning Cash</Text>
|
||||
<Text fw={700} size="lg" ff="monospace">{fmt(data?.beginning_cash || '0')}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" px="sm">
|
||||
<Text fw={700} size="xl">Ending Cash</Text>
|
||||
<Text fw={700} size="xl" ff="monospace" c="teal">
|
||||
{fmt(data?.ending_cash || '0')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
304
frontend/src/pages/reports/YearEndPage.tsx
Normal file
304
frontend/src/pages/reports/YearEndPage.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
|
||||
ThemeIcon, NumberInput, Divider, Accordion,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconCalendar, IconFileInvoice, IconCash, IconShieldCheck,
|
||||
IconBuildingBank, IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import api from '../../services/api';
|
||||
|
||||
interface Vendor1099 {
|
||||
id: string; name: string; tax_id: string;
|
||||
address_line1: string; city: string; state: string; zip_code: string;
|
||||
total_paid: string;
|
||||
}
|
||||
|
||||
interface ReserveItem {
|
||||
name: string; current_fund_balance: string;
|
||||
replacement_cost: string; percent_funded: string;
|
||||
}
|
||||
|
||||
interface Collections {
|
||||
total_invoices: string; paid_invoices: string; outstanding_invoices: string;
|
||||
total_assessed: string; total_collected: string; total_outstanding: string;
|
||||
}
|
||||
|
||||
interface YearEndData {
|
||||
year: number;
|
||||
income_statement: {
|
||||
income: any[]; expenses: any[];
|
||||
total_income: string; total_expenses: string; net_income: string;
|
||||
};
|
||||
balance_sheet: {
|
||||
total_assets: string; total_liabilities: string; total_equity: string;
|
||||
};
|
||||
vendors_1099: Vendor1099[];
|
||||
collections: Collections;
|
||||
reserve_status: ReserveItem[];
|
||||
}
|
||||
|
||||
export function YearEndPage() {
|
||||
const [year, setYear] = useState(new Date().getFullYear());
|
||||
|
||||
const { data, isLoading } = useQuery<YearEndData>({
|
||||
queryKey: ['year-end', year],
|
||||
queryFn: async () => { const { data } = await api.get(`/reports/year-end?year=${year}`); 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 collections = data?.collections;
|
||||
const collectionRate = collections
|
||||
? (parseFloat(collections.total_collected) / Math.max(parseFloat(collections.total_assessed), 1) * 100).toFixed(1)
|
||||
: '0';
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Title order={2}>Year-End Package</Title>
|
||||
<Text c="dimmed" size="sm">Annual financial summary and compliance reports</Text>
|
||||
</div>
|
||||
<NumberInput label="Fiscal Year" value={year} onChange={(v) => setYear(Number(v) || year)}
|
||||
min={2020} max={2030} w={120} />
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
||||
<Card withBorder padding="lg">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Income</Text>
|
||||
<Text fw={700} size="xl" c={parseFloat(data?.income_statement?.net_income || '0') >= 0 ? 'green' : 'red'}>
|
||||
{fmt(data?.income_statement?.net_income || '0')}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon color="green" variant="light" size={48} radius="md">
|
||||
<IconCash size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder padding="lg">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Assets</Text>
|
||||
<Text fw={700} size="xl">{fmt(data?.balance_sheet?.total_assets || '0')}</Text>
|
||||
</div>
|
||||
<ThemeIcon color="blue" variant="light" size={48} radius="md">
|
||||
<IconBuildingBank size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder padding="lg">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Collection Rate</Text>
|
||||
<Text fw={700} size="xl">{collectionRate}%</Text>
|
||||
</div>
|
||||
<ThemeIcon color="violet" variant="light" size={48} radius="md">
|
||||
<IconFileInvoice size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
<Card withBorder padding="lg">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>1099 Vendors</Text>
|
||||
<Text fw={700} size="xl">{data?.vendors_1099?.length || 0}</Text>
|
||||
</div>
|
||||
<ThemeIcon color="orange" variant="light" size={48} radius="md">
|
||||
<IconUsers size={28} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Accordion variant="separated" multiple defaultValue={['income', 'collections', 'vendors', 'reserves']}>
|
||||
{/* Income Statement Summary */}
|
||||
<Accordion.Item value="income">
|
||||
<Accordion.Control icon={<IconCash size={20} />}>
|
||||
Income Statement Summary
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
<div>
|
||||
<Text fw={600} mb="xs">Income</Text>
|
||||
<Table>
|
||||
<Table.Tbody>
|
||||
{(data?.income_statement?.income || []).map((i: any) => (
|
||||
<Table.Tr key={i.account_number}>
|
||||
<Table.Td>{i.name}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(i.amount)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={700}>Total Income</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(data?.income_statement?.total_income || '0')}</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
</div>
|
||||
<div>
|
||||
<Text fw={600} mb="xs">Expenses</Text>
|
||||
<Table>
|
||||
<Table.Tbody>
|
||||
{(data?.income_statement?.expenses || []).map((e: any) => (
|
||||
<Table.Tr key={e.account_number}>
|
||||
<Table.Td>{e.name}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(e.amount)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={700}>Total Expenses</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">{fmt(data?.income_statement?.total_expenses || '0')}</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
</div>
|
||||
</SimpleGrid>
|
||||
<Divider my="md" />
|
||||
<Group justify="space-between" px="sm">
|
||||
<Text fw={700} size="lg">Net Income / (Loss)</Text>
|
||||
<Text fw={700} size="lg" ff="monospace"
|
||||
c={parseFloat(data?.income_statement?.net_income || '0') >= 0 ? 'green' : 'red'}>
|
||||
{fmt(data?.income_statement?.net_income || '0')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Collections Summary */}
|
||||
<Accordion.Item value="collections">
|
||||
<Accordion.Control icon={<IconFileInvoice size={20} />}>
|
||||
Assessment Collections Summary
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase">Total Assessed</Text>
|
||||
<Text fw={700} size="lg">{fmt(collections?.total_assessed || '0')}</Text>
|
||||
<Text size="xs" c="dimmed">{collections?.total_invoices || 0} invoices</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase">Total Collected</Text>
|
||||
<Text fw={700} size="lg" c="green">{fmt(collections?.total_collected || '0')}</Text>
|
||||
<Text size="xs" c="dimmed">{collections?.paid_invoices || 0} paid</Text>
|
||||
</Card>
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase">Outstanding</Text>
|
||||
<Text fw={700} size="lg" c="red">{fmt(collections?.total_outstanding || '0')}</Text>
|
||||
<Text size="xs" c="dimmed">{collections?.outstanding_invoices || 0} unpaid</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* 1099 Vendors */}
|
||||
<Accordion.Item value="vendors">
|
||||
<Accordion.Control icon={<IconUsers size={20} />}>
|
||||
1099-NEC Vendor Report ({data?.vendors_1099?.length || 0} vendors)
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
{(data?.vendors_1099 || []).length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="md">No vendors met the $600 threshold for 1099 reporting.</Text>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vendor Name</Table.Th>
|
||||
<Table.Th>Tax ID</Table.Th>
|
||||
<Table.Th>Address</Table.Th>
|
||||
<Table.Th ta="right">Total Paid</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(data?.vendors_1099 || []).map((v) => (
|
||||
<Table.Tr key={v.id}>
|
||||
<Table.Td fw={500}>{v.name}</Table.Td>
|
||||
<Table.Td>
|
||||
{v.tax_id ? (
|
||||
<Badge variant="light" size="sm">***-**-{v.tax_id.slice(-4)}</Badge>
|
||||
) : (
|
||||
<Badge color="red" variant="light" size="sm">Missing</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{v.address_line1}{v.city ? `, ${v.city}` : ''}{v.state ? `, ${v.state}` : ''} {v.zip_code}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace" fw={500}>{fmt(v.total_paid)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={3} fw={700}>Total 1099 Payments</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">
|
||||
{fmt((data?.vendors_1099 || []).reduce((s, v) => s + parseFloat(v.total_paid), 0))}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
)}
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
|
||||
{/* Reserve Fund Status */}
|
||||
<Accordion.Item value="reserves">
|
||||
<Accordion.Control icon={<IconShieldCheck size={20} />}>
|
||||
Reserve Fund Status
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Component</Table.Th>
|
||||
<Table.Th ta="right">Current Balance</Table.Th>
|
||||
<Table.Th ta="right">Replacement Cost</Table.Th>
|
||||
<Table.Th ta="right">% Funded</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(data?.reserve_status || []).map((r) => {
|
||||
const pct = parseFloat(r.percent_funded);
|
||||
const color = pct >= 80 ? 'green' : pct >= 50 ? 'yellow' : 'red';
|
||||
return (
|
||||
<Table.Tr key={r.name}>
|
||||
<Table.Td fw={500}>{r.name}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(r.current_fund_balance)}</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(r.replacement_cost)}</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Badge color={color} variant="light">{pct}%</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
<Table.Tfoot>
|
||||
<Table.Tr>
|
||||
<Table.Td fw={700}>Total</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">
|
||||
{fmt((data?.reserve_status || []).reduce((s, r) => s + parseFloat(r.current_fund_balance), 0))}
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" fw={700} ff="monospace">
|
||||
{fmt((data?.reserve_status || []).reduce((s, r) => s + parseFloat(r.replacement_cost), 0))}
|
||||
</Table.Td>
|
||||
<Table.Td />
|
||||
</Table.Tr>
|
||||
</Table.Tfoot>
|
||||
</Table>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
131
frontend/src/pages/settings/SettingsPage.tsx
Normal file
131
frontend/src/pages/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
Title, Text, Card, Stack, Group, SimpleGrid, Badge, ThemeIcon, Divider,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBuilding, IconUser, IconUsers, IconSettings, IconShieldLock,
|
||||
IconCalendar,
|
||||
} from '@tabler/icons-react';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
|
||||
export function SettingsPage() {
|
||||
const { user, currentOrg } = useAuthStore();
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<div>
|
||||
<Title order={2}>Settings</Title>
|
||||
<Text c="dimmed" size="sm">Organization and account settings</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, md: 2 }}>
|
||||
{/* Organization Info */}
|
||||
<Card withBorder padding="lg">
|
||||
<Group mb="md">
|
||||
<ThemeIcon color="blue" variant="light" size={40} radius="md">
|
||||
<IconBuilding size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="lg">Organization</Text>
|
||||
<Text c="dimmed" size="sm">Current organization details</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Name</Text>
|
||||
<Text size="sm" fw={500}>{currentOrg?.name || 'N/A'}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Your Role</Text>
|
||||
<Badge variant="light">{currentOrg?.role || 'N/A'}</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Schema</Text>
|
||||
<Text size="sm" ff="monospace" c="dimmed">{currentOrg?.schemaName || 'N/A'}</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* User Profile */}
|
||||
<Card withBorder padding="lg">
|
||||
<Group mb="md">
|
||||
<ThemeIcon color="green" variant="light" size={40} radius="md">
|
||||
<IconUser size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="lg">Your Profile</Text>
|
||||
<Text c="dimmed" size="sm">Account information</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Name</Text>
|
||||
<Text size="sm" fw={500}>{user?.firstName} {user?.lastName}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Email</Text>
|
||||
<Text size="sm" fw={500}>{user?.email}</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">User ID</Text>
|
||||
<Text size="sm" ff="monospace" c="dimmed">{user?.id?.slice(0, 8)}...</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Security */}
|
||||
<Card withBorder padding="lg">
|
||||
<Group mb="md">
|
||||
<ThemeIcon color="red" variant="light" size={40} radius="md">
|
||||
<IconShieldLock size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="lg">Security</Text>
|
||||
<Text c="dimmed" size="sm">Authentication and access</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Authentication</Text>
|
||||
<Badge color="green" variant="light">Active Session</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Two-Factor Auth</Text>
|
||||
<Badge color="gray" variant="light">Not Configured</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">OAuth Providers</Text>
|
||||
<Badge color="gray" variant="light">None Linked</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* System Info */}
|
||||
<Card withBorder padding="lg">
|
||||
<Group mb="md">
|
||||
<ThemeIcon color="violet" variant="light" size={40} radius="md">
|
||||
<IconSettings size={24} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={600} size="lg">System</Text>
|
||||
<Text c="dimmed" size="sm">Platform information</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Platform</Text>
|
||||
<Text size="sm" fw={500}>HOA LedgerIQ</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Version</Text>
|
||||
<Badge variant="light">0.1.0 MVP</Badge>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">API</Text>
|
||||
<Text size="sm" ff="monospace" c="dimmed">/api/docs</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user