From fb66dcd6bfd4d474d5a0db61e064bb1338af9779 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 2 Jul 2026 14:46:52 +0000 Subject: [PATCH] Save transaction checks, till float variance, fix image upload - Persist transaction breakdown checked state in cash_ups.checked_transactions (JSONB column); saved on each draft/final save and restored on page load. Re-fetching Newbook no longer resets the checked items. - Add till_float_target setting; Float Count card shows target and variance so staff can see at a glance whether the till balances. - Compress images client-side (max 1600px, 85% JPEG) before upload to avoid 504 gateway timeouts from NPM when uploading large mobile photos. - File input accepts image/* only (was image/*+PDF which confuses mobile pickers). Co-Authored-By: Claude Sonnet 4.6 --- backend/src/db.js | 2 ++ backend/src/routes/cashup.js | 11 +++--- backend/src/routes/settings.js | 2 +- frontend/src/pages/DailyCashUp.tsx | 54 +++++++++++++++++++++++++++--- frontend/src/pages/Settings.tsx | 3 ++ frontend/src/types.ts | 1 + 6 files changed, 63 insertions(+), 10 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 5845743..97b0ac9 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -126,6 +126,7 @@ export async function initDb() { ); 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; + ALTER TABLE cash_ups ADD COLUMN IF NOT EXISTS checked_transactions JSONB NOT NULL DEFAULT '[]'::jsonb; CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, @@ -138,6 +139,7 @@ export async function initDb() { INSERT INTO settings (key, value) VALUES ('default_report_days', '7'), ('petty_cash_float', '200.00'), + ('till_float_target', '0.00'), ('sales_breakdown_columns', '[]'), ('change_tin_breakdown', '{"50.00":0,"20.00":0,"10.00":0,"5.00":0,"2.00":20,"1.00":20,"0.50":10,"0.20":10,"0.10":5,"0.05":5}') ON CONFLICT (key) DO NOTHING diff --git a/backend/src/routes/cashup.js b/backend/src/routes/cashup.js index 9aac7c3..fc71bb1 100644 --- a/backend/src/routes/cashup.js +++ b/backend/src/routes/cashup.js @@ -33,7 +33,7 @@ export async function cashupRoutes(app) { // POST /api/cashup/save app.post('/api/cashup/save', async (req, reply) => { - const { session_date, status, notes, denominations = [], card_machines = [] } = req.body + const { session_date, status, notes, denominations = [], card_machines = [], checked_transactions = [] } = req.body if (!session_date) return reply.status(400).send({ error: 'session_date required' }) if (!['draft', 'final'].includes(status)) return reply.status(400).send({ error: 'invalid status' }) @@ -63,21 +63,22 @@ export async function cashupRoutes(app) { status, totalFloat, totalCash, notes || null, status === 'final' ? new Date() : null, status === 'final' ? req.user.email : null, - new Date(), cashUpId, + new Date(), JSON.stringify(checked_transactions), cashUpId, ] await pool.query( `UPDATE cash_ups SET status=$1, total_float_counted=$2, total_cash_counted=$3, notes=$4, - submitted_at=$5, submitted_by=$6, updated_at=$7 WHERE id=$8`, + submitted_at=$5, submitted_by=$6, updated_at=$7, checked_transactions=$8 WHERE id=$9`, updateData ) } else { const ins = await pool.query( - `INSERT INTO cash_ups (session_date, created_by, status, total_float_counted, total_cash_counted, notes, submitted_at, submitted_by) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id`, + `INSERT INTO cash_ups (session_date, created_by, status, total_float_counted, total_cash_counted, notes, submitted_at, submitted_by, checked_transactions) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id`, [ session_date, req.user.email, status, totalFloat, totalCash, notes || null, status === 'final' ? new Date() : null, status === 'final' ? req.user.email : null, + JSON.stringify(checked_transactions), ] ) cashUpId = ins.rows[0].id diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js index a4a3a5c..5dd65e1 100644 --- a/backend/src/routes/settings.js +++ b/backend/src/routes/settings.js @@ -3,7 +3,7 @@ import { requireAuth, hasCap } from '../auth.js' import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js' const ALL_KEYS = [ - 'default_report_days', 'petty_cash_float', + 'default_report_days', 'petty_cash_float', 'till_float_target', 'sales_breakdown_columns', 'change_tin_breakdown', ] diff --git a/frontend/src/pages/DailyCashUp.tsx b/frontend/src/pages/DailyCashUp.tsx index 78eabbd..f3aa284 100644 --- a/frontend/src/pages/DailyCashUp.tsx +++ b/frontend/src/pages/DailyCashUp.tsx @@ -49,6 +49,7 @@ export function DailyCashUp({ user }: Props) { const [tillPayments, setTillPayments] = useState([]) const [transactionBreakdown, setTransactionBreakdown] = useState(null) const [checkedItems, setCheckedItems] = useState>(new Set()) + const [tillFloatTarget, setTillFloatTarget] = useState(0) const [fetching, setFetching] = useState(false) const [saving, setSaving] = useState(false) const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null) @@ -67,6 +68,7 @@ export function DailyCashUp({ user }: Props) { setCashUp(data.cash_up) setNotes(data.cash_up.notes || '') setAttachments(data.attachments || []) + setCheckedItems(new Set(data.cash_up.checked_transactions || [])) const rebuild = (ct: 'takings' | 'float') => GBP_DENOMINATIONS.map(d => { @@ -96,6 +98,13 @@ export function DailyCashUp({ user }: Props) { setPageState(data.cash_up.status === 'final' ? 'locked' : 'editing') } + // Fetch till float target once on mount + useEffect(() => { + api.get<{ till_float_target?: string }>('/settings') + .then(s => setTillFloatTarget(parseFloat(s.till_float_target || '0') || 0)) + .catch(() => {}) + }, []) + // Auto-check on mount and whenever date changes; auto-fetch Newbook in parallel useEffect(() => { setPageState('checking') @@ -164,7 +173,6 @@ export function DailyCashUp({ user }: Props) { setNewbookTotals(data.totals) setTillPayments(data.till_payments || []) setTransactionBreakdown(data.transaction_breakdown || null) - setCheckedItems(new Set()) flash(`Fetched ${data.count} payment(s) from Newbook.`) } catch (e: unknown) { flash(e instanceof Error ? e.message : 'Failed to fetch Newbook data.', false) @@ -181,6 +189,7 @@ export function DailyCashUp({ user }: Props) { session_date: date, status, notes, + checked_transactions: [...checkedItems], denominations: allDenoms.map(d => ({ count_type: d.count_type, type: d.denomination_type, @@ -219,6 +228,8 @@ export function DailyCashUp({ user }: Props) { ] : [] const totalPdq = machines.reduce((s, m) => s + m.total_amount, 0) + const floatCounted = denomTotal(float) + const floatVariance = tillFloatTarget > 0 ? floatCounted - tillFloatTarget : null return (
@@ -289,9 +300,19 @@ export function DailyCashUp({ user }: Props) { }}>

Float Count

- {fmtGBP(denomTotal(float))} {showFloat ? '▲' : '▼'} + {fmtGBP(floatCounted)} {showFloat ? '▲' : '▼'} + {floatVariance !== null && ( +
+ Target: {fmtGBP(tillFloatTarget)} + 0 ? 'var(--success)' : 'var(--danger)' }}> + {Math.abs(floatVariance) < 0.01 + ? 'In balance' + : (floatVariance > 0 ? '+' : '') + fmtGBP(Math.abs(floatVariance)) + ' variance'} + +
+ )} {showFloat && (
updateDenom(float, setFloat, i, f, v)} disabled={isFinal} tabBase={24} /> @@ -538,6 +559,30 @@ function DenomGrid({ denoms, onChange, disabled, tabBase = 0 }: { ) } +async function compressImage(file: File, maxWidth = 1600, quality = 0.85): Promise { + if (!file.type.startsWith('image/')) return file + return new Promise(resolve => { + const img = new Image() + const url = URL.createObjectURL(file) + img.onload = () => { + URL.revokeObjectURL(url) + const scale = img.width > maxWidth ? maxWidth / img.width : 1 + const canvas = document.createElement('canvas') + canvas.width = Math.round(img.width * scale) + canvas.height = Math.round(img.height * scale) + canvas.getContext('2d')!.drawImage(img, 0, 0, canvas.width, canvas.height) + canvas.toBlob(blob => { + if (!blob) { resolve(file); return } + const name = file.name.replace(/\.[^.]+$/, '.jpg') + const compressed = new File([blob], name, { type: 'image/jpeg' }) + resolve(compressed.size < file.size ? compressed : file) + }, 'image/jpeg', quality) + } + img.onerror = () => { URL.revokeObjectURL(url); resolve(file) } + img.src = url + }) +} + function PhotoUploader({ cashUpId, attachmentType, label, attachments, onAdded, onRemoved, disabled, }: { @@ -561,7 +606,8 @@ function PhotoUploader({ setUploading(true) for (const file of Array.from(files)) { try { - const a = await uploadAttachment(cashUpId, file, attachmentType, label ?? undefined) + const toUpload = await compressImage(file) + const a = await uploadAttachment(cashUpId, toUpload, attachmentType, label ?? undefined) onAdded(a as Attachment) } catch (e) { console.error('Upload failed', e) @@ -608,7 +654,7 @@ function PhotoUploader({ {uploading ? : <>Add photo} )} - e.target.files?.length && handleFiles(e.target.files)} />
) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index dcd97d4..e82c3d8 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -6,6 +6,7 @@ import { can, type User } from '../types' interface SettingsData { default_report_days: string petty_cash_float: string + till_float_target: string sales_breakdown_columns: string change_tin_breakdown: string } @@ -131,6 +132,8 @@ export function SettingsPage({ user }: { user: User }) {

Float Settings

+ set('till_float_target', v)} /> set('petty_cash_float', v)} />
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 939b51e..6d13b6e 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -24,6 +24,7 @@ export interface CashUp { notes: string | null submitted_at: string | null submitted_by: string | null + checked_transactions: string[] } export interface Denomination {