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

@ -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,

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])
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]