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

View File

@@ -27,8 +27,25 @@ interface BudgetVsActualData {
total_expense_actual: number;
}
const monthFilterOptions = [
{ value: '', label: 'Full Year' },
{ 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' },
];
export function BudgetVsActualPage() {
const [year, setYear] = useState(new Date().getFullYear().toString());
const [month, setMonth] = useState('');
const yearOptions = Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i;
@@ -36,9 +53,10 @@ export function BudgetVsActualPage() {
});
const { data, isLoading } = useQuery<BudgetVsActualData>({
queryKey: ['budget-vs-actual', year],
queryKey: ['budget-vs-actual', year, month],
queryFn: async () => {
const { data } = await api.get(`/budgets/${year}/vs-actual`);
const params = month ? `?month=${month}` : '';
const { data } = await api.get(`/budgets/${year}/vs-actual${params}`);
return data;
},
});
@@ -127,7 +145,17 @@ export function BudgetVsActualPage() {
<Stack>
<Group justify="space-between">
<Title order={2}>Budget vs. Actual</Title>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={120} />
<Group>
<Select data={yearOptions} value={year} onChange={(v) => v && setYear(v)} w={100} />
<Select
data={monthFilterOptions}
value={month}
onChange={(v) => setMonth(v || '')}
w={150}
placeholder="Month"
clearable={false}
/>
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 4 }}>

View File

@@ -8,7 +8,8 @@ import { DateInput } from '@mantine/dates';
import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconPlus, IconEye, IconCheck, IconX, IconTrash } from '@tabler/icons-react';
import { IconPlus, IconEye, IconCheck, IconX, IconTrash, IconShieldCheck } from '@tabler/icons-react';
import { AttachmentPanel } from '../../components/attachments/AttachmentPanel';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
@@ -30,6 +31,7 @@ interface JournalEntry {
entry_type: string;
is_posted: boolean;
is_void: boolean;
is_reconciled?: boolean;
created_at: string;
lines?: JournalEntryLine[];
total_debit?: string;
@@ -190,13 +192,22 @@ export function TransactionsPage() {
<Table.Td ta="right" ff="monospace">{fmt(e.total_debit || '0')}</Table.Td>
<Table.Td ta="right" ff="monospace">{fmt(e.total_credit || '0')}</Table.Td>
<Table.Td>
{e.is_void ? (
<Badge color="red" variant="light" size="sm">Void</Badge>
) : e.is_posted ? (
<Badge color="green" variant="light" size="sm">Posted</Badge>
) : (
<Badge color="yellow" variant="light" size="sm">Draft</Badge>
)}
<Group gap={4}>
{e.is_void ? (
<Badge color="red" variant="light" size="sm">Void</Badge>
) : e.is_posted ? (
<Badge color="green" variant="light" size="sm">Posted</Badge>
) : (
<Badge color="yellow" variant="light" size="sm">Draft</Badge>
)}
{e.is_reconciled && (
<Tooltip label="Reconciled">
<Badge color="teal" variant="light" size="sm" leftSection={<IconShieldCheck size={12} />}>
Reconciled
</Badge>
</Tooltip>
)}
</Group>
</Table.Td>
<Table.Td>
<Group gap="xs">
@@ -353,6 +364,11 @@ export function TransactionsPage() {
</Group>
<Text><strong>Description:</strong> {viewEntry.description}</Text>
{viewEntry.reference_number && <Text><strong>Ref #:</strong> {viewEntry.reference_number}</Text>}
{viewEntry.is_reconciled && (
<Badge color="teal" variant="light" leftSection={<IconShieldCheck size={14} />}>
Reconciled
</Badge>
)}
<Table>
<Table.Thead>
<Table.Tr>
@@ -373,6 +389,10 @@ export function TransactionsPage() {
))}
</Table.Tbody>
</Table>
{/* Attachments */}
<Text fw={500} mt="md">Attachments</Text>
<AttachmentPanel journalEntryId={viewEntry.id} />
</Stack>
)}
</Modal>