Phase 3: Optimize & clean up — unified projects, account enhancements, new tenant fix

- Unify reserve_components + capital_projects into single projects model with
  full CRUD backend and new Projects page frontend
- Rewrite Capital Planning to read from unified projects/planning endpoint;
  add empty state directing users to Projects page when no planning items exist
- Add default designation to assessment groups with auto-set on first creation;
  units now require an assessment group (pre-populated with default)
- Add primary account designation (one per fund type) and balance adjustment
  via journal entries against equity offset accounts (3000/3100)
- Add computed investment fields (interest earned, maturity value, days remaining)
  with PostgreSQL date arithmetic fix for DATE - DATE integer result
- Restructure sidebar: investments in Accounts tab, Year-End under Reports,
  Planning section with Projects and Capital Planning
- Fix new tenant creation seeding unwanted default chart of accounts —
  new tenants now start with a blank slate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 14:32:35 -05:00
parent 17fdacc0f2
commit 301f8a7bde
20 changed files with 1760 additions and 145 deletions

View File

@@ -19,11 +19,23 @@ import {
Center,
Tooltip,
SimpleGrid,
Alert,
} 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, IconSearch, IconArchive, IconArchiveOff } from '@tabler/icons-react';
import {
IconPlus,
IconEdit,
IconSearch,
IconArchive,
IconArchiveOff,
IconStar,
IconStarFilled,
IconAdjustments,
IconInfoCircle,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -37,6 +49,36 @@ interface Account {
is_1099_reportable: boolean;
is_active: boolean;
is_system: boolean;
is_primary: boolean;
balance: string;
}
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;
interest_earned: string | null;
maturity_value: string | null;
days_remaining: number | null;
}
interface TrialBalanceEntry {
id: string;
account_number: number;
name: string;
account_type: string;
fund_type: string;
total_debits: string;
total_credits: string;
balance: string;
}
@@ -48,15 +90,21 @@ const accountTypeColors: Record<string, string> = {
expense: 'orange',
};
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
export function AccountsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [adjustOpened, { open: openAdjust, close: closeAdjust }] = useDisclosure(false);
const [editing, setEditing] = useState<Account | null>(null);
const [adjustingAccount, setAdjustingAccount] = useState<Account | null>(null);
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<string | null>(null);
const [filterFund, setFilterFund] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const queryClient = useQueryClient();
// ── Accounts query ──
const { data: accounts = [], isLoading } = useQuery<Account[]>({
queryKey: ['accounts', showArchived],
queryFn: async () => {
@@ -66,6 +114,25 @@ export function AccountsPage() {
},
});
// ── Investments query ──
const { data: investments = [], isLoading: investmentsLoading } = useQuery<Investment[]>({
queryKey: ['investments'],
queryFn: async () => {
const { data } = await api.get('/investment-accounts');
return data;
},
});
// ── Trial balance query (for balance adjustment) ──
const { data: trialBalance = [] } = useQuery<TrialBalanceEntry[]>({
queryKey: ['trial-balance'],
queryFn: async () => {
const { data } = await api.get('/accounts/trial-balance');
return data;
},
});
// ── Create / Edit form ──
const form = useForm({
initialValues: {
accountNumber: 0,
@@ -82,6 +149,20 @@ export function AccountsPage() {
},
});
// ── Balance adjustment form ──
const adjustForm = useForm({
initialValues: {
targetBalance: 0,
asOfDate: new Date() as Date | null,
memo: '',
},
validate: {
targetBalance: (v) => (v !== undefined && v !== null ? null : 'Required'),
asOfDate: (v) => (v ? null : 'Required'),
},
});
// ── Mutations ──
const createMutation = useMutation({
mutationFn: (values: any) => {
if (editing) {
@@ -113,6 +194,38 @@ export function AccountsPage() {
},
});
const setPrimaryMutation = useMutation({
mutationFn: (accountId: string) => api.put(`/accounts/${accountId}/set-primary`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
notifications.show({ message: 'Primary account updated', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
},
});
const adjustBalanceMutation = useMutation({
mutationFn: (values: { accountId: string; targetBalance: number; asOfDate: string; memo: string }) =>
api.post(`/accounts/${values.accountId}/adjust-balance`, {
targetBalance: values.targetBalance,
asOfDate: values.asOfDate,
memo: values.memo,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
notifications.show({ message: 'Balance adjusted successfully', color: 'green' });
closeAdjust();
setAdjustingAccount(null);
adjustForm.reset();
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error adjusting balance', color: 'red' });
},
});
// ── Handlers ──
const handleEdit = (account: Account) => {
setEditing(account);
form.setValues({
@@ -133,6 +246,28 @@ export function AccountsPage() {
open();
};
const handleAdjustBalance = (account: Account) => {
setAdjustingAccount(account);
const tbEntry = trialBalance.find((tb) => tb.id === account.id);
adjustForm.setValues({
targetBalance: parseFloat(tbEntry?.balance || account.balance || '0'),
asOfDate: new Date(),
memo: '',
});
openAdjust();
};
const handleAdjustSubmit = (values: { targetBalance: number; asOfDate: Date | null; memo: string }) => {
if (!adjustingAccount || !values.asOfDate) return;
adjustBalanceMutation.mutate({
accountId: adjustingAccount.id,
targetBalance: values.targetBalance,
asOfDate: values.asOfDate.toISOString().split('T')[0],
memo: values.memo,
});
};
// ── Filtering ──
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;
@@ -140,26 +275,42 @@ export function AccountsPage() {
return true;
});
const activeAccounts = filtered.filter(a => a.is_active);
const archivedAccounts = filtered.filter(a => !a.is_active);
const activeAccounts = filtered.filter((a) => a.is_active);
const archivedAccounts = filtered.filter((a) => !a.is_active);
const totalsByType = accounts.reduce((acc, a) => {
if (a.is_active) {
acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0');
}
return acc;
}, {} as Record<string, number>);
// ── Summary cards ──
const totalsByType = accounts.reduce(
(acc, a) => {
if (a.is_active) {
acc[a.account_type] = (acc[a.account_type] || 0) + parseFloat(a.balance || '0');
}
return acc;
},
{} as Record<string, number>,
);
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
// ── Adjust modal: current balance from trial balance ──
const adjustCurrentBalance = adjustingAccount
? parseFloat(
trialBalance.find((tb) => tb.id === adjustingAccount.id)?.balance ||
adjustingAccount.balance ||
'0',
)
: 0;
const adjustmentAmount = (adjustForm.values.targetBalance || 0) - adjustCurrentBalance;
if (isLoading) {
return <Center h={300}><Loader /></Center>;
return (
<Center h={300}>
<Loader />
</Center>
);
}
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Chart of Accounts</Title>
<Title order={2}>Accounts</Title>
<Group>
<Switch
label="Show Archived"
@@ -176,8 +327,12 @@ export function AccountsPage() {
<SimpleGrid cols={{ base: 2, sm: 5 }}>
{Object.entries(totalsByType).map(([type, total]) => (
<Card withBorder p="xs" key={type}>
<Text size="xs" c="dimmed" tt="capitalize">{type}</Text>
<Text fw={700} size="sm" c={accountTypeColors[type]}>{fmt(total)}</Text>
<Text size="xs" c="dimmed" tt="capitalize">
{type}
</Text>
<Text fw={700} size="sm" c={accountTypeColors[type]}>
{fmt(total)}
</Text>
</Card>
))}
</SimpleGrid>
@@ -213,27 +368,59 @@ export function AccountsPage() {
<Tabs.Tab value="all">All ({activeAccounts.length})</Tabs.Tab>
<Tabs.Tab value="operating">Operating</Tabs.Tab>
<Tabs.Tab value="reserve">Reserve</Tabs.Tab>
<Tabs.Tab value="investments">Investments</Tabs.Tab>
{showArchived && archivedAccounts.length > 0 && (
<Tabs.Tab value="archived" color="gray">Archived ({archivedAccounts.length})</Tabs.Tab>
<Tabs.Tab value="archived" color="gray">
Archived ({archivedAccounts.length})
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="all" pt="sm">
<AccountTable accounts={activeAccounts} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
<AccountTable
accounts={activeAccounts}
onEdit={handleEdit}
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
/>
</Tabs.Panel>
<Tabs.Panel value="operating" pt="sm">
<AccountTable accounts={activeAccounts.filter(a => a.fund_type === 'operating')} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
<AccountTable
accounts={activeAccounts.filter((a) => a.fund_type === 'operating')}
onEdit={handleEdit}
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
/>
</Tabs.Panel>
<Tabs.Panel value="reserve" pt="sm">
<AccountTable accounts={activeAccounts.filter(a => a.fund_type === 'reserve')} onEdit={handleEdit} onArchive={archiveMutation.mutate} />
<AccountTable
accounts={activeAccounts.filter((a) => a.fund_type === 'reserve')}
onEdit={handleEdit}
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
/>
</Tabs.Panel>
<Tabs.Panel value="investments" pt="sm">
<InvestmentsTab investments={investments} isLoading={investmentsLoading} />
</Tabs.Panel>
{showArchived && (
<Tabs.Panel value="archived" pt="sm">
<AccountTable accounts={archivedAccounts} onEdit={handleEdit} onArchive={archiveMutation.mutate} isArchivedView />
<AccountTable
accounts={archivedAccounts}
onEdit={handleEdit}
onArchive={archiveMutation.mutate}
onSetPrimary={(id) => setPrimaryMutation.mutate(id)}
onAdjustBalance={handleAdjustBalance}
isArchivedView
/>
</Tabs.Panel>
)}
</Tabs>
{/* Create / Edit Account Modal */}
<Modal opened={opened} onClose={close} title={editing ? 'Edit Account' : 'New Account'} size="md">
<form onSubmit={form.onSubmit((values) => createMutation.mutate(values))}>
<Stack>
@@ -279,30 +466,87 @@ export function AccountsPage() {
</Stack>
</form>
</Modal>
{/* Balance Adjustment Modal */}
<Modal opened={adjustOpened} onClose={closeAdjust} title="Adjust Balance" size="md">
{adjustingAccount && (
<form onSubmit={adjustForm.onSubmit(handleAdjustSubmit)}>
<Stack>
<Text size="sm" c="dimmed">
Account: <strong>{adjustingAccount.account_number} - {adjustingAccount.name}</strong>
</Text>
<TextInput
label="Current Balance"
value={fmt(adjustCurrentBalance)}
readOnly
variant="filled"
/>
<NumberInput
label="Target Balance"
required
prefix="$"
decimalScale={2}
thousandSeparator=","
{...adjustForm.getInputProps('targetBalance')}
/>
<DateInput
label="As-of Date"
required
clearable
{...adjustForm.getInputProps('asOfDate')}
/>
<TextInput
label="Memo"
placeholder="Optional memo for this adjustment"
{...adjustForm.getInputProps('memo')}
/>
<Alert icon={<IconInfoCircle size={16} />} color={adjustmentAmount >= 0 ? 'blue' : 'orange'} variant="light">
<Text size="sm">
Adjustment amount: <strong>{fmt(adjustmentAmount)}</strong>
{adjustmentAmount > 0 && ' (increase)'}
{adjustmentAmount < 0 && ' (decrease)'}
{adjustmentAmount === 0 && ' (no change)'}
</Text>
</Alert>
<Button type="submit" loading={adjustBalanceMutation.isPending}>
Apply Adjustment
</Button>
</Stack>
</form>
)}
</Modal>
</Stack>
);
}
// ── Account Table Component ──
function AccountTable({
accounts,
onEdit,
onArchive,
onSetPrimary,
onAdjustBalance,
isArchivedView = false,
}: {
accounts: Account[];
onEdit: (a: Account) => void;
onArchive: (a: Account) => void;
onSetPrimary: (id: string) => void;
onAdjustBalance: (a: Account) => void;
isArchivedView?: boolean;
}) {
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 w={40}></Table.Th>
<Table.Th>Acct #</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Type</Table.Th>
@@ -315,7 +559,7 @@ function AccountTable({
<Table.Tbody>
{accounts.length === 0 && (
<Table.Tr>
<Table.Td colSpan={7}>
<Table.Td colSpan={8}>
<Text ta="center" c="dimmed" py="lg">
{isArchivedView ? 'No archived accounts' : 'No accounts found'}
</Text>
@@ -324,11 +568,22 @@ function AccountTable({
)}
{accounts.map((a) => (
<Table.Tr key={a.id} style={{ opacity: a.is_active ? 1 : 0.6 }}>
<Table.Td>
{a.is_primary && (
<Tooltip label="Primary account">
<IconStarFilled size={16} style={{ color: 'var(--mantine-color-yellow-5)' }} />
</Tooltip>
)}
</Table.Td>
<Table.Td fw={500}>{a.account_number}</Table.Td>
<Table.Td>
<div>
<Text size="sm">{a.name}</Text>
{a.description && <Text size="xs" c="dimmed">{a.description}</Text>}
{a.description && (
<Text size="xs" c="dimmed">
{a.description}
</Text>
)}
</div>
</Table.Td>
<Table.Td>
@@ -341,10 +596,32 @@ function AccountTable({
{a.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(a.balance)}</Table.Td>
<Table.Td>{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}</Table.Td>
<Table.Td ta="right" ff="monospace">
{fmt(a.balance)}
</Table.Td>
<Table.Td>
{a.is_1099_reportable ? <Badge size="xs" color="yellow">1099</Badge> : ''}
</Table.Td>
<Table.Td>
<Group gap={4}>
{a.account_type === 'asset' && (
<Tooltip label={a.is_primary ? 'Primary account' : 'Set as Primary'}>
<ActionIcon
variant="subtle"
color="yellow"
onClick={() => onSetPrimary(a.id)}
>
{a.is_primary ? <IconStarFilled size={16} /> : <IconStar size={16} />}
</ActionIcon>
</Tooltip>
)}
{a.account_type === 'asset' && (
<Tooltip label="Adjust Balance">
<ActionIcon variant="subtle" color="blue" onClick={() => onAdjustBalance(a)}>
<IconAdjustments size={16} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Edit account">
<ActionIcon variant="subtle" onClick={() => onEdit(a)}>
<IconEdit size={16} />
@@ -369,3 +646,134 @@ function AccountTable({
</Table>
);
}
// ── Investments Tab Component ──
function InvestmentsTab({
investments,
isLoading,
}: {
investments: Investment[];
isLoading: boolean;
}) {
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={200}>
<Loader />
</Center>
);
}
return (
<Stack>
<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 ta="right">Interest Earned</Table.Th>
<Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th ta="right">Days Remaining</Table.Th>
<Table.Th>Maturity Date</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{investments.length === 0 && (
<Table.Tr>
<Table.Td colSpan={10}>
<Text ta="center" c="dimmed" py="lg">
No investments yet
</Text>
</Table.Td>
</Table.Tr>
)}
{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'} variant="light">
{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 ta="right" ff="monospace">
{inv.interest_earned !== null ? fmt(inv.interest_earned) : '-'}
</Table.Td>
<Table.Td ta="right" ff="monospace">
{inv.maturity_value !== null ? fmt(inv.maturity_value) : '-'}
</Table.Td>
<Table.Td ta="right">
{inv.days_remaining !== null ? (
<Badge
size="sm"
color={inv.days_remaining <= 30 ? 'red' : inv.days_remaining <= 90 ? 'yellow' : 'gray'}
variant="light"
>
{inv.days_remaining} days
</Badge>
) : (
'-'
)}
</Table.Td>
<Table.Td>
{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
);
}

View File

@@ -1,13 +1,13 @@
import { useState } from 'react';
import {
Title, Text, Card, Table, SimpleGrid, Group, Stack, Badge, Loader, Center,
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon,
ThemeIcon, Button, Modal, TextInput, NumberInput, Textarea, Select, ActionIcon, Tooltip,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconPlus, IconEdit, IconCategory, IconCash, IconHome, IconArchive,
IconPlus, IconEdit, IconCategory, IconCash, IconHome, IconArchive, IconStarFilled, IconStar,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -24,6 +24,7 @@ interface AssessmentGroup {
monthly_operating_income: string;
monthly_reserve_income: string;
total_monthly_income: string;
is_default: boolean;
is_active: boolean;
}
@@ -105,6 +106,17 @@ export function AssessmentGroupsPage() {
},
});
const setDefaultMutation = useMutation({
mutationFn: (id: string) => api.put(`/assessment-groups/${id}/set-default`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['assessment-groups'] });
notifications.show({ message: 'Default group updated', color: 'green' });
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Error', color: 'red' });
},
});
const handleEdit = (group: AssessmentGroup) => {
setEditing(group);
form.setValues({
@@ -223,10 +235,17 @@ export function AssessmentGroupsPage() {
{groups.map((g) => (
<Table.Tr key={g.id} style={{ opacity: g.is_active ? 1 : 0.5 }}>
<Table.Td>
<div>
<Text fw={500}>{g.name}</Text>
{g.description && <Text size="xs" c="dimmed">{g.description}</Text>}
</div>
<Group gap={8}>
<div>
<Group gap={6}>
<Text fw={500}>{g.name}</Text>
{g.is_default && (
<Badge color="yellow" variant="light" size="xs">Default</Badge>
)}
</Group>
{g.description && <Text size="xs" c="dimmed">{g.description}</Text>}
</div>
</Group>
</Table.Td>
<Table.Td ta="center">
<Badge variant="light">{g.actual_unit_count || g.unit_count}</Badge>
@@ -256,6 +275,16 @@ export function AssessmentGroupsPage() {
</Table.Td>
<Table.Td>
<Group gap={4}>
<Tooltip label={g.is_default ? 'Default group' : 'Set as default'}>
<ActionIcon
variant="subtle"
color={g.is_default ? 'yellow' : 'gray'}
onClick={() => !g.is_default && setDefaultMutation.mutate(g.id)}
disabled={g.is_default}
>
{g.is_default ? <IconStarFilled size={16} /> : <IconStar size={16} />}
</ActionIcon>
</Tooltip>
<ActionIcon variant="subtle" onClick={() => handleEdit(g)}>
<IconEdit size={16} />
</ActionIcon>

View File

@@ -8,9 +8,10 @@ import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconPlus, IconEdit, IconTable, IconLayoutKanban, IconFileTypePdf,
IconGripVertical,
IconEdit, IconTable, IconLayoutKanban, IconFileTypePdf,
IconGripVertical, IconCalendar, IconClipboardList,
} from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -18,10 +19,21 @@ import api from '../../services/api';
// Types & constants
// ---------------------------------------------------------------------------
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;
interface Project {
id: string;
name: string;
description: string;
category: string;
estimated_cost: string;
actual_cost: string;
fund_source: string;
funded_percentage: string;
planned_date: string;
target_year: number;
target_month: number;
status: string;
priority: number;
notes: string;
}
const FUTURE_YEAR = 9999;
@@ -38,17 +50,30 @@ const fmt = (v: string | number) =>
const yearLabel = (year: number) => (year === FUTURE_YEAR ? 'Future' : String(year));
const formatPlannedDate = (d: string | null | undefined) => {
if (!d) return null;
try {
const date = new Date(d);
if (isNaN(date.getTime())) return null;
return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
} catch {
return null;
}
};
// ---------------------------------------------------------------------------
// Kanban card
// ---------------------------------------------------------------------------
interface KanbanCardProps {
project: CapitalProject;
onEdit: (p: CapitalProject) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: CapitalProject) => void;
project: Project;
onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
}
function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
const plannedLabel = formatPlannedDate(project.planned_date);
return (
<Card
shadow="sm"
@@ -85,9 +110,16 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
{fmt(project.estimated_cost)}
</Text>
<Badge size="xs" variant="light" color="violet">
{project.fund_source?.replace('_', ' ') || 'reserve'}
</Badge>
<Group gap={6} wrap="wrap">
<Badge size="xs" variant="light" color="violet">
{project.fund_source?.replace('_', ' ') || 'reserve'}
</Badge>
{plannedLabel && (
<Badge size="xs" variant="light" color="cyan" leftSection={<IconCalendar size={10} />}>
{plannedLabel}
</Badge>
)}
</Group>
</Card>
);
}
@@ -98,9 +130,9 @@ function KanbanCard({ project, onEdit, onDragStart }: KanbanCardProps) {
interface KanbanColumnProps {
year: number;
projects: CapitalProject[];
onEdit: (p: CapitalProject) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: CapitalProject) => void;
projects: Project[];
onEdit: (p: Project) => void;
onDragStart: (e: DragEvent<HTMLDivElement>, project: Project) => void;
onDrop: (e: DragEvent<HTMLDivElement>, targetYear: number) => void;
isDragOver: boolean;
onDragOverHandler: (e: DragEvent<HTMLDivElement>, year: number) => void;
@@ -177,7 +209,7 @@ const printStyles = `
export function CapitalProjectsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<CapitalProject | null>(null);
const [editing, setEditing] = useState<Project | null>(null);
const [viewMode, setViewMode] = useState<string>('kanban');
const [printMode, setPrintMode] = useState(false);
const [dragOverYear, setDragOverYear] = useState<number | null>(null);
@@ -186,12 +218,12 @@ export function CapitalProjectsPage() {
// ---- Data fetching ----
const { data: projects = [], isLoading } = useQuery<CapitalProject[]>({
queryKey: ['capital-projects'],
queryFn: async () => { const { data } = await api.get('/capital-projects'); return data; },
const { data: projects = [], isLoading } = useQuery<Project[]>({
queryKey: ['projects-planning'],
queryFn: async () => { const { data } = await api.get('/projects/planning'); return data; },
});
// ---- Form ----
// ---- Form (simplified edit modal) ----
const currentYear = new Date().getFullYear();
@@ -205,26 +237,48 @@ export function CapitalProjectsPage() {
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'),
status: 'planned',
priority: 3,
target_year: currentYear,
target_month: 6,
planned_date: '',
notes: '',
},
});
// ---- Mutations ----
const saveMutation = useMutation({
mutationFn: (values: any) =>
editing
? api.put(`/capital-projects/${editing.id}`, values)
: api.post('/capital-projects', values),
mutationFn: (values: {
status: string;
priority: number;
target_year: number;
target_month: number;
planned_date: string;
notes: string;
}) => {
if (!editing) return Promise.reject(new Error('No project selected'));
const payload: Record<string, unknown> = {
status: values.status,
priority: values.priority,
target_year: values.target_year,
target_month: values.target_month,
notes: values.notes,
};
// Derive planned_date from target_year/target_month if not explicitly set
if (values.planned_date) {
payload.planned_date = values.planned_date;
} else if (values.target_year !== FUTURE_YEAR) {
payload.planned_date = `${values.target_year}-${String(values.target_month || 6).padStart(2, '0')}-01`;
} else {
payload.planned_date = null;
}
return api.put(`/projects/${editing.id}`, payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['capital-projects'] });
notifications.show({ message: editing ? 'Project updated' : 'Project created', color: 'green' });
queryClient.invalidateQueries({ queryKey: ['projects-planning'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
notifications.show({ message: 'Project updated', color: 'green' });
close(); setEditing(null); form.reset();
},
onError: (err: any) => {
@@ -233,10 +287,19 @@ export function CapitalProjectsPage() {
});
const moveProjectMutation = useMutation({
mutationFn: ({ id, target_year }: { id: string; target_year: number }) =>
api.put(`/capital-projects/${id}`, { target_year }),
mutationFn: ({ id, target_year, target_month }: { id: string; target_year: number; target_month: number }) => {
const payload: Record<string, unknown> = { target_year };
// Derive planned_date based on the new year
if (target_year === FUTURE_YEAR) {
payload.planned_date = null;
} else {
payload.planned_date = `${target_year}-${String(target_month || 6).padStart(2, '0')}-01`;
}
return api.put(`/projects/${id}`, payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['capital-projects'] });
queryClient.invalidateQueries({ queryKey: ['projects-planning'] });
queryClient.invalidateQueries({ queryKey: ['projects'] });
notifications.show({ message: 'Project moved successfully', color: 'green' });
},
onError: (err: any) => {
@@ -261,25 +324,19 @@ export function CapitalProjectsPage() {
// ---- Handlers ----
const handleEdit = (p: CapitalProject) => {
const handleEdit = (p: Project) => {
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',
status: p.status || 'planned',
priority: p.priority || 3,
target_year: p.target_year,
target_month: p.target_month || 6,
planned_date: p.planned_date || '',
notes: p.notes || '',
});
open();
};
const handleNewProject = () => {
setEditing(null);
form.reset();
open();
};
const handlePdfExport = () => {
// If already in table view, just print directly
if (viewMode === 'table') {
@@ -292,8 +349,12 @@ export function CapitalProjectsPage() {
// ---- Drag & Drop ----
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: CapitalProject) => {
e.dataTransfer.setData('application/json', JSON.stringify({ id: project.id, source_year: project.target_year }));
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>, project: Project) => {
e.dataTransfer.setData('application/json', JSON.stringify({
id: project.id,
source_year: project.target_year,
target_month: project.target_month,
}));
e.dataTransfer.effectAllowed = 'move';
}, []);
@@ -313,7 +374,11 @@ export function CapitalProjectsPage() {
try {
const payload = JSON.parse(e.dataTransfer.getData('application/json'));
if (payload.source_year !== targetYear) {
moveProjectMutation.mutate({ id: payload.id, target_year: targetYear });
moveProjectMutation.mutate({
id: payload.id,
target_year: targetYear,
target_month: payload.target_month || 6,
});
}
} catch {
// ignore malformed drag data
@@ -336,15 +401,50 @@ export function CapitalProjectsPage() {
// ---- Loading state ----
const navigate = useNavigate();
if (isLoading) return <Center h={300}><Loader /></Center>;
// ---- Empty state when no planning projects exist ----
if (projects.length === 0) {
return (
<Stack>
<Group justify="space-between">
<Title order={2}>Capital Planning</Title>
</Group>
<Center py={80}>
<Stack align="center" gap="md" maw={420}>
<IconClipboardList size={64} color="var(--mantine-color-dimmed)" stroke={1.2} />
<Title order={3} c="dimmed" ta="center">
No projects in the capital plan
</Title>
<Text c="dimmed" ta="center" size="sm">
Capital Planning displays projects that have a target year assigned.
Head over to the Projects page to define your reserve and operating
projects, then assign target years to see them here.
</Text>
<Button
variant="light"
size="md"
leftSection={<IconClipboardList size={18} />}
onClick={() => navigate('/projects')}
>
Go to Projects
</Button>
</Stack>
</Center>
</Stack>
);
}
// ---- Render: Table view ----
const renderTableView = () => (
<>
{years.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No capital projects planned yet. Add your first project.
No projects in the capital plan. Assign a target year to projects in the Projects page.
</Text>
) : (
years.map((year) => {
@@ -361,12 +461,15 @@ export function CapitalProjectsPage() {
<Table.Thead>
<Table.Tr>
<Table.Th>Project</Table.Th>
<Table.Th>Category</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>Funded</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Planned Date</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
@@ -374,6 +477,7 @@ export function CapitalProjectsPage() {
{yearProjects.map((p) => (
<Table.Tr key={p.id}>
<Table.Td fw={500}>{p.name}</Table.Td>
<Table.Td>{p.category || '-'}</Table.Td>
<Table.Td>
{p.target_year === FUTURE_YEAR
? 'Future'
@@ -394,10 +498,18 @@ export function CapitalProjectsPage() {
<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>
<Badge size="sm" variant="light">{p.fund_source?.replace('_', ' ') || 'reserve'}</Badge>
</Table.Td>
<Table.Td>
{parseFloat(p.funded_percentage || '0') > 0
? `${parseFloat(p.funded_percentage).toFixed(0)}%`
: '-'}
</Table.Td>
<Table.Td>
<Badge size="sm" color={statusColors[p.status] || 'gray'}>{p.status?.replace('_', ' ')}</Badge>
</Table.Td>
<Table.Td>{formatPlannedDate(p.planned_date) || '-'}</Table.Td>
<Table.Td>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
@@ -447,7 +559,7 @@ export function CapitalProjectsPage() {
<style>{printStyles}</style>
<Group justify="space-between">
<Title order={2}>Capital Projects</Title>
<Title order={2}>Capital Planning</Title>
<Group gap="sm">
<SegmentedControl
value={viewMode}
@@ -478,9 +590,6 @@ export function CapitalProjectsPage() {
PDF
</Button>
</Tooltip>
<Button leftSection={<IconPlus size={16} />} onClick={handleNewProject}>
Add Project
</Button>
</Group>
</Group>
@@ -503,14 +612,22 @@ export function CapitalProjectsPage() {
</>
)}
<Modal opened={opened} onClose={close} title={editing ? 'Edit Project' : 'New Capital Project'} size="lg">
{/* Simplified edit modal - full project editing is done in ProjectsPage */}
<Modal opened={opened} onClose={close} title="Edit Capital Plan Details" size="md">
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
<TextInput label="Project Name" required {...form.getInputProps('name')} />
<Textarea label="Description" {...form.getInputProps('description')} />
{editing && (
<Text size="sm" fw={600} c="dimmed">
{editing.name}
</Text>
)}
<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')} />
<Select
label="Status"
data={Object.keys(statusColors).map((s) => ({ value: s, label: s.replace('_', ' ') }))}
{...form.getInputProps('status')}
/>
<NumberInput label="Priority (1=High, 5=Low)" min={1} max={5} {...form.getInputProps('priority')} />
</Group>
<Group grow>
<Select
@@ -530,24 +647,14 @@ export function CapitalProjectsPage() {
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>
<TextInput
label="Planned Date"
placeholder="YYYY-MM-DD"
description="Leave blank to auto-derive from target year/month"
{...form.getInputProps('planned_date')}
/>
<Textarea label="Notes" autosize minRows={2} maxRows={6} {...form.getInputProps('notes')} />
<Button type="submit" loading={saveMutation.isPending}>Update</Button>
</Stack>
</form>
</Modal>

View File

@@ -16,6 +16,9 @@ interface Investment {
investment_type: string; fund_type: string; principal: string;
interest_rate: string; maturity_date: string; purchase_date: string;
current_value: string; is_active: boolean;
interest_earned: string | null;
maturity_value: string | null;
days_remaining: number | null;
}
export function InvestmentsPage() {
@@ -71,8 +74,16 @@ export function InvestmentsPage() {
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 totalInterestEarned = investments.reduce((s, i) => s + parseFloat(i.interest_earned || '0'), 0);
const avgRate = investments.length > 0 ? investments.reduce((s, i) => s + parseFloat(i.interest_rate || '0'), 0) / investments.length : 0;
const daysRemainingColor = (days: number | null) => {
if (days === null) return 'gray';
if (days <= 30) return 'red';
if (days <= 90) return 'yellow';
return 'gray';
};
if (isLoading) return <Center h={300}><Loader /></Center>;
return (
@@ -81,9 +92,10 @@ export function InvestmentsPage() {
<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 }}>
<SimpleGrid cols={{ base: 1, sm: 4 }}>
<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">Interest Earned</Text><Text fw={700} size="xl" c="teal">{fmt(totalInterestEarned)}</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>
@@ -91,7 +103,11 @@ export function InvestmentsPage() {
<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.Th ta="right">Rate</Table.Th>
<Table.Th ta="right">Interest Earned</Table.Th>
<Table.Th ta="right">Maturity Value</Table.Th>
<Table.Th ta="center">Days Left</Table.Th>
<Table.Th>Maturity</Table.Th><Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -103,11 +119,24 @@ export function InvestmentsPage() {
<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 ta="right" ff="monospace" c="teal">
{inv.interest_earned !== null ? fmt(inv.interest_earned) : '-'}
</Table.Td>
<Table.Td ta="right" ff="monospace">
{inv.maturity_value !== null ? fmt(inv.maturity_value) : '-'}
</Table.Td>
<Table.Td ta="center">
{inv.days_remaining !== null ? (
<Badge size="sm" color={daysRemainingColor(inv.days_remaining)} variant="light">
{inv.days_remaining}d
</Badge>
) : '-'}
</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>}
{investments.length === 0 && <Table.Tr><Table.Td colSpan={11}><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">

View File

@@ -0,0 +1,590 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, Text, Modal, TextInput,
NumberInput, Select, Textarea, Badge, ActionIcon, Loader, Center,
Card, SimpleGrid, Progress,
} 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';
// ---------------------------------------------------------------------------
// Types & constants
// ---------------------------------------------------------------------------
interface Project {
id: string;
name: string;
description: string;
category: string;
estimated_cost: string;
actual_cost: string;
current_fund_balance: string;
annual_contribution: string;
fund_source: string;
funded_percentage: string;
useful_life_years: number;
remaining_life_years: number;
condition_rating: number;
last_replacement_date: string;
next_replacement_date: string;
planned_date: string;
target_year: number;
target_month: number;
status: string;
priority: number;
account_id: string;
notes: string;
is_active: boolean;
}
const FUTURE_YEAR = 9999;
const categories = [
'roof', 'pool', 'hvac', 'paving', 'painting',
'fencing', 'elevator', 'irrigation', 'clubhouse', 'other',
];
const statusColors: Record<string, string> = {
planned: 'blue',
approved: 'green',
in_progress: 'yellow',
completed: 'teal',
deferred: 'gray',
cancelled: 'red',
};
const fundSourceColors: Record<string, string> = {
operating: 'gray',
reserve: 'violet',
special_assessment: 'orange',
};
const fmt = (v: string | number) =>
parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
// ---------------------------------------------------------------------------
// Main page component
// ---------------------------------------------------------------------------
export function ProjectsPage() {
const [opened, { open, close }] = useDisclosure(false);
const [editing, setEditing] = useState<Project | null>(null);
const queryClient = useQueryClient();
// ---- Data fetching ----
const { data: projects = [], isLoading } = useQuery<Project[]>({
queryKey: ['projects'],
queryFn: async () => {
const { data } = await api.get('/projects');
return data;
},
});
// ---- Derived summary values ----
const totalEstimatedCost = projects.reduce(
(sum, p) => sum + parseFloat(p.estimated_cost || '0'),
0,
);
const reserveProjects = projects.filter((p) => p.fund_source === 'reserve');
const totalFundedReserve = reserveProjects.reduce(
(sum, p) => sum + parseFloat(p.current_fund_balance || '0'),
0,
);
const totalReserveReplacementCost = reserveProjects.reduce(
(sum, p) => sum + parseFloat(p.estimated_cost || '0'),
0,
);
const pctFundedReserve =
totalReserveReplacementCost > 0
? (totalFundedReserve / totalReserveReplacementCost) * 100
: 0;
// ---- Form setup ----
const currentYear = new Date().getFullYear();
const targetYearOptions = [
...Array.from({ length: 6 }, (_, i) => ({
value: String(currentYear + i),
label: String(currentYear + i),
})),
{ value: String(FUTURE_YEAR), label: 'Future (Beyond 5-Year)' },
];
const monthOptions = Array.from({ length: 12 }, (_, i) => ({
value: String(i + 1),
label: new Date(2000, i).toLocaleString('default', { month: 'long' }),
}));
const form = useForm({
initialValues: {
name: '',
category: 'other',
description: '',
fund_source: 'reserve',
status: 'planned',
priority: 3,
estimated_cost: 0,
actual_cost: 0,
current_fund_balance: 0,
annual_contribution: 0,
funded_percentage: 0,
useful_life_years: 20,
remaining_life_years: 10,
condition_rating: 5,
last_replacement_date: null as Date | null,
next_replacement_date: null as Date | null,
planned_date: null as Date | null,
target_year: currentYear,
target_month: 6,
notes: '',
},
validate: {
name: (v) => (v.length > 0 ? null : 'Required'),
estimated_cost: (v) => (v > 0 ? null : 'Required'),
},
});
// ---- Mutations ----
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,
planned_date:
values.planned_date?.toISOString?.()?.split('T')[0] || null,
};
return editing
? api.put(`/projects/${editing.id}`, payload)
: api.post('/projects', payload);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['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',
});
},
});
// ---- Handlers ----
const handleEdit = (p: Project) => {
setEditing(p);
form.setValues({
name: p.name,
category: p.category || 'other',
description: p.description || '',
fund_source: p.fund_source || 'reserve',
status: p.status || 'planned',
priority: p.priority || 3,
estimated_cost: parseFloat(p.estimated_cost || '0'),
actual_cost: parseFloat(p.actual_cost || '0'),
current_fund_balance: parseFloat(p.current_fund_balance || '0'),
annual_contribution: parseFloat(p.annual_contribution || '0'),
funded_percentage: parseFloat(p.funded_percentage || '0'),
useful_life_years: p.useful_life_years || 0,
remaining_life_years: p.remaining_life_years || 0,
condition_rating: p.condition_rating || 5,
last_replacement_date: p.last_replacement_date
? new Date(p.last_replacement_date)
: null,
next_replacement_date: p.next_replacement_date
? new Date(p.next_replacement_date)
: null,
planned_date: p.planned_date ? new Date(p.planned_date) : null,
target_year: p.target_year || currentYear,
target_month: p.target_month || 6,
notes: p.notes || '',
});
open();
};
const handleNew = () => {
setEditing(null);
form.reset();
open();
};
// ---- Helpers for table rendering ----
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return '-';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return '-';
return d.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const conditionBadge = (rating: number | null | undefined) => {
if (rating == null) return <Text c="dimmed">-</Text>;
const color = rating >= 7 ? 'green' : rating >= 4 ? 'yellow' : 'red';
return (
<Badge size="sm" color={color}>
{rating}/10
</Badge>
);
};
const fundedPercentageCell = (project: Project) => {
if (project.fund_source !== 'reserve') {
return <Text c="dimmed">-</Text>;
}
const cost = parseFloat(project.estimated_cost || '0');
const funded = parseFloat(project.current_fund_balance || '0');
const pct = cost > 0 ? (funded / cost) * 100 : 0;
const color = pct >= 70 ? 'green' : pct >= 40 ? 'yellow' : 'red';
return (
<Text span c={color} ff="monospace">
{pct.toFixed(0)}%
</Text>
);
};
// ---- Loading state ----
if (isLoading) return <Center h={300}><Loader /></Center>;
// ---- Render ----
return (
<Stack>
{/* Header */}
<Group justify="space-between">
<Title order={2}>Projects</Title>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
+ Add Project
</Button>
</Group>
{/* Summary Cards */}
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Total Estimated Cost
</Text>
<Text fw={700} size="xl">
{fmt(totalEstimatedCost)}
</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Total Funded - Reserve Only
</Text>
<Text fw={700} size="xl" c="green">
{fmt(totalFundedReserve)}
</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
Percent Funded - Reserve Only
</Text>
<Group>
<Text
fw={700}
size="xl"
c={
pctFundedReserve >= 70
? 'green'
: pctFundedReserve >= 40
? 'yellow'
: 'red'
}
>
{pctFundedReserve.toFixed(1)}%
</Text>
<Progress
value={pctFundedReserve}
size="lg"
style={{ flex: 1 }}
color={
pctFundedReserve >= 70
? 'green'
: pctFundedReserve >= 40
? 'yellow'
: 'red'
}
/>
</Group>
</Card>
</SimpleGrid>
{/* Projects Table */}
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Project Name</Table.Th>
<Table.Th>Category</Table.Th>
<Table.Th>Fund Source</Table.Th>
<Table.Th ta="right">Estimated Cost</Table.Th>
<Table.Th ta="right">Funded %</Table.Th>
<Table.Th>Condition</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Planned Date</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{projects.map((p) => (
<Table.Tr key={p.id}>
<Table.Td>
<Text fw={500}>{p.name}</Text>
{p.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{p.description}
</Text>
)}
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{p.category
? p.category.charAt(0).toUpperCase() + p.category.slice(1)
: '-'}
</Badge>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={fundSourceColors[p.fund_source] || 'gray'}
>
{p.fund_source?.replace('_', ' ') || '-'}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
{fmt(p.estimated_cost)}
</Table.Td>
<Table.Td ta="right">{fundedPercentageCell(p)}</Table.Td>
<Table.Td>{conditionBadge(p.condition_rating)}</Table.Td>
<Table.Td>
<Badge
size="sm"
color={statusColors[p.status] || 'gray'}
>
{p.status?.replace('_', ' ') || '-'}
</Badge>
</Table.Td>
<Table.Td>{formatDate(p.planned_date)}</Table.Td>
<Table.Td>
<ActionIcon variant="subtle" onClick={() => handleEdit(p)}>
<IconEdit size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
{projects.length === 0 && (
<Table.Tr>
<Table.Td colSpan={9}>
<Text ta="center" c="dimmed" py="lg">
No projects yet
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
{/* Create / Edit Modal */}
<Modal
opened={opened}
onClose={close}
title={editing ? 'Edit Project' : 'New Project'}
size="lg"
>
<form onSubmit={form.onSubmit((v) => saveMutation.mutate(v))}>
<Stack>
{/* Row 1: Name + Category */}
<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>
{/* Row 2: Description */}
<Textarea
label="Description"
{...form.getInputProps('description')}
/>
{/* Row 3: Fund Source, Status, Priority */}
<Group grow>
<Select
label="Fund Source"
data={[
{ value: 'operating', label: 'Operating' },
{ value: 'reserve', label: 'Reserve' },
{ value: 'special_assessment', label: 'Special Assessment' },
]}
{...form.getInputProps('fund_source')}
/>
<Select
label="Status"
data={Object.keys(statusColors).map((s) => ({
value: s,
label: s.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
}))}
{...form.getInputProps('status')}
/>
<NumberInput
label="Priority (1-5)"
min={1}
max={5}
{...form.getInputProps('priority')}
/>
</Group>
{/* Row 4: Estimated Cost, Actual Cost */}
<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>
{/* Row 5: Conditional reserve fields */}
{form.values.fund_source === 'reserve' && (
<>
<Group grow>
<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')}
/>
<NumberInput
label="Funded Percentage"
suffix="%"
decimalScale={1}
min={0}
max={100}
{...form.getInputProps('funded_percentage')}
/>
</Group>
<Group grow>
<NumberInput
label="Useful Life (years)"
min={0}
{...form.getInputProps('useful_life_years')}
/>
<NumberInput
label="Remaining Life (years)"
min={0}
decimalScale={1}
{...form.getInputProps('remaining_life_years')}
/>
<NumberInput
label="Condition Rating (1-10)"
min={1}
max={10}
{...form.getInputProps('condition_rating')}
/>
</Group>
</>
)}
{/* Row 6: Last / Next Replacement Date */}
<Group grow>
<DateInput
label="Last Replacement Date"
clearable
{...form.getInputProps('last_replacement_date')}
/>
<DateInput
label="Next Replacement Date"
clearable
{...form.getInputProps('next_replacement_date')}
/>
</Group>
{/* Row 7: Planned Date */}
<DateInput
label="Planned Date"
description="Defaults to Next Replacement Date if not set"
clearable
{...form.getInputProps('planned_date')}
/>
{/* Row 8: Target Year + Target Month */}
<Group grow>
<Select
label="Target Year"
data={targetYearOptions}
value={String(form.values.target_year)}
onChange={(v) => form.setFieldValue('target_year', Number(v))}
/>
<Select
label="Target Month"
data={monthOptions}
value={String(form.values.target_month)}
onChange={(v) => form.setFieldValue('target_month', Number(v))}
/>
</Group>
{/* Row 9: Notes */}
<Textarea label="Notes" {...form.getInputProps('notes')} />
<Button type="submit" loading={saveMutation.isPending}>
{editing ? 'Update' : 'Create'}
</Button>
</Stack>
</form>
</Modal>
</Stack>
);
}

View File

@@ -1,12 +1,12 @@
import { useState } from 'react';
import {
Title, Table, Group, Button, Stack, TextInput, Modal,
NumberInput, Select, Badge, ActionIcon, Text, Loader, Center, Tooltip,
NumberInput, Select, Badge, ActionIcon, Text, Loader, Center, Tooltip, Alert,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEdit, IconSearch, IconTrash } from '@tabler/icons-react';
import { IconPlus, IconEdit, IconSearch, IconTrash, IconInfoCircle } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -30,6 +30,7 @@ interface AssessmentGroup {
name: string;
regular_assessment: string;
frequency: string;
is_default: boolean;
}
export function UnitsPage() {
@@ -49,13 +50,19 @@ export function UnitsPage() {
queryFn: async () => { const { data } = await api.get('/assessment-groups'); return data; },
});
const defaultGroup = assessmentGroups.find(g => g.is_default);
const hasGroups = assessmentGroups.length > 0;
const form = useForm({
initialValues: {
unit_number: '', address_line1: '', city: '', state: '', zip_code: '',
owner_name: '', owner_email: '', owner_phone: '', monthly_assessment: 0,
assessment_group_id: '' as string | null,
},
validate: { unit_number: (v) => (v.length > 0 ? null : 'Required') },
validate: {
unit_number: (v) => (v.length > 0 ? null : 'Required'),
assessment_group_id: (v) => (v && v.length > 0 ? null : 'Assessment group is required'),
},
});
const saveMutation = useMutation({
@@ -95,6 +102,17 @@ export function UnitsPage() {
open();
};
const handleNew = () => {
setEditing(null);
form.reset();
// Pre-populate with default group
if (defaultGroup) {
form.setFieldValue('assessment_group_id', defaultGroup.id);
form.setFieldValue('monthly_assessment', parseFloat(defaultGroup.regular_assessment || '0'));
}
open();
};
const handleGroupChange = (groupId: string | null) => {
form.setFieldValue('assessment_group_id', groupId);
if (groupId) {
@@ -116,8 +134,21 @@ export function UnitsPage() {
<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>
{hasGroups ? (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>Add Unit</Button>
) : (
<Tooltip label="Create an assessment group first">
<Button leftSection={<IconPlus size={16} />} disabled>Add Unit</Button>
</Tooltip>
)}
</Group>
{!hasGroups && (
<Alert icon={<IconInfoCircle size={16} />} color="yellow" variant="light">
You must create at least one assessment group before adding units. Go to Assessment Groups to create one.
</Alert>
)}
<TextInput placeholder="Search units..." leftSection={<IconSearch size={16} />} value={search} onChange={(e) => setSearch(e.currentTarget.value)} />
<Table striped highlightOnHover>
<Table.Thead>
@@ -182,14 +213,15 @@ export function UnitsPage() {
<TextInput label="Owner Phone" {...form.getInputProps('owner_phone')} />
<Select
label="Assessment Group"
placeholder="Select a group (optional)"
description="Required — all units must belong to an assessment group"
required
data={assessmentGroups.map(g => ({
value: g.id,
label: `${g.name}$${parseFloat(g.regular_assessment || '0').toFixed(2)}/${g.frequency || 'mo'}`,
label: `${g.name}${g.is_default ? ' (Default)' : ''}$${parseFloat(g.regular_assessment || '0').toFixed(2)}/${g.frequency || 'mo'}`,
}))}
value={form.values.assessment_group_id}
onChange={handleGroupChange}
clearable
error={form.errors.assessment_group_id}
/>
<NumberInput label="Monthly Assessment" prefix="$" decimalScale={2} min={0} {...form.getInputProps('monthly_assessment')} />
<Button type="submit" loading={saveMutation.isPending}>{editing ? 'Update' : 'Create'}</Button>