feat: add Board Planning module with investment/assessment scenario modeling
Implements Phase 11 Forecasting Tools - a "what-if" scenario planning system for HOA boards to model financial decisions before committing. Backend: - 3 new tenant-scoped tables: board_scenarios, scenario_investments, scenario_assessments - Migration script (013) for existing tenants - Full CRUD service for scenarios, investments, and assessments - Projection engine adapted from cash flow forecast with investment/assessment deltas - Scenario comparison endpoint (up to 4 scenarios) - Investment execution flow: converts planned → real investment_accounts + journal entry Frontend: - New "Board Planning" sidebar section with 3 pages - Investment Scenarios: list, create, detail with investments table + timeline - Assessment Scenarios: list, create, detail with changes table - Scenario Comparison: multi-select overlay chart + summary metrics - Shared components: ProjectionChart, InvestmentTimeline, ScenarioCard, forms - AI Recommendation → Investment Plan integration (Story 1A) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||
Loader, Center, Menu, Select, Modal, TextInput, Alert,
|
||||
} from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import {
|
||||
IconPlus, IconArrowLeft, IconDots, IconTrash, IconEdit,
|
||||
IconPlayerPlay, IconRefresh, IconAlertTriangle,
|
||||
} 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 { InvestmentForm } from './components/InvestmentForm';
|
||||
import { ProjectionChart } from './components/ProjectionChart';
|
||||
import { InvestmentTimeline } from './components/InvestmentTimeline';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
export function InvestmentScenarioDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editInv, setEditInv] = useState<any>(null);
|
||||
const [executeInv, setExecuteInv] = useState<any>(null);
|
||||
const [executionDate, setExecutionDate] = useState<Date | null>(new Date());
|
||||
|
||||
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, refetch: refetchProjection } = 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}/investments`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
setAddOpen(false);
|
||||
notifications.show({ message: 'Investment added', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ invId, ...dto }: any) => api.put(`/board-planning/investments/${invId}`, dto),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
setEditInv(null);
|
||||
notifications.show({ message: 'Investment updated', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (invId: string) => api.delete(`/board-planning/investments/${invId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
notifications.show({ message: 'Investment removed', color: 'orange' });
|
||||
},
|
||||
});
|
||||
|
||||
const executeMutation = useMutation({
|
||||
mutationFn: ({ invId, executionDate }: { invId: string; executionDate: string }) =>
|
||||
api.post(`/board-planning/investments/${invId}/execute`, { executionDate }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenario', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
setExecuteInv(null);
|
||||
notifications.show({ message: 'Investment executed and recorded', color: 'green' });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
notifications.show({ message: err.response?.data?.message || 'Execution failed', color: 'red' });
|
||||
},
|
||||
});
|
||||
|
||||
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'] });
|
||||
},
|
||||
});
|
||||
|
||||
const refreshProjection = useMutation({
|
||||
mutationFn: () => api.post(`/board-planning/scenarios/${id}/projection/refresh`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
notifications.show({ message: 'Projection refreshed', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
||||
|
||||
const investments = scenario.investments || [];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<Group>
|
||||
<ActionIcon variant="subtle" onClick={() => navigate('/board-planning/investments')}>
|
||||
<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" variant="light" leftSection={<IconRefresh size={16} />} onClick={() => refreshProjection.mutate()} loading={refreshProjection.isPending}>
|
||||
Refresh Projection
|
||||
</Button>
|
||||
<Button size="sm" leftSection={<IconPlus size={16} />} onClick={() => setAddOpen(true)}>
|
||||
Add Investment
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Investments Table */}
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Planned Investments ({investments.length})</Title>
|
||||
{investments.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Label</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>Purchase</Table.Th>
|
||||
<Table.Th>Maturity</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th w={80}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{investments.map((inv: any) => (
|
||||
<Table.Tr key={inv.id}>
|
||||
<Table.Td fw={500}>{inv.label}</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' : 'blue'}>{inv.fund_type}</Badge></Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">{fmt(parseFloat(inv.principal))}</Table.Td>
|
||||
<Table.Td ta="right">{inv.interest_rate ? `${parseFloat(inv.interest_rate).toFixed(2)}%` : '-'}</Table.Td>
|
||||
<Table.Td>{inv.purchase_date ? new Date(inv.purchase_date).toLocaleDateString() : '-'}</Table.Td>
|
||||
<Table.Td>{inv.maturity_date ? new Date(inv.maturity_date).toLocaleDateString() : '-'}</Table.Td>
|
||||
<Table.Td>
|
||||
{inv.executed_investment_id
|
||||
? <Badge size="sm" color="green">Executed</Badge>
|
||||
: <Badge size="sm" color="gray">Planned</Badge>}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray"><IconDots size={16} /></ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => setEditInv(inv)}>Edit</Menu.Item>
|
||||
{!inv.executed_investment_id && (
|
||||
<Menu.Item leftSection={<IconPlayerPlay size={14} />} color="green" onClick={() => { setExecuteInv(inv); setExecutionDate(new Date()); }}>
|
||||
Execute
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => removeMutation.mutate(inv.id)}>Remove</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text ta="center" c="dimmed" py="lg">
|
||||
No investments added yet. Click "Add Investment" to model an investment allocation.
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Investment Timeline */}
|
||||
{investments.length > 0 && <InvestmentTimeline investments={investments} />}
|
||||
|
||||
{/* Projection Chart */}
|
||||
{projection && (
|
||||
<ProjectionChart
|
||||
datapoints={projection.datapoints || []}
|
||||
title="Scenario Projection"
|
||||
summary={projection.summary}
|
||||
/>
|
||||
)}
|
||||
{projLoading && <Center py="xl"><Loader /></Center>}
|
||||
|
||||
{/* Add/Edit Investment Modal */}
|
||||
<InvestmentForm
|
||||
opened={addOpen || !!editInv}
|
||||
onClose={() => { setAddOpen(false); setEditInv(null); }}
|
||||
onSubmit={(data) => {
|
||||
if (editInv) {
|
||||
updateMutation.mutate({ invId: editInv.id, ...data });
|
||||
} else {
|
||||
addMutation.mutate(data);
|
||||
}
|
||||
}}
|
||||
initialData={editInv}
|
||||
loading={addMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
|
||||
{/* Execute Confirmation Modal */}
|
||||
<Modal opened={!!executeInv} onClose={() => setExecuteInv(null)} title="Execute Investment">
|
||||
<Stack>
|
||||
<Alert color="blue" variant="light">
|
||||
This will create a real investment account record and post a journal entry transferring funds.
|
||||
</Alert>
|
||||
{executeInv && (
|
||||
<>
|
||||
<Text size="sm"><strong>Investment:</strong> {executeInv.label}</Text>
|
||||
<Text size="sm"><strong>Amount:</strong> {fmt(parseFloat(executeInv.principal))}</Text>
|
||||
<DateInput
|
||||
label="Execution Date"
|
||||
required
|
||||
value={executionDate}
|
||||
onChange={setExecutionDate}
|
||||
description="The date the investment is actually purchased"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setExecuteInv(null)}>Cancel</Button>
|
||||
<Button
|
||||
color="green"
|
||||
leftSection={<IconPlayerPlay size={16} />}
|
||||
onClick={() => {
|
||||
if (executeInv && executionDate) {
|
||||
executeMutation.mutate({
|
||||
invId: executeInv.id,
|
||||
executionDate: executionDate.toISOString().split('T')[0],
|
||||
});
|
||||
}
|
||||
}}
|
||||
loading={executeMutation.isPending}
|
||||
>
|
||||
Execute Investment
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user