Add photo upload for PDQ Z-reports and receipt evidence

DB:
- Add attachment_type ('pdq_z_report' | 'receipt_error' | 'other') and
  label columns to cash_count_attachments; migrate existing rows via
  ALTER TABLE IF NOT EXISTS (no data loss)

Backend:
- Upload route now reads attachment_type and label from multipart form
  fields alongside the file, stores them in the DB

Frontend:
- PhotoUploader component: thumbnail grid, upload button (images + PDF),
  spinner during upload, ✕ delete button on each photo, click opens in
  new tab
- DailyCashUp: PDQ Z-Reports card (one section per machine — Front Desk /
  Restaurant Bar) appears after Card Machines when a cash up exists
- DailyCashUp: Receipt Evidence card at bottom for any discrepancy photos;
  stays editable even on final cash ups so receipts can be added later

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 09:22:22 +00:00
parent 1f5f1639c7
commit 43c332be9b
5 changed files with 166 additions and 21 deletions

View file

@ -120,8 +120,12 @@ export async function initDb() {
file_size BIGINT NOT NULL, file_size BIGINT NOT NULL,
mime_type TEXT NOT NULL, mime_type TEXT NOT NULL,
uploaded_by TEXT NOT NULL, uploaded_by TEXT NOT NULL,
uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 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 ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,

View file

@ -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]) 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' }) if (!rows.length) return reply.status(404).send({ error: 'Cash up not found' })
const data = await req.file() // Iterate multipart parts to collect file + text fields
if (!data) return reply.status(400).send({ error: 'No file uploaded' }) 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'] 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' }) 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 filename = randomUUID() + ext
const dir = join(UPLOADS_DIR, 'cashup', String(cashUpId)) const dir = join(UPLOADS_DIR, 'cashup', String(cashUpId))
await mkdir(dir, { recursive: true }) await mkdir(dir, { recursive: true })
let size = 0 let size = 0
const dest = createWriteStream(join(dir, filename)) 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)) await new Promise(r => dest.end(r))
const filePath = `/cashup/${cashUpId}/${filename}` const filePath = `/cashup/${cashUpId}/${filename}`
const { rows: ins } = await pool.query( const { rows: ins } = await pool.query(
`INSERT INTO cash_count_attachments (cash_up_id, file_name, file_path, file_size, mime_type, uploaded_by) `INSERT INTO cash_count_attachments
VALUES ($1,$2,$3,$4,$5,$6) RETURNING *`, (cash_up_id, file_name, file_path, file_size, mime_type, uploaded_by, attachment_type, label)
[cashUpId, data.filename, filePath, size, data.mimetype, req.user.email] 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] return ins.rows[0]

View file

@ -21,9 +21,16 @@ export const api = {
delete: <T>(path: string) => req<T>('DELETE', path), delete: <T>(path: string) => req<T>('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() const fd = new FormData()
fd.append('file', file) fd.append('file', file)
fd.append('attachment_type', attachmentType)
if (label) fd.append('label', label)
const res = await fetch(`${BASE}/attachments/upload/${cashUpId}`, { const res = await fetch(`${BASE}/attachments/upload/${cashUpId}`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',

View file

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { RefreshCw, Save, CheckCircle, Loader } from 'lucide-react' import { RefreshCw, Save, CheckCircle, Loader, Camera, FileText, X } from 'lucide-react'
import { api } from '../api' import { api, uploadAttachment } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
import { import {
GBP_DENOMINATIONS, fmtGBP, today, GBP_DENOMINATIONS, fmtGBP, today,
@ -43,7 +43,7 @@ export function DailyCashUp({ user: _user }: Props) {
const [float, setFloat] = useState<Denomination[]>(initDenominations('float')) const [float, setFloat] = useState<Denomination[]>(initDenominations('float'))
const [machines, setMachines] = useState<CardMachine[]>(initMachines()) const [machines, setMachines] = useState<CardMachine[]>(initMachines())
const [notes, setNotes] = useState('') const [notes, setNotes] = useState('')
const [, setAttachments] = useState<Attachment[]>([]) const [attachments, setAttachments] = useState<Attachment[]>([])
const [newbookTotals, setNewbookTotals] = useState<PaymentTotals | null>(null) const [newbookTotals, setNewbookTotals] = useState<PaymentTotals | null>(null)
const [tillPayments, setTillPayments] = useState<TillPayment[]>([]) const [tillPayments, setTillPayments] = useState<TillPayment[]>([])
const [transactionBreakdown, setTransactionBreakdown] = useState<TransactionBreakdown | null>(null) const [transactionBreakdown, setTransactionBreakdown] = useState<TransactionBreakdown | null>(null)
@ -325,6 +325,32 @@ export function DailyCashUp({ user: _user }: Props) {
</div> </div>
</Card> </Card>
{/* PDQ Z-Reports — one upload area per machine */}
{cashUp && (
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '0.75rem' }}>PDQ Z-Reports</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
Upload the end-of-day Z-report printout for each card machine.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
{MACHINES.map(name => (
<div key={name}>
<div style={{ fontSize: '0.8rem', fontWeight: 600, marginBottom: '0.5rem' }}>{name}</div>
<PhotoUploader
cashUpId={cashUp.id}
attachmentType="pdq_z_report"
label={name}
attachments={attachments}
onAdded={a => setAttachments(prev => [...prev, a])}
onRemoved={id => setAttachments(prev => prev.filter(a => a.id !== id))}
disabled={isFinal}
/>
</div>
))}
</div>
</Card>
)}
{/* Newbook + Reconciliation */} {/* Newbook + Reconciliation */}
<Card style={{ marginBottom: '1rem' }}> <Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
@ -402,6 +428,25 @@ export function DailyCashUp({ user: _user }: Props) {
)} )}
</Card> </Card>
{/* Receipt / discrepancy evidence */}
{cashUp && (
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '0.5rem' }}>Receipt Evidence &amp; Error Photos</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '0.75rem' }}>
Upload photos of receipts with errors, discrepancies or anything needing a record.
</p>
<PhotoUploader
cashUpId={cashUp.id}
attachmentType="receipt_error"
label={null}
attachments={attachments}
onAdded={a => setAttachments(prev => [...prev, a])}
onRemoved={id => setAttachments(prev => prev.filter(a => a.id !== id))}
disabled={false}
/>
</Card>
)}
{/* Notes */} {/* Notes */}
<Card style={{ marginBottom: '1rem' }}> <Card style={{ marginBottom: '1rem' }}>
<label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label> <label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label>
@ -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<HTMLInputElement>(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 (
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
{mine.map(a => (
<div key={a.id} style={{ position: 'relative', width: '88px', height: '88px', flexShrink: 0 }}>
{a.mime_type.startsWith('image/') ? (
<a href={`/cashup/api/uploads${a.file_path}`} target="_blank" rel="noopener noreferrer">
<img src={`/cashup/api/uploads${a.file_path}`} alt={a.file_name}
style={{ width: '88px', height: '88px', objectFit: 'cover', borderRadius: '6px', border: '1px solid var(--card-border)', display: 'block' }} />
</a>
) : (
<a href={`/cashup/api/uploads${a.file_path}`} target="_blank" rel="noopener noreferrer"
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '88px', height: '88px', border: '1px solid var(--card-border)', borderRadius: '6px', background: 'var(--body-bg)', textDecoration: 'none', color: 'var(--text-mid)', gap: '4px' }}>
<FileText size={24} />
<span style={{ fontSize: '0.6rem', textAlign: 'center', padding: '0 4px', wordBreak: 'break-all' }}>
{a.file_name.slice(0, 14)}
</span>
</a>
)}
{!disabled && (
<button onClick={() => remove(a.id)}
style={{ position: 'absolute', top: '-8px', right: '-8px', width: '20px', height: '20px', borderRadius: '50%', background: '#dc2626', color: '#fff', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1 }}>
<X size={10} />
</button>
)}
</div>
))}
{!disabled && (
<button onClick={() => inputRef.current?.click()}
style={{ width: '88px', height: '88px', border: '2px dashed var(--card-border)', borderRadius: '6px', background: 'var(--body-bg)', cursor: 'pointer', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '4px', color: 'var(--text-mid)', fontSize: '0.7rem' }}>
{uploading ? <Loader size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <><Camera size={18} /><span>Add photo</span></>}
</button>
)}
<input ref={inputRef} type="file" accept="image/*,application/pdf" multiple hidden
onChange={e => e.target.files?.length && handleFiles(e.target.files)} />
</div>
)
}
const inputSt: React.CSSProperties = { const inputSt: React.CSSProperties = {
border: '1px solid var(--card-border)', borderRadius: '4px', border: '1px solid var(--card-border)', borderRadius: '4px',
padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%', padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%',

View file

@ -82,6 +82,8 @@ export interface Attachment {
file_size: number file_size: number
mime_type: string mime_type: string
uploaded_at: string uploaded_at: string
attachment_type: 'pdq_z_report' | 'receipt_error' | 'other'
label: string | null
} }
export interface FloatCount { export interface FloatCount {