From 5623f64732b2d98cc626d107ec5c40335b9e7163 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 2 Jul 2026 15:29:38 +0000 Subject: [PATCH] Float management: bagged coins, history nav fix, change order sheet, upload 500 fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix history nav: type underscore → hyphen for route match - Change tin: Bags + Loose inputs per denomination with UK bag values - Change tin target: per-denomination bag count in Settings - Change order sheet: inline table showing bags to order vs counted - db: bag_quantity column on float_denominations - api.ts: put text fields before file in FormData so busboy doesn't drain file stream early - db.js: additive migration for uploaded_by column on cash_count_attachments Co-Authored-By: Claude Sonnet 4.6 --- backend/src/db.js | 2 + backend/src/routes/floats.js | 6 +- frontend/src/api.ts | 2 +- frontend/src/pages/FloatManagement.tsx | 164 ++++++++++++++++++++----- frontend/src/pages/Settings.tsx | 46 ++++++- frontend/src/types.ts | 1 + 6 files changed, 182 insertions(+), 39 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 97b0ac9..df28e80 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -126,7 +126,9 @@ 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_count_attachments ADD COLUMN IF NOT EXISTS uploaded_by TEXT NOT NULL DEFAULT ''; ALTER TABLE cash_ups ADD COLUMN IF NOT EXISTS checked_transactions JSONB NOT NULL DEFAULT '[]'::jsonb; + ALTER TABLE float_denominations ADD COLUMN IF NOT EXISTS bag_quantity INTEGER NOT NULL DEFAULT 0; CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, diff --git a/backend/src/routes/floats.js b/backend/src/routes/floats.js index d62f719..2cfcde3 100644 --- a/backend/src/routes/floats.js +++ b/backend/src/routes/floats.js @@ -29,9 +29,9 @@ export async function floatRoutes(app) { for (const d of denominations) { await pool.query( - `INSERT INTO float_denominations (float_count_id, denomination_value, quantity, total_amount) - VALUES ($1,$2,$3,$4)`, - [countId, parseFloat(d.denomination), parseInt(d.quantity), parseFloat(d.total)] + `INSERT INTO float_denominations (float_count_id, denomination_value, quantity, bag_quantity, total_amount) + VALUES ($1,$2,$3,$4,$5)`, + [countId, parseFloat(d.denomination), parseInt(d.quantity), parseInt(d.bag_quantity ?? 0), parseFloat(d.total)] ) } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 15145ab..319d37d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -28,9 +28,9 @@ export async function uploadAttachment( label?: string, ) { const fd = new FormData() - fd.append('file', file) fd.append('attachment_type', attachmentType) if (label) fd.append('label', label) + fd.append('file', file) const res = await fetch(`${BASE}/attachments/upload/${cashUpId}`, { method: 'POST', credentials: 'include', diff --git a/frontend/src/pages/FloatManagement.tsx b/frontend/src/pages/FloatManagement.tsx index 0a4a0e9..bafd465 100644 --- a/frontend/src/pages/FloatManagement.tsx +++ b/frontend/src/pages/FloatManagement.tsx @@ -13,12 +13,23 @@ const TYPE_LABELS: Record = { safe_cash: 'Safe Cash', } -// Denominations relevant for each type (change_tin uses bags, no £0.02/£0.01) +// Denominations relevant for each type (change_tin excludes 1p/2p) const CHANGE_TIN_DENOMS = GBP_DENOMINATIONS.filter(d => d.value >= 0.05) +// UK standard bag values (£ per sealed bag of each denomination) +const BAG_VALUES: Record = { + 0.05: 5, // 100 × 5p + 0.10: 5, // 50 × 10p + 0.20: 10, // 50 × 20p + 0.50: 10, // 20 × 50p + 1.00: 20, // 20 × £1 + 2.00: 20, // 10 × £2 +} + export function FloatCountForm({ type }: { type: CountType }) { const navigate = useNavigate() const [denomQtys, setDenomQtys] = useState>({}) + const [bagQtys, setBagQtys] = useState>({}) const [receipts, setReceipts] = useState>([]) const [notes, setNotes] = useState('') const [saving, setSaving] = useState(false) @@ -41,7 +52,9 @@ export function FloatCountForm({ type }: { type: CountType }) { }).catch(() => {}) }, [type]) - const totalCounted = denoms.reduce((s, d) => s + d.value * (denomQtys[d.value] ?? 0), 0) + const totalCounted = type === 'change_tin' + ? denoms.reduce((s, d) => s + d.value * (denomQtys[d.value] ?? 0) + (BAG_VALUES[d.value] ?? 0) * (bagQtys[d.value] ?? 0), 0) + : denoms.reduce((s, d) => s + d.value * (denomQtys[d.value] ?? 0), 0) const totalReceipts = receipts.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0) const targetAmount = type === 'petty_cash' ? pettyTarget : type === 'change_tin' ? Object.entries(changeTinTargets).reduce((s, [, v]) => s + (v || 0), 0) @@ -53,12 +66,21 @@ export function FloatCountForm({ type }: { type: CountType }) { async function save() { setSaving(true) try { + const denominations = type === 'change_tin' + ? denoms + .filter(d => (denomQtys[d.value] ?? 0) > 0 || (bagQtys[d.value] ?? 0) > 0) + .map(d => { + const loose = denomQtys[d.value] ?? 0 + const bags = bagQtys[d.value] ?? 0 + return { denomination: d.value, quantity: loose, bag_quantity: bags, total: d.value * loose + (BAG_VALUES[d.value] ?? 0) * bags } + }) + : denoms.filter(d => (denomQtys[d.value] ?? 0) > 0).map(d => ({ + denomination: d.value, quantity: denomQtys[d.value] ?? 0, bag_quantity: 0, total: d.value * (denomQtys[d.value] ?? 0), + })) await api.post('/floats/save', { count_type: type, count_date: new Date().toISOString(), - denominations: denoms.filter(d => (denomQtys[d.value] ?? 0) > 0).map(d => ({ - denomination: d.value, quantity: denomQtys[d.value] ?? 0, total: d.value * (denomQtys[d.value] ?? 0), - })), + denominations, receipts: type === 'petty_cash' ? receipts.filter(r => r.amount) : [], total_counted: totalCounted, total_receipts: totalReceipts, @@ -68,6 +90,7 @@ export function FloatCountForm({ type }: { type: CountType }) { }) setMsg({ text: 'Count saved.', ok: true }) setDenomQtys({}) + setBagQtys({}) setReceipts([]) setNotes('') } catch (e: unknown) { @@ -93,25 +116,51 @@ export function FloatCountForm({ type }: { type: CountType }) {

DENOMINATIONS

- {denoms.map(d => { - const qty = denomQtys[d.value] ?? 0 - const target = type === 'change_tin' ? parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? '0')) : undefined - const rowTotal = d.value * qty - return ( -
- {d.label} - setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))} - placeholder="0" style={inpSt} /> - {target !== undefined && ( - - tgt {fmtGBP(target)} - - )} - {rowTotal > 0 ? fmtGBP(rowTotal) : '—'} + {type === 'change_tin' ? ( + <> +
+ + Bags + Loose + Total
- ) - })} + {denoms.map(d => { + const bags = bagQtys[d.value] ?? 0 + const loose = denomQtys[d.value] ?? 0 + const bagVal = BAG_VALUES[d.value] ?? 0 + const rowTotal = bagVal * bags + d.value * loose + return ( +
+ {d.label} +
+ setBagQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))} + placeholder="0" style={inpSt} /> +
={fmtGBP(bagVal)} ea
+
+ setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))} + placeholder="0" style={inpSt} /> + {rowTotal > 0 ? fmtGBP(rowTotal) : '—'} +
+ ) + })} + + ) : ( + denoms.map(d => { + const qty = denomQtys[d.value] ?? 0 + const rowTotal = d.value * qty + return ( +
+ {d.label} + setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))} + placeholder="0" style={inpSt} /> + {rowTotal > 0 ? fmtGBP(rowTotal) : '—'} +
+ ) + }) + )}
Total Counted {fmtGBP(totalCounted)} @@ -159,6 +208,48 @@ export function FloatCountForm({ type }: { type: CountType }) { )} + {type === 'change_tin' && Object.values(changeTinTargets).some(v => v > 0) && ( + +

CHANGE ORDER

+ + + + + + + + + + + {denoms.map(d => { + const bagVal = BAG_VALUES[d.value] ?? 0 + if (!bagVal) return null + const target = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0)) + if (target <= 0) return null + const targetBags = Math.round(target / bagVal) + const bags = bagQtys[d.value] ?? 0 + const loose = denomQtys[d.value] ?? 0 + const countedVal = bags * bagVal + loose * d.value + const needed = target - countedVal + const orderBags = needed > 0.005 ? Math.ceil(needed / bagVal) : 0 + return ( + + + + + + + ) + })} + +
DenomTargetCountedOrder
{d.label}{targetBags} bag{targetBags !== 1 ? 's' : ''} + {bags > 0 && `${bags}bg`}{bags > 0 && loose > 0 ? ' + ' : ''}{loose > 0 && `${loose}×`}{(bags === 0 && loose === 0) && '—'} + 0 ? 'var(--danger)' : '#16a34a' }}> + {orderBags > 0 ? `+${orderBags} bag${orderBags !== 1 ? 's' : ''}` : 'OK'} +
+
+ )} +