- Reorder sidebar: Assessment Scenarios now directly under Budget Planning - Simplify special assessment form: remove Total Amount, keep Per Unit only - Replace Duration field from free-text installments to dropdown (one-time/quarterly/6mo/annual) - Update Change column display to show total per-unit with duration label - Fix Reserve Coverage to use planned capital project costs instead of budget expenses - Include capital_projects table in projection engine alongside projects table - Replace actions dropdown menu with inline Edit/Remove icon buttons - Remove Refresh Projection button (projection auto-refreshes on changes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
265 lines
10 KiB
TypeScript
265 lines
10 KiB
TypeScript
import { useState } from 'react';
|
|
import {
|
|
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
|
Loader, Center, Select, SimpleGrid, Tooltip,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconPlus, IconArrowLeft, IconTrash, IconEdit,
|
|
} from '@tabler/icons-react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { notifications } from '@mantine/notifications';
|
|
import api from '../../services/api';
|
|
import { AssessmentChangeForm } from './components/AssessmentChangeForm';
|
|
import { ProjectionChart } from './components/ProjectionChart';
|
|
|
|
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
|
|
|
const statusColors: Record<string, string> = {
|
|
draft: 'gray', active: 'blue', approved: 'green', archived: 'red',
|
|
};
|
|
|
|
const changeTypeLabels: Record<string, string> = {
|
|
dues_increase: 'Dues Increase',
|
|
dues_decrease: 'Dues Decrease',
|
|
special_assessment: 'Special Assessment',
|
|
};
|
|
|
|
const changeTypeColors: Record<string, string> = {
|
|
dues_increase: 'green',
|
|
dues_decrease: 'orange',
|
|
special_assessment: 'violet',
|
|
};
|
|
|
|
export function AssessmentScenarioDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
const [editAsmt, setEditAsmt] = useState<any>(null);
|
|
|
|
const { data: scenario, isLoading } = useQuery({
|
|
queryKey: ['board-planning-scenario', id],
|
|
queryFn: async () => {
|
|
const { data } = await api.get(`/board-planning/scenarios/${id}`);
|
|
return data;
|
|
},
|
|
});
|
|
|
|
const { data: projection, isLoading: projLoading } = useQuery({
|
|
queryKey: ['board-planning-projection', id],
|
|
queryFn: async () => {
|
|
const { data } = await api.get(`/board-planning/scenarios/${id}/projection`);
|
|
return data;
|
|
},
|
|
enabled: !!id,
|
|
});
|
|
|
|
const addMutation = useMutation({
|
|
mutationFn: (dto: any) => api.post(`/board-planning/scenarios/${id}/assessments`, dto),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
|
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
|
setAddOpen(false);
|
|
notifications.show({ message: 'Assessment change added', color: 'green' });
|
|
},
|
|
});
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: ({ asmtId, ...dto }: any) => api.put(`/board-planning/assessments/${asmtId}`, dto),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
|
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
|
setEditAsmt(null);
|
|
notifications.show({ message: 'Assessment change updated', color: 'green' });
|
|
},
|
|
});
|
|
|
|
const removeMutation = useMutation({
|
|
mutationFn: (asmtId: string) => api.delete(`/board-planning/assessments/${asmtId}`),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
|
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
|
notifications.show({ message: 'Assessment change removed', color: 'orange' });
|
|
},
|
|
});
|
|
|
|
const statusMutation = useMutation({
|
|
mutationFn: (status: string) => api.put(`/board-planning/scenarios/${id}`, { status }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
|
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
|
},
|
|
});
|
|
|
|
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
|
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
|
|
|
const assessments = scenario.assessments || [];
|
|
|
|
return (
|
|
<Stack>
|
|
{/* Header */}
|
|
<Group justify="space-between" align="flex-start">
|
|
<Group>
|
|
<ActionIcon variant="subtle" onClick={() => navigate('/board-planning/assessments')}>
|
|
<IconArrowLeft size={20} />
|
|
</ActionIcon>
|
|
<div>
|
|
<Group gap="xs">
|
|
<Title order={2}>{scenario.name}</Title>
|
|
<Badge color={statusColors[scenario.status]}>{scenario.status}</Badge>
|
|
</Group>
|
|
{scenario.description && <Text c="dimmed" size="sm">{scenario.description}</Text>}
|
|
</div>
|
|
</Group>
|
|
<Group>
|
|
<Select
|
|
size="xs"
|
|
value={scenario.status}
|
|
onChange={(v) => v && statusMutation.mutate(v)}
|
|
data={[
|
|
{ value: 'draft', label: 'Draft' },
|
|
{ value: 'active', label: 'Active' },
|
|
{ value: 'approved', label: 'Approved' },
|
|
]}
|
|
/>
|
|
<Button size="sm" leftSection={<IconPlus size={16} />} onClick={() => setAddOpen(true)}>
|
|
Add Change
|
|
</Button>
|
|
</Group>
|
|
</Group>
|
|
|
|
{/* Summary Cards */}
|
|
{projection?.summary && (
|
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
|
|
<Card withBorder p="md">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>End Liquidity</Text>
|
|
<Text fw={700} size="xl" ff="monospace">{fmt(projection.summary.end_liquidity || 0)}</Text>
|
|
</Card>
|
|
<Card withBorder p="md">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Period Change</Text>
|
|
<Text fw={700} size="xl" ff="monospace" c={projection.summary.period_change >= 0 ? 'green' : 'red'}>
|
|
{projection.summary.period_change >= 0 ? '+' : ''}{fmt(projection.summary.period_change || 0)}
|
|
</Text>
|
|
</Card>
|
|
<Card withBorder p="md">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Min Liquidity</Text>
|
|
<Text fw={700} size="xl" ff="monospace" c={projection.summary.min_liquidity < 0 ? 'red' : undefined}>
|
|
{fmt(projection.summary.min_liquidity || 0)}
|
|
</Text>
|
|
</Card>
|
|
<Card withBorder p="md">
|
|
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Coverage</Text>
|
|
<Text fw={700} size="xl" ff="monospace">
|
|
{projection.summary.reserve_coverage_months > 0
|
|
? `${projection.summary.reserve_coverage_months.toFixed(1)} mo`
|
|
: 'N/A'}
|
|
</Text>
|
|
{projection.summary.reserve_coverage_months <= 0 && (
|
|
<Text size="xs" c="dimmed">No planned capital projects</Text>
|
|
)}
|
|
</Card>
|
|
</SimpleGrid>
|
|
)}
|
|
|
|
{/* Assessment Changes Table */}
|
|
<Card withBorder p="lg">
|
|
<Title order={4} mb="md">Assessment Changes ({assessments.length})</Title>
|
|
{assessments.length > 0 ? (
|
|
<Table striped highlightOnHover>
|
|
<Table.Thead>
|
|
<Table.Tr>
|
|
<Table.Th>Label</Table.Th>
|
|
<Table.Th>Type</Table.Th>
|
|
<Table.Th>Target Fund</Table.Th>
|
|
<Table.Th ta="right">Change</Table.Th>
|
|
<Table.Th>Effective</Table.Th>
|
|
<Table.Th>End</Table.Th>
|
|
<Table.Th w={80}>Actions</Table.Th>
|
|
</Table.Tr>
|
|
</Table.Thead>
|
|
<Table.Tbody>
|
|
{assessments.map((a: any) => (
|
|
<Table.Tr key={a.id}>
|
|
<Table.Td fw={500}>{a.label}</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="sm" color={changeTypeColors[a.change_type] || 'gray'}>
|
|
{changeTypeLabels[a.change_type] || a.change_type}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td>
|
|
<Badge size="sm" variant="light">
|
|
{a.target_fund}
|
|
</Badge>
|
|
</Table.Td>
|
|
<Table.Td ta="right" ff="monospace">
|
|
{a.change_type === 'special_assessment'
|
|
? `${fmt(parseFloat(a.special_per_unit) || 0)}/unit${(() => {
|
|
const inst = parseInt(a.special_installments) || 1;
|
|
if (inst === 1) return ', one-time';
|
|
if (inst === 3) return ', quarterly';
|
|
if (inst === 12) return ', annual';
|
|
return `, ${inst} mo`;
|
|
})()}`
|
|
: a.percentage_change
|
|
? `${parseFloat(a.percentage_change).toFixed(1)}%`
|
|
: a.flat_amount_change
|
|
? `${fmt(parseFloat(a.flat_amount_change))}/unit/mo`
|
|
: '-'}
|
|
</Table.Td>
|
|
<Table.Td>{a.effective_date ? new Date(a.effective_date).toLocaleDateString() : '-'}</Table.Td>
|
|
<Table.Td>{a.end_date ? new Date(a.end_date).toLocaleDateString() : 'Ongoing'}</Table.Td>
|
|
<Table.Td>
|
|
<Group gap={4} wrap="nowrap">
|
|
<Tooltip label="Edit">
|
|
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => setEditAsmt(a)}>
|
|
<IconEdit size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
<Tooltip label="Remove">
|
|
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(a.id)}>
|
|
<IconTrash size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
</Group>
|
|
</Table.Td>
|
|
</Table.Tr>
|
|
))}
|
|
</Table.Tbody>
|
|
</Table>
|
|
) : (
|
|
<Text ta="center" c="dimmed" py="lg">
|
|
No assessment changes added yet. Click "Add Change" to model a dues increase or special assessment.
|
|
</Text>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Projection Chart */}
|
|
{projection && (
|
|
<ProjectionChart
|
|
datapoints={projection.datapoints || []}
|
|
title="Assessment Impact Projection"
|
|
summary={projection.summary}
|
|
/>
|
|
)}
|
|
{projLoading && <Center py="xl"><Loader /></Center>}
|
|
|
|
{/* Add/Edit Modal */}
|
|
<AssessmentChangeForm
|
|
opened={addOpen || !!editAsmt}
|
|
onClose={() => { setAddOpen(false); setEditAsmt(null); }}
|
|
onSubmit={(data) => {
|
|
if (editAsmt) {
|
|
updateMutation.mutate({ asmtId: editAsmt.id, ...data });
|
|
} else {
|
|
addMutation.mutate(data);
|
|
}
|
|
}}
|
|
initialData={editAsmt}
|
|
loading={addMutation.isPending || updateMutation.isPending}
|
|
/>
|
|
</Stack>
|
|
);
|
|
}
|