From e0272f9d8a49dc4b24887906e64566356213a576 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Wed, 18 Feb 2026 09:09:50 -0500 Subject: [PATCH] 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 --- backend/package.json | 4 +- backend/src/main.ts | 4 +- .../src/modules/reports/reports.controller.ts | 18 ++ .../src/modules/reports/reports.service.ts | 215 +++++++++++++ db/init/00-init.sql | 2 +- db/seed/seed.sql | 2 +- frontend/index.html | 2 +- frontend/package.json | 2 +- frontend/src/App.tsx | 13 +- frontend/src/components/layout/AppLayout.tsx | 2 +- frontend/src/pages/auth/LoginPage.tsx | 2 +- .../src/pages/dashboard/DashboardPage.tsx | 2 +- .../src/pages/reports/AgingReportPage.tsx | 254 +++++++++++++++ frontend/src/pages/reports/CashFlowPage.tsx | 257 +++++++++++++++ frontend/src/pages/reports/YearEndPage.tsx | 304 ++++++++++++++++++ frontend/src/pages/settings/SettingsPage.tsx | 131 ++++++++ frontend/src/stores/authStore.ts | 4 +- 17 files changed, 1200 insertions(+), 18 deletions(-) create mode 100644 frontend/src/pages/reports/AgingReportPage.tsx create mode 100644 frontend/src/pages/reports/CashFlowPage.tsx create mode 100644 frontend/src/pages/reports/YearEndPage.tsx create mode 100644 frontend/src/pages/settings/SettingsPage.tsx diff --git a/backend/package.json b/backend/package.json index 8c28bba..a48036d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,7 +1,7 @@ { - "name": "hoa-financial-platform-backend", + "name": "hoa-ledgeriq-backend", "version": "0.1.0", - "description": "HOA Financial Intelligence Platform - Backend API", + "description": "HOA LedgerIQ - Backend API", "private": true, "scripts": { "build": "nest build", diff --git a/backend/src/main.ts b/backend/src/main.ts index 3c9f213..1079c90 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -28,8 +28,8 @@ async function bootstrap() { }); const config = new DocumentBuilder() - .setTitle('HOA Financial Platform API') - .setDescription('API for the HOA Financial Intelligence Platform') + .setTitle('HOA LedgerIQ API') + .setDescription('API for the HOA LedgerIQ') .setVersion('0.1.0') .addBearerAuth() .build(); diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts index c54f05d..6d49ff4 100644 --- a/backend/src/modules/reports/reports.controller.ts +++ b/backend/src/modules/reports/reports.controller.ts @@ -28,6 +28,24 @@ export class ReportsController { 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') getDashboardKPIs() { return this.reportsService.getDashboardKPIs(); diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index da8d2e5..c926abe 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -178,6 +178,221 @@ export class ReportsService { 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(); + 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() { // Total cash (all asset accounts with 'Cash' in name) const cash = await this.tenant.query(` diff --git a/db/init/00-init.sql b/db/init/00-init.sql index 72c23d4..6fe0ba1 100644 --- a/db/init/00-init.sql +++ b/db/init/00-init.sql @@ -1,4 +1,4 @@ --- HOA Financial Platform - Database Initialization +-- HOA LedgerIQ - Database Initialization -- Creates shared schema and base tables for multi-tenant architecture CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; diff --git a/db/seed/seed.sql b/db/seed/seed.sql index 3b9e90c..f38df90 100644 --- a/db/seed/seed.sql +++ b/db/seed/seed.sql @@ -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 -- ============================================================ -- This script: diff --git a/frontend/index.html b/frontend/index.html index 7b58446..6f7c247 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - HOA Financial Platform + HOA LedgerIQ
diff --git a/frontend/package.json b/frontend/package.json index d369d37..dc44097 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "hoa-financial-platform-frontend", + "name": "hoa-ledgeriq-frontend", "version": "0.1.0", "private": true, "type": "module", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a486be..c49f633 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> - } /> - } /> + } /> + } /> ); diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 57d309e..e707ca9 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -30,7 +30,7 @@ export function AppLayout() { - HOA Financial Platform + HOA LedgerIQ {currentOrg && ( diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index 0512a45..2b644d6 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -53,7 +53,7 @@ export function LoginPage() { return ( - HOA Financial Platform + HOA LedgerIQ Don't have an account?{' '} diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index e495395..a40ed6b 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -57,7 +57,7 @@ export function DashboardPage() { {!currentOrg ? ( - Welcome to the HOA Financial Platform + Welcome to HOA LedgerIQ Create or select an organization to get started. diff --git a/frontend/src/pages/reports/AgingReportPage.tsx b/frontend/src/pages/reports/AgingReportPage.tsx new file mode 100644 index 0000000..52af9c6 --- /dev/null +++ b/frontend/src/pages/reports/AgingReportPage.tsx @@ -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 = { + 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 ( + + {bucket} + + ); +} + +function InvoiceRows({ invoices }: { invoices: Invoice[] }) { + return ( + + + + + + + Invoice # + Description + Due Date + Amount + Paid + Balance + Bucket + + + + {invoices.map((inv) => ( + + + {inv.invoice_number} + {inv.description} + {new Date(inv.due_date).toLocaleDateString()} + {fmt(inv.amount)} + {fmt(inv.amount_paid)} + {fmt(inv.balance_due)} + + + ))} + +
+
+
+ ); +} + +export function AgingReportPage() { + const [expandedRows, setExpandedRows] = useState>(new Set()); + + const { data, isLoading } = useQuery({ + 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
; + + 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: }, + { label: '1-30 Days', value: summary?.days_1_30 || 0, color: 'yellow', icon: }, + { label: '31-60 Days', value: summary?.days_31_60 || 0, color: 'orange', icon: }, + { label: '61-90 Days', value: summary?.days_61_90 || 0, color: 'red', icon: }, + { label: '90+ Days', value: summary?.days_90_plus || 0, color: 'red', icon: }, + { label: 'Total Outstanding', value: summary?.total || 0, color: 'blue', icon: }, + ]; + + return ( + + + Aging Report + {summary && ( + + {summary.unit_count} {summary.unit_count === 1 ? 'unit' : 'units'} with outstanding balances + + )} + + + + {summaryCards.map((card) => ( + + + + {card.icon} + + {card.label} + + 0 ? card.color : undefined}> + {fmt(card.value)} + + + ))} + + + + + + + + Outstanding Balances by Unit + + + + + + + Unit # + Owner + Current + 1-30 + 31-60 + 61-90 + 90+ + Total + + + + {units.map((unit) => { + const isExpanded = expandedRows.has(unit.unit_id); + return ( + <> + toggleRow(unit.unit_id)} + style={{ cursor: 'pointer' }} + > + + + {isExpanded ? : } + + + {unit.unit_number} + {unit.owner_name} + 0 ? 'green' : 'dimmed'}> + {fmt(unit.current)} + + 0 ? 'yellow.7' : 'dimmed'}> + {fmt(unit.days_1_30)} + + 0 ? 'orange' : 'dimmed'}> + {fmt(unit.days_31_60)} + + 0 ? 'red' : 'dimmed'}> + {fmt(unit.days_61_90)} + + 0 ? 'red.8' : 'dimmed'}> + {fmt(unit.days_90_plus)} + + + {fmt(unit.total)} + + + {isExpanded && ( + + )} + + ); + })} + + + + + Totals + {fmt(summary?.current || 0)} + {fmt(summary?.days_1_30 || 0)} + {fmt(summary?.days_31_60 || 0)} + {fmt(summary?.days_61_90 || 0)} + {fmt(summary?.days_90_plus || 0)} + {fmt(summary?.total || 0)} + + +
+
+
+ ); +} diff --git a/frontend/src/pages/reports/CashFlowPage.tsx b/frontend/src/pages/reports/CashFlowPage.tsx new file mode 100644 index 0000000..13750e2 --- /dev/null +++ b/frontend/src/pages/reports/CashFlowPage.tsx @@ -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({ + 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
; + + return ( + + + Cash Flow Statement + + setFromDate(e.currentTarget.value)} + w={160} + /> + setToDate(e.currentTarget.value)} + w={160} + /> + + + + + {/* Summary Cards */} + + + + + + + Beginning Cash + + {fmt(beginningCash)} + + + + = 0 ? 'green' : 'red'} size="sm"> + {totalOperating >= 0 ? : } + + Net Operating + + = 0 ? 'green' : 'red'}> + {fmt(totalOperating)} + + + + + = 0 ? 'green' : 'red'} size="sm"> + + + Net Reserve + + = 0 ? 'green' : 'red'}> + {fmt(totalReserve)} + + + + + + + + Ending Cash + + {fmt(endingCash)} + + + + {/* Operating Activities */} + + Operating Activities + + + + Activity + Type + Amount + + + + {(data?.operating_activities || []).length === 0 && ( + + + No operating activities for this period. + + + )} + {(data?.operating_activities || []).map((a, idx) => ( + + {a.name} + + + {a.type} + + + = 0 ? 'green' : 'red'}> + {fmt(a.amount)} + + + ))} + + + + Total Operating Activities + = 0 ? 'green' : 'red'}> + {fmt(data?.total_operating || '0')} + + + +
+
+ + {/* Reserve Fund Activities */} + + Reserve Fund Activities + + + + Activity + Amount + + + + {(data?.reserve_activities || []).length === 0 && ( + + + No reserve fund activities for this period. + + + )} + {(data?.reserve_activities || []).map((a, idx) => ( + + {a.name} + = 0 ? 'green' : 'red'}> + {fmt(a.amount)} + + + ))} + + + + Total Reserve Fund Activities + = 0 ? 'green' : 'red'}> + {fmt(data?.total_reserve || '0')} + + + +
+
+ + {/* Net Cash Change Summary */} + + + + Net Cash Change + = 0 ? 'green' : 'red'} + > + {fmt(data?.net_cash_change || '0')} + + + + + Beginning Cash + {fmt(data?.beginning_cash || '0')} + + + Ending Cash + + {fmt(data?.ending_cash || '0')} + + + + +
+ ); +} diff --git a/frontend/src/pages/reports/YearEndPage.tsx b/frontend/src/pages/reports/YearEndPage.tsx new file mode 100644 index 0000000..dccfd60 --- /dev/null +++ b/frontend/src/pages/reports/YearEndPage.tsx @@ -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({ + 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
; + + const collections = data?.collections; + const collectionRate = collections + ? (parseFloat(collections.total_collected) / Math.max(parseFloat(collections.total_assessed), 1) * 100).toFixed(1) + : '0'; + + return ( + + +
+ Year-End Package + Annual financial summary and compliance reports +
+ setYear(Number(v) || year)} + min={2020} max={2030} w={120} /> +
+ + {/* Summary Cards */} + + + +
+ Net Income + = 0 ? 'green' : 'red'}> + {fmt(data?.income_statement?.net_income || '0')} + +
+ + + +
+
+ + +
+ Total Assets + {fmt(data?.balance_sheet?.total_assets || '0')} +
+ + + +
+
+ + +
+ Collection Rate + {collectionRate}% +
+ + + +
+
+ + +
+ 1099 Vendors + {data?.vendors_1099?.length || 0} +
+ + + +
+
+
+ + + {/* Income Statement Summary */} + + }> + Income Statement Summary + + + +
+ Income + + + {(data?.income_statement?.income || []).map((i: any) => ( + + {i.name} + {fmt(i.amount)} + + ))} + + + + Total Income + {fmt(data?.income_statement?.total_income || '0')} + + +
+
+
+ Expenses + + + {(data?.income_statement?.expenses || []).map((e: any) => ( + + {e.name} + {fmt(e.amount)} + + ))} + + + + Total Expenses + {fmt(data?.income_statement?.total_expenses || '0')} + + +
+
+
+ + + Net Income / (Loss) + = 0 ? 'green' : 'red'}> + {fmt(data?.income_statement?.net_income || '0')} + + +
+
+ + {/* Collections Summary */} + + }> + Assessment Collections Summary + + + + + Total Assessed + {fmt(collections?.total_assessed || '0')} + {collections?.total_invoices || 0} invoices + + + Total Collected + {fmt(collections?.total_collected || '0')} + {collections?.paid_invoices || 0} paid + + + Outstanding + {fmt(collections?.total_outstanding || '0')} + {collections?.outstanding_invoices || 0} unpaid + + + + + + {/* 1099 Vendors */} + + }> + 1099-NEC Vendor Report ({data?.vendors_1099?.length || 0} vendors) + + + {(data?.vendors_1099 || []).length === 0 ? ( + No vendors met the $600 threshold for 1099 reporting. + ) : ( + + + + Vendor Name + Tax ID + Address + Total Paid + + + + {(data?.vendors_1099 || []).map((v) => ( + + {v.name} + + {v.tax_id ? ( + ***-**-{v.tax_id.slice(-4)} + ) : ( + Missing + )} + + + {v.address_line1}{v.city ? `, ${v.city}` : ''}{v.state ? `, ${v.state}` : ''} {v.zip_code} + + {fmt(v.total_paid)} + + ))} + + + + Total 1099 Payments + + {fmt((data?.vendors_1099 || []).reduce((s, v) => s + parseFloat(v.total_paid), 0))} + + + +
+ )} +
+
+ + {/* Reserve Fund Status */} + + }> + Reserve Fund Status + + + + + + Component + Current Balance + Replacement Cost + % Funded + + + + {(data?.reserve_status || []).map((r) => { + const pct = parseFloat(r.percent_funded); + const color = pct >= 80 ? 'green' : pct >= 50 ? 'yellow' : 'red'; + return ( + + {r.name} + {fmt(r.current_fund_balance)} + {fmt(r.replacement_cost)} + + {pct}% + + + ); + })} + + + + Total + + {fmt((data?.reserve_status || []).reduce((s, r) => s + parseFloat(r.current_fund_balance), 0))} + + + {fmt((data?.reserve_status || []).reduce((s, r) => s + parseFloat(r.replacement_cost), 0))} + + + + +
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx new file mode 100644 index 0000000..4f90763 --- /dev/null +++ b/frontend/src/pages/settings/SettingsPage.tsx @@ -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 ( + +
+ Settings + Organization and account settings +
+ + + {/* Organization Info */} + + + + + +
+ Organization + Current organization details +
+
+ + + Name + {currentOrg?.name || 'N/A'} + + + Your Role + {currentOrg?.role || 'N/A'} + + + Schema + {currentOrg?.schemaName || 'N/A'} + + +
+ + {/* User Profile */} + + + + + +
+ Your Profile + Account information +
+
+ + + Name + {user?.firstName} {user?.lastName} + + + Email + {user?.email} + + + User ID + {user?.id?.slice(0, 8)}... + + +
+ + {/* Security */} + + + + + +
+ Security + Authentication and access +
+
+ + + Authentication + Active Session + + + Two-Factor Auth + Not Configured + + + OAuth Providers + None Linked + + +
+ + {/* System Info */} + + + + + +
+ System + Platform information +
+
+ + + Platform + HOA LedgerIQ + + + Version + 0.1.0 MVP + + + API + /api/docs + + +
+
+
+ ); +} diff --git a/frontend/src/stores/authStore.ts b/frontend/src/stores/authStore.ts index cf4cfe1..7679dbf 100644 --- a/frontend/src/stores/authStore.ts +++ b/frontend/src/stores/authStore.ts @@ -54,8 +54,8 @@ export const useAuthStore = create()( }), }), { - name: 'hoa-auth', - version: 2, + name: 'ledgeriq-auth', + version: 3, migrate: () => ({ token: null, user: null,