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>
This commit is contained in:
196
frontend/src/pages/reports/CapitalPlanningPage.tsx
Normal file
196
frontend/src/pages/reports/CapitalPlanningPage.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user