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:
@@ -19,6 +19,9 @@ import {
|
||||
Tabs,
|
||||
Collapse,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Select,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBulb,
|
||||
@@ -32,9 +35,11 @@ import {
|
||||
IconPigMoney,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconPlaylistAdd,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../services/api';
|
||||
|
||||
// ── Types ──
|
||||
@@ -188,10 +193,12 @@ function RecommendationsDisplay({
|
||||
aiResult,
|
||||
lastUpdated,
|
||||
lastFailed,
|
||||
onAddToPlan,
|
||||
}: {
|
||||
aiResult: AIResponse;
|
||||
lastUpdated?: string;
|
||||
lastFailed?: boolean;
|
||||
onAddToPlan?: (rec: Recommendation) => void;
|
||||
}) {
|
||||
return (
|
||||
<Stack>
|
||||
@@ -327,6 +334,17 @@ function RecommendationsDisplay({
|
||||
<Alert variant="light" color="gray" title="Rationale">
|
||||
<Text size="sm">{rec.rationale}</Text>
|
||||
</Alert>
|
||||
|
||||
{onAddToPlan && rec.type !== 'liquidity_warning' && rec.type !== 'general' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
leftSection={<IconPlaylistAdd size={16} />}
|
||||
onClick={() => onAddToPlan(rec)}
|
||||
>
|
||||
Add to Investment Plan
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
@@ -345,8 +363,86 @@ function RecommendationsDisplay({
|
||||
// ── Main Component ──
|
||||
|
||||
export function InvestmentPlanningPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [ratesExpanded, setRatesExpanded] = useState(true);
|
||||
const [isTriggering, setIsTriggering] = useState(false);
|
||||
const [planModalOpen, setPlanModalOpen] = useState(false);
|
||||
const [selectedRec, setSelectedRec] = useState<Recommendation | null>(null);
|
||||
const [targetScenarioId, setTargetScenarioId] = useState<string | null>(null);
|
||||
const [newScenarioName, setNewScenarioName] = useState('');
|
||||
|
||||
// Load investment scenarios for the "Add to Plan" modal
|
||||
const { data: investmentScenarios } = useQuery<any[]>({
|
||||
queryKey: ['board-planning-scenarios', 'investment'],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get('/board-planning/scenarios?type=investment');
|
||||
return data;
|
||||
},
|
||||
});
|
||||
|
||||
const addToPlanMutation = useMutation({
|
||||
mutationFn: async ({ scenarioId, rec }: { scenarioId: string; rec: Recommendation }) => {
|
||||
await api.post(`/board-planning/scenarios/${scenarioId}/investments/from-recommendation`, {
|
||||
title: rec.title,
|
||||
investmentType: rec.type === 'cd_ladder' ? 'cd' : rec.type === 'new_investment' ? undefined : undefined,
|
||||
fundType: rec.fund_type || 'reserve',
|
||||
suggestedAmount: rec.suggested_amount,
|
||||
suggestedRate: rec.suggested_rate,
|
||||
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
||||
bankName: rec.bank_name,
|
||||
rationale: rec.rationale,
|
||||
});
|
||||
return scenarioId;
|
||||
},
|
||||
onSuccess: (scenarioId) => {
|
||||
setPlanModalOpen(false);
|
||||
setSelectedRec(null);
|
||||
setTargetScenarioId(null);
|
||||
notifications.show({
|
||||
message: 'Recommendation added to investment scenario',
|
||||
color: 'green',
|
||||
autoClose: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const createAndAddMutation = useMutation({
|
||||
mutationFn: async ({ name, rec }: { name: string; rec: Recommendation }) => {
|
||||
const { data: scenario } = await api.post('/board-planning/scenarios', {
|
||||
name, scenarioType: 'investment',
|
||||
});
|
||||
await api.post(`/board-planning/scenarios/${scenario.id}/investments/from-recommendation`, {
|
||||
title: rec.title,
|
||||
investmentType: rec.type === 'cd_ladder' ? 'cd' : undefined,
|
||||
fundType: rec.fund_type || 'reserve',
|
||||
suggestedAmount: rec.suggested_amount,
|
||||
suggestedRate: rec.suggested_rate,
|
||||
termMonths: rec.suggested_term ? parseInt(rec.suggested_term) || null : null,
|
||||
bankName: rec.bank_name,
|
||||
rationale: rec.rationale,
|
||||
});
|
||||
return scenario.id;
|
||||
},
|
||||
onSuccess: (scenarioId) => {
|
||||
setPlanModalOpen(false);
|
||||
setSelectedRec(null);
|
||||
setNewScenarioName('');
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-scenarios'] });
|
||||
notifications.show({
|
||||
message: 'New scenario created with recommendation',
|
||||
color: 'green',
|
||||
autoClose: 5000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddToPlan = (rec: Recommendation) => {
|
||||
setSelectedRec(rec);
|
||||
setTargetScenarioId(null);
|
||||
setNewScenarioName('');
|
||||
setPlanModalOpen(true);
|
||||
};
|
||||
|
||||
// Load financial snapshot on mount
|
||||
const { data: snapshot, isLoading: snapshotLoading } = useQuery<FinancialSnapshot>({
|
||||
@@ -737,6 +833,7 @@ export function InvestmentPlanningPage() {
|
||||
aiResult={aiResult}
|
||||
lastUpdated={savedRec?.created_at || undefined}
|
||||
lastFailed={lastFailed}
|
||||
onAddToPlan={handleAddToPlan}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -758,6 +855,60 @@ export function InvestmentPlanningPage() {
|
||||
</Paper>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Add to Investment Plan Modal */}
|
||||
<Modal opened={planModalOpen} onClose={() => setPlanModalOpen(false)} title="Add to Investment Plan">
|
||||
<Stack>
|
||||
{selectedRec && (
|
||||
<Alert variant="light" color="blue">
|
||||
<Text size="sm" fw={600}>{selectedRec.title}</Text>
|
||||
{selectedRec.suggested_amount != null && (
|
||||
<Text size="sm">Amount: {fmt(selectedRec.suggested_amount)}</Text>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{investmentScenarios && investmentScenarios.length > 0 && (
|
||||
<Select
|
||||
label="Add to existing scenario"
|
||||
placeholder="Select a scenario..."
|
||||
data={investmentScenarios.map((s: any) => ({ value: s.id, label: s.name }))}
|
||||
value={targetScenarioId}
|
||||
onChange={setTargetScenarioId}
|
||||
clearable
|
||||
/>
|
||||
)}
|
||||
|
||||
<Divider label="or" labelPosition="center" />
|
||||
|
||||
<TextInput
|
||||
label="Create new scenario"
|
||||
placeholder="e.g. Conservative Strategy"
|
||||
value={newScenarioName}
|
||||
onChange={(e) => { setNewScenarioName(e.target.value); setTargetScenarioId(null); }}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setPlanModalOpen(false)}>Cancel</Button>
|
||||
{targetScenarioId && selectedRec && (
|
||||
<Button
|
||||
onClick={() => addToPlanMutation.mutate({ scenarioId: targetScenarioId, rec: selectedRec })}
|
||||
loading={addToPlanMutation.isPending}
|
||||
>
|
||||
Add to Scenario
|
||||
</Button>
|
||||
)}
|
||||
{newScenarioName && !targetScenarioId && selectedRec && (
|
||||
<Button
|
||||
onClick={() => createAndAddMutation.mutate({ name: newScenarioName, rec: selectedRec })}
|
||||
loading={createAndAddMutation.isPending}
|
||||
>
|
||||
Create & Add
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user