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:
191
frontend/src/components/attachments/AttachmentPanel.tsx
Normal file
191
frontend/src/components/attachments/AttachmentPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user