diff --git a/backend/src/db.js b/backend/src/db.js index ded8924..5845743 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -113,15 +113,19 @@ export async function initDb() { ); CREATE TABLE IF NOT EXISTS cash_count_attachments ( - id SERIAL PRIMARY KEY, - cash_up_id INTEGER NOT NULL REFERENCES cash_ups(id) ON DELETE CASCADE, - file_name TEXT NOT NULL, - file_path TEXT NOT NULL, - file_size BIGINT NOT NULL, - mime_type TEXT NOT NULL, - uploaded_by TEXT NOT NULL, - uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + id SERIAL PRIMARY KEY, + cash_up_id INTEGER NOT NULL REFERENCES cash_ups(id) ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_path TEXT NOT NULL, + file_size BIGINT NOT NULL, + mime_type TEXT NOT NULL, + uploaded_by TEXT NOT NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + attachment_type TEXT NOT NULL DEFAULT 'other', + label TEXT ); + ALTER TABLE cash_count_attachments ADD COLUMN IF NOT EXISTS attachment_type TEXT NOT NULL DEFAULT 'other'; + ALTER TABLE cash_count_attachments ADD COLUMN IF NOT EXISTS label TEXT; CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, diff --git a/backend/src/index.js b/backend/src/index.js index 285ec74..170092b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -50,29 +50,40 @@ app.post('/api/attachments/upload/:cash_up_id', { preHandler: requireAuth }, asy const { rows } = await pool.query('SELECT id FROM cash_ups WHERE id = $1', [cashUpId]) if (!rows.length) return reply.status(404).send({ error: 'Cash up not found' }) - const data = await req.file() - if (!data) return reply.status(400).send({ error: 'No file uploaded' }) + // Iterate multipart parts to collect file + text fields + let fileData = null, attachmentType = 'other', label = null + for await (const part of req.parts()) { + if (part.type === 'file') { + fileData = part + } else { + const val = await part.value + if (part.fieldname === 'attachment_type') attachmentType = String(val || 'other') + if (part.fieldname === 'label') label = String(val || '') || null + } + } + if (!fileData) return reply.status(400).send({ error: 'No file uploaded' }) const allowed = ['image/jpeg', 'image/jpg', 'image/png', 'application/pdf'] - if (!allowed.includes(data.mimetype)) { + if (!allowed.includes(fileData.mimetype)) { return reply.status(400).send({ error: 'Only JPEG, PNG and PDF files are allowed' }) } - const ext = extname(data.filename) || '.bin' + const ext = extname(fileData.filename) || '.bin' const filename = randomUUID() + ext const dir = join(UPLOADS_DIR, 'cashup', String(cashUpId)) await mkdir(dir, { recursive: true }) let size = 0 const dest = createWriteStream(join(dir, filename)) - for await (const chunk of data.file) { dest.write(chunk); size += chunk.length } + for await (const chunk of fileData.file) { dest.write(chunk); size += chunk.length } await new Promise(r => dest.end(r)) const filePath = `/cashup/${cashUpId}/${filename}` const { rows: ins } = await pool.query( - `INSERT INTO cash_count_attachments (cash_up_id, file_name, file_path, file_size, mime_type, uploaded_by) - VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, - [cashUpId, data.filename, filePath, size, data.mimetype, req.user.email] + `INSERT INTO cash_count_attachments + (cash_up_id, file_name, file_path, file_size, mime_type, uploaded_by, attachment_type, label) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`, + [cashUpId, fileData.filename, filePath, size, fileData.mimetype, req.user.email, attachmentType, label] ) return ins.rows[0] diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4304c41..15145ab 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -21,9 +21,16 @@ export const api = { delete: (path: string) => req('DELETE', path), } -export async function uploadAttachment(cashUpId: number, file: File) { +export async function uploadAttachment( + cashUpId: number, + file: File, + attachmentType: 'pdq_z_report' | 'receipt_error' | 'other' = 'other', + label?: string, +) { const fd = new FormData() fd.append('file', file) + fd.append('attachment_type', attachmentType) + if (label) fd.append('label', label) const res = await fetch(`${BASE}/attachments/upload/${cashUpId}`, { method: 'POST', credentials: 'include', diff --git a/frontend/src/pages/DailyCashUp.tsx b/frontend/src/pages/DailyCashUp.tsx index 53b5235..0a62506 100644 --- a/frontend/src/pages/DailyCashUp.tsx +++ b/frontend/src/pages/DailyCashUp.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react' -import { RefreshCw, Save, CheckCircle, Loader } from 'lucide-react' -import { api } from '../api' +import { useState, useEffect, useRef } from 'react' +import { RefreshCw, Save, CheckCircle, Loader, Camera, FileText, X } from 'lucide-react' +import { api, uploadAttachment } from '../api' import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { GBP_DENOMINATIONS, fmtGBP, today, @@ -43,7 +43,7 @@ export function DailyCashUp({ user: _user }: Props) { const [float, setFloat] = useState(initDenominations('float')) const [machines, setMachines] = useState(initMachines()) const [notes, setNotes] = useState('') - const [, setAttachments] = useState([]) + const [attachments, setAttachments] = useState([]) const [newbookTotals, setNewbookTotals] = useState(null) const [tillPayments, setTillPayments] = useState([]) const [transactionBreakdown, setTransactionBreakdown] = useState(null) @@ -325,6 +325,32 @@ export function DailyCashUp({ user: _user }: Props) { + {/* PDQ Z-Reports — one upload area per machine */} + {cashUp && ( + +

PDQ Z-Reports

+

+ Upload the end-of-day Z-report printout for each card machine. +

+
+ {MACHINES.map(name => ( +
+
{name}
+ setAttachments(prev => [...prev, a])} + onRemoved={id => setAttachments(prev => prev.filter(a => a.id !== id))} + disabled={isFinal} + /> +
+ ))} +
+
+ )} + {/* Newbook + Reconciliation */}
@@ -402,6 +428,25 @@ export function DailyCashUp({ user: _user }: Props) { )} + {/* Receipt / discrepancy evidence */} + {cashUp && ( + +

Receipt Evidence & Error Photos

+

+ Upload photos of receipts with errors, discrepancies or anything needing a record. +

+ setAttachments(prev => [...prev, a])} + onRemoved={id => setAttachments(prev => prev.filter(a => a.id !== id))} + disabled={false} + /> +
+ )} + {/* Notes */} @@ -486,6 +531,82 @@ function DenomGrid({ denoms, onChange, disabled, tabBase = 0 }: { ) } +function PhotoUploader({ + cashUpId, attachmentType, label, attachments, onAdded, onRemoved, disabled, +}: { + cashUpId: number + attachmentType: 'pdq_z_report' | 'receipt_error' | 'other' + label: string | null + attachments: Attachment[] + onAdded: (a: Attachment) => void + onRemoved: (id: number) => void + disabled: boolean +}) { + const [uploading, setUploading] = useState(false) + const inputRef = useRef(null) + + const mine = attachments.filter(a => + a.attachment_type === attachmentType && + (label === null ? !a.label : a.label === label) + ) + + async function handleFiles(files: FileList) { + setUploading(true) + for (const file of Array.from(files)) { + try { + const a = await uploadAttachment(cashUpId, file, attachmentType, label ?? undefined) + onAdded(a as Attachment) + } catch (e) { + console.error('Upload failed', e) + } + } + setUploading(false) + if (inputRef.current) inputRef.current.value = '' + } + + async function remove(id: number) { + await api.delete(`/attachments/${id}`) + onRemoved(id) + } + + return ( +
+ {mine.map(a => ( +
+ {a.mime_type.startsWith('image/') ? ( + + {a.file_name} + + ) : ( + + + + {a.file_name.slice(0, 14)} + + + )} + {!disabled && ( + + )} +
+ ))} + {!disabled && ( + + )} + e.target.files?.length && handleFiles(e.target.files)} /> +
+ ) +} + const inputSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '4px', padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%', diff --git a/frontend/src/types.ts b/frontend/src/types.ts index da075ea..e894182 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -82,6 +82,8 @@ export interface Attachment { file_size: number mime_type: string uploaded_at: string + attachment_type: 'pdq_z_report' | 'receipt_error' | 'other' + label: string | null } export interface FloatCount {