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