diff --git a/backend/src/index.js b/backend/src/index.js index 6fba57a..aa2df17 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -13,7 +13,7 @@ import { floatRoutes } from './routes/floats.js' import { settingsRoutes } from './routes/settings.js' const __dirname = dirname(fileURLToPath(import.meta.url)) -const UPLOADS_DIR = join(__dirname, '..', '..', 'uploads') +const UPLOADS_DIR = join(__dirname, '..', 'uploads') const app = Fastify({ logger: true, trustProxy: true }) diff --git a/frontend/src/pages/FloatManagement.tsx b/frontend/src/pages/FloatManagement.tsx index 62a7028..3da201a 100644 --- a/frontend/src/pages/FloatManagement.tsx +++ b/frontend/src/pages/FloatManagement.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { Routes, Route, NavLink, Navigate, useNavigate } from 'react-router-dom' import { api } from '../api' import { PageHeader, Card, Btn } from '../components/Layout' @@ -13,34 +13,219 @@ const TYPE_LABELS: Record = { safe_cash: 'Safe Cash', } -// Denominations relevant for each type (change_tin excludes 1p/2p) +// Change tin counts all denoms from 5p upward (notes + coins, no 1p/2p) const CHANGE_TIN_DENOMS = GBP_DENOMINATIONS.filter(d => d.value >= 0.05) -// UK standard bag values (£ per sealed bag of each denomination) +// UK standard sealed bag values per coin 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 + 0.05: 5, + 0.10: 5, + 0.20: 10, + 0.50: 10, + 1.00: 20, + 2.00: 20, } +// ── Shared record detail + print view ───────────────────────────────────────── + +type DetailRecord = FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] } + +function FloatRecordPrint({ record, changeTinTargets, onClose, closeLabel = 'New Count' }: { + record: DetailRecord + changeTinTargets: Record + onClose: () => void + closeLabel?: string +}) { + const printRef = useRef(null) + const isPetty = record.count_type === 'petty_cash' + const isTin = record.count_type === 'change_tin' + const cash = parseFloat(record.total_counted) + const receipts = parseFloat(record.total_receipts) + const combined = cash + receipts + const variance = parseFloat(record.variance) + const target = parseFloat(record.target_amount) + + function doPrint() { + const content = printRef.current?.innerHTML ?? '' + const win = window.open('', '_blank', 'width=700,height=900') + if (!win) return + win.document.write(`${TYPE_LABELS[record.count_type as CountType]} Count + +${content}`) + win.document.close() + win.focus() + win.print() + } + + // Build exchange table for change tin: surplus → "to bank", shortfall → "in exchange" + const exchangeRows = isTin ? CHANGE_TIN_DENOMS.map(d => { + const tgt = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0)) + if (tgt <= 0) return null + const denom = record.denominations.find(x => Math.abs(parseFloat(x.denomination_value) - d.value) < 0.001) + const counted = parseFloat(denom?.total_amount ?? '0') + const diff = counted - tgt + if (Math.abs(diff) < 0.005) return null + return { + label: d.label, + toBank: diff > 0 ? diff : 0, + required: diff < 0 ? Math.abs(diff) : 0, + } + }).filter(Boolean) : [] + + const totalToBank = exchangeRows.reduce((s, r) => s + (r?.toBank ?? 0), 0) + const totalRequired = exchangeRows.reduce((s, r) => s + (r?.required ?? 0), 0) + const varClass = Math.abs(variance) < 0.01 ? 'ok' : variance > 0 ? 'over' : 'short' + + return ( +
+
+ Print + {closeLabel} +
+ +
+

{TYPE_LABELS[record.count_type as CountType]} Count

+

+ {new Date(record.count_date).toLocaleString('en-GB', { dateStyle: 'full', timeStyle: 'short' })} + {record.created_by && ` · ${record.created_by}`} +

+ +

DENOMINATION COUNT

+ + + + + + + + + + {record.denominations.map(d => ( + + + + + + ))} + + + + + +
DenominationQtyAmount
{fmtGBP(d.denomination_value)}×{d.quantity}{fmtGBP(d.total_amount)}
Total Cash{fmtGBP(cash)}
+ + {isPetty && record.receipts.length > 0 && ( + <> +

RECEIPTS

+ + + + {record.receipts.map(r => ( + + + + + ))} + + + + + +
DescriptionAmount
{r.receipt_description || '—'}{fmtGBP(r.receipt_value)}
Total Receipts{fmtGBP(receipts)}
+ + )} + + {(isPetty || isTin) && ( + + + {isPetty && ( + + )} + + + + + + +
Cash + Receipts{fmtGBP(combined)}
Target{fmtGBP(target)}
Variance + {Math.abs(variance) < 0.01 ? '£0.00' : (variance > 0 ? '+' : '') + fmtGBP(Math.abs(variance))} +
+ )} + + {isTin && exchangeRows.length > 0 && ( + <> +

BANK EXCHANGE ORDER

+

Present this slip to the bank. Total taken to bank must equal total required in exchange.

+ + + + + + + + + + {exchangeRows.map(r => r && ( + + + + + + ))} + + + + + + +
DenominationValue Taken to BankRequired in Exchange
{r.label}{r.toBank > 0 ? fmtGBP(r.toBank) : '—'}{r.required > 0 ? fmtGBP(r.required) : '—'}
Total{totalToBank > 0 ? fmtGBP(totalToBank) : '—'}{totalRequired > 0 ? fmtGBP(totalRequired) : '—'}
+ + )} + + {record.notes &&

Notes: {record.notes}

} + +
+
Counted by
+
Checked by
+
Date
+
+
+
+ ) +} + +// ── Count form ───────────────────────────────────────────────────────────────── + 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) - const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null) - - const denoms = type === 'change_tin' ? CHANGE_TIN_DENOMS : GBP_DENOMINATIONS - - // Load settings for change tin targets + const [error, setError] = useState(null) + const [savedRecord, setSavedRecord] = useState(null) const [changeTinTargets, setChangeTinTargets] = useState>({}) const [pettyTarget, setPettyTarget] = useState(200) + const denoms = type === 'change_tin' ? CHANGE_TIN_DENOMS : GBP_DENOMINATIONS + useEffect(() => { api.get>('/settings').then(s => { if (type === 'change_tin') { @@ -52,12 +237,7 @@ export function FloatCountForm({ type }: { type: CountType }) { }).catch(() => {}) }, [type]) - const totalCounted = type === 'change_tin' - ? denoms.reduce((s, d) => { - const bagVal = BAG_VALUES[d.value] ?? 0 - return s + d.value * (denomQtys[d.value] ?? 0) + bagVal * (bagQtys[d.value] ?? 0) - }, 0) - : denoms.reduce((s, d) => s + d.value * (denomQtys[d.value] ?? 0), 0) + const totalCounted = 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) @@ -68,19 +248,13 @@ export function FloatCountForm({ type }: { type: CountType }) { async function save() { setSaving(true) + setError(null) 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', { + const denominations = 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), + })) + const result = await api.post<{ count_id: number }>('/floats/save', { count_type: type, count_date: new Date().toISOString(), denominations, @@ -91,91 +265,60 @@ export function FloatCountForm({ type }: { type: CountType }) { variance, notes, }) - setMsg({ text: 'Count saved.', ok: true }) - setDenomQtys({}) - setBagQtys({}) - setReceipts([]) - setNotes('') + const detail = await api.get(`/floats/${result.count_id}`) + setSavedRecord(detail) } catch (e: unknown) { - setMsg({ text: e instanceof Error ? e.message : 'Save failed.', ok: false }) + setError(e instanceof Error ? e.message : 'Save failed.') } finally { setSaving(false) } } + function reset() { + setSavedRecord(null) + setDenomQtys({}) + setReceipts([]) + setNotes('') + } + + if (savedRecord) { + return ( +
+ + +
+ ) + } + return ( -
+
- {msg && ( + {error && (
- {msg.text} + {error}
)}

DENOMINATIONS

- {type === 'change_tin' ? ( - <> -
- - Bags / Qty - Loose - Total + {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) : '—'}
- {denoms.map(d => { - const isNote = !BAG_VALUES[d.value] - const qty = denomQtys[d.value] ?? 0 - const bags = bagQtys[d.value] ?? 0 - const bagVal = BAG_VALUES[d.value] ?? 0 - const rowTotal = isNote ? d.value * qty : bagVal * bags + d.value * (denomQtys[d.value] ?? 0) - return ( -
- {d.label} - {isNote ? ( - <> - setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))} - placeholder="0 notes" style={inpSt} /> - - - ) : ( - <> -
- 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)} @@ -210,8 +353,14 @@ export function FloatCountForm({ type }: { type: CountType }) { {type !== 'safe_cash' && ( + {type === 'petty_cash' && ( +
+ Cash + Receipts + {fmtGBP(totalCounted + totalReceipts)} +
+ )}
- Target Amount + Target {fmtGBP(targetAmount)}
@@ -223,53 +372,51 @@ export function FloatCountForm({ type }: { type: CountType }) { )} - {type === 'change_tin' && Object.values(changeTinTargets).some(v => v > 0) && ( - -

CHANGE ORDER

- - - - - - - - - - - {denoms.map(d => { - const isNote = !BAG_VALUES[d.value] - const bagVal = BAG_VALUES[d.value] ?? 0 - const unitVal = isNote ? d.value : bagVal - const target = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0)) - if (target <= 0) return null - const targetUnits = unitVal > 0 ? Math.round(target / unitVal) : 0 - const bags = bagQtys[d.value] ?? 0 - const looseOrNotes = denomQtys[d.value] ?? 0 - const countedVal = isNote ? d.value * looseOrNotes : bagVal * bags + d.value * looseOrNotes - const needed = target - countedVal - const orderUnits = needed > 0.005 ? Math.ceil(needed / unitVal) : 0 - const unitLabel = isNote ? 'note' : 'bag' - return ( - - + {type === 'change_tin' && Object.values(changeTinTargets).some(v => v > 0) && (() => { + const orderRows = CHANGE_TIN_DENOMS.map(d => { + const bagVal = BAG_VALUES[d.value] ?? 0 + const unitVal = bagVal > 0 ? bagVal : d.value + const unitLabel = bagVal > 0 ? 'bag' : 'note' + const target = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0)) + if (target <= 0) return null + const targetUnits = unitVal > 0 ? Math.round(target / unitVal) : 0 + const counted = d.value * (denomQtys[d.value] ?? 0) + const needed = target - counted + const orderUnits = needed > 0.005 ? Math.ceil(needed / unitVal) : 0 + return { d, target, targetUnits, counted, orderUnits, unitLabel } + }).filter(Boolean) + if (!orderRows.length) return null + return ( + +

CHANGE ORDER

+
DenomTargetCountedOrder
{d.label}
+ + + {['Denom', 'Target', 'Counted', 'Order'].map(h => ( + + ))} + + + + {orderRows.map(r => r && ( + + - - ) - })} - -
{h}
{r.d.label} - {targetUnits} {unitLabel}{targetUnits !== 1 ? 's' : ''} + {r.targetUnits} {r.unitLabel}{r.targetUnits !== 1 ? 's' : ''} - {isNote - ? (looseOrNotes > 0 ? `${looseOrNotes}` : '—') - : (bags > 0 ? `${bags}bg` : '') + (bags > 0 && looseOrNotes > 0 ? ' + ' : '') + (looseOrNotes > 0 ? `${looseOrNotes}×` : '') + (bags === 0 && looseOrNotes === 0 ? '—' : '')} + {r.counted > 0 ? fmtGBP(r.counted) : '—'} 0 ? 'var(--danger)' : '#16a34a' }}> - {orderUnits > 0 ? `+${orderUnits} ${unitLabel}${orderUnits !== 1 ? 's' : ''}` : 'OK'} + 0 ? 'var(--danger)' : '#16a34a' }}> + {r.orderUnits > 0 ? `+${r.orderUnits} ${r.unitLabel}${r.orderUnits !== 1 ? 's' : ''}` : 'OK'}
-
- )} + ))} + + + + ) + })()} @@ -285,13 +432,24 @@ export function FloatCountForm({ type }: { type: CountType }) { ) } +// ── History list + detail ────────────────────────────────────────────────────── + export function FloatHistory({ type }: { type: CountType }) { const [rows, setRows] = useState([]) const [total, setTotal] = useState(0) const [offset, setOffset] = useState(0) - const [detail, setDetail] = useState<(FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }) | null>(null) + const [detail, setDetail] = useState(null) + const [changeTinTargets, setChangeTinTargets] = useState>({}) const limit = 10 + useEffect(() => { + if (type === 'change_tin') { + api.get>('/settings').then(s => { + try { setChangeTinTargets(JSON.parse(s.change_tin_breakdown || '{}')) } catch {} + }).catch(() => {}) + } + }, [type]) + useEffect(() => { api.get<{ rows: FloatCount[]; total: number }>(`/floats?type=${type}&offset=${offset}&limit=${limit}`) .then(d => { setRows(d.rows); setTotal(d.total) }) @@ -299,55 +457,23 @@ export function FloatHistory({ type }: { type: CountType }) { }, [type, offset]) async function loadDetail(id: number) { - const d = await api.get(`/floats/${id}`) + const d = await api.get(`/floats/${id}`) setDetail(d) } + const isPetty = type === 'petty_cash' + return (
{detail ? ( -
-

{new Date(detail.count_date).toLocaleString('en-GB')}

- setDetail(null)}>Back -
-
- Total: {fmtGBP(detail.total_counted)} - {detail.count_type !== 'safe_cash' && Variance: {fmtGBP(detail.variance)}} - {detail.count_type === 'petty_cash' && Receipts: {fmtGBP(detail.total_receipts)}} -
- - - {detail.denominations.map(d => { - const bags = d.bag_quantity ?? 0 - return ( - - - - - {d.target !== undefined && d.target > 0 && ( - - )} - - ) - })} - -
{fmtGBP(d.denomination_value)} - {bags > 0 ? `${bags} bag${bags !== 1 ? 's' : ''}` : ''}{bags > 0 && d.quantity > 0 ? ' + ' : ''}{d.quantity > 0 ? `×${d.quantity}` : ''}{bags === 0 && d.quantity === 0 ? '—' : ''} - {fmtGBP(d.total_amount)}tgt {fmtGBP(d.target)}
- {detail.receipts.length > 0 && ( - <> -

RECEIPTS

- {detail.receipts.map(r => ( -
- {r.receipt_description || '—'} - {fmtGBP(r.receipt_value)} -
- ))} - - )} - {detail.notes &&

{detail.notes}

} + setDetail(null)} + closeLabel="Back to History" + />
) : ( <> @@ -359,24 +485,34 @@ export function FloatHistory({ type }: { type: CountType }) { Date / Time + {isPetty && Cash} + {isPetty && Receipts} Total {type !== 'safe_cash' && Variance} - {rows.map(row => ( - - {new Date(row.count_date).toLocaleString('en-GB')} - {fmtGBP(row.total_counted)} - {type !== 'safe_cash' && ( - 0 ? 'var(--success)' : 'var(--danger)' }}> - {Math.abs(parseFloat(row.variance)) < 0.01 ? '£0.00' : (parseFloat(row.variance) > 0 ? '+' : '') + fmtGBP(Math.abs(parseFloat(row.variance)))} - - )} - loadDetail(row.id)}>View - - ))} + {rows.map(row => { + const cash = parseFloat(row.total_counted) + const rec = parseFloat(row.total_receipts) + const combined = cash + rec + const v = parseFloat(row.variance) + return ( + + {new Date(row.count_date).toLocaleString('en-GB')} + {isPetty && {fmtGBP(cash)}} + {isPetty && {rec > 0 ? fmtGBP(rec) : '—'}} + {fmtGBP(isPetty ? combined : cash)} + {type !== 'safe_cash' && ( + 0 ? 'var(--success)' : 'var(--danger)' }}> + {Math.abs(v) < 0.01 ? '£0.00' : (v > 0 ? '+' : '') + fmtGBP(Math.abs(v))} + + )} + loadDetail(row.id)}>View + + ) + })} @@ -394,6 +530,8 @@ export function FloatHistory({ type }: { type: CountType }) { ) } +// ── Shell ────────────────────────────────────────────────────────────────────── + export function FloatManagement() { const tabs: Array<{ path: string; label: string; type: CountType }> = [ { path: 'petty-cash', label: 'Petty Cash', type: 'petty_cash' },