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