Initial commit: HOA Financial Intelligence Platform MVP

Multi-tenant financial management platform for homeowner associations featuring:
- NestJS backend with 16 modules (auth, accounts, transactions, budgets, units,
  invoices, payments, vendors, reserves, investments, capital projects, reports)
- React + Mantine frontend with dashboard, CRUD pages, and financial reports
- Schema-per-tenant PostgreSQL isolation with JWT-based tenant resolution
- Docker Compose infrastructure (nginx, backend, frontend, postgres, redis)
- Comprehensive seed data for Sunrise Valley HOA demo
- 39 API endpoints with Swagger documentation
- Double-entry bookkeeping with journal entries
- Budget vs actual reporting and Sankey cash flow visualization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 19:58:04 -05:00
commit 243770cea5
118 changed files with 8569 additions and 0 deletions

12
frontend/Dockerfile.dev Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HOA Financial Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

41
frontend/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "hoa-financial-platform-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx"
},
"dependencies": {
"@mantine/core": "^7.15.3",
"@mantine/hooks": "^7.15.3",
"@mantine/form": "^7.15.3",
"@mantine/dates": "^7.15.3",
"@mantine/notifications": "^7.15.3",
"@mantine/modals": "^7.15.3",
"@tabler/icons-react": "^3.28.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.2",
"recharts": "^2.15.0",
"d3-sankey": "^0.12.3",
"zustand": "^4.5.5",
"axios": "^1.7.9",
"@tanstack/react-query": "^5.64.2",
"dayjs": "^1.11.13"
},
"devDependencies": {
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/d3-sankey": "^0.12.4",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^5.4.14",
"postcss": "^8.4.49",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1"
}
}

View File

@@ -0,0 +1,14 @@
module.exports = {
plugins: {
'postcss-preset-mantine': {},
'postcss-simple-vars': {
variables: {
'mantine-breakpoint-xs': '36em',
'mantine-breakpoint-sm': '48em',
'mantine-breakpoint-md': '62em',
'mantine-breakpoint-lg': '75em',
'mantine-breakpoint-xl': '88em',
},
},
},
};

104
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuthStore } from './stores/authStore';
import { AppLayout } from './components/layout/AppLayout';
import { LoginPage } from './pages/auth/LoginPage';
import { RegisterPage } from './pages/auth/RegisterPage';
import { SelectOrgPage } from './pages/auth/SelectOrgPage';
import { DashboardPage } from './pages/dashboard/DashboardPage';
import { AccountsPage } from './pages/accounts/AccountsPage';
import { TransactionsPage } from './pages/transactions/TransactionsPage';
import { BudgetsPage } from './pages/budgets/BudgetsPage';
import { UnitsPage } from './pages/units/UnitsPage';
import { InvoicesPage } from './pages/invoices/InvoicesPage';
import { PaymentsPage } from './pages/payments/PaymentsPage';
import { VendorsPage } from './pages/vendors/VendorsPage';
import { ReservesPage } from './pages/reserves/ReservesPage';
import { InvestmentsPage } from './pages/investments/InvestmentsPage';
import { CapitalProjectsPage } from './pages/capital-projects/CapitalProjectsPage';
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';
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
if (!token) return <Navigate to="/login" replace />;
return <>{children}</>;
}
function OrgRequiredRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
const currentOrg = useAuthStore((s) => s.currentOrg);
if (!token) return <Navigate to="/login" replace />;
if (!currentOrg) return <Navigate to="/select-org" replace />;
return <>{children}</>;
}
function AuthRoute({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token);
const currentOrg = useAuthStore((s) => s.currentOrg);
if (token && currentOrg) return <Navigate to="/" replace />;
if (token && !currentOrg) return <Navigate to="/select-org" replace />;
return <>{children}</>;
}
export function App() {
return (
<Routes>
<Route
path="/login"
element={
<AuthRoute>
<LoginPage />
</AuthRoute>
}
/>
<Route
path="/register"
element={
<AuthRoute>
<RegisterPage />
</AuthRoute>
}
/>
<Route
path="/select-org"
element={
<ProtectedRoute>
<SelectOrgPage />
</ProtectedRoute>
}
/>
<Route
path="/*"
element={
<OrgRequiredRoute>
<AppLayout />
</OrgRequiredRoute>
}
>
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<DashboardPage />} />
<Route path="accounts" element={<AccountsPage />} />
<Route path="transactions" element={<TransactionsPage />} />
<Route path="budgets/:year" element={<BudgetsPage />} />
<Route path="units" element={<UnitsPage />} />
<Route path="invoices" element={<InvoicesPage />} />
<Route path="payments" element={<PaymentsPage />} />
<Route path="vendors" element={<VendorsPage />} />
<Route path="reserves" element={<ReservesPage />} />
<Route path="investments" element={<InvestmentsPage />} />
<Route path="capital-projects" element={<CapitalProjectsPage />} />
<Route path="reports/balance-sheet" element={<BalanceSheetPage />} />
<Route path="reports/income-statement" element={<IncomeStatementPage />} />
<Route path="reports/budget-vs-actual" element={<BudgetVsActualPage />} />
<Route path="reports/cash-flow" element={<PlaceholderPage title="Cash Flow Statement" />} />
<Route path="reports/aging" element={<PlaceholderPage title="Aging Report" />} />
<Route path="reports/sankey" element={<SankeyPage />} />
<Route path="year-end" element={<PlaceholderPage title="Year-End Package" />} />
<Route path="settings" element={<PlaceholderPage title="Settings" />} />
</Route>
</Routes>
);
}

View File

@@ -0,0 +1,81 @@
import { useState } from 'react';
import { AppShell, Burger, Group, Title, Text, Menu, UnstyledButton, Avatar } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import {
IconLogout,
IconSwitchHorizontal,
IconChevronDown,
} from '@tabler/icons-react';
import { Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../stores/authStore';
import { Sidebar } from './Sidebar';
export function AppLayout() {
const [opened, { toggle }] = useDisclosure();
const { user, currentOrg, logout } = useAuthStore();
const navigate = useNavigate();
const handleLogout = () => {
logout();
navigate('/login');
};
return (
<AppShell
header={{ height: 60 }}
navbar={{ width: 260, breakpoint: 'sm', collapsed: { mobile: !opened } }}
padding="md"
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between">
<Group>
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="sm" />
<Title order={3} c="blue">HOA Financial Platform</Title>
</Group>
<Group>
{currentOrg && (
<Text size="sm" c="dimmed">{currentOrg.name}</Text>
)}
<Menu shadow="md" width={200}>
<Menu.Target>
<UnstyledButton>
<Group gap="xs">
<Avatar size="sm" radius="xl" color="blue">
{user?.firstName?.[0]}{user?.lastName?.[0]}
</Avatar>
<Text size="sm">{user?.firstName} {user?.lastName}</Text>
<IconChevronDown size={14} />
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconSwitchHorizontal size={14} />}
onClick={() => navigate('/select-org')}
>
Switch Organization
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<IconLogout size={14} />}
onClick={handleLogout}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar>
<Sidebar />
</AppShell.Navbar>
<AppShell.Main>
<Outlet />
</AppShell.Main>
</AppShell>
);
}

View File

@@ -0,0 +1,86 @@
import { NavLink, ScrollArea } from '@mantine/core';
import { useNavigate, useLocation } from 'react-router-dom';
import {
IconDashboard,
IconListDetails,
IconReceipt,
IconHome,
IconFileInvoice,
IconCash,
IconReportAnalytics,
IconChartSankey,
IconShieldCheck,
IconPigMoney,
IconBuildingBank,
IconCalendarEvent,
IconUsers,
IconFileText,
IconSettings,
} from '@tabler/icons-react';
const navItems = [
{ label: 'Dashboard', icon: IconDashboard, path: '/dashboard' },
{ label: 'Chart of Accounts', icon: IconListDetails, path: '/accounts' },
{ label: 'Transactions', icon: IconReceipt, path: '/transactions' },
{ label: 'Units / Homeowners', icon: IconHome, path: '/units' },
{ label: 'Invoices', icon: IconFileInvoice, path: '/invoices' },
{ label: 'Payments', icon: IconCash, path: '/payments' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
{
label: 'Reports',
icon: IconChartSankey,
children: [
{ label: 'Balance Sheet', path: '/reports/balance-sheet' },
{ label: 'Income Statement', path: '/reports/income-statement' },
{ label: 'Cash Flow', path: '/reports/cash-flow' },
{ label: 'Budget vs Actual', path: '/reports/budget-vs-actual' },
{ label: 'Aging Report', path: '/reports/aging' },
{ label: 'Sankey Diagram', path: '/reports/sankey' },
],
},
{ label: 'Reserves', icon: IconShieldCheck, path: '/reserves' },
{ label: 'Investments', icon: IconPigMoney, path: '/investments' },
{ label: 'Capital Projects', icon: IconBuildingBank, path: '/capital-projects' },
{ label: 'Vendors', icon: IconUsers, path: '/vendors' },
{ label: 'Year-End', icon: IconFileText, path: '/year-end' },
{ label: 'Settings', icon: IconSettings, path: '/settings' },
];
export function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
return (
<ScrollArea p="sm">
{navItems.map((item) =>
item.children ? (
<NavLink
key={item.label}
label={item.label}
leftSection={<item.icon size={18} />}
defaultOpened={item.children.some((c) =>
location.pathname.startsWith(c.path),
)}
>
{item.children.map((child) => (
<NavLink
key={child.path}
label={child.label}
active={location.pathname === child.path}
onClick={() => navigate(child.path)}
/>
))}
</NavLink>
) : (
<NavLink
key={item.path}
label={item.label}
leftSection={<item.icon size={18} />}
active={location.pathname === item.path}
onClick={() => navigate(item.path!)}
/>
),
)}
</ScrollArea>
);
}

37
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,37 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/notifications/styles.css';
import { App } from './App';
import { theme } from './theme/theme';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<MantineProvider theme={theme}>
<Notifications position="top-right" />
<ModalsProvider>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</ModalsProvider>
</MantineProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,13 @@
import { Title, Text, Card, Stack } from '@mantine/core';
export function PlaceholderPage({ title }: { title: string }) {
return (
<Stack>
<Title order={2}>{title}</Title>
<Card withBorder p="xl" ta="center">
<Text size="lg" c="dimmed">Coming soon</Text>
<Text size="sm" c="dimmed" mt="sm">This feature is under development.</Text>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,271 @@
import { useState } from 'react';
import {
Title,
Table,
Badge,
Group,
Button,
TextInput,
Select,
Modal,
Stack,
NumberInput,
Switch,
Text,
Card,
ActionIcon,
Tabs,
Loader,
Center,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconSearch } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface Account {
id: string;
account_number: number;
name: string;
description: string;
account_type: string;
fund_type: string;
is_1099_reportable: boolean;
is_active: boolean;
is_system: boolean;
balance: string;
}
const accountTypeColors: Record<string, string> = {
asset: 'green',
liability: 'red',
equity: 'violet',
income: 'blue',
expense: 'orange',
};
export function AccountsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Account | null>(null);
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<string | null>(null);
const [filterFund, setFilterFund] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data: accounts = [], isLoading } = useQuery<Account[]>({
queryKey: ['accounts'],
queryFn: async () => {
const { data } = await api.get('/accounts');
return data;
},
});
const form = useForm({
initialValues: {
account_number: 0,
name: '',
description: '',
account_type: 'expense',
fund_type: 'operating',
is_1099_reportable: false,
},
validate: {
account_number: (v) => (v > 0 ? null : 'Required'),
name: (v) => (v.length > 0 ? null : 'Required'),
},
});
const createMutation = useMutation({
mutationFn: (values: any) =>
editing
? api.put(`/accounts/${editing.id}`, values)
: api.post('/accounts', values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
notifications.show({ message: editing ? 'Account updated' : 'Account created', color: 'green' });
close();
setEditing(null);
form.reset();
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
},
});
const handleEdit = (account: Account) => {
setEditing(account);
form.setValues({
account_number: account.account_number,
name: account.name,
description: account.description || '',
account_type: account.account_type,
fund_type: account.fund_type,
is_1099_reportable: account.is_1099_reportable,
});
open();
};
const handleNew = () => {
setEditing(null);
form.reset();
open();
};
const filtered = accounts.filter((a) => {
if (search && !a.name.toLowerCase().includes(search.toLowerCase()) && !String(a.account_number).includes(search)) return false;
if (filterType && a.account_type !== filterType) return false;
if (filterFund && a.fund_type !== filterFund) return false;
return true;
});
const totalsByType = accounts.reduce((acc, a) => {
acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0');
return acc;
}, {} as Record<string, number>);
if (isLoading) {
return <Center h={300}><Loader /></Center>;
}
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Chart of Accounts</Title>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
</Group>
<Group>
<TextInput
placeholder="Search accounts..."
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
style={{ flex: 1 }}
/>
<Select
placeholder="Type"
clearable
data={['asset', 'liability', 'equity', 'income', 'expense']}
value={filterType}
onChange={setFilterType}
w={150}
/>
<Select
placeholder="Fund"
clearable
data={['operating', 'reserve']}
value={filterFund}
onChange={setFilterFund}
w={150}
/>
</Group>
<Tabs defaultValue="all">
<Tabs.List>
<Tabs.Tab value="all">All ({accounts.length})</Tabs.Tab>
<Tabs.Tab value="operating">Operating</Tabs.Tab>
<Tabs.Tab value="reserve">Reserve</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="all" pt="sm">
<AccountTable accounts={filtered} onEdit={handleEdit} />
</Tabs.Panel>
<Tabs.Panel value="operating" pt="sm">
<AccountTable accounts={filtered.filter(a => a.fund_type === 'operating')} onEdit={handleEdit} />
</Tabs.Panel>
<Tabs.Panel value="reserve" pt="sm">
<AccountTable accounts={filtered.filter(a => a.fund_type === 'reserve')} onEdit={handleEdit} />
</Tabs.Panel>
</Tabs>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Account' : 'New Account'} size="md">
<form onSubmit={form.onSubmit((values) => createMutation.mutate(values))}>
<Stack>
<NumberInput label="Account Number" required {...form.getInputProps('account_number')} />
<TextInput label="Account Name" required {...form.getInputProps('name')} />
<TextInput label="Description" {...form.getInputProps('description')} />
<Select
label="Account Type"
required
data={[
{ value: 'asset', label: 'Asset' },
{ value: 'liability', label: 'Liability' },
{ value: 'equity', label: 'Equity' },
{ value: 'income', label: 'Income' },
{ value: 'expense', label: 'Expense' },
]}
{...form.getInputProps('account_type')}
/>
<Select
label="Fund Type"
required
data={[
{ value: 'operating', label: 'Operating' },
{ value: 'reserve', label: 'Reserve' },
]}
{...form.getInputProps('fund_type')}
/>
<Switch label="1099 Reportable" {...form.getInputProps('is_1099_reportable', { type: 'checkbox' })} />
<Button type="submit" loading={createMutation.isPending}>
{editing ? 'Update' : 'Create'}
</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}
function AccountTable({ accounts, onEdit }: { accounts: Account[]; onEdit: (a: Account) => void }) {
const fmt = (v: string) => {
const n = parseFloat(v || '0');
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
};
return (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Acct #</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Balance</Table.Th>
<Table.Th>1099</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{accounts.map((a) => (
<Table.Tr key={a.id}>
<Table.Td fw={500}>{a.account_number}</Table.Td>
<Table.Td>{a.name}</Table.Td>
<Table.Td>
<Badge color={accountTypeColors[a.account_type]} variant="light" size="sm">
{a.account_type}
</Badge>
</Table.Td>
<Table.Td>
<Badge color={a.fund_type === 'reserve' ? 'violet' : 'gray'} variant="light" size="sm">
{a.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(a.balance)}</Table.Td>
<Table.Td>{a.is_1099_reportable ? '1099' : ''}</Table.Td>
<Table.Td>
{!a.is_system && (
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
<IconEdit size={16} />
</ActionIcon>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}

View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
import {
Container,
Paper,
Title,
Text,
TextInput,
PasswordInput,
Button,
Anchor,
Stack,
Alert,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconAlertCircle } from '@tabler/icons-react';
import { useNavigate, Link } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
export function LoginPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const form = useForm({
initialValues: { email: '', password: '' },
validate: {
email: (v) => (/^\S+@\S+$/.test(v) ? null : 'Invalid email'),
password: (v) => (v.length >= 1 ? null : 'Password required'),
},
});
const handleSubmit = async (values: typeof form.values) => {
setLoading(true);
setError('');
try {
const { data } = await api.post('/auth/login', values);
setAuth(data.accessToken, data.user, data.organizations);
// Always go through org selection to ensure correct JWT with orgSchema
if (data.organizations.length >= 1) {
navigate('/select-org');
} else {
navigate('/');
}
} catch (err: any) {
setError(err.response?.data?.message || 'Login failed');
} finally {
setLoading(false);
}
};
return (
<Container size={420} my={80}>
<Title ta="center" order={2}>
HOA Financial Platform
</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Don&apos;t have an account?{' '}
<Anchor component={Link} to="/register" size="sm">
Register
</Anchor>
</Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
{error}
</Alert>
)}
<TextInput
label="Email"
placeholder="your@email.com"
required
{...form.getInputProps('email')}
/>
<PasswordInput
label="Password"
placeholder="Your password"
required
{...form.getInputProps('password')}
/>
<Button type="submit" fullWidth loading={loading}>
Sign in
</Button>
</Stack>
</form>
</Paper>
</Container>
);
}

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import {
Container,
Paper,
Title,
Text,
TextInput,
PasswordInput,
Button,
Anchor,
Stack,
Alert,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconAlertCircle } from '@tabler/icons-react';
import { useNavigate, Link } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
export function RegisterPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const navigate = useNavigate();
const setAuth = useAuthStore((s) => s.setAuth);
const form = useForm({
initialValues: { email: '', password: '', firstName: '', lastName: '' },
validate: {
email: (v) => (/^\S+@\S+$/.test(v) ? null : 'Invalid email'),
password: (v) => (v.length >= 8 ? null : 'Min 8 characters'),
firstName: (v) => (v.length >= 1 ? null : 'Required'),
lastName: (v) => (v.length >= 1 ? null : 'Required'),
},
});
const handleSubmit = async (values: typeof form.values) => {
setLoading(true);
setError('');
try {
const { data } = await api.post('/auth/register', values);
setAuth(data.accessToken, data.user, data.organizations);
navigate('/');
} catch (err: any) {
setError(err.response?.data?.message || 'Registration failed');
} finally {
setLoading(false);
}
};
return (
<Container size={420} my={80}>
<Title ta="center" order={2}>
Create Account
</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Already have an account?{' '}
<Anchor component={Link} to="/login" size="sm">
Sign in
</Anchor>
</Text>
<Paper withBorder shadow="md" p={30} mt={30} radius="md">
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
{error}
</Alert>
)}
<TextInput
label="First Name"
placeholder="Jane"
required
{...form.getInputProps('firstName')}
/>
<TextInput
label="Last Name"
placeholder="Doe"
required
{...form.getInputProps('lastName')}
/>
<TextInput
label="Email"
placeholder="your@email.com"
required
{...form.getInputProps('email')}
/>
<PasswordInput
label="Password"
placeholder="Min 8 characters"
required
{...form.getInputProps('password')}
/>
<Button type="submit" fullWidth loading={loading}>
Create Account
</Button>
</Stack>
</form>
</Paper>
</Container>
);
}

View File

@@ -0,0 +1,166 @@
import { useState } from 'react';
import {
Container,
Paper,
Title,
Text,
Stack,
Button,
Card,
Group,
Badge,
TextInput,
Modal,
Alert,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { IconBuilding, IconPlus, IconAlertCircle } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import api from '../../services/api';
import { useAuthStore } from '../../stores/authStore';
export function SelectOrgPage() {
const { organizations, setCurrentOrg, logout } = useAuthStore();
const navigate = useNavigate();
const [opened, { open, close }] = useDisclosure(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// If no organizations in store (stale session), redirect to login
if (!organizations || organizations.length === 0) {
return (
<Container size={500} my={80}>
<Title ta="center" order={2}>No Organizations</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Please log in again to refresh your session.
</Text>
<Button fullWidth mt="lg" onClick={() => { logout(); navigate('/login'); }}>
Go to Login
</Button>
</Container>
);
}
const form = useForm({
initialValues: { name: '', addressLine1: '', city: '', state: '', zipCode: '' },
validate: {
name: (v) => (v.length >= 2 ? null : 'Name required'),
},
});
const handleSelect = async (org: any) => {
try {
const { data } = await api.post('/auth/switch-org', {
organizationId: org.id,
});
setCurrentOrg(data.organization, data.accessToken);
navigate('/dashboard');
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to switch organization. Please try logging in again.');
}
};
const handleCreateOrg = async (values: typeof form.values) => {
setLoading(true);
setError('');
try {
const { data } = await api.post('/organizations', values);
// Switch to the new org
const switchRes = await api.post('/auth/switch-org', {
organizationId: data.id,
});
setCurrentOrg(
{ id: data.id, name: data.name, role: 'president' },
switchRes.data.accessToken,
);
close();
navigate('/');
} catch (err: any) {
setError(err.response?.data?.message || 'Failed to create organization');
} finally {
setLoading(false);
}
};
return (
<Container size={500} my={80}>
<Title ta="center" order={2}>Select Organization</Title>
<Text c="dimmed" size="sm" ta="center" mt={5}>
Choose an HOA to manage or create a new one
</Text>
<Stack mt={30}>
{organizations.map((org) => (
<Card
key={org.id}
shadow="sm"
padding="lg"
radius="md"
withBorder
style={{ cursor: 'pointer' }}
onClick={() => handleSelect(org)}
>
<Group justify="space-between">
<Group>
<IconBuilding size={24} />
<div>
<Text fw={500}>{org.name}</Text>
<Group gap={4}>
<Badge size="sm" variant="light">{org.role}</Badge>
{org.schemaName && (
<Badge size="xs" variant="dot" color="gray">
{org.schemaName}
</Badge>
)}
</Group>
</div>
</Group>
<Button variant="light" size="xs">Select</Button>
</Group>
</Card>
))}
<Button
variant="outline"
leftSection={<IconPlus size={16} />}
onClick={open}
fullWidth
>
Create New HOA
</Button>
</Stack>
<Modal opened={opened} onClose={close} title="Create New HOA">
<form onSubmit={form.onSubmit(handleCreateOrg)}>
<Stack>
{error && (
<Alert icon={<IconAlertCircle size={16} />} color="red" variant="light">
{error}
</Alert>
)}
<TextInput
label="HOA Name"
placeholder="Sunrise Valley HOA"
required
{...form.getInputProps('name')}
/>
<TextInput
label="Address"
placeholder="123 Main St"
{...form.getInputProps('addressLine1')}
/>
<Group grow>
<TextInput label="City" placeholder="Springfield" {...form.getInputProps('city')} />
<TextInput label="State" placeholder="IL" {...form.getInputProps('state')} />
<TextInput label="ZIP" placeholder="62701" {...form.getInputProps('zipCode')} />
</Group>
<Button type="submit" fullWidth loading={loading}>
Create Organization
</Button>
</Stack>
</form>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,175 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Badge, Card,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconDeviceFloppy } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface BudgetLine {
account_id: string;
account_number: number;
account_name: string;
account_type: string;
fund_type: string;
jan: number; feb: number; mar: number; apr: number;
may: number; jun: number; jul: number; aug: number;
sep: number; oct: number; nov: number; dec_amt: number;
annual_total: number;
}
const months = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec_amt'];
const monthLabels = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
export function BudgetsPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [budgetData, setBudgetData] = useState<BudgetLine[]>([]);
const queryClient = useQueryClient();
const { isLoading } = useQuery<BudgetLine[]>({
queryKey: ['budgets', year],
queryFn: async () => {
const { data } = await api.get(`/budgets/${year}`);
setBudgetData(data);
return data;
},
});
const saveMutation = useMutation({
mutationFn: async () => {
const lines = budgetData
.filter((b) => months.some((m) => (b as any)[m] > 0))
.map((b) => ({
account_id: b.account_id,
fund_type: b.fund_type,
jan: b.jan, feb: b.feb, mar: b.mar, apr: b.apr,
may: b.may, jun: b.jun, jul: b.jul, aug: b.aug,
sep: b.sep, oct: b.oct, nov: b.nov, dec_amt: b.dec_amt,
}));
return api.put(`/budgets/${year}`, { lines });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['budgets', year] });
notifications.show({ message: 'Budget saved', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Save failed', color: 'red' });
},
});
const updateCell = (idx: number, month: string, value: number) => {
const updated = [...budgetData];
(updated[idx] as any)[month] = value || 0;
updated[idx].annual_total = months.reduce((s, m) => s + ((updated[idx] as any)[m] || 0), 0);
setBudgetData(updated);
};
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 1 + i;
return { value: String(y), label: String(y) };
});
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
if (isLoading) return <Center h={300}><Loader /></Center>;
const incomeLines = budgetData.filter((b) => b.account_type === 'income');
const expenseLines = budgetData.filter((b) => b.account_type === 'expense');
const totalIncome = months.reduce((s, m) => s + incomeLines.reduce((a, b) => a + ((b as any)[m] || 0), 0), 0);
const totalExpense = months.reduce((s, m) => s + expenseLines.reduce((a, b) => a + ((b as any)[m] || 0), 0), 0);
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Budget Manager</Title>
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
<Button leftSection={<IconDeviceFloppy size={16} />} onClick={() => saveMutation.mutate()} loading={saveMutation.isPending}>
Save Budget
</Button>
</Group>
</Group>
<Group>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Income</Text>
<Text fw={700} c="green">{fmt(totalIncome)}</Text>
</Card>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Total Expenses</Text>
<Text fw={700} c="red">{fmt(totalExpense)}</Text>
</Card>
<Card withBorder p="sm">
<Text size="xs" c="dimmed">Net</Text>
<Text fw={700} c={totalIncome - totalExpense >= 0 ? 'green' : 'red'}>
{fmt(totalIncome - totalExpense)}
</Text>
</Card>
</Group>
<div style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover style={{ minWidth: 1400 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: 'white', zIndex: 1, minWidth: 250 }}>Account</Table.Th>
{monthLabels.map((m) => (
<Table.Th key={m} ta="right" style={{ minWidth: 90 }}>{m}</Table.Th>
))}
<Table.Th ta="right" style={{ minWidth: 100 }}>Annual</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{budgetData.length === 0 && (
<Table.Tr>
<Table.Td colSpan={14}>
<Text ta="center" c="dimmed" py="lg">No budget data. Income and expense accounts will appear here.</Text>
</Table.Td>
</Table.Tr>
)}
{['income', 'expense'].map((type) => {
const lines = budgetData.filter((b) => b.account_type === type);
if (lines.length === 0) return null;
return [
<Table.Tr key={`header-${type}`} style={{ background: type === 'income' ? '#e6f9e6' : '#fde8e8' }}>
<Table.Td colSpan={14} fw={700} tt="capitalize">{type}</Table.Td>
</Table.Tr>,
...lines.map((line) => {
const idx = budgetData.indexOf(line);
return (
<Table.Tr key={line.account_id}>
<Table.Td style={{ position: 'sticky', left: 0, background: 'white', zIndex: 1 }}>
<Group gap="xs">
<Text size="sm" c="dimmed">{line.account_number}</Text>
<Text size="sm">{line.account_name}</Text>
{line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>}
</Group>
</Table.Td>
{months.map((m) => (
<Table.Td key={m} p={2}>
<NumberInput
value={(line as any)[m] || 0}
onChange={(v) => updateCell(idx, m, Number(v) || 0)}
size="xs"
hideControls
decimalScale={2}
min={0}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
</Table.Td>
))}
<Table.Td ta="right" fw={500} ff="monospace">
{fmt(line.annual_total || 0)}
</Table.Td>
</Table.Tr>
);
}),
];
})}
</Table.Tbody>
</Table>
</div>
</Stack>
);
}

View File

@@ -0,0 +1,139 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface CapitalProject {
id: string; name: string; description: string; estimated_cost: string;
actual_cost: string; target_year: number; target_month: number;
status: string; fund_source: string; priority: number;
}
const statusColors: Record<string, string> = {
planned: 'blue', approved: 'green', in_progress: 'yellow',
completed: 'teal', deferred: 'gray', cancelled: 'red',
};
export function CapitalProjectsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<CapitalProject | null>(null);
const queryClient = useQueryClient();
const { data: projects = [], isLoading } = useQuery<CapitalProject[]>({
queryKey: ['capital-projects'],
queryFn: async () => { const { data } = await api.get('/capital-projects'); return data; },
});
const form = useForm({
initialValues: {
name: '', description: '', estimated_cost: 0, actual_cost: 0,
target_year: new Date().getFullYear(), target_month: 6,
status: 'planned', fund_source: 'reserve', priority: 3,
},
validate: { name: (v) => (v.length > 0 ? null : 'Required'), estimated_cost: (v) => (v > 0 ? null : 'Required') },
});
const saveMutation = useMutation({
mutationFn: (values: any) => editing ? api.put(`/capital-projects/${editing.id}`, values) : api.post('/capital-projects', values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['capital-projects'] });
notifications.show({ message: editing ? 'Project updated' : 'Project created', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const handleEdit = (p: CapitalProject) => {
setEditing(p);
form.setValues({
name: p.name, description: p.description || '',
estimated_cost: parseFloat(p.estimated_cost || '0'), actual_cost: parseFloat(p.actual_cost || '0'),
target_year: p.target_year, target_month: p.target_month || 6,
status: p.status, fund_source: p.fund_source || 'reserve', priority: p.priority || 3,
});
open();
};
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const years = [...new Set(projects.map(p => p.target_year))].sort();
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Capital Projects (5-Year Plan)</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Project</Button>
</Group>
{years.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">No capital projects planned yet. Add your first project.</Text>
) : years.map(year => {
const yearProjects = projects.filter(p => p.target_year === year);
const totalEst = yearProjects.reduce((s, p) => s + parseFloat(p.estimated_cost || '0'), 0);
return (
<Stack key={year} gap="xs">
<Group>
<Title order={4}>{year}</Title>
<Badge size="lg" variant="light">{fmt(totalEst)} estimated</Badge>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Project</Table.Th><Table.Th>Target</Table.Th><Table.Th>Priority</Table.Th>
<Table.Th ta="right">Estimated</Table.Th><Table.Th ta="right">Actual</Table.Th>
<Table.Th>Source</Table.Th><Table.Th>Status</Table.Th><Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{yearProjects.map((p) => (
<Table.Tr key={p.id}>
<Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td>{p.target_month ? new Date(2000, p.target_month - 1).toLocaleString('default', { month: 'short' }) : ''} {p.target_year}</Table.Td>
<Table.Td><Badge size="sm" color={p.priority <= 2 ? 'red' : p.priority <= 3 ? 'yellow' : 'gray'}>P{p.priority}</Badge></Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(p.estimated_cost)}</Table.Td>
<Table.Td ta="right" ff="monospace">{parseFloat(p.actual_cost || '0') > 0 ? fmt(p.actual_cost) : '-'}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{p.fund_source}</Badge></Table.Td>
<Table.Td><Badge size="sm" color={statusColors[p.status] || 'gray'}>{p.status}</Badge></Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(p)}><IconEdit size={16} /></ActionIcon></Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
);
})}
<Modal opened={opened} onClose={close} title={editing ? 'Edit Project' : 'New Capital Project'} size="lg">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
<TextInput label="Project Name" required {...form.getInputProps('name')} />
<Textarea label="Description" {...form.getInputProps('description')} />
<Group grow>
<NumberInput label="Estimated Cost" required prefix="$" decimalScale={2} min={0} {...form.getInputProps('estimated_cost')} />
<NumberInput label="Actual Cost" prefix="$" decimalScale={2} min={0} {...form.getInputProps('actual_cost')} />
</Group>
<Group grow>
<NumberInput label="Target Year" required min={2024} max={2040} {...form.getInputProps('target_year')} />
<Select label="Target Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))}
value={String(form.values.target_month)} onChange={(v) => form.setFieldValue('target_month', Number(v))} />
</Group>
<Group grow>
<Select label="Status" data={Object.keys(statusColors).map(s => ({ value: s, label: s.replace('_', ' ') }))} {...form.getInputProps('status')} />
<Select label="Fund Source" data={[{value:'reserve',label:'Reserve'},{value:'operating',label:'Operating'},{value:'special_assessment',label:'Special Assessment'}]} {...form.getInputProps('fund_source')} />
<NumberInput label="Priority (1=High, 5=Low)" min={1} max={5} {...form.getInputProps('priority')} />
</Group>
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,147 @@
import {
Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table,
Badge, Loader, Center,
} from '@mantine/core';
import {
IconCash,
IconFileInvoice,
IconShieldCheck,
IconAlertTriangle,
} from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../stores/authStore';
import api from '../../services/api';
interface DashboardData {
total_cash: string;
total_receivables: string;
reserve_fund_balance: string;
delinquent_units: number;
recent_transactions: {
id: string; entry_date: string; description: string; entry_type: string; amount: string;
}[];
}
export function DashboardPage() {
const currentOrg = useAuthStore((s) => s.currentOrg);
const { data, isLoading } = useQuery<DashboardData>({
queryKey: ['dashboard'],
queryFn: async () => { const { data } = await api.get('/reports/dashboard'); return data; },
enabled: !!currentOrg,
});
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const stats = [
{ title: 'Total Cash', value: fmt(data?.total_cash || '0'), icon: IconCash, color: 'green' },
{ title: 'Total Receivables', value: fmt(data?.total_receivables || '0'), icon: IconFileInvoice, color: 'blue' },
{ title: 'Reserve Fund', value: fmt(data?.reserve_fund_balance || '0'), icon: IconShieldCheck, color: 'violet' },
{ title: 'Delinquent Accounts', value: String(data?.delinquent_units || 0), icon: IconAlertTriangle, color: 'orange' },
];
const entryTypeColors: Record<string, string> = {
manual: 'gray', assessment: 'blue', payment: 'green', late_fee: 'red',
transfer: 'cyan', adjustment: 'yellow', closing: 'dark', opening_balance: 'indigo',
};
return (
<Stack>
<div>
<Title order={2}>Dashboard</Title>
<Text c="dimmed" size="sm">
{currentOrg ? `${currentOrg.name} - ${currentOrg.role}` : 'No organization selected'}
</Text>
</div>
{!currentOrg ? (
<Card withBorder p="xl" ta="center">
<Text size="lg" fw={500}>Welcome to the HOA Financial Platform</Text>
<Text c="dimmed" mt="sm">
Create or select an organization to get started.
</Text>
</Card>
) : isLoading ? (
<Center h={200}><Loader /></Center>
) : (
<>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
{stats.map((stat) => (
<Card key={stat.title} withBorder padding="lg" radius="md">
<Group justify="space-between">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{stat.title}
</Text>
<Text fw={700} size="xl">
{stat.value}
</Text>
</div>
<ThemeIcon color={stat.color} variant="light" size={48} radius="md">
<stat.icon size={28} />
</ThemeIcon>
</Group>
</Card>
))}
</SimpleGrid>
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Card withBorder padding="lg" radius="md">
<Title order={4} mb="sm">Recent Transactions</Title>
{(data?.recent_transactions || []).length === 0 ? (
<Text c="dimmed" size="sm">No transactions yet. Start by entering journal entries.</Text>
) : (
<Table striped highlightOnHover>
<Table.Tbody>
{(data?.recent_transactions || []).map((tx) => (
<Table.Tr key={tx.id}>
<Table.Td>
<Text size="xs" c="dimmed">{new Date(tx.entry_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Text size="sm" lineClamp={1}>{tx.description}</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" color={entryTypeColors[tx.entry_type] || 'gray'} variant="light">
{tx.entry_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace" fw={500}>
{fmt(tx.amount)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Card>
<Card withBorder padding="lg" radius="md">
<Title order={4}>Quick Stats</Title>
<Stack mt="sm" gap="xs">
<Group justify="space-between">
<Text size="sm" c="dimmed">Cash Position</Text>
<Text size="sm" fw={500} c="green">{fmt(data?.total_cash || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Outstanding AR</Text>
<Text size="sm" fw={500} c="blue">{fmt(data?.total_receivables || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Reserve Funding</Text>
<Text size="sm" fw={500} c="violet">{fmt(data?.reserve_fund_balance || '0')}</Text>
</Group>
<Group justify="space-between">
<Text size="sm" c="dimmed">Delinquent Units</Text>
<Text size="sm" fw={500} c={data?.delinquent_units ? 'red' : 'green'}>
{data?.delinquent_units || 0}
</Text>
</Group>
</Stack>
</Card>
</SimpleGrid>
</>
)}
</Stack>
);
}

View File

@@ -0,0 +1,146 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Badge, ActionIcon, Loader, Center, Card, SimpleGrid,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface Investment {
id: string; name: string; institution: string; account_number_last4: string;
investment_type: string; fund_type: string; principal: string;
interest_rate: string; maturity_date: string; purchase_date: string;
current_value: string; is_active: boolean;
}
export function InvestmentsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Investment | null>(null);
const queryClient = useQueryClient();
const { data: investments = [], isLoading } = useQuery<Investment[]>({
queryKey: ['investments'],
queryFn: async () => { const { data } = await api.get('/investment-accounts'); return data; },
});
const form = useForm({
initialValues: {
name: '', institution: '', account_number_last4: '',
investment_type: 'cd', fund_type: 'reserve',
principal: 0, interest_rate: 0, current_value: 0,
purchase_date: null as Date | null, maturity_date: null as Date | null,
},
validate: { name: (v) => (v.length > 0 ? null : 'Required'), principal: (v) => (v > 0 ? null : 'Required') },
});
const saveMutation = useMutation({
mutationFn: (values: any) => {
const payload = {
...values,
purchase_date: values.purchase_date?.toISOString?.()?.split('T')[0] || null,
maturity_date: values.maturity_date?.toISOString?.()?.split('T')[0] || null,
};
return editing ? api.put(`/investment-accounts/${editing.id}`, payload) : api.post('/investment-accounts', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['investments'] });
notifications.show({ message: editing ? 'Investment updated' : 'Investment created', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const handleEdit = (inv: Investment) => {
setEditing(inv);
form.setValues({
name: inv.name, institution: inv.institution || '', account_number_last4: inv.account_number_last4 || '',
investment_type: inv.investment_type, fund_type: inv.fund_type,
principal: parseFloat(inv.principal || '0'), interest_rate: parseFloat(inv.interest_rate || '0'),
current_value: parseFloat(inv.current_value || '0'),
purchase_date: inv.purchase_date ? new Date(inv.purchase_date) : null,
maturity_date: inv.maturity_date ? new Date(inv.maturity_date) : null,
});
open();
};
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const totalPrincipal = investments.reduce((s, i) => s + parseFloat(i.principal || '0'), 0);
const totalValue = investments.reduce((s, i) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const avgRate = investments.length > 0 ? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length : 0;
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Investment Accounts</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Investment</Button>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Principal</Text><Text fw={700} size="xl">{fmt(totalPrincipal)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Total Current Value</Text><Text fw={700} size="xl" c="green">{fmt(totalValue)}</Text></Card>
<Card withBorder p="md"><Text size="xs" c="dimmed">Avg Interest Rate</Text><Text fw={700} size="xl">{avgRate.toFixed(2)}%</Text></Card>
</SimpleGrid>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th><Table.Th>Institution</Table.Th><Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th><Table.Th ta="right">Principal</Table.Th>
<Table.Th ta="right">Rate</Table.Th><Table.Th>Maturity</Table.Th><Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{investments.map((inv) => (
<Table.Tr key={inv.id}>
<Table.Td fw={500}>{inv.name}</Table.Td>
<Table.Td>{inv.institution}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{inv.investment_type}</Badge></Table.Td>
<Table.Td><Badge size="sm" color={inv.fund_type === 'reserve' ? 'violet' : 'gray'}>{inv.fund_type}</Badge></Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(inv.principal)}</Table.Td>
<Table.Td ta="right">{parseFloat(inv.interest_rate || '0').toFixed(2)}%</Table.Td>
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(inv)}><IconEdit size={16} /></ActionIcon></Table.Td>
</Table.Tr>
))}
{investments.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No investments yet</Text></Table.Td></Table.Tr>}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Investment' : 'New Investment'} size="lg">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
<Group grow>
<TextInput label="Name" required {...form.getInputProps('name')} />
<TextInput label="Institution" {...form.getInputProps('institution')} />
</Group>
<Group grow>
<Select label="Type" data={[
{ value: 'cd', label: 'CD' }, { value: 'money_market', label: 'Money Market' },
{ value: 'treasury', label: 'Treasury' }, { value: 'savings', label: 'Savings' },
{ value: 'other', label: 'Other' },
]} {...form.getInputProps('investment_type')} />
<Select label="Fund" data={[
{ value: 'operating', label: 'Operating' }, { value: 'reserve', label: 'Reserve' },
]} {...form.getInputProps('fund_type')} />
<TextInput label="Last 4 Digits" maxLength={4} {...form.getInputProps('account_number_last4')} />
</Group>
<Group grow>
<NumberInput label="Principal" required prefix="$" decimalScale={2} min={0} {...form.getInputProps('principal')} />
<NumberInput label="Interest Rate %" decimalScale={4} min={0} max={100} {...form.getInputProps('interest_rate')} />
<NumberInput label="Current Value" prefix="$" decimalScale={2} min={0} {...form.getInputProps('current_value')} />
</Group>
<Group grow>
<DateInput label="Purchase Date" clearable {...form.getInputProps('purchase_date')} />
<DateInput label="Maturity Date" clearable {...form.getInputProps('maturity_date')} />
</Group>
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,115 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, Loader, Center, Card,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconFileInvoice, IconSend } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface Invoice {
id: string; invoice_number: string; unit_number: string; unit_id: string;
invoice_date: string; due_date: string; invoice_type: string;
description: string; amount: string; amount_paid: string; balance_due: string;
status: string;
}
const statusColors: Record<string, string> = {
draft: 'gray', sent: 'blue', paid: 'green', partial: 'yellow', overdue: 'red', void: 'dark',
};
export function InvoicesPage() {
const [bulkOpened, { open: openBulk, close: closeBulk }] = useDisclosure(false);
const queryClient = useQueryClient();
const { data: invoices = [], isLoading } = useQuery<Invoice[]>({
queryKey: ['invoices'],
queryFn: async () => { const { data } = await api.get('/invoices'); return data; },
});
const bulkForm = useForm({
initialValues: { month: new Date().getMonth() + 1, year: new Date().getFullYear() },
});
const bulkMutation = useMutation({
mutationFn: (values: any) => api.post('/invoices/generate-bulk', values),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
notifications.show({ message: `Generated ${res.data.created} invoices`, color: 'green' });
closeBulk();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const lateFeesMutation = useMutation({
mutationFn: () => api.post('/invoices/apply-late-fees', { grace_period_days: 15, late_fee_amount: 25 }),
onSuccess: (res) => {
queryClient.invalidateQueries({ queryKey: ['invoices'] });
notifications.show({ message: `Applied ${res.data.applied} late fees`, color: 'yellow' });
},
});
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
if (isLoading) return <Center h={300}><Loader /></Center>;
const totalOutstanding = invoices.filter(i => i.status !== 'paid' && i.status !== 'void').reduce((s, i) => s + parseFloat(i.balance_due || '0'), 0);
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Invoices</Title>
<Group>
<Button variant="outline" onClick={() => lateFeesMutation.mutate()} loading={lateFeesMutation.isPending}>Apply Late Fees</Button>
<Button leftSection={<IconSend size={16} />} onClick={openBulk}>Generate Monthly Invoices</Button>
</Group>
</Group>
<Group>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Total Invoices</Text><Text fw={700}>{invoices.length}</Text></Card>
<Card withBorder p="sm"><Text size="xs" c="dimmed">Outstanding</Text><Text fw={700} c="red">{fmt(String(totalOutstanding))}</Text></Card>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Invoice #</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Date</Table.Th>
<Table.Th>Due</Table.Th><Table.Th>Type</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>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{invoices.map((i) => (
<Table.Tr key={i.id}>
<Table.Td fw={500}>{i.invoice_number}</Table.Td>
<Table.Td>{i.unit_number}</Table.Td>
<Table.Td>{new Date(i.invoice_date).toLocaleDateString()}</Table.Td>
<Table.Td>{new Date(i.due_date).toLocaleDateString()}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{i.invoice_type}</Badge></Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(i.amount)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(i.amount_paid)}</Table.Td>
<Table.Td ta="right" ff="monospace" fw={500}>{fmt(i.balance_due)}</Table.Td>
<Table.Td><Badge color={statusColors[i.status] || 'gray'} size="sm">{i.status}</Badge></Table.Td>
</Table.Tr>
))}
{invoices.length === 0 && <Table.Tr><Table.Td colSpan={9}><Text ta="center" c="dimmed" py="lg">No invoices yet</Text></Table.Td></Table.Tr>}
</Table.Tbody>
</Table>
<Modal opened={bulkOpened} onClose={closeBulk} title="Generate Monthly Assessments">
<form onSubmit={bulkForm.onSubmit((v) => bulkMutation.mutate(v))}>
<Stack>
<Group grow>
<Select label="Month" data={Array.from({length:12},(_,i)=>({value:String(i+1),label:new Date(2026,i).toLocaleString('default',{month:'long'})}))} value={String(bulkForm.values.month)} onChange={(v)=>bulkForm.setFieldValue('month',Number(v))} />
<NumberInput label="Year" {...bulkForm.getInputProps('year')} />
</Group>
<Text size="sm" c="dimmed">This will generate invoices for all active units based on their monthly assessment amount.</Text>
<Button type="submit" loading={bulkMutation.isPending}>Generate Invoices</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,125 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Badge, Modal,
NumberInput, Select, TextInput, Loader, Center,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface Payment {
id: string; unit_id: string; unit_number: string; invoice_id: string;
invoice_number: string; payment_date: string; amount: string;
payment_method: string; reference_number: string; status: string;
}
export function PaymentsPage() {
const [opened, { open, close }] = useDisclosure(false);
const queryClient = useQueryClient();
const { data: payments = [], isLoading } = useQuery<Payment[]>({
queryKey: ['payments'],
queryFn: async () => { const { data } = await api.get('/payments'); return data; },
});
const { data: invoices = [] } = useQuery<any[]>({
queryKey: ['invoices-unpaid'],
queryFn: async () => {
const { data } = await api.get('/invoices');
return data.filter((i: any) => i.status !== 'paid' && i.status !== 'void');
},
});
const form = useForm({
initialValues: {
invoice_id: '', amount: 0, payment_method: 'check',
reference_number: '', payment_date: new Date(),
},
});
const createMutation = useMutation({
mutationFn: (values: any) => {
const inv = invoices.find((i: any) => i.id === values.invoice_id);
return api.post('/payments', {
...values,
unit_id: inv?.unit_id,
payment_date: values.payment_date.toISOString().split('T')[0],
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['payments'] });
queryClient.invalidateQueries({ queryKey: ['invoices'] });
queryClient.invalidateQueries({ queryKey: ['invoices-unpaid'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
notifications.show({ message: 'Payment recorded', color: 'green' });
close(); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const fmt = (v: string) => parseFloat(v || '0').toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const invoiceOptions = invoices.map((i: any) => ({
value: i.id,
label: `${i.invoice_number} - ${i.unit_number || 'Unit'} - Balance: $${parseFloat(i.balance_due || i.amount).toFixed(2)}`,
}));
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Payments</Title>
<Button leftSection={<IconPlus size={16} />} onClick={open}>Record Payment</Button>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Date</Table.Th><Table.Th>Unit</Table.Th><Table.Th>Invoice</Table.Th>
<Table.Th ta="right">Amount</Table.Th><Table.Th>Method</Table.Th>
<Table.Th>Reference</Table.Th><Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{payments.map((p) => (
<Table.Tr key={p.id}>
<Table.Td>{new Date(p.payment_date).toLocaleDateString()}</Table.Td>
<Table.Td>{p.unit_number}</Table.Td>
<Table.Td>{p.invoice_number}</Table.Td>
<Table.Td ta="right" ff="monospace" fw={500}>{fmt(p.amount)}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{p.payment_method}</Badge></Table.Td>
<Table.Td>{p.reference_number}</Table.Td>
<Table.Td><Badge color={p.status === 'completed' ? 'green' : 'yellow'} size="sm">{p.status}</Badge></Table.Td>
</Table.Tr>
))}
{payments.length === 0 && (
<Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No payments recorded yet</Text></Table.Td></Table.Tr>
)}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title="Record Payment">
<form onSubmit={form.onSubmit((v) => createMutation.mutate(v))}>
<Stack>
<Select label="Invoice" required data={invoiceOptions} searchable
{...form.getInputProps('invoice_id')} />
<DateInput label="Payment Date" required {...form.getInputProps('payment_date')} />
<NumberInput label="Amount" required prefix="$" decimalScale={2} min={0.01}
{...form.getInputProps('amount')} />
<Select label="Payment Method" data={[
{ value: 'check', label: 'Check' }, { value: 'ach', label: 'ACH' },
{ value: 'credit_card', label: 'Credit Card' }, { value: 'cash', label: 'Cash' },
{ value: 'wire', label: 'Wire' }, { value: 'other', label: 'Other' },
]} {...form.getInputProps('payment_method')} />
<TextInput label="Reference Number" placeholder="Check # or transaction ID"
{...form.getInputProps('reference_number')} />
<Button type="submit" loading={createMutation.isPending}>Record Payment</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import {
Title, Table, Group, Stack, Text, Card, Loader, Center, Divider, Badge,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useQuery } from '@tanstack/react-query';
import api from '../../services/api';
interface AccountLine { account_number: number; name: string; balance: string; fund_type: string; }
interface BalanceSheetData {
as_of: string;
assets: AccountLine[]; liabilities: AccountLine[]; equity: AccountLine[];
total_assets: string; total_liabilities: string; total_equity: string;
}
export function BalanceSheetPage() {
const [asOf, setAsOf] = useState(new Date());
const dateStr = asOf.toISOString().split('T')[0];
const { data, isLoading } = useQuery<BalanceSheetData>({
queryKey: ['balance-sheet', dateStr],
queryFn: async () => { const { data } = await api.get(`/reports/balance-sheet?as_of=${dateStr}`); 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>;
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Balance Sheet</Title>
<DateInput label="As of" value={asOf} onChange={(v) => v && setAsOf(v)} w={200} />
</Group>
<Card withBorder>
<Title order={4} mb="md">Assets</Title>
<Table>
<Table.Tbody>
{(data?.assets || []).map((a) => (
<Table.Tr key={a.account_number}>
<Table.Td w={80}>{a.account_number}</Table.Td>
<Table.Td>{a.name} <Badge size="xs" variant="light">{a.fund_type}</Badge></Table.Td>
<Table.Td ta="right" ff="monospace" w={140}>{fmt(a.balance)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr><Table.Td colSpan={2} fw={700}>Total Assets</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(data?.total_assets || '0')}</Table.Td></Table.Tr>
</Table.Tfoot>
</Table>
<Divider my="md" />
<Title order={4} mb="md">Liabilities</Title>
<Table>
<Table.Tbody>
{(data?.liabilities || []).map((a) => (
<Table.Tr key={a.account_number}>
<Table.Td w={80}>{a.account_number}</Table.Td>
<Table.Td>{a.name}</Table.Td>
<Table.Td ta="right" ff="monospace" w={140}>{fmt(a.balance)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr><Table.Td colSpan={2} fw={700}>Total Liabilities</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(data?.total_liabilities || '0')}</Table.Td></Table.Tr>
</Table.Tfoot>
</Table>
<Divider my="md" />
<Title order={4} mb="md">Equity</Title>
<Table>
<Table.Tbody>
{(data?.equity || []).map((a) => (
<Table.Tr key={a.account_number}>
<Table.Td w={80}>{a.account_number}</Table.Td>
<Table.Td>{a.name}</Table.Td>
<Table.Td ta="right" ff="monospace" w={140}>{fmt(a.balance)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr><Table.Td colSpan={2} fw={700}>Total Equity</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(data?.total_equity || '0')}</Table.Td></Table.Tr>
</Table.Tfoot>
</Table>
<Divider my="md" />
<Group justify="space-between" px="sm">
<Text fw={700} size="lg">Total Liabilities + Equity</Text>
<Text fw={700} size="lg" ff="monospace">
{fmt(String(parseFloat(data?.total_liabilities || '0') + parseFloat(data?.total_equity || '0')))}
</Text>
</Group>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,187 @@
import { useState } from 'react';
import {
Title, Table, Group, Stack, Text, Card, Loader, Center,
Select, Badge, Progress, SimpleGrid,
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import api from '../../services/api';
interface BudgetVsActualLine {
account_id: string;
account_number: number;
account_name: string;
account_type: string;
fund_type: string;
budget_amount: number;
actual_amount: number;
variance: number;
variance_pct: number;
}
interface BudgetVsActualData {
year: number;
lines: BudgetVsActualLine[];
total_income_budget: number;
total_income_actual: number;
total_expense_budget: number;
total_expense_actual: number;
}
export function BudgetVsActualPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i;
return { value: String(y), label: String(y) };
});
const { data, isLoading } = useQuery<BudgetVsActualData>({
queryKey: ['budget-vs-actual', year],
queryFn: async () => {
const { data } = await api.get(`/budgets/${year}/vs-actual`);
return data;
},
});
const fmt = (v: number) =>
(v || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
const pctFmt = (v: number) => `${(v || 0).toFixed(1)}%`;
if (isLoading) return <Center h={300}><Loader /></Center>;
const lines = data?.lines || [];
const incomeLines = lines.filter((l) => l.account_type === 'income');
const expenseLines = lines.filter((l) => l.account_type === 'expense');
const totalIncomeBudget = data?.total_income_budget || incomeLines.reduce((s, l) => s + l.budget_amount, 0);
const totalIncomeActual = data?.total_income_actual || incomeLines.reduce((s, l) => s + l.actual_amount, 0);
const totalExpenseBudget = data?.total_expense_budget || expenseLines.reduce((s, l) => s + l.budget_amount, 0);
const totalExpenseActual = data?.total_expense_actual || expenseLines.reduce((s, l) => s + l.actual_amount, 0);
const incomeVariance = totalIncomeActual - totalIncomeBudget;
const expenseVariance = totalExpenseActual - totalExpenseBudget;
const netBudget = totalIncomeBudget - totalExpenseBudget;
const netActual = totalIncomeActual - totalExpenseActual;
const varianceColor = (variance: number, isExpense: boolean) => {
if (variance === 0) return 'gray';
// For income: positive variance (actual > budget) is good
// For expenses: negative variance (actual < budget) is good
if (isExpense) return variance < 0 ? 'green' : 'red';
return variance > 0 ? 'green' : 'red';
};
const renderSection = (title: string, sectionLines: BudgetVsActualLine[], isExpense: boolean, totalBudget: number, totalActual: number) => (
<>
<Table.Tr style={{ background: isExpense ? '#fde8e8' : '#e6f9e6' }}>
<Table.Td colSpan={6} fw={700}>{title}</Table.Td>
</Table.Tr>
{sectionLines.map((line) => {
const usagePct = line.budget_amount > 0 ? (line.actual_amount / line.budget_amount) * 100 : 0;
return (
<Table.Tr key={line.account_id}>
<Table.Td>
<Group gap="xs">
<Text size="sm" c="dimmed">{line.account_number}</Text>
<Text size="sm">{line.account_name}</Text>
{line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>}
</Group>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(line.budget_amount)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(line.actual_amount)}</Table.Td>
<Table.Td ta="right" ff="monospace" c={varianceColor(line.variance, isExpense)} fw={500}>
{line.variance > 0 ? '+' : ''}{fmt(line.variance)}
</Table.Td>
<Table.Td ta="right" c={varianceColor(line.variance, isExpense)}>
{line.budget_amount > 0 ? pctFmt(line.variance_pct) : '—'}
</Table.Td>
<Table.Td w={120}>
{line.budget_amount > 0 && (
<Progress
value={Math.min(usagePct, 100)}
size="sm"
color={usagePct > 100 ? (isExpense ? 'red' : 'green') : (isExpense ? 'green' : 'yellow')}
/>
)}
</Table.Td>
</Table.Tr>
);
})}
<Table.Tr style={{ fontWeight: 700 }}>
<Table.Td>Total {title}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(totalBudget)}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(totalActual)}</Table.Td>
<Table.Td ta="right" ff="monospace" c={varianceColor(totalActual - totalBudget, isExpense)}>
{(totalActual - totalBudget) > 0 ? '+' : ''}{fmt(totalActual - totalBudget)}
</Table.Td>
<Table.Td ta="right" c={varianceColor(totalActual - totalBudget, isExpense)}>
{totalBudget > 0 ? pctFmt(((totalActual - totalBudget) / totalBudget) * 100) : '—'}
</Table.Td>
<Table.Td></Table.Td>
</Table.Tr>
</>
);
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Budget vs. Actual</Title>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
</Group>
<SimpleGrid cols={{ base: 1, sm: 4 }}>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Income Variance</Text>
<Text fw={700} size="xl" c={incomeVariance >= 0 ? 'green' : 'red'}>
{incomeVariance >= 0 ? '+' : ''}{fmt(incomeVariance)}
</Text>
<Text size="xs" c="dimmed">{fmt(totalIncomeActual)} of {fmt(totalIncomeBudget)} budgeted</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Expense Variance</Text>
<Text fw={700} size="xl" c={expenseVariance <= 0 ? 'green' : 'red'}>
{expenseVariance >= 0 ? '+' : ''}{fmt(expenseVariance)}
</Text>
<Text size="xs" c="dimmed">{fmt(totalExpenseActual)} of {fmt(totalExpenseBudget)} budgeted</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Budget</Text>
<Text fw={700} size="xl" c={netBudget >= 0 ? 'green' : 'red'}>{fmt(netBudget)}</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Actual</Text>
<Text fw={700} size="xl" c={netActual >= 0 ? 'green' : 'red'}>{fmt(netActual)}</Text>
</Card>
</SimpleGrid>
<Card withBorder>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ minWidth: 250 }}>Account</Table.Th>
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
<Table.Th ta="right" style={{ minWidth: 110 }}>Actual</Table.Th>
<Table.Th ta="right" style={{ minWidth: 110 }}>Variance ($)</Table.Th>
<Table.Th ta="right" style={{ minWidth: 80 }}>Variance %</Table.Th>
<Table.Th style={{ minWidth: 120 }}>Progress</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.length === 0 && (
<Table.Tr>
<Table.Td colSpan={6}>
<Text ta="center" c="dimmed" py="lg">
No budget vs actual data available. Create a budget first.
</Text>
</Table.Td>
</Table.Tr>
)}
{incomeLines.length > 0 && renderSection('Income', incomeLines, false, totalIncomeBudget, totalIncomeActual)}
{expenseLines.length > 0 && renderSection('Expenses', expenseLines, true, totalExpenseBudget, totalExpenseActual)}
</Table.Tbody>
</Table>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,91 @@
import { useState } from 'react';
import {
Title, Table, Group, Stack, Text, Card, Loader, Center, Divider, Badge,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useQuery } from '@tanstack/react-query';
import api from '../../services/api';
interface AccountLine { account_number: number; name: string; amount: string; fund_type: string; }
interface IncomeStatementData {
from: string; to: string;
income: AccountLine[]; expenses: AccountLine[];
total_income: string; total_expenses: string; net_income: string;
}
export function IncomeStatementPage() {
const [from, setFrom] = useState(new Date(new Date().getFullYear(), 0, 1));
const [to, setTo] = useState(new Date());
const { data, isLoading } = useQuery<IncomeStatementData>({
queryKey: ['income-statement', from.toISOString().split('T')[0], to.toISOString().split('T')[0]],
queryFn: async () => {
const { data } = await api.get(`/reports/income-statement?from=${from.toISOString().split('T')[0]}&to=${to.toISOString().split('T')[0]}`);
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 netIncome = parseFloat(data?.net_income || '0');
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Income Statement</Title>
<Group>
<DateInput label="From" value={from} onChange={(v) => v && setFrom(v)} w={160} />
<DateInput label="To" value={to} onChange={(v) => v && setTo(v)} w={160} />
</Group>
</Group>
<Card withBorder>
<Title order={4} mb="md" c="green">Income</Title>
<Table>
<Table.Tbody>
{(data?.income || []).map((a) => (
<Table.Tr key={a.account_number}>
<Table.Td w={80}>{a.account_number}</Table.Td>
<Table.Td>{a.name} <Badge size="xs" variant="light">{a.fund_type}</Badge></Table.Td>
<Table.Td ta="right" ff="monospace" w={140}>{fmt(a.amount)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr><Table.Td colSpan={2} fw={700}>Total Income</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace" c="green">{fmt(data?.total_income || '0')}</Table.Td></Table.Tr>
</Table.Tfoot>
</Table>
<Divider my="md" />
<Title order={4} mb="md" c="red">Expenses</Title>
<Table>
<Table.Tbody>
{(data?.expenses || []).map((a) => (
<Table.Tr key={a.account_number}>
<Table.Td w={80}>{a.account_number}</Table.Td>
<Table.Td>{a.name} <Badge size="xs" variant="light">{a.fund_type}</Badge></Table.Td>
<Table.Td ta="right" ff="monospace" w={140}>{fmt(a.amount)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr><Table.Td colSpan={2} fw={700}>Total Expenses</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace" c="red">{fmt(data?.total_expenses || '0')}</Table.Td></Table.Tr>
</Table.Tfoot>
</Table>
<Divider my="md" />
<Group justify="space-between" px="sm">
<Text fw={700} size="xl">Net Income</Text>
<Text fw={700} size="xl" ff="monospace" c={netIncome >= 0 ? 'green' : 'red'}>
{fmt(data?.net_income || '0')}
</Text>
</Group>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,247 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Title, Group, Stack, Text, Card, Loader, Center, Select, SimpleGrid,
} from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import {
sankey as d3Sankey,
sankeyLinkHorizontal,
sankeyJustify,
SankeyNode,
SankeyLink,
} from 'd3-sankey';
import api from '../../services/api';
interface FlowNode {
name: string;
category: string;
}
interface FlowLink {
source: number;
target: number;
value: number;
}
interface CashFlowData {
nodes: FlowNode[];
links: FlowLink[];
total_income: number;
total_expenses: number;
net_cash_flow: number;
}
type SNode = SankeyNode<FlowNode, FlowLink>;
type SLink = SankeyLink<FlowNode, FlowLink>;
const CATEGORY_COLORS: Record<string, string> = {
income: '#40c057',
expense: '#fa5252',
reserve: '#7950f2',
transfer: '#228be6',
net: '#868e96',
operating: '#15aabf',
};
function getNodeColor(node: FlowNode): string {
return CATEGORY_COLORS[node.category] || '#868e96';
}
export function SankeyPage() {
const svgRef = useRef<SVGSVGElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [dimensions, setDimensions] = useState({ width: 900, height: 500 });
const [year, setYear] = useState(new Date().getFullYear().toString());
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i;
return { value: String(y), label: String(y) };
});
const { data, isLoading, isError } = useQuery<CashFlowData>({
queryKey: ['sankey', year],
queryFn: async () => {
const { data } = await api.get(`/reports/cash-flow-sankey?year=${year}`);
return data;
},
});
// Resize observer
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width } = entry.contentRect;
if (width > 0) {
setDimensions({ width: Math.max(width - 32, 400), height: Math.max(400, Math.min(600, width * 0.5)) });
}
}
});
observer.observe(container);
return () => observer.disconnect();
}, []);
const renderSankey = useCallback(() => {
if (!data || !svgRef.current || data.nodes.length === 0 || data.links.length === 0) return;
const { width, height } = dimensions;
const margin = { top: 10, right: 150, bottom: 10, left: 150 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
// Build sankey layout
const sankeyLayout = d3Sankey<FlowNode, FlowLink>()
.nodeWidth(20)
.nodePadding(12)
.nodeAlign(sankeyJustify)
.extent([[0, 0], [innerWidth, innerHeight]]);
// Deep clone data so d3 can mutate it
const graph = sankeyLayout({
nodes: data.nodes.map((d) => ({ ...d })),
links: data.links.map((d) => ({ ...d })),
});
const svg = svgRef.current;
// Clear previous content
while (svg.firstChild) svg.removeChild(svg.firstChild);
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('transform', `translate(${margin.left},${margin.top})`);
svg.appendChild(g);
// Render links
const linkPath = sankeyLinkHorizontal<SLink, SNode>();
(graph.links as SLink[]).forEach((link) => {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
const d = linkPath(link as any);
if (d) path.setAttribute('d', d);
const sourceNode = link.source as SNode;
path.setAttribute('fill', 'none');
path.setAttribute('stroke', getNodeColor(sourceNode));
path.setAttribute('stroke-opacity', '0.3');
path.setAttribute('stroke-width', String(Math.max(1, (link as any).width || 1)));
// Hover effect
path.addEventListener('mouseenter', () => {
path.setAttribute('stroke-opacity', '0.6');
});
path.addEventListener('mouseleave', () => {
path.setAttribute('stroke-opacity', '0.3');
});
// Tooltip
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
const sn = link.source as SNode;
const tn = link.target as SNode;
const val = (link.value || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
title.textContent = `${sn.name}${tn.name}: ${val}`;
path.appendChild(title);
g.appendChild(path);
});
// Render nodes
(graph.nodes as SNode[]).forEach((node) => {
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('x', String(node.x0 || 0));
rect.setAttribute('y', String(node.y0 || 0));
rect.setAttribute('width', String((node.x1 || 0) - (node.x0 || 0)));
rect.setAttribute('height', String(Math.max(1, (node.y1 || 0) - (node.y0 || 0))));
rect.setAttribute('fill', getNodeColor(node));
rect.setAttribute('rx', '2');
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
const val = (node.value || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
title.textContent = `${node.name}: ${val}`;
rect.appendChild(title);
g.appendChild(rect);
// Label
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
const isLeftSide = (node.x0 || 0) < innerWidth / 2;
text.setAttribute('x', String(isLeftSide ? (node.x0 || 0) - 6 : (node.x1 || 0) + 6));
text.setAttribute('y', String(((node.y0 || 0) + (node.y1 || 0)) / 2));
text.setAttribute('dy', '0.35em');
text.setAttribute('text-anchor', isLeftSide ? 'end' : 'start');
text.setAttribute('font-size', '11');
text.setAttribute('fill', '#495057');
text.textContent = node.name;
g.appendChild(text);
});
}, [data, dimensions]);
useEffect(() => {
renderSankey();
}, [renderSankey]);
const fmt = (v: number) =>
(v || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
if (isLoading) return <Center h={300}><Loader /></Center>;
// Fallback if no data from API yet — show a helpful empty state
const hasData = data && data.nodes.length > 0 && data.links.length > 0;
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Cash Flow Visualization</Title>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Income</Text>
<Text fw={700} size="xl" c="green">{fmt(data?.total_income || 0)}</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Expenses</Text>
<Text fw={700} size="xl" c="red">{fmt(data?.total_expenses || 0)}</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Cash Flow</Text>
<Text fw={700} size="xl" c={(data?.net_cash_flow || 0) >= 0 ? 'green' : 'red'}>
{fmt(data?.net_cash_flow || 0)}
</Text>
</Card>
</SimpleGrid>
<Card withBorder p="md" ref={containerRef}>
{isError ? (
<Center h={300}>
<Text c="dimmed">Unable to load cash flow data. Ensure the reports API is available.</Text>
</Center>
) : !hasData ? (
<Center h={300}>
<Stack align="center" gap="sm">
<Text c="dimmed" size="lg">No cash flow data for {year}</Text>
<Text c="dimmed" size="sm">
Record income and expense transactions to see the Sankey diagram.
</Text>
</Stack>
</Center>
) : (
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
style={{ display: 'block', margin: '0 auto' }}
/>
)}
</Card>
<Card withBorder p="sm">
<Group gap="lg">
{Object.entries(CATEGORY_COLORS).map(([key, color]) => (
<Group key={key} gap={4}>
<div style={{ width: 12, height: 12, borderRadius: 2, background: color }} />
<Text size="xs" tt="capitalize">{key}</Text>
</Group>
))}
</Group>
</Card>
</Stack>
);
}

View File

@@ -0,0 +1,175 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
Card, SimpleGrid, Progress, RingProgress,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface ReserveComponent {
id: string; name: string; category: string; description: string;
useful_life_years: number; remaining_life_years: number;
replacement_cost: string; current_fund_balance: string;
annual_contribution: string; last_replacement_date: string;
next_replacement_date: string; condition_rating: number;
}
const categories = ['roof', 'pool', 'hvac', 'paving', 'painting', 'fencing', 'elevator', 'irrigation', 'clubhouse', 'other'];
export function ReservesPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<ReserveComponent | null>(null);
const queryClient = useQueryClient();
const { data: components = [], isLoading } = useQuery<ReserveComponent[]>({
queryKey: ['reserve-components'],
queryFn: async () => { const { data } = await api.get('/reserve-components'); return data; },
});
const form = useForm({
initialValues: {
name: '', category: 'other', description: '', useful_life_years: 20,
remaining_life_years: 10, replacement_cost: 0, current_fund_balance: 0,
annual_contribution: 0, condition_rating: 5,
last_replacement_date: null as Date | null, next_replacement_date: null as Date | null,
},
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
useful_life_years: (v) => (v > 0 ? null : 'Required'),
replacement_cost: (v) => (v > 0 ? null : 'Required'),
},
});
const saveMutation = useMutation({
mutationFn: (values: any) => {
const payload = {
...values,
last_replacement_date: values.last_replacement_date?.toISOString?.()?.split('T')[0] || null,
next_replacement_date: values.next_replacement_date?.toISOString?.()?.split('T')[0] || null,
};
return editing ? api.put(`/reserve-components/${editing.id}`, payload) : api.post('/reserve-components', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reserve-components'] });
notifications.show({ message: editing ? 'Component updated' : 'Component created', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const handleEdit = (c: ReserveComponent) => {
setEditing(c);
form.setValues({
name: c.name, category: c.category || 'other', description: c.description || '',
useful_life_years: c.useful_life_years, remaining_life_years: c.remaining_life_years || 0,
replacement_cost: parseFloat(c.replacement_cost || '0'),
current_fund_balance: parseFloat(c.current_fund_balance || '0'),
annual_contribution: parseFloat(c.annual_contribution || '0'),
condition_rating: c.condition_rating || 5,
last_replacement_date: c.last_replacement_date ? new Date(c.last_replacement_date) : null,
next_replacement_date: c.next_replacement_date ? new Date(c.next_replacement_date) : null,
});
open();
};
const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
const totalCost = components.reduce((s, c) => s + parseFloat(c.replacement_cost || '0'), 0);
const totalFunded = components.reduce((s, c) => s + parseFloat(c.current_fund_balance || '0'), 0);
const pctFunded = totalCost > 0 ? (totalFunded / totalCost) * 100 : 0;
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Reserve Components</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Component</Button>
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Replacement Cost</Text>
<Text fw={700} size="xl">{fmt(totalCost)}</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Total Funded</Text>
<Text fw={700} size="xl" c="green">{fmt(totalFunded)}</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Percent Funded</Text>
<Group>
<Text fw={700} size="xl" c={pctFunded >= 70 ? 'green' : pctFunded >= 40 ? 'yellow' : 'red'}>
{pctFunded.toFixed(1)}%
</Text>
<Progress value={pctFunded} size="lg" style={{ flex: 1 }} color={pctFunded >= 70 ? 'green' : pctFunded >= 40 ? 'yellow' : 'red'} />
</Group>
</Card>
</SimpleGrid>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Component</Table.Th><Table.Th>Category</Table.Th>
<Table.Th>Useful Life</Table.Th><Table.Th>Remaining</Table.Th>
<Table.Th ta="right">Replacement Cost</Table.Th><Table.Th ta="right">Funded</Table.Th>
<Table.Th>Condition</Table.Th><Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{components.map((c) => {
const funded = parseFloat(c.current_fund_balance || '0');
const cost = parseFloat(c.replacement_cost || '0');
const pct = cost > 0 ? (funded / cost) * 100 : 0;
return (
<Table.Tr key={c.id}>
<Table.Td fw={500}>{c.name}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{c.category}</Badge></Table.Td>
<Table.Td>{c.useful_life_years} yrs</Table.Td>
<Table.Td>{c.remaining_life_years} yrs</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(c.replacement_cost)}</Table.Td>
<Table.Td ta="right" ff="monospace">
<Text span c={pct >= 70 ? 'green' : pct >= 40 ? 'yellow' : 'red'}>{fmt(c.current_fund_balance)} ({pct.toFixed(0)}%)</Text>
</Table.Td>
<Table.Td>
<Badge color={c.condition_rating >= 7 ? 'green' : c.condition_rating >= 4 ? 'yellow' : 'red'} size="sm">
{c.condition_rating}/10
</Badge>
</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(c)}><IconEdit size={16} /></ActionIcon></Table.Td>
</Table.Tr>
);
})}
{components.length === 0 && <Table.Tr><Table.Td colSpan={8}><Text ta="center" c="dimmed" py="lg">No reserve components yet</Text></Table.Td></Table.Tr>}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Component' : 'New Reserve Component'} size="lg">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
<Group grow><TextInput label="Name" required {...form.getInputProps('name')} />
<Select label="Category" data={categories.map(c => ({ value: c, label: c.charAt(0).toUpperCase() + c.slice(1) }))} {...form.getInputProps('category')} /></Group>
<Textarea label="Description" {...form.getInputProps('description')} />
<Group grow>
<NumberInput label="Useful Life (years)" required min={1} {...form.getInputProps('useful_life_years')} />
<NumberInput label="Remaining Life (years)" min={0} decimalScale={1} {...form.getInputProps('remaining_life_years')} />
<NumberInput label="Condition (1-10)" min={1} max={10} {...form.getInputProps('condition_rating')} />
</Group>
<Group grow>
<NumberInput label="Replacement Cost" required prefix="$" decimalScale={2} min={0} {...form.getInputProps('replacement_cost')} />
<NumberInput label="Current Fund Balance" prefix="$" decimalScale={2} min={0} {...form.getInputProps('current_fund_balance')} />
<NumberInput label="Annual Contribution" prefix="$" decimalScale={2} min={0} {...form.getInputProps('annual_contribution')} />
</Group>
<Group grow>
<DateInput label="Last Replacement" clearable {...form.getInputProps('last_replacement_date')} />
<DateInput label="Next Replacement" clearable {...form.getInputProps('next_replacement_date')} />
</Group>
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,381 @@
import { useState } from 'react';
import {
Title, Table, Badge, Group, Button, Stack, Text, Modal,
TextInput, Textarea, Select, NumberInput, ActionIcon,
Card, Loader, Center, Tooltip,
} from '@mantine/core';
import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEye, IconCheck, IconX, IconTrash } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface JournalEntryLine {
id?: string;
account_id: string;
account_name?: string;
account_number?: number;
debit: number;
credit: number;
memo: string;
}
interface JournalEntry {
id: string;
entry_date: string;
description: string;
reference_number: string;
entry_type: string;
is_posted: boolean;
is_void: boolean;
created_at: string;
lines?: JournalEntryLine[];
total_debit?: string;
total_credit?: string;
}
interface Account {
id: string;
account_number: number;
name: string;
}
export function TransactionsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [viewId, setViewId] = useState<string | null>(null);
const queryClient = useQueryClient();
const { data: entries = [], isLoading } = useQuery<JournalEntry[]>({
queryKey: ['journal-entries'],
queryFn: async () => {
const { data } = await api.get('/journal-entries');
return data;
},
});
const { data: accounts = [] } = useQuery<Account[]>({
queryKey: ['accounts'],
queryFn: async () => {
const { data } = await api.get('/accounts');
return data;
},
});
const { data: viewEntry } = useQuery<JournalEntry>({
queryKey: ['journal-entry', viewId],
queryFn: async () => {
const { data } = await api.get(`/journal-entries/${viewId}`);
return data;
},
enabled: !!viewId,
});
const [lines, setLines] = useState<JournalEntryLine[]>([
{ account_id: '', debit: 0, credit: 0, memo: '' },
{ account_id: '', debit: 0, credit: 0, memo: '' },
]);
const form = useForm({
initialValues: {
entry_date: new Date(),
description: '',
reference_number: '',
entry_type: 'manual',
},
validate: {
description: (v) => (v.length > 0 ? null : 'Required'),
},
});
const createMutation = useMutation({
mutationFn: async (values: any) => {
const payload = {
...values,
entry_date: values.entry_date.toISOString().split('T')[0],
lines: lines.filter((l) => l.account_id && (l.debit > 0 || l.credit > 0)),
};
return api.post('/journal-entries', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
notifications.show({ message: 'Journal entry created', color: 'green' });
close();
form.reset();
setLines([
{ account_id: '', debit: 0, credit: 0, memo: '' },
{ account_id: '', debit: 0, credit: 0, memo: '' },
]);
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
},
});
const postMutation = useMutation({
mutationFn: (id: string) => api.post(`/journal-entries/${id}/post`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
notifications.show({ message: 'Entry posted', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Post failed', color: 'red' });
},
});
const voidMutation = useMutation({
mutationFn: (id: string) => api.post(`/journal-entries/${id}/void`, { reason: 'Voided by user' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
notifications.show({ message: 'Entry voided', color: 'yellow' });
},
});
const addLine = () => setLines([...lines, { account_id: '', debit: 0, credit: 0, memo: '' }]);
const removeLine = (idx: number) => setLines(lines.filter((_, i) => i !== idx));
const updateLine = (idx: number, field: string, value: any) => {
const updated = [...lines];
(updated[idx] as any)[field] = value;
setLines(updated);
};
const totalDebit = lines.reduce((s, l) => s + (l.debit || 0), 0);
const totalCredit = lines.reduce((s, l) => s + (l.credit || 0), 0);
const isBalanced = Math.abs(totalDebit - totalCredit) < 0.01 && totalDebit > 0;
const accountOptions = accounts.map((a) => ({
value: a.id,
label: `${a.account_number} - ${a.name}`,
}));
const fmt = (v: string | number) => {
const n = typeof v === 'string' ? parseFloat(v) : v;
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
};
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Journal Entries</Title>
<Button leftSection={<IconPlus size={16} />} onClick={open}>
New Entry
</Button>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Date</Table.Th>
<Table.Th>Description</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Ref #</Table.Th>
<Table.Th ta="right">Debit</Table.Th>
<Table.Th ta="right">Credit</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{entries.map((e) => (
<Table.Tr key={e.id} style={e.is_void ? { opacity: 0.5 } : undefined}>
<Table.Td>{new Date(e.entry_date).toLocaleDateString()}</Table.Td>
<Table.Td>{e.description}</Table.Td>
<Table.Td><Badge size="sm" variant="light">{e.entry_type}</Badge></Table.Td>
<Table.Td>{e.reference_number}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(e.total_debit || '0')}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(e.total_credit || '0')}</Table.Td>
<Table.Td>
{e.is_void ? (
<Badge color="red" variant="light" size="sm">Void</Badge>
) : e.is_posted ? (
<Badge color="green" variant="light" size="sm">Posted</Badge>
) : (
<Badge color="yellow" variant="light" size="sm">Draft</Badge>
)}
</Table.Td>
<Table.Td>
<Group gap="xs">
<Tooltip label="View">
<ActionIcon variant="subtle" onClick={() => setViewId(e.id)}>
<IconEye size={16} />
</ActionIcon>
</Tooltip>
{!e.is_posted && !e.is_void && (
<Tooltip label="Post">
<ActionIcon variant="subtle" color="green" onClick={() => postMutation.mutate(e.id)}>
<IconCheck size={16} />
</ActionIcon>
</Tooltip>
)}
{e.is_posted && !e.is_void && (
<Tooltip label="Void">
<ActionIcon variant="subtle" color="red" onClick={() => voidMutation.mutate(e.id)}>
<IconX size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
{entries.length === 0 && (
<Table.Tr>
<Table.Td colSpan={8}>
<Text ta="center" c="dimmed" py="lg">No journal entries yet</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
{/* New Entry Modal */}
<Modal opened={opened} onClose={close} title="New Journal Entry" size="xl">
<form onSubmit={form.onSubmit((values) => createMutation.mutate(values))}>
<Stack>
<Group grow>
<DateInput label="Date" required {...form.getInputProps('entry_date')} />
<TextInput label="Reference #" {...form.getInputProps('reference_number')} />
<Select
label="Type"
data={[
{ value: 'manual', label: 'Manual' },
{ value: 'adjustment', label: 'Adjustment' },
{ value: 'transfer', label: 'Transfer' },
]}
{...form.getInputProps('entry_type')}
/>
</Group>
<Textarea label="Description" required {...form.getInputProps('description')} />
<Text fw={500} size="sm">Line Items</Text>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Account</Table.Th>
<Table.Th>Debit</Table.Th>
<Table.Th>Credit</Table.Th>
<Table.Th>Memo</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{lines.map((line, idx) => (
<Table.Tr key={idx}>
<Table.Td>
<Select
data={accountOptions}
searchable
value={line.account_id}
onChange={(v) => updateLine(idx, 'account_id', v || '')}
placeholder="Select account"
/>
</Table.Td>
<Table.Td>
<NumberInput
value={line.debit}
onChange={(v) => updateLine(idx, 'debit', v || 0)}
min={0}
decimalScale={2}
prefix="$"
/>
</Table.Td>
<Table.Td>
<NumberInput
value={line.credit}
onChange={(v) => updateLine(idx, 'credit', v || 0)}
min={0}
decimalScale={2}
prefix="$"
/>
</Table.Td>
<Table.Td>
<TextInput
value={line.memo}
onChange={(e) => updateLine(idx, 'memo', e.currentTarget.value)}
placeholder="Memo"
/>
</Table.Td>
<Table.Td>
{lines.length > 2 && (
<ActionIcon color="red" variant="subtle" onClick={() => removeLine(idx)}>
<IconTrash size={16} />
</ActionIcon>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr>
<Table.Td><Button variant="subtle" size="xs" onClick={addLine}>+ Add Line</Button></Table.Td>
<Table.Td ta="right" fw={700}>{fmt(totalDebit)}</Table.Td>
<Table.Td ta="right" fw={700}>{fmt(totalCredit)}</Table.Td>
<Table.Td colSpan={2}>
{isBalanced ? (
<Badge color="green">Balanced</Badge>
) : (
<Badge color="red">Out of balance: {fmt(Math.abs(totalDebit - totalCredit))}</Badge>
)}
</Table.Td>
</Table.Tr>
</Table.Tfoot>
</Table>
<Button type="submit" disabled={!isBalanced} loading={createMutation.isPending}>
Create Journal Entry
</Button>
</Stack>
</form>
</Modal>
{/* View Entry Modal */}
<Modal opened={!!viewId} onClose={() => setViewId(null)} title="Journal Entry Detail" size="lg">
{viewEntry && (
<Stack>
<Group>
<Text fw={500}>Date:</Text>
<Text>{new Date(viewEntry.entry_date).toLocaleDateString()}</Text>
<Text fw={500}>Type:</Text>
<Badge>{viewEntry.entry_type}</Badge>
<Text fw={500}>Status:</Text>
{viewEntry.is_void ? (
<Badge color="red">Void</Badge>
) : viewEntry.is_posted ? (
<Badge color="green">Posted</Badge>
) : (
<Badge color="yellow">Draft</Badge>
)}
</Group>
<Text><strong>Description:</strong> {viewEntry.description}</Text>
{viewEntry.reference_number && <Text><strong>Ref #:</strong> {viewEntry.reference_number}</Text>}
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Account</Table.Th>
<Table.Th ta="right">Debit</Table.Th>
<Table.Th ta="right">Credit</Table.Th>
<Table.Th>Memo</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{viewEntry.lines?.map((l, i) => (
<Table.Tr key={i}>
<Table.Td>{l.account_number} - {l.account_name}</Table.Td>
<Table.Td ta="right" ff="monospace">{parseFloat(String(l.debit)) > 0 ? fmt(l.debit) : ''}</Table.Td>
<Table.Td ta="right" ff="monospace">{parseFloat(String(l.credit)) > 0 ? fmt(l.credit) : ''}</Table.Td>
<Table.Td>{l.memo}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
)}
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,120 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, TextInput, Modal,
NumberInput, Select, Badge, ActionIcon, Text, Loader, Center,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconSearch } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface Unit {
id: string;
unit_number: string;
address_line1: string;
owner_name: string;
owner_email: string;
monthly_assessment: string;
status: string;
balance_due?: string;
}
export function UnitsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Unit | null>(null);
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
const { data: units = [], isLoading } = useQuery<Unit[]>({
queryKey: ['units'],
queryFn: async () => { const { data } = await api.get('/units'); return data; },
});
const form = useForm({
initialValues: {
unit_number: '', address_line1: '', city: '', state: '', zip_code: '',
owner_name: '', owner_email: '', owner_phone: '', monthly_assessment: 0,
},
validate: { unit_number: (v) => (v.length > 0 ? null : 'Required') },
});
const saveMutation = useMutation({
mutationFn: (values: any) => editing ? api.put(`/units/${editing.id}`, values) : api.post('/units', values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['units'] });
notifications.show({ message: editing ? 'Unit updated' : 'Unit created', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const handleEdit = (u: Unit) => {
setEditing(u);
form.setValues({
unit_number: u.unit_number, address_line1: u.address_line1 || '',
city: '', state: '', zip_code: '', owner_name: u.owner_name || '',
owner_email: u.owner_email || '', owner_phone: '', monthly_assessment: parseFloat(u.monthly_assessment || '0'),
});
open();
};
const filtered = units.filter((u) =>
!search || u.unit_number.toLowerCase().includes(search.toLowerCase()) ||
(u.owner_name || '').toLowerCase().includes(search.toLowerCase())
);
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Units / Homeowners</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Unit</Button>
</Group>
<TextInput placeholder="Search units..." leftSection={<IconSearch size={16} />} value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Unit #</Table.Th><Table.Th>Address</Table.Th><Table.Th>Owner</Table.Th>
<Table.Th>Email</Table.Th><Table.Th ta="right">Assessment</Table.Th>
<Table.Th>Status</Table.Th><Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((u) => (
<Table.Tr key={u.id}>
<Table.Td fw={500}>{u.unit_number}</Table.Td>
<Table.Td>{u.address_line1}</Table.Td>
<Table.Td>{u.owner_name}</Table.Td>
<Table.Td>{u.owner_email}</Table.Td>
<Table.Td ta="right" ff="monospace">${parseFloat(u.monthly_assessment || '0').toFixed(2)}</Table.Td>
<Table.Td><Badge color={u.status === 'active' ? 'green' : 'gray'} size="sm">{u.status}</Badge></Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(u)}><IconEdit size={16} /></ActionIcon></Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No units yet</Text></Table.Td></Table.Tr>}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Unit' : 'New Unit'}>
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
<TextInput label="Unit Number" required {...form.getInputProps('unit_number')} />
<TextInput label="Address" {...form.getInputProps('address_line1')} />
<Group grow>
<TextInput label="City" {...form.getInputProps('city')} />
<TextInput label="State" {...form.getInputProps('state')} />
<TextInput label="ZIP" {...form.getInputProps('zip_code')} />
</Group>
<TextInput label="Owner Name" {...form.getInputProps('owner_name')} />
<TextInput label="Owner Email" {...form.getInputProps('owner_email')} />
<TextInput label="Owner Phone" {...form.getInputProps('owner_phone')} />
<NumberInput label="Monthly Assessment" prefix="$" decimalScale={2} min={0} {...form.getInputProps('monthly_assessment')} />
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,118 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, TextInput, Modal,
Switch, Badge, ActionIcon, Text, Loader, Center,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconSearch } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface Vendor {
id: string; name: string; contact_name: string; email: string; phone: string;
address_line1: string; city: string; state: string; zip_code: string;
tax_id: string; is_1099_eligible: boolean; is_active: boolean; ytd_payments: string;
}
export function VendorsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Vendor | null>(null);
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
const { data: vendors = [], isLoading } = useQuery<Vendor[]>({
queryKey: ['vendors'],
queryFn: async () => { const { data } = await api.get('/vendors'); return data; },
});
const form = useForm({
initialValues: {
name: '', contact_name: '', email: '', phone: '',
address_line1: '', city: '', state: '', zip_code: '',
tax_id: '', is_1099_eligible: false,
},
validate: { name: (v) => (v.length > 0 ? null : 'Required') },
});
const saveMutation = useMutation({
mutationFn: (values: any) => editing ? api.put(`/vendors/${editing.id}`, values) : api.post('/vendors', values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['vendors'] });
notifications.show({ message: editing ? 'Vendor updated' : 'Vendor created', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => { notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' }); },
});
const handleEdit = (v: Vendor) => {
setEditing(v);
form.setValues({
name: v.name, contact_name: v.contact_name || '', email: v.email || '',
phone: v.phone || '', address_line1: v.address_line1 || '', city: v.city || '',
state: v.state || '', zip_code: v.zip_code || '', tax_id: v.tax_id || '',
is_1099_eligible: v.is_1099_eligible,
});
open();
};
const filtered = vendors.filter((v) => !search || v.name.toLowerCase().includes(search.toLowerCase()));
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Vendors</Title>
<Button leftSection={<IconPlus size={16} />} onClick={() => { setEditing(null); form.reset(); open(); }}>Add Vendor</Button>
</Group>
<TextInput placeholder="Search vendors..." leftSection={<IconSearch size={16} />}
value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th><Table.Th>Contact</Table.Th><Table.Th>Email</Table.Th>
<Table.Th>Phone</Table.Th><Table.Th>1099</Table.Th>
<Table.Th ta="right">YTD Payments</Table.Th><Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{filtered.map((v) => (
<Table.Tr key={v.id}>
<Table.Td fw={500}>{v.name}</Table.Td>
<Table.Td>{v.contact_name}</Table.Td>
<Table.Td>{v.email}</Table.Td>
<Table.Td>{v.phone}</Table.Td>
<Table.Td>{v.is_1099_eligible && <Badge color="orange" size="sm">1099</Badge>}</Table.Td>
<Table.Td ta="right" ff="monospace">${parseFloat(v.ytd_payments || '0').toFixed(2)}</Table.Td>
<Table.Td><ActionIcon variant="subtle" onClick={() => handleEdit(v)}><IconEdit size={16} /></ActionIcon></Table.Td>
</Table.Tr>
))}
{filtered.length === 0 && <Table.Tr><Table.Td colSpan={7}><Text ta="center" c="dimmed" py="lg">No vendors yet</Text></Table.Td></Table.Tr>}
</Table.Tbody>
</Table>
<Modal opened={opened} onClose={close} title={editing ? 'Edit Vendor' : 'New Vendor'}>
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
<TextInput label="Vendor Name" required {...form.getInputProps('name')} />
<TextInput label="Contact Name" {...form.getInputProps('contact_name')} />
<Group grow>
<TextInput label="Email" {...form.getInputProps('email')} />
<TextInput label="Phone" {...form.getInputProps('phone')} />
</Group>
<TextInput label="Address" {...form.getInputProps('address_line1')} />
<Group grow>
<TextInput label="City" {...form.getInputProps('city')} />
<TextInput label="State" {...form.getInputProps('state')} />
<TextInput label="ZIP" {...form.getInputProps('zip_code')} />
</Group>
<TextInput label="Tax ID (EIN/SSN)" {...form.getInputProps('tax_id')} />
<Switch label="1099 Eligible" {...form.getInputProps('is_1099_eligible', { type: 'checkbox' })} />
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -0,0 +1,28 @@
import axios from 'axios';
import { useAuthStore } from '../stores/authStore';
const api = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
});
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
useAuthStore.getState().logout();
window.location.href = '/login';
}
return Promise.reject(error);
},
);
export default api;

View File

@@ -0,0 +1,67 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface Organization {
id: string;
name: string;
role: string;
schemaName?: string;
}
interface User {
id: string;
email: string;
firstName: string;
lastName: string;
}
interface AuthState {
token: string | null;
user: User | null;
organizations: Organization[];
currentOrg: Organization | null;
setAuth: (token: string, user: User, organizations: Organization[]) => void;
setCurrentOrg: (org: Organization, token?: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
organizations: [],
currentOrg: null,
setAuth: (token, user, organizations) =>
set({
token,
user,
organizations,
// Don't auto-select org — force user through SelectOrgPage
currentOrg: null,
}),
setCurrentOrg: (org, token) =>
set((state) => ({
currentOrg: org,
token: token || state.token,
})),
logout: () =>
set({
token: null,
user: null,
organizations: [],
currentOrg: null,
}),
}),
{
name: 'hoa-auth',
version: 2,
migrate: () => ({
token: null,
user: null,
organizations: [],
currentOrg: null,
}),
},
),
);

View File

@@ -0,0 +1,10 @@
import { createTheme } from '@mantine/core';
export const theme = createTheme({
primaryColor: 'blue',
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
headings: {
fontFamily: '-apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif',
},
defaultRadius: 'md',
});

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://backend:3000',
changeOrigin: true,
},
},
},
});