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:
2026-03-24 14:41:02 -04:00
parent ae856bfb2f
commit 2b331bb3ef
15 changed files with 801 additions and 21 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "hoa-ledgeriq-frontend",
"version": "2026.3.19",
"version": "2026.3.24",
"private": true,
"type": "module",
"scripts": {

View File

@@ -24,6 +24,7 @@ import { CashFlowPage } from './pages/reports/CashFlowPage';
import { AgingReportPage } from './pages/reports/AgingReportPage';
import { YearEndPage } from './pages/reports/YearEndPage';
import { QuarterlyReportPage } from './pages/reports/QuarterlyReportPage';
import { CapitalPlanningPage } from './pages/reports/CapitalPlanningPage';
import { SettingsPage } from './pages/settings/SettingsPage';
import { UserPreferencesPage } from './pages/preferences/UserPreferencesPage';
import { OrgMembersPage } from './pages/org-members/OrgMembersPage';
@@ -167,6 +168,7 @@ export function App() {
<Route path="reports/sankey" element={<SankeyPage />} />
<Route path="reports/year-end" element={<YearEndPage />} />
<Route path="reports/quarterly" element={<QuarterlyReportPage />} />
<Route path="reports/capital-planning" element={<CapitalPlanningPage />} />
<Route path="board-planning/budgets" element={<BudgetPlanningPage />} />
<Route path="board-planning/investments" element={<InvestmentScenariosPage />} />
<Route path="board-planning/investments/:id" element={<InvestmentScenarioDetailPage />} />

View File

@@ -94,6 +94,7 @@ const navSections = [
{ label: 'Sankey Diagram', path: '/reports/sankey' },
{ label: 'Year-End', path: '/reports/year-end' },
{ label: 'Quarterly Financial', path: '/reports/quarterly' },
{ label: 'Capital Planning', path: '/reports/capital-planning' },
],
},
],

View File

@@ -37,6 +37,7 @@ import {
IconStarFilled,
IconAdjustments,
IconInfoCircle,
IconArrowsTransferDown,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -126,6 +127,7 @@ export function AccountsPage() {
const [search, setSearch] = useState('');
const [filterType, setFilterType] = useState<string | null>(null);
const [showArchived, setShowArchived] = useState(false);
const [transferOpened, { open: openTransfer, close: closeTransfer }] = useDisclosure(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
@@ -283,6 +285,39 @@ export function AccountsPage() {
},
});
// ── Transfer form ──
const transferForm = useForm({
initialValues: {
fromAccountId: '',
toAccountId: '',
amount: 0,
transferDate: new Date() as Date | null,
memo: '',
},
validate: {
fromAccountId: (v) => (v ? null : 'Required'),
toAccountId: (v, values) => !v ? 'Required' : v === values.fromAccountId ? 'Must be different from source' : null,
amount: (v) => (v > 0 ? null : 'Must be greater than 0'),
transferDate: (v) => (v ? null : 'Required'),
},
});
const transferMutation = useMutation({
mutationFn: (values: { fromAccountId: string; toAccountId: string; amount: number; transferDate: string; memo: string }) =>
api.post('/accounts/transfer', values),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['trial-balance'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
notifications.show({ message: 'Transfer completed successfully', color: 'green' });
closeTransfer();
transferForm.reset();
},
onError: (err: any) => {
notifications.show({ message: err.response?.data?.message || 'Transfer failed', color: 'red' });
},
});
// ── Investment edit form ──
const invForm = useForm({
initialValues: {
@@ -408,6 +443,9 @@ export function AccountsPage() {
const activeAccounts = filtered.filter((a) => a.is_active);
const archivedAccounts = filtered.filter((a) => !a.is_active);
// Asset accounts for transfer modal (all active asset accounts, not just filtered by search)
const assetAccounts = accounts.filter((a) => a.is_active && !a.is_system && a.account_type === 'asset');
// ── Investments split by fund type ──
const operatingInvestments = investments.filter((i) => i.fund_type === 'operating' && i.is_active);
const reserveInvestments = investments.filter((i) => i.fund_type === 'reserve' && i.is_active);
@@ -505,9 +543,14 @@ export function AccountsPage() {
size="sm"
/>
{!isReadOnly && (
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
<>
<Button variant="light" leftSection={<IconArrowsTransferDown size={16} />} onClick={openTransfer}>
Transfer Funds
</Button>
<Button leftSection={<IconPlus size={16} />} onClick={handleNew}>
Add Account
</Button>
</>
)}
</Group>
</Group>
@@ -854,6 +897,69 @@ export function AccountsPage() {
)}
</Modal>
{/* Transfer Funds Modal */}
<Modal opened={transferOpened} onClose={closeTransfer} title="Transfer Funds Between Accounts" size="md" closeOnClickOutside={false}>
<form onSubmit={transferForm.onSubmit((values) => {
transferMutation.mutate({
...values,
transferDate: values.transferDate ? values.transferDate.toISOString().split('T')[0] : new Date().toISOString().split('T')[0],
});
})}>
<Stack>
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
This creates a journal entry transferring funds between asset accounts.
Both accounts will be updated in the general ledger.
</Alert>
<Select
label="From Account"
placeholder="Select source account"
required
data={assetAccounts.map((a) => ({
value: a.id,
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
}))}
searchable
{...transferForm.getInputProps('fromAccountId')}
/>
<Select
label="To Account"
placeholder="Select destination account"
required
data={assetAccounts
.filter((a) => a.id !== transferForm.values.fromAccountId)
.map((a) => ({
value: a.id,
label: `${a.name} (${a.fund_type}) — ${fmt(a.balance)}`,
}))}
searchable
{...transferForm.getInputProps('toAccountId')}
/>
<NumberInput
label="Amount"
required
prefix="$"
decimalScale={2}
thousandSeparator=","
min={0.01}
{...transferForm.getInputProps('amount')}
/>
<DateInput
label="Transfer Date"
required
{...transferForm.getInputProps('transferDate')}
/>
<TextInput
label="Memo (optional)"
placeholder="e.g. Monthly reserve contribution"
{...transferForm.getInputProps('memo')}
/>
<Button type="submit" leftSection={<IconArrowsTransferDown size={16} />} loading={transferMutation.isPending}>
Complete Transfer
</Button>
</Stack>
</form>
</Modal>
{/* Investment Edit Modal */}
<Modal opened={invEditOpened} onClose={closeInvEdit} title="Edit Investment Account" size="md" closeOnClickOutside={false}>
{editingInvestment && (

View File

@@ -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>}

View File

@@ -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;

View File

@@ -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);

View File

@@ -15,6 +15,8 @@ import {
IconHeartbeat,
IconRefresh,
IconInfoCircle,
IconCoin,
IconCalendarEvent,
} from '@tabler/icons-react';
import { useState, useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
@@ -362,6 +364,16 @@ export function DashboardPage() {
enabled: !!currentOrg,
});
const { data: investmentActivities } = useQuery<{
maturing_investments: any[];
upcoming_scenario_investments: any[];
total_activities: number;
}>({
queryKey: ['upcoming-investment-activities'],
queryFn: async () => { const { data } = await api.get('/reports/upcoming-investment-activities'); return data; },
enabled: !!currentOrg,
});
const { data: healthScores } = useQuery<HealthScoresData>({
queryKey: ['health-scores'],
queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; },
@@ -531,6 +543,97 @@ export function DashboardPage() {
</Card>
</SimpleGrid>
{/* Upcoming Investment Activities */}
{(investmentActivities?.total_activities || 0) > 0 && (
<Card withBorder padding="lg" radius="md">
<Group justify="space-between" mb="sm">
<Group gap="xs">
<ThemeIcon color="teal" variant="light" size={28} radius="md">
<IconCalendarEvent size={16} />
</ThemeIcon>
<Title order={4}>Upcoming Investment Activities</Title>
</Group>
<Badge variant="light" color="teal">{investmentActivities?.total_activities} upcoming</Badge>
</Group>
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Activity</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Fund</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
<Table.Th>Date</Table.Th>
<Table.Th>Timeline</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(investmentActivities?.maturing_investments || []).map((inv: any) => (
<Table.Tr key={`mat-${inv.id}`}>
<Table.Td>
<Group gap={6}>
<IconCoin size={14} color="var(--mantine-color-orange-6)" />
<Text size="sm" fw={500}>{inv.name}</Text>
</Group>
{inv.institution && <Text size="xs" c="dimmed">{inv.institution}</Text>}
</Table.Td>
<Table.Td>
<Badge size="xs" color="orange" variant="light">Maturing</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" color={inv.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
{inv.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
<Text size="sm" fw={500}>{fmt(inv.maturity_value)}</Text>
<Text size="xs" c="green">+{fmt(inv.interest_earned)} interest</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{new Date(inv.maturity_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color={inv.days_remaining <= 14 ? 'red' : inv.days_remaining <= 30 ? 'yellow' : 'gray'} variant="light">
{inv.days_remaining} days
</Badge>
</Table.Td>
</Table.Tr>
))}
{(investmentActivities?.upcoming_scenario_investments || []).map((si: any) => (
<Table.Tr key={`plan-${si.id}`}>
<Table.Td>
<Group gap={6}>
<IconTrendingUp size={14} color="var(--mantine-color-blue-6)" />
<Text size="sm" fw={500}>{si.label}</Text>
</Group>
<Text size="xs" c="dimmed">Scenario: {si.scenario_name}</Text>
</Table.Td>
<Table.Td>
<Badge size="xs" color="blue" variant="light">Planned Purchase</Badge>
</Table.Td>
<Table.Td>
<Badge size="xs" color={si.fund_type === 'reserve' ? 'violet' : 'blue'} variant="light">
{si.fund_type}
</Badge>
</Table.Td>
<Table.Td ta="right" ff="monospace">
<Text size="sm" fw={500}>{fmt(si.principal)}</Text>
{si.interest_rate && <Text size="xs" c="dimmed">{parseFloat(si.interest_rate).toFixed(2)}% APY</Text>}
</Table.Td>
<Table.Td>
<Text size="sm">{new Date(si.purchase_date).toLocaleDateString()}</Text>
</Table.Td>
<Table.Td>
<Badge size="sm" color={si.days_until <= 14 ? 'red' : si.days_until <= 30 ? 'yellow' : 'gray'} variant="light">
{si.days_until} days
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
)}
<SimpleGrid cols={{ base: 1, md: 2 }}>
<Card withBorder padding="lg" radius="md">
<Title order={4}>Quick Stats</Title>

View 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>
);
}