From ed9cce1d762ba5d90a9e9abed963349e1edb4cf0 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 7 Jul 2026 11:56:45 +0000 Subject: [PATCH] Cashup: safe count adjust flow, cash summary cross-tab, table denomination view Safe Count (new flow): - Replaces simple re-use of FloatCountForm with a dedicated 3-column table: Current in Safe | Adjustment | New Total - Loads the most recent safe_cash float count on mount to pre-populate Current - Adjustment inputs accept positive (deposit) or negative (uplift/exchange) values - New Total = Current + Adjustment per denomination; minimum clamped to 0 - Saves a new safe_cash float_count record via existing /floats/save endpoint Cash Summary (redesign): - Backend adds a per-date denomination query (by_date_denom) to the cash-summary endpoint in addition to the existing aggregate query - Frontend rewritten as a cross-tab: denominations down rows, dates across columns, row totals on the right, per-day cash totals in the footer - Status badges (draft/final) shown per column in a header row - "+ Add to Safe" button navigates to /safe passing the period's denomination totals as React Router state, pre-filling the adjustment column Float Management: - Denomination entry form switched from CSS grid divs to a proper matching the style of the print view (header row, qty input right-aligned, amount column, total in tfoot) - DetailRecord type and FloatRecordPrint component exported so SafeCount can reuse the existing print/view component for safe cash records Co-Authored-By: Claude Sonnet 4.6 --- backend/src/routes/reports.js | 12 +- frontend/src/pages/CashSummary.tsx | 184 ++++++++++++-------- frontend/src/pages/FloatManagement.tsx | 72 +++++--- frontend/src/pages/SafeCount.tsx | 227 ++++++++++++++++++++++++- 4 files changed, 392 insertions(+), 103 deletions(-) diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js index 21b9172..4511be0 100644 --- a/backend/src/routes/reports.js +++ b/backend/src/routes/reports.js @@ -211,7 +211,7 @@ export async function reportRoutes(app) { const { from, to } = req.query if (!from || !to) return reply.status(400).send({ error: 'from and to required' }) - const [denomResult, byDateResult] = await Promise.all([ + const [denomResult, byDateResult, byDateDenomResult] = await Promise.all([ pool.query( `SELECT d.denomination_value, ROUND(SUM(d.total_amount) / NULLIF(d.denomination_value, 0)) AS total_quantity, @@ -230,11 +230,21 @@ export async function reportRoutes(app) { ORDER BY session_date ASC`, [from, to] ), + pool.query( + `SELECT d.denomination_value, c.session_date, SUM(d.total_amount) AS total_amount + FROM denominations d + JOIN cash_ups c ON d.cash_up_id = c.id + WHERE c.session_date >= $1 AND c.session_date <= $2 AND d.count_type = 'takings' + GROUP BY d.denomination_value, c.session_date + ORDER BY d.denomination_value DESC, c.session_date ASC`, + [from, to] + ), ]) return { denominations: denomResult.rows, by_date: byDateResult.rows, + by_date_denom: byDateDenomResult.rows, period: { from, to }, } }) diff --git a/frontend/src/pages/CashSummary.tsx b/frontend/src/pages/CashSummary.tsx index 3986641..6a85dbc 100644 --- a/frontend/src/pages/CashSummary.tsx +++ b/frontend/src/pages/CashSummary.tsx @@ -1,22 +1,39 @@ import { useState } from 'react' +import { useNavigate } from 'react-router-dom' import { api } from '../api' import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { GBP_DENOMINATIONS, fmtGBP, today } from '../types' -interface DenomRow { denomination_value: string; total_quantity: string; total_value: string } -interface DayRow { session_date: string; status: 'draft' | 'final'; total_cash_counted: string; submitted_by: string | null } +interface DayRow { + session_date: string + status: 'draft' | 'final' + total_cash_counted: string + submitted_by: string | null +} +interface DenomDateRow { + denomination_value: string + session_date: string + total_amount: string +} interface SummaryResult { - denominations: DenomRow[] by_date: DayRow[] + by_date_denom: DenomDateRow[] period: { from: string; to: string } } -function fmtDate(d: string) { - return new Date(d.slice(0, 10) + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }) +function fmtDateShort(d: string) { + return new Date(d.slice(0, 10) + 'T12:00:00').toLocaleDateString('en-GB', { + weekday: 'short', day: '2-digit', month: 'short', + }) } export function CashSummary() { - const [from, setFrom] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 6); return d.toISOString().slice(0, 10) }) + const navigate = useNavigate() + const [from, setFrom] = useState(() => { + const d = new Date() + d.setDate(d.getDate() - 6) + return d.toISOString().slice(0, 10) + }) const [to, setTo] = useState(today) const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) @@ -34,11 +51,32 @@ export function CashSummary() { } } - const grandTotal = result?.denominations.reduce((s, r) => s + parseFloat(r.total_value), 0) ?? 0 + function addToSafe() { + if (!result) return + const adjustDenoms: Record = {} + result.by_date_denom.forEach(r => { + adjustDenoms[r.denomination_value] = + (adjustDenoms[r.denomination_value] || 0) + parseFloat(r.total_amount) + }) + navigate('/safe', { state: { adjustDenoms } }) + } + + // Cross-tab lookups: denomMap[date_str][denom_float] = amount + const denomMap: Record> = {} + const rowTotals: Record = {} + result?.by_date_denom.forEach(r => { + const date = r.session_date.slice(0, 10) + const dv = parseFloat(r.denomination_value) + const amt = parseFloat(r.total_amount) + if (!denomMap[date]) denomMap[date] = {} + denomMap[date][dv] = amt + rowTotals[dv] = (rowTotals[dv] || 0) + amt + }) + const grandTotal = Object.values(rowTotals).reduce((s, v) => s + v, 0) return ( -
- +
+
@@ -50,6 +88,11 @@ export function CashSummary() { setTo(e.target.value)} style={inpSt} />
{loading ? 'Loading…' : 'Generate'} + {result && result.by_date.length > 0 && ( + + + Add to Safe + + )}
{error && ( @@ -58,91 +101,86 @@ export function CashSummary() {
)} - {result && (<> - - {/* Daily breakdown */} - -

{result.period.from} → {result.period.to}

- {result.by_date.length === 0 ? ( -

No cash ups recorded in this period.

- ) : ( -
- - - - - - - - - - {result.by_date.map(row => ( - - - - - - - ))} - - - - - - - -
DateStatusCash CountedSubmitted By
{fmtDate(row.session_date)}{fmtGBP(row.total_cash_counted)}{row.submitted_by ?? '—'}
Period Total - {fmtGBP(result.by_date.reduce((s, r) => s + parseFloat(r.total_cash_counted), 0))} - -
- )} - - - {/* Denomination breakdown */} - {result.denominations.length > 0 && ( + {result && ( + result.by_date.length === 0 ? ( -

Denomination Breakdown

-

- Takings only (excludes float counts) +

+ No cash ups recorded in this period.

- + + ) : ( + +
- - - - + + + {result.by_date.map(d => ( + + ))} + + + + + {result.by_date.map(d => ( + + ))} + {GBP_DENOMINATIONS.map(d => { - const row = result.denominations.find(r => Math.abs(parseFloat(r.denomination_value) - d.value) < 0.001) - if (!row) return null + if (!rowTotals[d.value]) return null return ( - - - + + {result.by_date.map(row => { + const date = row.session_date.slice(0, 10) + const amt = denomMap[date]?.[d.value] || 0 + return ( + + ) + })} + ) })} - - - - + + + {result.by_date.map(row => ( + + ))} +
DenominationTotal QtyTotal Value
+ Status + + +
+ Denomination + + {fmtDateShort(d.session_date)} + Total
{d.label}{row.total_quantity}{fmtGBP(row.total_value)} + {d.label} + {amt > 0 ? fmtGBP(amt) : '—'} + {fmtGBP(rowTotals[d.value])} +
Grand Total{fmtGBP(grandTotal)}
+ Cash Total + + {fmtGBP(row.total_cash_counted)} + + {fmtGBP(grandTotal)} +
- )} - - )} + ) + )} ) } const lbl: React.CSSProperties = { fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' } const inpSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' } -const secSt: React.CSSProperties = { fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem' } -const thSt: React.CSSProperties = { padding: '0.5rem 0.5rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)' } +const th: React.CSSProperties = { padding: '0.45rem 0.5rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)' } +const td: React.CSSProperties = { padding: '0.4rem 0.5rem', textAlign: 'right' } diff --git a/frontend/src/pages/FloatManagement.tsx b/frontend/src/pages/FloatManagement.tsx index ba7eff0..6c79225 100644 --- a/frontend/src/pages/FloatManagement.tsx +++ b/frontend/src/pages/FloatManagement.tsx @@ -28,9 +28,9 @@ const BAG_VALUES: Record = { // ── Shared record detail + print view ───────────────────────────────────────── -type DetailRecord = FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] } +export type DetailRecord = FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] } -function FloatRecordPrint({ record, changeTinTargets, onClose, closeLabel = 'New Count' }: { +export function FloatRecordPrint({ record, changeTinTargets, onClose, closeLabel = 'New Count' }: { record: DetailRecord changeTinTargets: Record onClose: () => void @@ -324,30 +324,48 @@ export function FloatCountForm({ type }: { type: CountType }) { )} - -

DENOMINATIONS

- {denoms.map(d => { - const qty = denomQtys[d.value] ?? 0 - const uv = unitVal(d) - const rowTotal = uv * qty - const isBag = type === 'change_tin' && BAG_VALUES[d.value] !== undefined - return ( -
-
- {d.label} - {isBag &&
{fmtGBP(uv)}/bag
} -
- setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))} - placeholder="0" style={inpSt} /> - {rowTotal > 0 ? fmtGBP(rowTotal) : '—'} -
- ) - })} -
- Total Counted - {fmtGBP(totalCounted)} -
+ +

DENOMINATIONS

+ + + + + + + + + + {denoms.map(d => { + const qty = denomQtys[d.value] ?? 0 + const uv = unitVal(d) + const rowTotal = uv * qty + const isBag = type === 'change_tin' && BAG_VALUES[d.value] !== undefined + return ( + + + + + + ) + })} + + + + + + + +
Denomination{type === 'change_tin' ? 'Bags' : 'Qty'}Amount
+ {d.label} + {isBag &&
{fmtGBP(uv)}/bag
} +
+ setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))} + placeholder="0" + style={{ border: '1px solid var(--card-border)', borderRadius: '4px', padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '80px', textAlign: 'right' }} /> + + {rowTotal > 0 ? fmtGBP(rowTotal) : '—'} +
Total Counted{fmtGBP(totalCounted)}
{type === 'petty_cash' && ( @@ -601,5 +619,7 @@ const inpSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '4px', padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%', } +const fthL: React.CSSProperties = { padding: '0.4rem 0.75rem', textAlign: 'left', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem' } +const fthR: React.CSSProperties = { padding: '0.4rem 0.75rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem' } const thS: React.CSSProperties = { padding: '0.6rem 0.75rem', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem', textAlign: 'right' } const tdS: React.CSSProperties = { padding: '0.6rem 0.75rem' } diff --git a/frontend/src/pages/SafeCount.tsx b/frontend/src/pages/SafeCount.tsx index caa2fa1..ad00bcc 100644 --- a/frontend/src/pages/SafeCount.tsx +++ b/frontend/src/pages/SafeCount.tsx @@ -1,14 +1,235 @@ -import { Routes, Route, Navigate } from 'react-router-dom' -import { FloatCountForm, FloatHistory } from './FloatManagement' +import { useState, useEffect } from 'react' +import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom' +import { api } from '../api' +import { PageHeader, Card, Btn } from '../components/Layout' +import { GBP_DENOMINATIONS, fmtGBP } from '../types' +import type { FloatCount, FloatDenomination } from '../types' +import { FloatHistory, FloatRecordPrint } from './FloatManagement' +import type { DetailRecord } from './FloatManagement' + +function SafeCountAdjust() { + const navigate = useNavigate() + const location = useLocation() + const prefill = (location.state as { adjustDenoms?: Record } | null)?.adjustDenoms + + const [current, setCurrent] = useState>({}) + const [lastCount, setLastCount] = useState(null) + const [loading, setLoading] = useState(true) + const [adjust, setAdjust] = useState>(() => { + if (!prefill) return {} + const pf: Record = {} + Object.entries(prefill).forEach(([k, v]) => { pf[parseFloat(k)] = v }) + return pf + }) + const [notes, setNotes] = useState('') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [savedRecord, setSavedRecord] = useState(null) + const [reloadKey, setReloadKey] = useState(0) + + useEffect(() => { + setLoading(true) + setCurrent({}) + setLastCount(null) + api.get<{ rows: FloatCount[]; total: number }>('/floats?type=safe_cash&offset=0&limit=1') + .then(async d => { + if (d.rows.length > 0) { + const last = d.rows[0] + setLastCount(last) + const detail = await api.get(`/floats/${last.id}`) + const curr: Record = {} + detail.denominations.forEach((dn: FloatDenomination) => { + curr[parseFloat(dn.denomination_value)] = parseFloat(String(dn.total_amount)) + }) + setCurrent(curr) + } + }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, [reloadKey]) + + // Derived + const newTotals: Record = {} + GBP_DENOMINATIONS.forEach(d => { + newTotals[d.value] = Math.max(0, (current[d.value] || 0) + (adjust[d.value] || 0)) + }) + const prevTotal = GBP_DENOMINATIONS.reduce((s, d) => s + (current[d.value] || 0), 0) + const newTotal = GBP_DENOMINATIONS.reduce((s, d) => s + newTotals[d.value], 0) + const adjustTotal = GBP_DENOMINATIONS.reduce((s, d) => s + (adjust[d.value] || 0), 0) + + async function save() { + setSaving(true); setError(null) + try { + const denominations = GBP_DENOMINATIONS + .filter(d => newTotals[d.value] > 0) + .map(d => ({ + denomination: d.value, + quantity: Math.round(newTotals[d.value] / d.value), + bag_quantity: 0, + total: newTotals[d.value], + })) + const res = await api.post<{ count_id: number }>('/floats/save', { + count_type: 'safe_cash', + count_date: new Date().toISOString(), + denominations, + total_counted: newTotal, + total_receipts: 0, + target_amount: 0, + variance: 0, + notes, + }) + const detail = await api.get(`/floats/${res.count_id}`) + setSavedRecord(detail) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Save failed.') + } finally { + setSaving(false) + } + } + + if (savedRecord) { + return ( +
+ + { + setSavedRecord(null) + setAdjust({}) + setNotes('') + setReloadKey(k => k + 1) + }} + closeLabel="New Adjustment" + /> +
+ ) + } + + const lastCountDate = lastCount + ? new Date(lastCount.count_date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + : null + + return ( +
+ + + {error && ( +
+ {error} +
+ )} + + {prefill && ( +
+ Adjustment pre-filled from cash summary takings. Review and adjust as needed before saving. +
+ )} + + + + + + + + + + + + + {GBP_DENOMINATIONS.map(d => { + const curr = current[d.value] || 0 + const adj = adjust[d.value] || 0 + const newT = newTotals[d.value] + return ( + + + + + + + ) + })} + + + + + + + + + +
DenominationCurrent in SafeAdjustmentNew Total
{d.label} + {curr > 0 ? fmtGBP(curr) : '—'} + + { + const v = e.target.value === '' ? 0 : parseFloat(e.target.value) || 0 + setAdjust(prev => ({ ...prev, [d.value]: v })) + }} + placeholder="0.00" + style={{ + border: '1px solid var(--card-border)', borderRadius: '4px', + padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '110px', + textAlign: 'right', + color: adj < 0 ? 'var(--danger)' : adj > 0 ? 'var(--success)' : undefined, + }} + /> + 0 || adj < 0) ? 'var(--danger)' : undefined, + }}> + {newT > 0 ? fmtGBP(newT) : (curr > 0 || adj !== 0) ? fmtGBP(0) : '—'} +
Total + {fmtGBP(prevTotal)} + 0 ? 'var(--success)' : 'var(--text-mid)' }}> + {adjustTotal !== 0 + ? (adjustTotal > 0 ? '+' : '-') + fmtGBP(Math.abs(adjustTotal)) + : '—'} + + {fmtGBP(newTotal)} +
+
+ + + +