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:
93
frontend/src/pages/auth/LoginPage.tsx
Normal file
93
frontend/src/pages/auth/LoginPage.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
102
frontend/src/pages/auth/RegisterPage.tsx
Normal file
102
frontend/src/pages/auth/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
frontend/src/pages/auth/SelectOrgPage.tsx
Normal file
166
frontend/src/pages/auth/SelectOrgPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user