Files
HOA_Financial_Platform/frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx
olsch01 82433955bd feat: dashboard quick stats enhancements and monthly actuals read/edit mode
Dashboard Quick Stats:
- Create Capital Projects section with "Planned Capital Spend 2026"
- Fix Interest Earned YTD to pull from actual journal entries on
  interest income accounts instead of unrealized investment gains
- Add Interest Earned YoY showing projected current year vs last year
  actuals with percentage change badge

Monthly Actuals:
- Default to read-only view when actuals are already reconciled
- Show "Edit Actuals" button instead of "Save Actuals" for reconciled months
- Add confirmation modal warning that editing will void existing journal
  entry before allowing edits
- New months without actuals open directly in edit mode

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 14:41:14 -04:00

385 lines
14 KiB
TypeScript

import { useState, useMemo } from 'react';
import {
Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Card, SimpleGrid, Badge, Alert, Modal,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import {
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth, IconEdit,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
import { useIsReadOnly } from '../../stores/authStore';
import { usePreferencesStore } from '../../stores/preferencesStore';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
interface ActualLine {
account_id: string;
account_number: string;
account_name: string;
account_type: string;
fund_type: string;
budget_amount: number;
actual_amount: number;
}
interface ActualsGrid {
year: number;
month: number;
month_label: string;
existing_journal_entry_id: string | null;
lines: ActualLine[];
}
const monthOptions = [
{ value: '1', label: 'January' },
{ value: '2', label: 'February' },
{ value: '3', label: 'March' },
{ value: '4', label: 'April' },
{ value: '5', label: 'May' },
{ value: '6', label: 'June' },
{ value: '7', label: 'July' },
{ value: '8', label: 'August' },
{ value: '9', label: 'September' },
{ value: '10', label: 'October' },
{ value: '11', label: 'November' },
{ value: '12', label: 'December' },
];
function getDefaultMonth(): { year: string; month: string } {
const now = new Date();
// Default to previous completed month
const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);
return {
year: String(prev.getFullYear()),
month: String(prev.getMonth() + 1),
};
}
const fmt = (v: number) =>
(v || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 0 });
export function MonthlyActualsPage() {
const defaults = getDefaultMonth();
const [year, setYear] = useState(defaults.year);
const [month, setMonth] = useState(defaults.month);
const [editedAmounts, setEditedAmounts] = useState<Record<string, number>>({});
const [savedJEId, setSavedJEId] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [confirmOpened, { open: openConfirm, close: closeConfirm }] = useDisclosure(false);
const queryClient = useQueryClient();
const isReadOnly = useIsReadOnly();
const isDark = usePreferencesStore((s) => s.colorScheme) === 'dark';
const stickyBg = isDark ? 'var(--mantine-color-dark-7)' : 'white';
const stickyBorder = isDark ? 'var(--mantine-color-dark-4)' : '#e9ecef';
const incomeBg = isDark ? 'var(--mantine-color-green-9)' : '#e6f9e6';
const expenseBg = isDark ? 'var(--mantine-color-red-9)' : '#fde8e8';
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i;
return { value: String(y), label: String(y) };
});
const { data: grid, isLoading } = useQuery<ActualsGrid>({
queryKey: ['monthly-actuals', year, month],
queryFn: async () => {
const { data } = await api.get(`/monthly-actuals/${year}/${month}`);
setEditedAmounts({});
setSavedJEId(data.existing_journal_entry_id || null);
// Default to read mode if actuals already exist, edit mode if new
setIsEditing(!data.existing_journal_entry_id);
return data;
},
});
// Whether actuals have been previously saved (reconciled)
const hasExistingActuals = !!savedJEId;
const saveMutation = useMutation({
mutationFn: async () => {
const lines = (grid?.lines || [])
.map((line) => ({
accountId: line.account_id,
amount: editedAmounts[line.account_id] !== undefined
? editedAmounts[line.account_id]
: line.actual_amount,
}))
.filter((l) => l.amount !== 0);
const { data } = await api.post(`/monthly-actuals/${year}/${month}`, { lines });
return data;
},
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['monthly-actuals'] });
queryClient.invalidateQueries({ queryKey: ['journal-entries'] });
queryClient.invalidateQueries({ queryKey: ['accounts'] });
queryClient.invalidateQueries({ queryKey: ['budget-vs-actual'] });
setSavedJEId(data.journal_entry_id);
setIsEditing(false);
setEditedAmounts({});
notifications.show({
message: data.message || 'Actuals saved and reconciled',
color: 'green',
autoClose: 5000,
});
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Failed to save actuals',
color: 'red',
});
},
});
const getAmount = (line: ActualLine): number => {
return editedAmounts[line.account_id] !== undefined
? editedAmounts[line.account_id]
: line.actual_amount;
};
const updateAmount = (accountId: string, value: number) => {
setEditedAmounts((prev) => ({ ...prev, [accountId]: value }));
};
const handleEditClick = () => {
if (hasExistingActuals) {
openConfirm();
} else {
setIsEditing(true);
}
};
const handleConfirmEdit = () => {
closeConfirm();
setIsEditing(true);
};
const lines = grid?.lines || [];
const incomeLines = lines.filter((l) => l.account_type === 'income');
const expenseLines = lines.filter((l) => l.account_type === 'expense');
const totals = useMemo(() => {
const incomeBudget = incomeLines.reduce((s, l) => s + l.budget_amount, 0);
const incomeActual = incomeLines.reduce((s, l) => s + getAmount(l), 0);
const expenseBudget = expenseLines.reduce((s, l) => s + l.budget_amount, 0);
const expenseActual = expenseLines.reduce((s, l) => s + getAmount(l), 0);
return { incomeBudget, incomeActual, expenseBudget, expenseActual };
}, [lines, editedAmounts]);
const monthLabel = monthOptions.find((m) => m.value === month)?.label || '';
if (isLoading) return <Center h={300}><Loader /></Center>;
const renderSection = (
title: string,
sectionLines: ActualLine[],
bgColor: string,
budgetTotal: number,
actualTotal: number,
) => {
if (sectionLines.length === 0) return null;
const variance = actualTotal - budgetTotal;
const isExpense = title === 'Expenses';
return [
<Table.Tr key={`header-${title}`} style={{ background: bgColor }}>
<Table.Td
colSpan={2}
fw={700}
style={{ position: 'sticky', left: 0, background: bgColor, zIndex: 2 }}
>
{title}
</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(budgetTotal)}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace">{fmt(actualTotal)}</Table.Td>
<Table.Td ta="right" fw={700} ff="monospace"
c={variance === 0 ? 'gray' : (isExpense ? (variance > 0 ? 'red' : 'green') : (variance > 0 ? 'green' : 'red'))}
>
{variance > 0 ? '+' : ''}{fmt(variance)}
</Table.Td>
</Table.Tr>,
...sectionLines.map((line) => {
const amount = getAmount(line);
const lineVariance = amount - line.budget_amount;
return (
<Table.Tr key={line.account_id}>
<Table.Td
style={{
position: 'sticky', left: 0, background: stickyBg, zIndex: 1,
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
</Table.Td>
<Table.Td
style={{
position: 'sticky', left: 120, background: stickyBg, zIndex: 1,
borderRight: `1px solid ${stickyBorder}`,
}}
>
<Group gap={6} wrap="nowrap">
<Text size="sm" style={{ whiteSpace: 'nowrap' }}>{line.account_name}</Text>
{line.fund_type === 'reserve' && <Badge size="xs" color="violet">R</Badge>}
</Group>
</Table.Td>
<Table.Td ta="right" ff="monospace" c="dimmed" style={{ minWidth: 110 }}>
{fmt(line.budget_amount)}
</Table.Td>
<Table.Td p={isEditing ? 2 : undefined} style={{ minWidth: 130 }}>
{isEditing ? (
<NumberInput
value={amount}
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
size="xs"
hideControls
decimalScale={2}
allowNegative
disabled={isReadOnly}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
) : (
<Text size="sm" ff="monospace" ta="right">{fmt(amount)}</Text>
)}
</Table.Td>
<Table.Td
ta="right" ff="monospace" style={{ minWidth: 110 }}
c={lineVariance === 0 ? 'gray' : (isExpense ? (lineVariance > 0 ? 'red' : 'green') : (lineVariance > 0 ? 'green' : 'red'))}
>
{lineVariance > 0 ? '+' : ''}{fmt(lineVariance)}
</Table.Td>
</Table.Tr>
);
}),
];
};
return (
<Stack>
<Group justify="space-between">
<Group>
<IconCalendarMonth size={28} />
<Title order={2}>Monthly Actuals</Title>
</Group>
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select data={monthOptions} value={month} onChange={(v) => v && setMonth(v)} w={150} />
{!isReadOnly && !isEditing && (
<Button
leftSection={<IconEdit size={16} />}
variant="light"
onClick={handleEditClick}
disabled={lines.length === 0}
>
Edit Actuals
</Button>
)}
{!isReadOnly && isEditing && (
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={lines.length === 0}
>
Save Actuals
</Button>
)}
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 4 }}>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Income Budget</Text>
<Text fw={700} size="lg">{fmt(totals.incomeBudget)}</Text>
<Text size="xs" c="dimmed">Actual: {fmt(totals.incomeActual)}</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Expense Budget</Text>
<Text fw={700} size="lg">{fmt(totals.expenseBudget)}</Text>
<Text size="xs" c="dimmed">Actual: {fmt(totals.expenseActual)}</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Budget</Text>
<Text fw={700} size="lg" c={totals.incomeBudget - totals.expenseBudget >= 0 ? 'green' : 'red'}>
{fmt(totals.incomeBudget - totals.expenseBudget)}
</Text>
</Card>
<Card withBorder p="md">
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>Net Actual</Text>
<Text fw={700} size="lg" c={totals.incomeActual - totals.expenseActual >= 0 ? 'green' : 'red'}>
{fmt(totals.incomeActual - totals.expenseActual)}
</Text>
</Card>
</SimpleGrid>
{lines.length === 0 && (
<Alert icon={<IconInfoCircle size={16} />} color="blue" variant="light">
No income or expense accounts found for {monthLabel} {year}. Import a budget first to create accounts.
</Alert>
)}
{hasExistingActuals && !isEditing && (
<Alert icon={<IconInfoCircle size={16} />} color="green" variant="light">
<Group justify="space-between" align="flex-start">
<Text size="sm">
Actuals for {monthLabel} {year} have been reconciled.
Journal entry created and auto-posted.
</Text>
<Badge color="green" variant="light">Reconciled</Badge>
</Group>
</Alert>
)}
<div style={{ overflowX: 'auto' }}>
<Table striped highlightOnHover style={{ minWidth: 700 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: 'sticky', left: 0, background: stickyBg, zIndex: 2, minWidth: 120 }}>
Acct #
</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: stickyBg, zIndex: 2, minWidth: 220 }}>
Account Name
</Table.Th>
<Table.Th ta="right" style={{ minWidth: 110 }}>Budget</Table.Th>
<Table.Th ta="right" style={{ minWidth: 130 }}>Actual</Table.Th>
<Table.Th ta="right" style={{ minWidth: 110 }}>Variance</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{renderSection('Income', incomeLines, incomeBg, totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, expenseBg, totals.expenseBudget, totals.expenseActual)}
</Table.Tbody>
</Table>
</div>
{/* Attachment panel - show when we have a saved journal entry for this month */}
{savedJEId && (
<Card withBorder>
<Text fw={600} mb="sm">Attachments (Receipts & Invoices)</Text>
<AttachmentPanel journalEntryId={savedJEId} />
</Card>
)}
{/* Confirmation modal for editing reconciled actuals */}
<Modal opened={confirmOpened} onClose={closeConfirm} title="Edit Reconciled Actuals" centered>
<Stack>
<Text size="sm">
Actuals for <Text span fw={700}>{monthLabel} {year}</Text> have already been
reconciled. Editing will void the existing journal entry and create a new one
when you save.
</Text>
<Text size="sm" c="dimmed">
Press Edit to proceed, or Cancel to keep the current values.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeConfirm}>Cancel</Button>
<Button color="orange" leftSection={<IconEdit size={16} />} onClick={handleConfirmEdit}>
Edit
</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
}