Files
HOA_Financial_Platform/frontend/src/pages/reports/CapitalPlanningPage.tsx
olsch01 2b331bb3ef feat: investment chart alignment, auto-renew records, fund transfers, capital planning report, and upcoming activities (v2026.3.24)
- Lock InvestmentTimeline and ProjectionChart to shared X axis range
- Auto-create renewal scenario_investments records when auto_renew is true
- Add fund transfer mechanism between asset accounts with journal entries
- Add Capital Planning Report (5-year forecast grouped by category)
- Add Upcoming Investment Activities dashboard card (maturities + planned purchases)
- Bump version to 2026.3.24

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 14:41:17 -04:00

197 lines
7.0 KiB
TypeScript

import { useState } from 'react';
import {
Title, Text, Card, Table, Group, Stack, Badge, Loader, Center,
Button, NumberInput,
} from '@mantine/core';
import { IconPrinter } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import api from '../../services/api';
interface ProjectItem {
id: string;
name: string;
description: string;
category: string;
estimated_cost: number;
target_year: number | null;
useful_life_years: number | null;
last_replacement_date: string | null;
fund_source: string;
status: string;
priority: number;
condition_rating: number | null;
year_amounts: Record<number, number>;
beyond: number;
}
interface CategoryGroup {
category: string;
projects: ProjectItem[];
}
interface CapitalPlanningData {
title: string;
start_year: number;
years: number[];
categories: CategoryGroup[];
year_totals: Record<number, number>;
beyond_total: number;
grand_total: number;
generated_at: string;
}
const fmt = (v: number) =>
v === 0 ? '-' : v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
export function CapitalPlanningPage() {
const [startYear, setStartYear] = useState(new Date().getFullYear());
const { data, isLoading } = useQuery<CapitalPlanningData>({
queryKey: ['capital-planning', startYear],
queryFn: async () => {
const { data } = await api.get(`/reports/capital-planning?startYear=${startYear}`);
return data;
},
});
if (isLoading) return <Center h={300}><Loader /></Center>;
const years = data?.years || [];
const hasProjects = (data?.categories || []).some((c) => c.projects.length > 0);
return (
<Stack>
<Group justify="space-between">
<div>
<Title order={2}>Capital Planning Report</Title>
<Text c="dimmed" size="sm">{data?.title || '5-Year Capital Project Forecast'}</Text>
</div>
<Group>
<NumberInput
size="xs"
w={100}
value={startYear}
onChange={(v) => v && setStartYear(Number(v))}
min={2020}
max={2050}
/>
<Button
variant="light"
leftSection={<IconPrinter size={16} />}
onClick={() => window.print()}
>
Print / PDF
</Button>
</Group>
</Group>
{!hasProjects ? (
<Card withBorder p="xl">
<Text ta="center" c="dimmed" py="lg">
No capital projects found. Add projects on the Projects page to generate this report.
</Text>
</Card>
) : (
<Card withBorder p="lg" className="capital-planning-print">
<Title order={3} ta="center" mb="xs">{data?.title}</Title>
<Text ta="center" c="dimmed" size="sm" mb="md">
Generated {new Date(data?.generated_at || '').toLocaleDateString()}
</Text>
<Table striped withTableBorder withColumnBorders>
<Table.Thead>
<Table.Tr>
<Table.Th>Description</Table.Th>
<Table.Th ta="center" w={60}>Life (yr)</Table.Th>
<Table.Th ta="center" w={90}>Last Done</Table.Th>
{years.map((y) => (
<Table.Th key={y} ta="right" w={100}>{y}</Table.Th>
))}
<Table.Th ta="right" w={100}>Beyond</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(data?.categories || []).map((cat) => {
const catTotals: Record<number, number> = {};
let catBeyond = 0;
for (const y of years) catTotals[y] = 0;
for (const p of cat.projects) {
for (const y of years) catTotals[y] += p.year_amounts[y] || 0;
catBeyond += p.beyond;
}
return [
<Table.Tr key={`cat-${cat.category}`} style={{ background: 'var(--mantine-color-blue-0)' }}>
<Table.Td colSpan={3 + years.length + 1}>
<Text fw={700} size="sm">{cat.category}</Text>
</Table.Td>
</Table.Tr>,
...cat.projects.map((p) => (
<Table.Tr key={p.id}>
<Table.Td>
<Text size="sm">{p.name}</Text>
{p.status !== 'planned' && (
<Badge size="xs" variant="light" ml={4}
color={p.status === 'completed' ? 'green' : p.status === 'in_progress' ? 'blue' : 'gray'}>
{p.status}
</Badge>
)}
</Table.Td>
<Table.Td ta="center">
<Text size="sm">{p.useful_life_years || '-'}</Text>
</Table.Td>
<Table.Td ta="center">
<Text size="sm">
{p.last_replacement_date
? new Date(p.last_replacement_date).getFullYear()
: '-'}
</Text>
</Table.Td>
{years.map((y) => (
<Table.Td key={y} ta="right" ff="monospace">
<Text size="sm">{fmt(p.year_amounts[y] || 0)}</Text>
</Table.Td>
))}
<Table.Td ta="right" ff="monospace">
<Text size="sm">{fmt(p.beyond)}</Text>
</Table.Td>
</Table.Tr>
)),
<Table.Tr key={`subtotal-${cat.category}`} style={{ borderTop: '2px solid var(--mantine-color-gray-4)' }}>
<Table.Td colSpan={3}>
<Text size="sm" fw={600} fs="italic">Subtotal {cat.category}</Text>
</Table.Td>
{years.map((y) => (
<Table.Td key={y} ta="right" ff="monospace">
<Text size="sm" fw={600}>{fmt(catTotals[y])}</Text>
</Table.Td>
))}
<Table.Td ta="right" ff="monospace">
<Text size="sm" fw={600}>{fmt(catBeyond)}</Text>
</Table.Td>
</Table.Tr>,
];
})}
</Table.Tbody>
<Table.Tfoot>
<Table.Tr style={{ background: 'var(--mantine-color-dark-0)' }}>
<Table.Td colSpan={3}>
<Text fw={700}>TOTAL</Text>
</Table.Td>
{years.map((y) => (
<Table.Td key={y} ta="right" ff="monospace">
<Text fw={700}>{fmt(data?.year_totals[y] || 0)}</Text>
</Table.Td>
))}
<Table.Td ta="right" ff="monospace">
<Text fw={700}>{fmt(data?.beyond_total || 0)}</Text>
</Table.Td>
</Table.Tr>
</Table.Tfoot>
</Table>
</Card>
)}
</Stack>
);
}