- 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>
197 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|