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

@@ -1,7 +1,7 @@
{ {
"name": "hoa-financial-platform-backend", "name": "hoa-ledgeriq-backend",
"version": "0.1.0", "version": "0.1.0",
"description": "HOA Financial Intelligence Platform - Backend API", "description": "HOA LedgerIQ - Backend API",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",

View File

@@ -28,8 +28,8 @@ async function bootstrap() {
}); });
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('HOA Financial Platform API') .setTitle('HOA LedgerIQ API')
.setDescription('API for the HOA Financial Intelligence Platform') .setDescription('API for the HOA LedgerIQ')
.setVersion('0.1.0') .setVersion('0.1.0')
.addBearerAuth() .addBearerAuth()
.build(); .build();

View File

@@ -28,6 +28,24 @@ export class ReportsController {
return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear()); return this.reportsService.getCashFlowSankey(parseInt(year || '') || new Date().getFullYear());
} }
@Get('cash-flow')
getCashFlowStatement(@Query('from') from?: string, @Query('to') to?: string) {
const now = new Date();
const defaultFrom = `${now.getFullYear()}-01-01`;
const defaultTo = now.toISOString().split('T')[0];
return this.reportsService.getCashFlowStatement(from || defaultFrom, to || defaultTo);
}
@Get('aging')
getAgingReport() {
return this.reportsService.getAgingReport();
}
@Get('year-end')
getYearEndSummary(@Query('year') year?: string) {
return this.reportsService.getYearEndSummary(parseInt(year || '') || new Date().getFullYear());
}
@Get('dashboard') @Get('dashboard')
getDashboardKPIs() { getDashboardKPIs() {
return this.reportsService.getDashboardKPIs(); return this.reportsService.getDashboardKPIs();

View File

@@ -178,6 +178,221 @@ export class ReportsService {
return { nodes, links, total_income: totalIncome, total_expenses: totalExpenses, net_cash_flow: netFlow }; return { nodes, links, total_income: totalIncome, total_expenses: totalExpenses, net_cash_flow: netFlow };
} }
async getCashFlowStatement(from: string, to: string) {
// Operating activities: income minus expenses from journal entries
const operating = await this.tenant.query(`
SELECT a.name, a.account_type,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE -(COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0))
END as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
WHERE a.account_type IN ('income', 'expense') AND a.is_active = true
AND a.fund_type = 'operating'
GROUP BY a.id, a.name, a.account_type
HAVING ABS(CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END) > 0
ORDER BY a.account_type, a.name
`, [from, to]);
// Reserve fund activities
const reserve = await this.tenant.query(`
SELECT a.name,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE -(COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0))
END as amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date BETWEEN $1 AND $2
WHERE a.is_active = true AND a.fund_type = 'reserve'
GROUP BY a.id, a.name, a.account_type
HAVING ABS(COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)) > 0
ORDER BY a.name
`, [from, to]);
// Cash beginning and ending balances
const beginCash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date < $1
WHERE a.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true
GROUP BY a.id
) sub
`, [from]);
const endCash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.bal), 0) as balance FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date <= $1
WHERE a.account_type = 'asset' AND a.name LIKE '%Cash%' AND a.is_active = true
GROUP BY a.id
) sub
`, [to]);
const operatingItems = operating.map((r: any) => ({
name: r.name, type: r.account_type, amount: parseFloat(r.amount),
}));
const reserveItems = reserve.map((r: any) => ({
name: r.name, amount: parseFloat(r.amount),
}));
const totalOperating = operatingItems.reduce((s: number, r: any) => s + r.amount, 0);
const totalReserve = reserveItems.reduce((s: number, r: any) => s + r.amount, 0);
const beginningBalance = parseFloat(beginCash[0]?.balance || '0');
const endingBalance = parseFloat(endCash[0]?.balance || '0');
return {
from, to,
operating_activities: operatingItems,
reserve_activities: reserveItems,
total_operating: totalOperating.toFixed(2),
total_reserve: totalReserve.toFixed(2),
net_cash_change: (totalOperating + totalReserve).toFixed(2),
beginning_cash: beginningBalance.toFixed(2),
ending_cash: endingBalance.toFixed(2),
};
}
async getAgingReport() {
const sql = `
SELECT
u.id as unit_id, u.unit_number, u.owner_name,
i.id as invoice_id, i.invoice_number, i.description,
i.due_date, i.amount, i.amount_paid,
i.amount - i.amount_paid as balance_due,
CASE
WHEN i.due_date >= CURRENT_DATE THEN 'current'
WHEN CURRENT_DATE - i.due_date BETWEEN 1 AND 30 THEN '1-30'
WHEN CURRENT_DATE - i.due_date BETWEEN 31 AND 60 THEN '31-60'
WHEN CURRENT_DATE - i.due_date BETWEEN 61 AND 90 THEN '61-90'
ELSE '90+'
END as aging_bucket
FROM invoices i
JOIN units u ON u.id = i.unit_id
WHERE i.status NOT IN ('paid', 'void', 'written_off')
AND i.amount - i.amount_paid > 0
ORDER BY u.unit_number, i.due_date
`;
const rows = await this.tenant.query(sql);
// Aggregate by unit
const unitMap = new Map<string, any>();
rows.forEach((r: any) => {
if (!unitMap.has(r.unit_id)) {
unitMap.set(r.unit_id, {
unit_id: r.unit_id, unit_number: r.unit_number, owner_name: r.owner_name,
current: 0, days_1_30: 0, days_31_60: 0, days_61_90: 0, days_90_plus: 0,
total: 0, invoices: [],
});
}
const unit = unitMap.get(r.unit_id);
const bal = parseFloat(r.balance_due);
unit.total += bal;
switch (r.aging_bucket) {
case 'current': unit.current += bal; break;
case '1-30': unit.days_1_30 += bal; break;
case '31-60': unit.days_31_60 += bal; break;
case '61-90': unit.days_61_90 += bal; break;
case '90+': unit.days_90_plus += bal; break;
}
unit.invoices.push({
invoice_id: r.invoice_id, invoice_number: r.invoice_number,
description: r.description, due_date: r.due_date,
amount: parseFloat(r.amount), amount_paid: parseFloat(r.amount_paid),
balance_due: bal, aging_bucket: r.aging_bucket,
});
});
const units = Array.from(unitMap.values());
const summary = {
current: units.reduce((s, u) => s + u.current, 0),
days_1_30: units.reduce((s, u) => s + u.days_1_30, 0),
days_31_60: units.reduce((s, u) => s + u.days_31_60, 0),
days_61_90: units.reduce((s, u) => s + u.days_61_90, 0),
days_90_plus: units.reduce((s, u) => s + u.days_90_plus, 0),
total: units.reduce((s, u) => s + u.total, 0),
unit_count: units.length,
};
return { units, summary };
}
async getYearEndSummary(year: number) {
// Income statement for the full year
const from = `${year}-01-01`;
const to = `${year}-12-31`;
const incomeStmt = await this.getIncomeStatement(from, to);
const balanceSheet = await this.getBalanceSheet(to);
// 1099 vendor data
const vendors1099 = await this.tenant.query(`
SELECT v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code,
COALESCE(SUM(p.amount), 0) as total_paid
FROM vendors v
JOIN (
SELECT vendor_id, amount FROM invoices
WHERE EXTRACT(YEAR FROM invoice_date) = $1
AND status IN ('paid', 'partial')
) p ON p.vendor_id = v.id
WHERE v.is_1099_eligible = true
GROUP BY v.id, v.name, v.tax_id, v.address_line1, v.city, v.state, v.zip_code
HAVING COALESCE(SUM(p.amount), 0) >= 600
ORDER BY v.name
`, [year]);
// Collection stats
const collections = await this.tenant.query(`
SELECT
COUNT(*) as total_invoices,
COUNT(*) FILTER (WHERE status = 'paid') as paid_invoices,
COUNT(*) FILTER (WHERE status IN ('overdue', 'sent')) as outstanding_invoices,
COALESCE(SUM(amount), 0) as total_assessed,
COALESCE(SUM(amount_paid), 0) as total_collected,
COALESCE(SUM(amount - amount_paid) FILTER (WHERE status NOT IN ('paid', 'void', 'written_off')), 0) as total_outstanding
FROM invoices
WHERE EXTRACT(YEAR FROM invoice_date) = $1
`, [year]);
// Reserve fund status
const reserveStatus = await this.tenant.query(`
SELECT name, current_fund_balance, replacement_cost,
CASE WHEN replacement_cost > 0
THEN ROUND((current_fund_balance / replacement_cost * 100)::numeric, 1)
ELSE 0 END as percent_funded
FROM reserve_components
ORDER BY name
`);
return {
year,
income_statement: incomeStmt,
balance_sheet: balanceSheet,
vendors_1099: vendors1099,
collections: collections[0] || {},
reserve_status: reserveStatus,
};
}
async getDashboardKPIs() { async getDashboardKPIs() {
// Total cash (all asset accounts with 'Cash' in name) // Total cash (all asset accounts with 'Cash' in name)
const cash = await this.tenant.query(` const cash = await this.tenant.query(`

View File

@@ -1,4 +1,4 @@
-- HOA Financial Platform - Database Initialization -- HOA LedgerIQ - Database Initialization
-- Creates shared schema and base tables for multi-tenant architecture -- Creates shared schema and base tables for multi-tenant architecture
CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

View File

@@ -1,5 +1,5 @@
-- ============================================================ -- ============================================================
-- HOA Financial Platform - Comprehensive Seed Data -- HOA LedgerIQ - Comprehensive Seed Data
-- "Sunrise Valley HOA" - 50 units, full year of financial data -- "Sunrise Valley HOA" - 50 units, full year of financial data
-- ============================================================ -- ============================================================
-- This script: -- This script:

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ export function AppLayout() {
<Group h="100%" px="md" justify="space-between"> <Group h="100%" px="md" justify="space-between">
<Group> <Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" /> <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>
<Group> <Group>
{currentOrg && ( {currentOrg && (

View File

@@ -53,7 +53,7 @@ export function LoginPage() {
return ( return (
<Container size={420} my={80}> <Container size={420} my={80}>
<Title ta="center" order={2}> <Title ta="center" order={2}>
HOA Financial Platform HOA LedgerIQ
</Title> </Title>
<Text c="dimmed" size="sm" ta="center" mt={5}> <Text c="dimmed" size="sm" ta="center" mt={5}>
Don&apos;t have an account?{' '} Don&apos;t have an account?{' '}

View File

@@ -57,7 +57,7 @@ export function DashboardPage() {
{!currentOrg ? ( {!currentOrg ? (
<Card withBorder p="xl" ta="center"> <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"> <Text c="dimmed" mt="sm">
Create or select an organization to get started. Create or select an organization to get started.
</Text> </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', name: 'ledgeriq-auth',
version: 2, version: 3,
migrate: () => ({ migrate: () => ({
token: null, token: null,
user: null, user: null,