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:
parent
1f5f1639c7
commit
43c332be9b
5 changed files with 166 additions and 21 deletions
|
|
@ -21,9 +21,16 @@ export const api = {
|
|||
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()
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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<Denomination[]>(initDenominations('float'))
|
||||
const [machines, setMachines] = useState<CardMachine[]>(initMachines())
|
||||
const [notes, setNotes] = useState('')
|
||||
const [, setAttachments] = useState<Attachment[]>([])
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [newbookTotals, setNewbookTotals] = useState<PaymentTotals | null>(null)
|
||||
const [tillPayments, setTillPayments] = useState<TillPayment[]>([])
|
||||
const [transactionBreakdown, setTransactionBreakdown] = useState<TransactionBreakdown | null>(null)
|
||||
|
|
@ -325,6 +325,32 @@ export function DailyCashUp({ user: _user }: Props) {
|
|||
</div>
|
||||
</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 */}
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
|
|
@ -402,6 +428,25 @@ export function DailyCashUp({ user: _user }: Props) {
|
|||
)}
|
||||
</Card>
|
||||
|
||||
{/* Receipt / discrepancy evidence */}
|
||||
{cashUp && (
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '0.5rem' }}>Receipt Evidence & 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 */}
|
||||
<Card style={{ marginBottom: '1rem' }}>
|
||||
<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 = {
|
||||
border: '1px solid var(--card-border)', borderRadius: '4px',
|
||||
padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%',
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue