fix: assessment scenarios UX tweaks and projection improvements
- Reorder sidebar: Assessment Scenarios now directly under Budget Planning - Simplify special assessment form: remove Total Amount, keep Per Unit only - Replace Duration field from free-text installments to dropdown (one-time/quarterly/6mo/annual) - Update Change column display to show total per-unit with duration label - Fix Reserve Coverage to use planned capital project costs instead of budget expenses - Include capital_projects table in projection engine alongside projects table - Replace actions dropdown menu with inline Edit/Remove icon buttons - Remove Refresh Projection button (projection auto-refreshes on changes) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -287,7 +287,7 @@ export class BoardPlanningProjectionService {
|
||||
else maturityIndex[key].reserve += maturityTotal;
|
||||
}
|
||||
|
||||
// Capital project expenses
|
||||
// Capital project expenses (from unified projects table)
|
||||
const projectExpenses = await this.tenant.query(`
|
||||
SELECT estimated_cost, target_year, target_month, fund_source
|
||||
FROM projects WHERE is_active = true AND status IN ('planned', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0
|
||||
@@ -303,6 +303,25 @@ export class BoardPlanningProjectionService {
|
||||
else projectIndex[key].reserve += cost;
|
||||
}
|
||||
|
||||
// Also include capital_projects table (Capital Planning page)
|
||||
try {
|
||||
const capitalProjectExpenses = await this.tenant.query(`
|
||||
SELECT estimated_cost, target_year, target_month, fund_source
|
||||
FROM capital_projects WHERE status IN ('planned', 'approved', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0
|
||||
`);
|
||||
for (const p of capitalProjectExpenses) {
|
||||
const yr = parseInt(p.target_year);
|
||||
const mo = parseInt(p.target_month) || 6;
|
||||
const key = `${yr}-${mo}`;
|
||||
if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 };
|
||||
const cost = parseFloat(p.estimated_cost) || 0;
|
||||
if (p.fund_source === 'operating') projectIndex[key].operating += cost;
|
||||
else projectIndex[key].reserve += cost;
|
||||
}
|
||||
} catch {
|
||||
// capital_projects table may not exist in all tenants
|
||||
}
|
||||
|
||||
return {
|
||||
openingBalances: {
|
||||
opCash: parseFloat(openingOp[0]?.total || '0'),
|
||||
@@ -464,15 +483,18 @@ export class BoardPlanningProjectionService {
|
||||
const minLiquidity = Math.min(...allLiquidity);
|
||||
const endLiquidity = allLiquidity[allLiquidity.length - 1];
|
||||
|
||||
// Monthly reserve expense from budgets (approximate average)
|
||||
let totalResExpense = 0;
|
||||
let budgetMonths = 0;
|
||||
for (const key of Object.keys(baseline.budgetsByYearMonth)) {
|
||||
const b = baseline.budgetsByYearMonth[key];
|
||||
if (b.resExpense > 0) { totalResExpense += b.resExpense; budgetMonths++; }
|
||||
// Reserve coverage: reserve balance / avg monthly reserve expenditure from planned capital projects
|
||||
let totalReserveProjectCost = 0;
|
||||
const projectionYears = Math.max(1, Math.ceil(datapoints.length / 12));
|
||||
for (const key of Object.keys(baseline.projectIndex)) {
|
||||
totalReserveProjectCost += baseline.projectIndex[key].reserve || 0;
|
||||
}
|
||||
const avgMonthlyResExpense = budgetMonths > 0 ? totalResExpense / budgetMonths : 1;
|
||||
const reserveCoverageMonths = (last.reserve_cash + last.reserve_investments) / Math.max(avgMonthlyResExpense, 1);
|
||||
const avgMonthlyReserveExpenditure = totalReserveProjectCost > 0
|
||||
? totalReserveProjectCost / (projectionYears * 12)
|
||||
: 0;
|
||||
const reserveCoverageMonths = avgMonthlyReserveExpenditure > 0
|
||||
? (last.reserve_cash + last.reserve_investments) / avgMonthlyReserveExpenditure
|
||||
: 0; // No planned projects = show 0 (N/A)
|
||||
|
||||
// Estimate total investment income from scenario investments
|
||||
const totalInterestEarned = datapoints.reduce((sum, d, i) => {
|
||||
|
||||
@@ -67,8 +67,8 @@ const navSections = [
|
||||
label: 'Board Planning',
|
||||
items: [
|
||||
{ label: 'Budget Planning', icon: IconReportAnalytics, path: '/board-planning/budgets' },
|
||||
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
|
||||
{ label: 'Assessment Scenarios', icon: IconCalculator, path: '/board-planning/assessments' },
|
||||
{ label: 'Investment Scenarios', icon: IconScale, path: '/board-planning/investments' },
|
||||
{ label: 'Compare Scenarios', icon: IconGitCompare, path: '/board-planning/compare' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Title, Text, Stack, Group, Button, Table, Badge, Card, ActionIcon,
|
||||
Loader, Center, Menu, Select, SimpleGrid,
|
||||
Loader, Center, Select, SimpleGrid, Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconPlus, IconArrowLeft, IconDots, IconTrash, IconEdit, IconRefresh,
|
||||
IconPlus, IconArrowLeft, IconTrash, IconEdit,
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
@@ -92,14 +92,6 @@ export function AssessmentScenarioDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const refreshProjection = useMutation({
|
||||
mutationFn: () => api.post(`/board-planning/scenarios/${id}/projection/refresh`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['board-planning-projection', id] });
|
||||
notifications.show({ message: 'Projection refreshed', color: 'green' });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) return <Center h={400}><Loader size="lg" /></Center>;
|
||||
if (!scenario) return <Center h={400}><Text>Scenario not found</Text></Center>;
|
||||
|
||||
@@ -132,9 +124,6 @@ export function AssessmentScenarioDetailPage() {
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
]}
|
||||
/>
|
||||
<Button size="sm" variant="light" leftSection={<IconRefresh size={16} />} onClick={() => refreshProjection.mutate()} loading={refreshProjection.isPending}>
|
||||
Refresh Projection
|
||||
</Button>
|
||||
<Button size="sm" leftSection={<IconPlus size={16} />} onClick={() => setAddOpen(true)}>
|
||||
Add Change
|
||||
</Button>
|
||||
@@ -163,8 +152,13 @@ export function AssessmentScenarioDetailPage() {
|
||||
<Card withBorder p="md">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Reserve Coverage</Text>
|
||||
<Text fw={700} size="xl" ff="monospace">
|
||||
{(projection.summary.reserve_coverage_months || 0).toFixed(1)} mo
|
||||
{projection.summary.reserve_coverage_months > 0
|
||||
? `${projection.summary.reserve_coverage_months.toFixed(1)} mo`
|
||||
: 'N/A'}
|
||||
</Text>
|
||||
{projection.summary.reserve_coverage_months <= 0 && (
|
||||
<Text size="xs" c="dimmed">No planned capital projects</Text>
|
||||
)}
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
)}
|
||||
@@ -201,7 +195,13 @@ export function AssessmentScenarioDetailPage() {
|
||||
</Table.Td>
|
||||
<Table.Td ta="right" ff="monospace">
|
||||
{a.change_type === 'special_assessment'
|
||||
? `${fmt(parseFloat(a.special_per_unit) || 0)}/unit x ${a.special_installments || 1} mo`
|
||||
? `${fmt(parseFloat(a.special_per_unit) || 0)}/unit${(() => {
|
||||
const inst = parseInt(a.special_installments) || 1;
|
||||
if (inst === 1) return ', one-time';
|
||||
if (inst === 3) return ', quarterly';
|
||||
if (inst === 12) return ', annual';
|
||||
return `, ${inst} mo`;
|
||||
})()}`
|
||||
: a.percentage_change
|
||||
? `${parseFloat(a.percentage_change).toFixed(1)}%`
|
||||
: a.flat_amount_change
|
||||
@@ -211,15 +211,18 @@ export function AssessmentScenarioDetailPage() {
|
||||
<Table.Td>{a.effective_date ? new Date(a.effective_date).toLocaleDateString() : '-'}</Table.Td>
|
||||
<Table.Td>{a.end_date ? new Date(a.end_date).toLocaleDateString() : 'Ongoing'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Menu withinPortal position="bottom-end" shadow="sm">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray"><IconDots size={16} /></ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<IconEdit size={14} />} onClick={() => setEditAsmt(a)}>Edit</Menu.Item>
|
||||
<Menu.Item leftSection={<IconTrash size={14} />} color="red" onClick={() => removeMutation.mutate(a.id)}>Remove</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => setEditAsmt(a)}>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Remove">
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => removeMutation.mutate(a.id)}>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
|
||||
@@ -117,32 +117,27 @@ export function AssessmentChangeForm({ opened, onClose, onSubmit, initialData, l
|
||||
|
||||
{isSpecial && (
|
||||
<>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Total Amount"
|
||||
value={form.specialTotal}
|
||||
onChange={(v) => setForm({ ...form, specialTotal: Number(v) || 0 })}
|
||||
label="Per Unit Amount"
|
||||
description="Total amount each unit will be assessed"
|
||||
value={form.specialPerUnit}
|
||||
onChange={(v) => setForm({ ...form, specialPerUnit: Number(v) || 0 })}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
thousandSeparator=","
|
||||
prefix="$"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Per Unit Amount"
|
||||
value={form.specialPerUnit}
|
||||
onChange={(v) => setForm({ ...form, specialPerUnit: Number(v) || 0 })}
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
prefix="$"
|
||||
/>
|
||||
</Group>
|
||||
<NumberInput
|
||||
label="Installments"
|
||||
description="1 = one-time lump sum, 6 = spread over 6 months, etc."
|
||||
value={form.specialInstallments}
|
||||
<Select
|
||||
label="Duration"
|
||||
description="How the assessment is collected"
|
||||
value={String(form.specialInstallments)}
|
||||
onChange={(v) => setForm({ ...form, specialInstallments: Number(v) || 1 })}
|
||||
min={1}
|
||||
max={60}
|
||||
data={[
|
||||
{ value: '1', label: 'One-time (lump sum)' },
|
||||
{ value: '3', label: 'Quarterly (3 monthly payments)' },
|
||||
{ value: '6', label: '6 months' },
|
||||
{ value: '12', label: 'Annual (12 monthly payments)' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user