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:
13
frontend/src/pages/PlaceholderPage.tsx
Normal file
13
frontend/src/pages/PlaceholderPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
175
frontend/src/pages/budgets/BudgetsPage.tsx
Normal file
175
frontend/src/pages/budgets/BudgetsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
frontend/src/pages/capital-projects/CapitalProjectsPage.tsx
Normal file
139
frontend/src/pages/capital-projects/CapitalProjectsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
147
frontend/src/pages/dashboard/DashboardPage.tsx
Normal file
147
frontend/src/pages/dashboard/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
frontend/src/pages/investments/InvestmentsPage.tsx
Normal file
146
frontend/src/pages/investments/InvestmentsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
frontend/src/pages/invoices/InvoicesPage.tsx
Normal file
115
frontend/src/pages/invoices/InvoicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
frontend/src/pages/payments/PaymentsPage.tsx
Normal file
125
frontend/src/pages/payments/PaymentsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
frontend/src/pages/reports/BalanceSheetPage.tsx
Normal file
102
frontend/src/pages/reports/BalanceSheetPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
frontend/src/pages/reports/BudgetVsActualPage.tsx
Normal file
187
frontend/src/pages/reports/BudgetVsActualPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
frontend/src/pages/reports/IncomeStatementPage.tsx
Normal file
91
frontend/src/pages/reports/IncomeStatementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
247
frontend/src/pages/reports/SankeyPage.tsx
Normal file
247
frontend/src/pages/reports/SankeyPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
frontend/src/pages/reserves/ReservesPage.tsx
Normal file
175
frontend/src/pages/reserves/ReservesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
381
frontend/src/pages/transactions/TransactionsPage.tsx
Normal file
381
frontend/src/pages/transactions/TransactionsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
frontend/src/pages/units/UnitsPage.tsx
Normal file
120
frontend/src/pages/units/UnitsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
frontend/src/pages/vendors/VendorsPage.tsx
vendored
Normal file
118
frontend/src/pages/vendors/VendorsPage.tsx
vendored
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user