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:
317
frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx
Normal file
317
frontend/src/pages/monthly-actuals/MonthlyActualsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user