- Add global WriteAccessGuard that blocks POST/PUT/PATCH/DELETE for viewer role - Add @AllowViewer() decorator for endpoints viewers need (switch-org, intro-seen, AI recommendations) - Add useIsReadOnly hook to auth store for frontend role checks - Hide write UI (add/edit/delete/import buttons, inline editors) in all 13 data pages for viewers - Disable inline NumberInputs on Budgets and Monthly Actuals pages for viewers - Skip onboarding wizard for viewer role users Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
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 { useIsReadOnly } from '../../stores/authStore';
|
|
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 isReadOnly = useIsReadOnly();
|
|
|
|
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}
|
|
allowNegative
|
|
disabled={isReadOnly}
|
|
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} />
|
|
{!isReadOnly && (
|
|
<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>
|
|
);
|
|
}
|