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

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>
);
}