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,191 @@
import { useRef } from 'react';
import {
Group, Text, Button, ActionIcon, Stack, Badge, Tooltip, Paper,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconUpload, IconDownload, IconTrash, IconFile, IconPhoto, IconFileSpreadsheet } from '@tabler/icons-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../../services/api';
interface Attachment {
id: string;
journal_entry_id: string;
filename: string;
mime_type: string;
file_size: number;
created_at: string;
}
interface AttachmentPanelProps {
journalEntryId: string | null;
readOnly?: boolean;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getFileIcon(mimeType: string) {
if (mimeType.startsWith('image/')) return IconPhoto;
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return IconFileSpreadsheet;
return IconFile;
}
export function AttachmentPanel({ journalEntryId, readOnly }: AttachmentPanelProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const { data: attachments = [], isLoading } = useQuery<Attachment[]>({
queryKey: ['attachments', journalEntryId],
queryFn: async () => {
const { data } = await api.get(`/attachments/journal-entry/${journalEntryId}`);
return data;
},
enabled: !!journalEntryId,
});
const uploadMutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData();
formData.append('file', file);
return api.post(`/attachments/journal-entry/${journalEntryId}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['attachments', journalEntryId] });
notifications.show({ message: 'File uploaded', color: 'green' });
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Upload failed',
color: 'red',
});
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['attachments', journalEntryId] });
notifications.show({ message: 'Attachment deleted', color: 'yellow' });
},
onError: (err: any) => {
notifications.show({
message: err.response?.data?.message || 'Delete failed',
color: 'red',
});
},
});
const handleDownload = async (attachment: Attachment) => {
try {
const response = await api.get(`/attachments/${attachment.id}/download`, {
responseType: 'blob',
});
const blob = new Blob([response.data], { type: attachment.mime_type });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = attachment.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch {
notifications.show({ message: 'Download failed', color: 'red' });
}
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
uploadMutation.mutate(file);
event.target.value = '';
};
if (!journalEntryId) {
return (
<Text size="sm" c="dimmed">
Save actuals first to attach receipts and invoices.
</Text>
);
}
return (
<Stack gap="xs">
{!readOnly && (
<>
<Button
variant="light"
size="xs"
leftSection={<IconUpload size={14} />}
onClick={() => fileInputRef.current?.click()}
loading={uploadMutation.isPending}
>
Upload File
</Button>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".pdf,.jpg,.jpeg,.png,.webp,.gif,.xls,.xlsx"
onChange={handleFileChange}
/>
</>
)}
{isLoading && <Text size="sm" c="dimmed">Loading attachments...</Text>}
{attachments.length === 0 && !isLoading && (
<Text size="sm" c="dimmed">No attachments yet.</Text>
)}
{attachments.map((att) => {
const FileIcon = getFileIcon(att.mime_type);
return (
<Paper key={att.id} p="xs" withBorder>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ overflow: 'hidden' }}>
<FileIcon size={18} />
<div style={{ overflow: 'hidden' }}>
<Text size="sm" fw={500} truncate="end">{att.filename}</Text>
<Group gap={6}>
<Badge size="xs" variant="light" color="gray">
{formatFileSize(att.file_size)}
</Badge>
<Text size="xs" c="dimmed">
{new Date(att.created_at).toLocaleDateString()}
</Text>
</Group>
</div>
</Group>
<Group gap={4} wrap="nowrap">
<Tooltip label="Download">
<ActionIcon variant="subtle" size="sm" onClick={() => handleDownload(att)}>
<IconDownload size={14} />
</ActionIcon>
</Tooltip>
{!readOnly && (
<Tooltip label="Delete">
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => deleteMutation.mutate(att.id)}
loading={deleteMutation.isPending}
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
</Paper>
);
})}
</Stack>
);
}

View File

@@ -15,6 +15,7 @@ import {
IconCrown,
IconCategory,
IconChartAreaLine,
IconClipboardCheck,
} from '@tabler/icons-react';
import { useAuthStore } from '../../stores/authStore';
@@ -29,6 +30,7 @@ const navSections = [
items: [
{ label: 'Accounts', icon: IconListDetails, path: '/accounts' },
{ label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' },
{ label: 'Monthly Actuals', icon: IconClipboardCheck, path: '/monthly-actuals' },
{ label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' },
],
},