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:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||
Loader, Center, Select, Modal, TextInput, Alert, SimpleGrid, Tooltip,
|
||||
@@ -106,6 +106,34 @@ export function InvestmentScenarioDetailPage() {
|
||||
const investments = scenario.investments || [];
|
||||
const summary = projection?.summary;
|
||||
|
||||
// Compute shared time range for aligned charts
|
||||
const { sharedStartDate, sharedEndDate } = useMemo(() => {
|
||||
const allDates: Date[] = [];
|
||||
|
||||
// Dates from investments
|
||||
for (const inv of investments) {
|
||||
if (inv.purchase_date) allDates.push(new Date(inv.purchase_date));
|
||||
if (inv.maturity_date) allDates.push(new Date(inv.maturity_date));
|
||||
}
|
||||
|
||||
// Dates from projection datapoints
|
||||
const dps = projection?.datapoints || [];
|
||||
if (dps.length > 0) {
|
||||
allDates.push(new Date(dps[0].year, dps[0].monthNum - 1, 1));
|
||||
const last = dps[dps.length - 1];
|
||||
allDates.push(new Date(last.year, last.monthNum - 1, 1));
|
||||
}
|
||||
|
||||
if (allDates.length === 0) return { sharedStartDate: undefined, sharedEndDate: undefined };
|
||||
|
||||
const min = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
||||
const max = new Date(Math.max(...allDates.map((d) => d.getTime())));
|
||||
return {
|
||||
sharedStartDate: new Date(min.getFullYear(), min.getMonth(), 1),
|
||||
sharedEndDate: new Date(max.getFullYear(), max.getMonth(), 1),
|
||||
};
|
||||
}, [investments, projection]);
|
||||
|
||||
// Build a lookup of per-investment interest from the projection
|
||||
const interestDetailMap: Record<string, { interest: number; principal: number }> = {};
|
||||
if (summary?.investment_interest_details) {
|
||||
@@ -259,7 +287,13 @@ export function InvestmentScenarioDetailPage() {
|
||||
</Card>
|
||||
|
||||
{/* Investment Timeline */}
|
||||
{investments.length > 0 && <InvestmentTimeline investments={investments} />}
|
||||
{investments.length > 0 && (
|
||||
<InvestmentTimeline
|
||||
investments={investments}
|
||||
sharedStartDate={sharedStartDate}
|
||||
sharedEndDate={sharedEndDate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Projection Chart */}
|
||||
{projection && (
|
||||
@@ -267,6 +301,8 @@ export function InvestmentScenarioDetailPage() {
|
||||
datapoints={projection.datapoints || []}
|
||||
title="Scenario Projection"
|
||||
summary={projection.summary}
|
||||
sharedStartDate={sharedStartDate}
|
||||
sharedEndDate={sharedEndDate}
|
||||
/>
|
||||
)}
|
||||
{projLoading && <Center py="xl"><Loader /></Center>}
|
||||
|
||||
@@ -13,9 +13,12 @@ const typeColors: Record<string, string> = {
|
||||
|
||||
interface Props {
|
||||
investments: any[];
|
||||
/** Optional shared time range to align with ProjectionChart */
|
||||
sharedStartDate?: Date;
|
||||
sharedEndDate?: Date;
|
||||
}
|
||||
|
||||
export function InvestmentTimeline({ investments }: Props) {
|
||||
export function InvestmentTimeline({ investments, sharedStartDate, sharedEndDate }: Props) {
|
||||
const { items, startDate, endDate, totalMonths } = useMemo(() => {
|
||||
const now = new Date();
|
||||
const items = investments
|
||||
@@ -28,16 +31,24 @@ export function InvestmentTimeline({ investments }: Props) {
|
||||
|
||||
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())));
|
||||
// Use shared range if provided (to align with ProjectionChart), otherwise compute from investments
|
||||
let startDate: Date;
|
||||
let endDate: Date;
|
||||
if (sharedStartDate && sharedEndDate) {
|
||||
startDate = sharedStartDate;
|
||||
endDate = sharedEndDate;
|
||||
} else {
|
||||
const allDates = items.flatMap((i: any) => [i.start, i.end].filter(Boolean)) as Date[];
|
||||
startDate = new Date(Math.min(...allDates.map((d) => d.getTime())));
|
||||
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]);
|
||||
}, [investments, sharedStartDate, sharedEndDate]);
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
|
||||
@@ -23,18 +23,31 @@ interface Props {
|
||||
datapoints: Datapoint[];
|
||||
title?: string;
|
||||
summary?: any;
|
||||
/** Optional shared time range to align with InvestmentTimeline */
|
||||
sharedStartDate?: Date;
|
||||
sharedEndDate?: Date;
|
||||
}
|
||||
|
||||
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary }: Props) {
|
||||
export function ProjectionChart({ datapoints, title = 'Financial Projection', summary, sharedStartDate, sharedEndDate }: Props) {
|
||||
const [fundFilter, setFundFilter] = useState('all');
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
return datapoints.map((d) => ({
|
||||
let filtered = datapoints;
|
||||
// If shared range provided, filter datapoints to match
|
||||
if (sharedStartDate && sharedEndDate) {
|
||||
const startKey = sharedStartDate.getFullYear() * 12 + sharedStartDate.getMonth();
|
||||
const endKey = sharedEndDate.getFullYear() * 12 + sharedEndDate.getMonth();
|
||||
filtered = datapoints.filter((d) => {
|
||||
const dpKey = d.year * 12 + (d.monthNum - 1);
|
||||
return dpKey >= startKey && dpKey <= endKey;
|
||||
});
|
||||
}
|
||||
return filtered.map((d) => ({
|
||||
...d,
|
||||
label: `${d.month}`,
|
||||
total: d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments,
|
||||
}));
|
||||
}, [datapoints]);
|
||||
}, [datapoints, sharedStartDate, sharedEndDate]);
|
||||
|
||||
// Find first forecast month for reference line
|
||||
const forecastStart = chartData.findIndex((d) => d.is_forecast);
|
||||
|
||||
Reference in New Issue
Block a user