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,164 @@
|
||||
import { Modal, TextInput, Select, NumberInput, Group, Button, Stack, Text } from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
initialData?: any;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function AssessmentChangeForm({ opened, onClose, onSubmit, initialData, loading }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
changeType: 'dues_increase' as string,
|
||||
label: '',
|
||||
targetFund: 'operating',
|
||||
percentageChange: 0,
|
||||
flatAmountChange: 0,
|
||||
specialTotal: 0,
|
||||
specialPerUnit: 0,
|
||||
specialInstallments: 1,
|
||||
effectiveDate: null as Date | null,
|
||||
endDate: null as Date | null,
|
||||
notes: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setForm({
|
||||
changeType: initialData.change_type || initialData.changeType || 'dues_increase',
|
||||
label: initialData.label || '',
|
||||
targetFund: initialData.target_fund || initialData.targetFund || 'operating',
|
||||
percentageChange: parseFloat(initialData.percentage_change || initialData.percentageChange) || 0,
|
||||
flatAmountChange: parseFloat(initialData.flat_amount_change || initialData.flatAmountChange) || 0,
|
||||
specialTotal: parseFloat(initialData.special_total || initialData.specialTotal) || 0,
|
||||
specialPerUnit: parseFloat(initialData.special_per_unit || initialData.specialPerUnit) || 0,
|
||||
specialInstallments: initialData.special_installments || initialData.specialInstallments || 1,
|
||||
effectiveDate: initialData.effective_date ? new Date(initialData.effective_date) : null,
|
||||
endDate: initialData.end_date ? new Date(initialData.end_date) : null,
|
||||
notes: initialData.notes || '',
|
||||
});
|
||||
} else {
|
||||
setForm({
|
||||
changeType: 'dues_increase', label: '', targetFund: 'operating',
|
||||
percentageChange: 0, flatAmountChange: 0, specialTotal: 0, specialPerUnit: 0,
|
||||
specialInstallments: 1, effectiveDate: null, endDate: null, notes: '',
|
||||
});
|
||||
}
|
||||
}, [initialData, opened]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
...form,
|
||||
effectiveDate: form.effectiveDate?.toISOString().split('T')[0] || null,
|
||||
endDate: form.endDate?.toISOString().split('T')[0] || null,
|
||||
});
|
||||
};
|
||||
|
||||
const isSpecial = form.changeType === 'special_assessment';
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={initialData ? 'Edit Assessment Change' : 'Add Assessment Change'} size="lg">
|
||||
<Stack>
|
||||
<Select
|
||||
label="Change Type"
|
||||
value={form.changeType}
|
||||
onChange={(v) => setForm({ ...form, changeType: v || 'dues_increase' })}
|
||||
data={[
|
||||
{ value: 'dues_increase', label: 'Dues Increase' },
|
||||
{ value: 'dues_decrease', label: 'Dues Decrease' },
|
||||
{ value: 'special_assessment', label: 'Special Assessment' },
|
||||
]}
|
||||
/>
|
||||
<TextInput
|
||||
label="Label"
|
||||
required
|
||||
value={form.label}
|
||||
onChange={(e) => setForm({ ...form, label: e.target.value })}
|
||||
placeholder={isSpecial ? 'e.g. Roof Replacement Assessment' : 'e.g. 5% Annual Increase'}
|
||||
/>
|
||||
<Select
|
||||
label="Target Fund"
|
||||
value={form.targetFund}
|
||||
onChange={(v) => setForm({ ...form, targetFund: v || 'operating' })}
|
||||
data={[
|
||||
{ value: 'operating', label: 'Operating' },
|
||||
{ value: 'reserve', label: 'Reserve' },
|
||||
{ value: 'both', label: 'Both' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{!isSpecial && (
|
||||
<>
|
||||
<Text size="sm" fw={500} c="dimmed">Set either a percentage or flat amount (not both):</Text>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Percentage Change (%)"
|
||||
value={form.percentageChange}
|
||||
onChange={(v) => setForm({ ...form, percentageChange: Number(v) || 0, flatAmountChange: 0 })}
|
||||
min={0}
|
||||
max={100}
|
||||
decimalScale={2}
|
||||
suffix="%"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Flat Amount Change ($/unit/mo)"
|
||||
value={form.flatAmountChange}
|
||||
onChange={(v) => setForm({ ...form, flatAmountChange: Number(v) || 0, percentageChange: 0 })}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
prefix="$"
|
||||
/>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isSpecial && (
|
||||
<>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Total Amount"
|
||||
value={form.specialTotal}
|
||||
onChange={(v) => setForm({ ...form, specialTotal: Number(v) || 0 })}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
prefix="$"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Per Unit Amount"
|
||||
value={form.specialPerUnit}
|
||||
onChange={(v) => setForm({ ...form, specialPerUnit: Number(v) || 0 })}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
prefix="$"
|
||||
/>
|
||||
</Group>
|
||||
<NumberInput
|
||||
label="Installments"
|
||||
description="1 = one-time lump sum, 6 = spread over 6 months, etc."
|
||||
value={form.specialInstallments}
|
||||
onChange={(v) => setForm({ ...form, specialInstallments: Number(v) || 1 })}
|
||||
min={1}
|
||||
max={60}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group grow>
|
||||
<DateInput label="Effective Date" required value={form.effectiveDate} onChange={(v) => setForm({ ...form, effectiveDate: v })} />
|
||||
<DateInput label="End Date (optional)" value={form.endDate} onChange={(v) => setForm({ ...form, endDate: v })} clearable />
|
||||
</Group>
|
||||
<TextInput label="Notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} loading={loading} disabled={!form.label || !form.effectiveDate}>
|
||||
{initialData ? 'Update' : 'Add Change'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
110
frontend/src/pages/board-planning/components/InvestmentForm.tsx
Normal file
110
frontend/src/pages/board-planning/components/InvestmentForm.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Modal, TextInput, Select, NumberInput, Group, Button, Stack, Switch } from '@mantine/core';
|
||||
import { DateInput } from '@mantine/dates';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: any) => void;
|
||||
initialData?: any;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function InvestmentForm({ opened, onClose, onSubmit, initialData, loading }: Props) {
|
||||
const [form, setForm] = useState({
|
||||
label: '',
|
||||
investmentType: 'cd',
|
||||
fundType: 'reserve',
|
||||
principal: 0,
|
||||
interestRate: 0,
|
||||
termMonths: 12,
|
||||
institution: '',
|
||||
purchaseDate: null as Date | null,
|
||||
maturityDate: null as Date | null,
|
||||
autoRenew: false,
|
||||
notes: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
setForm({
|
||||
label: initialData.label || '',
|
||||
investmentType: initialData.investment_type || initialData.investmentType || 'cd',
|
||||
fundType: initialData.fund_type || initialData.fundType || 'reserve',
|
||||
principal: parseFloat(initialData.principal) || 0,
|
||||
interestRate: parseFloat(initialData.interest_rate || initialData.interestRate) || 0,
|
||||
termMonths: initialData.term_months || initialData.termMonths || 12,
|
||||
institution: initialData.institution || '',
|
||||
purchaseDate: initialData.purchase_date ? new Date(initialData.purchase_date) : null,
|
||||
maturityDate: initialData.maturity_date ? new Date(initialData.maturity_date) : null,
|
||||
autoRenew: initialData.auto_renew || initialData.autoRenew || false,
|
||||
notes: initialData.notes || '',
|
||||
});
|
||||
} else {
|
||||
setForm({
|
||||
label: '', investmentType: 'cd', fundType: 'reserve', principal: 0,
|
||||
interestRate: 0, termMonths: 12, institution: '', purchaseDate: null,
|
||||
maturityDate: null, autoRenew: false, notes: '',
|
||||
});
|
||||
}
|
||||
}, [initialData, opened]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
...form,
|
||||
purchaseDate: form.purchaseDate?.toISOString().split('T')[0] || null,
|
||||
maturityDate: form.maturityDate?.toISOString().split('T')[0] || null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={initialData ? 'Edit Investment' : 'Add Investment'} size="lg">
|
||||
<Stack>
|
||||
<TextInput label="Label" required value={form.label} onChange={(e) => setForm({ ...form, label: e.target.value })} placeholder="e.g. 6-Month Treasury" />
|
||||
<Group grow>
|
||||
<Select
|
||||
label="Type"
|
||||
value={form.investmentType}
|
||||
onChange={(v) => setForm({ ...form, investmentType: v || 'cd' })}
|
||||
data={[
|
||||
{ value: 'cd', label: 'CD' },
|
||||
{ value: 'money_market', label: 'Money Market' },
|
||||
{ value: 'treasury', label: 'Treasury' },
|
||||
{ value: 'savings', label: 'Savings' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
label="Fund"
|
||||
value={form.fundType}
|
||||
onChange={(v) => setForm({ ...form, fundType: v || 'reserve' })}
|
||||
data={[
|
||||
{ value: 'operating', label: 'Operating' },
|
||||
{ value: 'reserve', label: 'Reserve' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Principal ($)" required value={form.principal} onChange={(v) => setForm({ ...form, principal: Number(v) || 0 })} min={0} decimalScale={2} thousandSeparator="," prefix="$" />
|
||||
<NumberInput label="Interest Rate (%)" value={form.interestRate} onChange={(v) => setForm({ ...form, interestRate: Number(v) || 0 })} min={0} max={20} decimalScale={3} suffix="%" />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Term (months)" value={form.termMonths} onChange={(v) => setForm({ ...form, termMonths: Number(v) || 0 })} min={1} max={120} />
|
||||
<TextInput label="Institution" value={form.institution} onChange={(e) => setForm({ ...form, institution: e.target.value })} placeholder="e.g. First National Bank" />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<DateInput label="Purchase Date" value={form.purchaseDate} onChange={(v) => setForm({ ...form, purchaseDate: v })} clearable />
|
||||
<DateInput label="Maturity Date" value={form.maturityDate} onChange={(v) => setForm({ ...form, maturityDate: v })} clearable />
|
||||
</Group>
|
||||
<Switch label="Auto-renew at maturity" checked={form.autoRenew} onChange={(e) => setForm({ ...form, autoRenew: e.currentTarget.checked })} />
|
||||
<TextInput label="Notes" value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSubmit} loading={loading} disabled={!form.label || !form.principal}>
|
||||
{initialData ? 'Update' : 'Add Investment'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Card, Title, Text, Group, Badge, Tooltip } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
cd: '#228be6',
|
||||
money_market: '#40c057',
|
||||
treasury: '#7950f2',
|
||||
savings: '#fd7e14',
|
||||
other: '#868e96',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
investments: any[];
|
||||
}
|
||||
|
||||
export function InvestmentTimeline({ investments }: Props) {
|
||||
const { items, startDate, endDate, totalMonths } = useMemo(() => {
|
||||
const now = new Date();
|
||||
const items = investments
|
||||
.filter((inv: any) => inv.purchase_date || inv.maturity_date)
|
||||
.map((inv: any) => ({
|
||||
...inv,
|
||||
start: inv.purchase_date ? new Date(inv.purchase_date) : now,
|
||||
end: inv.maturity_date ? new Date(inv.maturity_date) : null,
|
||||
}));
|
||||
|
||||
if (!items.length) return { items: [], startDate: now, endDate: now, totalMonths: 1 };
|
||||
|
||||
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
|
||||
const startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
||||
const endDate = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
||||
const totalMonths = Math.max(
|
||||
(endDate.getFullYear() - startDate.getFullYear()) * 12 + (endDate.getMonth() - startDate.getMonth()) + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
return { items, startDate, endDate, totalMonths };
|
||||
}, [investments]);
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
const getPercent = (date: Date) => {
|
||||
const months = (date.getFullYear() - startDate.getFullYear()) * 12 + (date.getMonth() - startDate.getMonth());
|
||||
return Math.max(0, Math.min(100, (months / totalMonths) * 100));
|
||||
};
|
||||
|
||||
// Generate year labels
|
||||
const yearLabels: { year: number; percent: number }[] = [];
|
||||
for (let y = startDate.getFullYear(); y <= endDate.getFullYear(); y++) {
|
||||
const janDate = new Date(y, 0, 1);
|
||||
if (janDate >= startDate && janDate <= endDate) {
|
||||
yearLabels.push({ year: y, percent: getPercent(janDate) });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Title order={4} mb="md">Investment Timeline</Title>
|
||||
|
||||
{/* Year markers */}
|
||||
<div style={{ position: 'relative', height: 20, marginBottom: 8 }}>
|
||||
{yearLabels.map((yl) => (
|
||||
<Text
|
||||
key={yl.year}
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={700}
|
||||
style={{ position: 'absolute', left: `${yl.percent}%`, transform: 'translateX(-50%)' }}
|
||||
>
|
||||
{yl.year}
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Timeline bars */}
|
||||
<div style={{ position: 'relative', minHeight: items.length * 40 + 10 }}>
|
||||
{/* Background grid */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, borderLeft: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRight: '1px solid var(--mantine-color-gray-3)',
|
||||
}}>
|
||||
{yearLabels.map((yl) => (
|
||||
<div
|
||||
key={yl.year}
|
||||
style={{
|
||||
position: 'absolute', left: `${yl.percent}%`, top: 0, bottom: 0,
|
||||
borderLeft: '1px dashed var(--mantine-color-gray-3)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{items.map((inv: any, idx: number) => {
|
||||
const leftPct = getPercent(inv.start);
|
||||
const rightPct = inv.end ? getPercent(inv.end) : leftPct + 2;
|
||||
const widthPct = Math.max(rightPct - leftPct, 1);
|
||||
const color = typeColors[inv.investment_type] || '#868e96';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={inv.id}
|
||||
label={
|
||||
<div>
|
||||
<Text size="xs" fw={600}>{inv.label}</Text>
|
||||
<Text size="xs">{fmt(parseFloat(inv.principal))} @ {parseFloat(inv.interest_rate || 0).toFixed(2)}%</Text>
|
||||
{inv.purchase_date && <Text size="xs">Start: {new Date(inv.purchase_date).toLocaleDateString()}</Text>}
|
||||
{inv.maturity_date && <Text size="xs">Maturity: {new Date(inv.maturity_date).toLocaleDateString()}</Text>}
|
||||
</div>
|
||||
}
|
||||
position="top"
|
||||
multiline
|
||||
withArrow
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: `${leftPct}%`,
|
||||
width: `${widthPct}%`,
|
||||
top: idx * 40 + 4,
|
||||
height: 28,
|
||||
borderRadius: 4,
|
||||
background: color,
|
||||
opacity: inv.executed_investment_id ? 0.5 : 0.85,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
cursor: 'pointer',
|
||||
minWidth: 60,
|
||||
}}
|
||||
>
|
||||
<Text size="xs" c="white" fw={600} truncate style={{ lineHeight: 1 }}>
|
||||
{inv.label} — {fmt(parseFloat(inv.principal))}
|
||||
</Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<Group gap="md" mt="md">
|
||||
{Object.entries(typeColors).map(([type, color]) => (
|
||||
<Group key={type} gap={4}>
|
||||
<div style={{ width: 12, height: 12, borderRadius: 2, background: color }} />
|
||||
<Text size="xs" c="dimmed">{type.replace('_', ' ')}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
124
frontend/src/pages/board-planning/components/ProjectionChart.tsx
Normal file
124
frontend/src/pages/board-planning/components/ProjectionChart.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Card, Title, Text, Group, Badge, SegmentedControl, Stack } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend,
|
||||
ResponsiveContainer, ReferenceLine,
|
||||
} from 'recharts';
|
||||
|
||||
const fmt = (v: number) => v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
|
||||
interface Datapoint {
|
||||
month: string;
|
||||
year: number;
|
||||
monthNum: number;
|
||||
is_forecast: boolean;
|
||||
operating_cash: number;
|
||||
operating_investments: number;
|
||||
reserve_cash: number;
|
||||
reserve_investments: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
datapoints: Datapoint[];
|
||||
title?: string;
|
||||
summary?: any;
|
||||
}
|
||||
|
||||
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) {
|
||||
const [fundFilter, setFundFilter] = useState('all');
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return datapoints.map((d) => ({
|
||||
...d,
|
||||
label: `${d.month}`,
|
||||
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
||||
}));
|
||||
}, [datapoints]);
|
||||
|
||||
// Find first forecast month for reference line
|
||||
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<Card shadow="sm" p="xs" withBorder style={{ background: 'var(--mantine-color-body)' }}>
|
||||
<Text fw={600} size="sm" mb={4}>{label}</Text>
|
||||
{payload.map((p: any) => (
|
||||
<Group key={p.name} justify="space-between" gap="xl">
|
||||
<Text size="xs" c={p.color}>{p.name}</Text>
|
||||
<Text size="xs" fw={600} ff="monospace">{fmt(p.value)}</Text>
|
||||
</Group>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const showOp = fundFilter === 'all' || fundFilter === 'operating';
|
||||
const showRes = fundFilter === 'all' || fundFilter === 'reserve';
|
||||
|
||||
return (
|
||||
<Card withBorder p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={4}>{title}</Title>
|
||||
{summary && (
|
||||
<Group gap="md" mt={4}>
|
||||
<Badge variant="light" color="teal">End Liquidity: {fmt(summary.end_liquidity || 0)}</Badge>
|
||||
<Badge variant="light" color="orange">Min Liquidity: {fmt(summary.min_liquidity || 0)}</Badge>
|
||||
{summary.reserve_coverage_months != null && (
|
||||
<Badge variant="light" color="violet">
|
||||
Reserve Coverage: {summary.reserve_coverage_months.toFixed(1)} mo
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</div>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={fundFilter}
|
||||
onChange={setFundFilter}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Operating', value: 'operating' },
|
||||
{ label: 'Reserve', value: 'reserve' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="opCash" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#228be6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#228be6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="opInv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#74c0fc" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#74c0fc" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="resCash" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#7950f2" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#7950f2" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="resInv" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#b197fc" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#b197fc" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} />
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Legend />
|
||||
{forecastStart > 0 && (
|
||||
<ReferenceLine x={chartData[forecastStart]?.month} stroke="#aaa" strokeDasharray="5 5" label="Forecast" />
|
||||
)}
|
||||
{showOp && <Area type="monotone" dataKey="operating_cash" name="Operating Cash" stroke="#228be6" fill="url(#opCash)" stackId="1" />}
|
||||
{showOp && <Area type="monotone" dataKey="operating_investments" name="Operating Investments" stroke="#74c0fc" fill="url(#opInv)" stackId="1" />}
|
||||
{showRes && <Area type="monotone" dataKey="reserve_cash" name="Reserve Cash" stroke="#7950f2" fill="url(#resCash)" stackId="1" />}
|
||||
{showRes && <Area type="monotone" dataKey="reserve_investments" name="Reserve Investments" stroke="#b197fc" fill="url(#resInv)" stackId="1" />}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Card, Group, Text, Badge, ActionIcon, Menu } from '@mantine/core';
|
||||
import { IconDots, IconTrash, IconEdit, IconPlayerPlay } from '@tabler/icons-react';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
scenario: any;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function ScenarioCard({ scenario, onClick, onEdit, onDelete }: Props) {
|
||||
return (
|
||||
<Card withBorder p="lg" style={{ cursor: 'pointer' }} onClick={onClick}>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Group gap="xs">
|
||||
<Text fw={600}>{scenario.name}</Text>
|
||||
<Badge size="xs" color={statusColors[scenario.status] || 'gray'}>
|
||||
{scenario.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={(e: any) => e.stopPropagation()}>
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={(e: any) => { e.stopPropagation(); onEdit(); }}>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={(e: any) => { e.stopPropagation(); onDelete(); }}>
|
||||
Archive
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
{scenario.description && (
|
||||
<Text size="sm" c="dimmed" mb="sm" lineClamp={2}>{scenario.description}</Text>
|
||||
)}
|
||||
<Group gap="lg">
|
||||
{scenario.scenario_type === 'investment' && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Investments</Text>
|
||||
<Text fw={600}>{scenario.investment_count || 0}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Total Principal</Text>
|
||||
<Text fw={600} ff="monospace">{fmt(parseFloat(scenario.total_principal) || 0)}</Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{scenario.scenario_type === 'assessment' && (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Changes</Text>
|
||||
<Text fw={600}>{scenario.assessment_count || 0}</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt="sm">
|
||||
Updated {new Date(scenario.updated_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user