Sprint 6: Monthly actuals input, reconciliation, and file attachments

Add spreadsheet-style Monthly Actuals page for entering monthly actuals
against budget with auto-generated journal entries and reconciliation flag.
Add file attachment support (PDF, images, spreadsheets) on journal entries
for receipts and invoices. Enhance Budget vs Actual report with month
filter dropdown. Add reconciled badge to Transactions page. Replace bcrypt
with bcryptjs to fix Docker cross-platform native binding issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 11:48:57 -05:00
parent ea49b91bb3
commit 84822474f8
20 changed files with 9868 additions and 22 deletions

View File

@@ -0,0 +1,317 @@
import { useState, useMemo } from 'react';
import {
Title, Table, Group, Button, Stack, Text, NumberInput,
Select, Loader, Center, Card, SimpleGrid, Badge, Alert,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconDeviceFloppy, IconInfoCircle, IconCalendarMonth,
} from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
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 queryClient = useQueryClient();
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);
return data;
},
});
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);
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 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 hasChanges = Object.keys(editedAmounts).length > 0;
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 />
<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: 'white', zIndex: 1,
borderRight: '1px solid #e9ecef',
}}
>
<Text size="sm" c="dimmed" ff="monospace">{line.account_number}</Text>
</Table.Td>
<Table.Td
style={{
position: 'sticky', left: 120, background: 'white', zIndex: 1,
borderRight: '1px solid #e9ecef',
}}
>
<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={2} style={{ minWidth: 130 }}>
<NumberInput
value={amount}
onChange={(v) => updateAmount(line.account_id, Number(v) || 0)}
size="xs"
hideControls
decimalScale={2}
min={0}
styles={{ input: { textAlign: 'right', fontFamily: 'monospace' } }}
/>
</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} />
<Button
leftSection={<IconDeviceFloppy size={16} />}
onClick={() => saveMutation.mutate()}
loading={saveMutation.isPending}
disabled={lines.length === 0}
>
{hasChanges ? 'Save & Reconcile' : '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>
)}
{savedJEId && (
<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: 'white', zIndex: 2, minWidth: 120 }}>
Acct #
</Table.Th>
<Table.Th style={{ position: 'sticky', left: 120, background: 'white', 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, '#e6f9e6', totals.incomeBudget, totals.incomeActual)}
{renderSection('Expenses', expenseLines, '#fde8e8', 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>
)}
</Stack>
);
}