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:
2026-02-18 09:09:50 -05:00
parent 243770cea5
commit e0272f9d8a
17 changed files with 1200 additions and 18 deletions

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HOA Financial Platform</title>
<title>HOA LedgerIQ</title>
</head>
<body>
<div id="root"></div>

View File

@@ -1,5 +1,5 @@
{
"name": "hoa-financial-platform-frontend",
"name": "hoa-ledgeriq-frontend",
"version": "0.1.0",
"private": true,
"type": "module",

View File

@@ -19,7 +19,10 @@ import { BalanceSheetPage } from './pages/reports/BalanceSheetPage';
import { IncomeStatementPage } from './pages/reports/IncomeStatementPage';
import { BudgetVsActualPage } from './pages/reports/BudgetVsActualPage';
import { SankeyPage } from './pages/reports/SankeyPage';
import { PlaceholderPage } from './pages/PlaceholderPage';
import { CashFlowPage } from './pages/reports/CashFlowPage';
import { AgingReportPage } from './pages/reports/AgingReportPage';
import { YearEndPage } from './pages/reports/YearEndPage';
import { SettingsPage } from './pages/settings/SettingsPage';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
@@ -93,11 +96,11 @@ export function App() {
<Route path="reports/balance-sheet" element={<BalanceSheetPage />} />
<Route path="reports/income-statement" element={<IncomeStatementPage />} />
<Route path="reports/budget-vs-actual" element={<BudgetVsActualPage />} />
<Route path="reports/cash-flow" element={<PlaceholderPage title="Cash Flow Statement" />} />
<Route path="reports/aging" element={<PlaceholderPage title="Aging Report" />} />
<Route path="reports/cash-flow" element={<CashFlowPage />} />
<Route path="reports/aging" element={<AgingReportPage />} />
<Route path="reports/sankey" element={<SankeyPage />} />
<Route path="year-end" element={<PlaceholderPage title="Year-End Package" />} />
<Route path="settings" element={<PlaceholderPage title="Settings" />} />
<Route path="year-end" element={<YearEndPage />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
</Routes>
);

View File

@@ -30,7 +30,7 @@ export function AppLayout() {
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Title order={3} c="blue">HOA Financial Platform</Title>
<Title order={3} c="blue">HOA LedgerIQ</Title>
</Group>
<Group>
{currentOrg && (

View File

@@ -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&apos;t have an account?{' '}

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -54,8 +54,8 @@ export const useAuthStore = create<AuthState>()(
}),
}),
{
name: 'hoa-auth',
version: 2,
name: 'ledgeriq-auth',
version: 3,
migrate: () => ({
token: null,
user: null,