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:
271
frontend/src/pages/accounts/AccountsPage.tsx
Normal file
271
frontend/src/pages/accounts/AccountsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user