Float management: save→view/print, history columns, uploads path fix

- Change tin count: remove bags/loose split — single qty per denomination
- After save: show saved record inline with Print button (clean new-window
  printout with denomination table, receipts, totals/variance, signature lines)
- Change tin print: bank exchange order table (surplus=to bank, shortfall=in exchange)
- Petty cash history: Cash | Receipts | Total | Variance columns
- Fix uploads path: /app/src/../../uploads resolved to /uploads (container root,
  outside Docker volume). Changed to /app/src/../uploads = /app/uploads so files
  persist across rebuilds on the named volume.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 20:15:50 +00:00
parent 6c805ce5a7
commit 8193b05acc
2 changed files with 335 additions and 197 deletions

View file

@ -13,7 +13,7 @@ import { floatRoutes } from './routes/floats.js'
import { settingsRoutes } from './routes/settings.js' import { settingsRoutes } from './routes/settings.js'
const __dirname = dirname(fileURLToPath(import.meta.url)) 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 }) const app = Fastify({ logger: true, trustProxy: true })

View file

@ -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 { Routes, Route, NavLink, Navigate, useNavigate } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { PageHeader, Card, Btn } from '../components/Layout' import { PageHeader, Card, Btn } from '../components/Layout'
@ -13,34 +13,219 @@ const TYPE_LABELS: Record<CountType, string> = {
safe_cash: 'Safe Cash', 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) 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<number, number> = { const BAG_VALUES: Record<number, number> = {
0.05: 5, // 100 × 5p 0.05: 5,
0.10: 5, // 50 × 10p 0.10: 5,
0.20: 10, // 50 × 20p 0.20: 10,
0.50: 10, // 20 × 50p 0.50: 10,
1.00: 20, // 20 × £1 1.00: 20,
2.00: 20, // 10 × £2 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<string, number>
onClose: () => void
closeLabel?: string
}) {
const printRef = useRef<HTMLDivElement>(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(`<!DOCTYPE html><html><head><title>${TYPE_LABELS[record.count_type as CountType]} Count</title>
<style>
body { font-family: Arial, sans-serif; font-size: 13px; margin: 24px; color: #000; }
h1 { font-size: 18px; margin: 0 0 4px; }
h2 { font-size: 13px; font-weight: 700; margin: 16px 0 6px; border-bottom: 1px solid #000; padding-bottom: 2px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
th, td { padding: 4px 8px; text-align: left; border-bottom: 1px solid #ddd; }
th { font-weight: 700; background: #f5f5f5; }
.right { text-align: right; }
.total-row { font-weight: 700; border-top: 2px solid #000; }
.variance { font-weight: 700; font-size: 14px; }
.variance.ok { color: #16a34a; }
.variance.over { color: #16a34a; }
.variance.short { color: #dc2626; }
.sig { margin-top: 32px; display: flex; gap: 48px; }
.sig-line { flex: 1; border-top: 1px solid #000; padding-top: 4px; font-size: 11px; color: #666; }
.subtitle { color: #666; font-size: 12px; margin: 0 0 12px; }
.exchange-note { font-size: 11px; color: #666; margin: 6px 0; }
</style>
</head><body>${content}</body></html>`)
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 (
<div>
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
<Btn onClick={doPrint}>Print</Btn>
<Btn variant="ghost" onClick={onClose}>{closeLabel}</Btn>
</div>
<div ref={printRef}>
<h1>{TYPE_LABELS[record.count_type as CountType]} Count</h1>
<p className="subtitle">
{new Date(record.count_date).toLocaleString('en-GB', { dateStyle: 'full', timeStyle: 'short' })}
{record.created_by && ` · ${record.created_by}`}
</p>
<h2>DENOMINATION COUNT</h2>
<table>
<thead>
<tr>
<th>Denomination</th>
<th className="right">Qty</th>
<th className="right">Amount</th>
</tr>
</thead>
<tbody>
{record.denominations.map(d => (
<tr key={String(d.denomination_value)}>
<td>{fmtGBP(d.denomination_value)}</td>
<td className="right">×{d.quantity}</td>
<td className="right">{fmtGBP(d.total_amount)}</td>
</tr>
))}
<tr className="total-row">
<td colSpan={2}>Total Cash</td>
<td className="right">{fmtGBP(cash)}</td>
</tr>
</tbody>
</table>
{isPetty && record.receipts.length > 0 && (
<>
<h2>RECEIPTS</h2>
<table>
<thead><tr><th>Description</th><th className="right">Amount</th></tr></thead>
<tbody>
{record.receipts.map(r => (
<tr key={r.id}>
<td>{r.receipt_description || '—'}</td>
<td className="right">{fmtGBP(r.receipt_value)}</td>
</tr>
))}
<tr className="total-row">
<td>Total Receipts</td>
<td className="right">{fmtGBP(receipts)}</td>
</tr>
</tbody>
</table>
</>
)}
{(isPetty || isTin) && (
<table>
<tbody>
{isPetty && (
<tr><td>Cash + Receipts</td><td className="right">{fmtGBP(combined)}</td></tr>
)}
<tr><td>Target</td><td className="right">{fmtGBP(target)}</td></tr>
<tr className="total-row">
<td>Variance</td>
<td className={`right variance ${varClass}`}>
{Math.abs(variance) < 0.01 ? '£0.00' : (variance > 0 ? '+' : '') + fmtGBP(Math.abs(variance))}
</td>
</tr>
</tbody>
</table>
)}
{isTin && exchangeRows.length > 0 && (
<>
<h2>BANK EXCHANGE ORDER</h2>
<p className="exchange-note">Present this slip to the bank. Total taken to bank must equal total required in exchange.</p>
<table>
<thead>
<tr>
<th>Denomination</th>
<th className="right">Value Taken to Bank</th>
<th className="right">Required in Exchange</th>
</tr>
</thead>
<tbody>
{exchangeRows.map(r => r && (
<tr key={r.label}>
<td>{r.label}</td>
<td className="right">{r.toBank > 0 ? fmtGBP(r.toBank) : '—'}</td>
<td className="right">{r.required > 0 ? fmtGBP(r.required) : '—'}</td>
</tr>
))}
<tr className="total-row">
<td>Total</td>
<td className="right">{totalToBank > 0 ? fmtGBP(totalToBank) : '—'}</td>
<td className="right">{totalRequired > 0 ? fmtGBP(totalRequired) : '—'}</td>
</tr>
</tbody>
</table>
</>
)}
{record.notes && <p style={{ marginTop: '12px', fontStyle: 'italic' }}>Notes: {record.notes}</p>}
<div className="sig">
<div className="sig-line">Counted by</div>
<div className="sig-line">Checked by</div>
<div className="sig-line">Date</div>
</div>
</div>
</div>
)
}
// ── Count form ─────────────────────────────────────────────────────────────────
export function FloatCountForm({ type }: { type: CountType }) { export function FloatCountForm({ type }: { type: CountType }) {
const navigate = useNavigate() const navigate = useNavigate()
const [denomQtys, setDenomQtys] = useState<Record<number, number>>({}) const [denomQtys, setDenomQtys] = useState<Record<number, number>>({})
const [bagQtys, setBagQtys] = useState<Record<number, number>>({})
const [receipts, setReceipts] = useState<Array<{ amount: string; description: string }>>([]) const [receipts, setReceipts] = useState<Array<{ amount: string; description: string }>>([])
const [notes, setNotes] = useState('') const [notes, setNotes] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null) const [error, setError] = useState<string | null>(null)
const [savedRecord, setSavedRecord] = useState<DetailRecord | null>(null)
const denoms = type === 'change_tin' ? CHANGE_TIN_DENOMS : GBP_DENOMINATIONS
// Load settings for change tin targets
const [changeTinTargets, setChangeTinTargets] = useState<Record<string, number>>({}) const [changeTinTargets, setChangeTinTargets] = useState<Record<string, number>>({})
const [pettyTarget, setPettyTarget] = useState(200) const [pettyTarget, setPettyTarget] = useState(200)
const denoms = type === 'change_tin' ? CHANGE_TIN_DENOMS : GBP_DENOMINATIONS
useEffect(() => { useEffect(() => {
api.get<Record<string, string>>('/settings').then(s => { api.get<Record<string, string>>('/settings').then(s => {
if (type === 'change_tin') { if (type === 'change_tin') {
@ -52,12 +237,7 @@ export function FloatCountForm({ type }: { type: CountType }) {
}).catch(() => {}) }).catch(() => {})
}, [type]) }, [type])
const totalCounted = type === 'change_tin' const totalCounted = denoms.reduce((s, d) => s + d.value * (denomQtys[d.value] ?? 0), 0)
? 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 totalReceipts = receipts.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0) const totalReceipts = receipts.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0)
const targetAmount = type === 'petty_cash' ? pettyTarget : type === 'change_tin' const targetAmount = type === 'petty_cash' ? pettyTarget : type === 'change_tin'
? Object.entries(changeTinTargets).reduce((s, [, v]) => s + (v || 0), 0) ? Object.entries(changeTinTargets).reduce((s, [, v]) => s + (v || 0), 0)
@ -68,19 +248,13 @@ export function FloatCountForm({ type }: { type: CountType }) {
async function save() { async function save() {
setSaving(true) setSaving(true)
setError(null)
try { try {
const denominations = type === 'change_tin' const denominations = denoms.filter(d => (denomQtys[d.value] ?? 0) > 0).map(d => ({
? denoms denomination: d.value, quantity: denomQtys[d.value] ?? 0, bag_quantity: 0,
.filter(d => (denomQtys[d.value] ?? 0) > 0 || (bagQtys[d.value] ?? 0) > 0) total: d.value * (denomQtys[d.value] ?? 0),
.map(d => { }))
const loose = denomQtys[d.value] ?? 0 const result = await api.post<{ count_id: number }>('/floats/save', {
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_type: type,
count_date: new Date().toISOString(), count_date: new Date().toISOString(),
denominations, denominations,
@ -91,91 +265,60 @@ export function FloatCountForm({ type }: { type: CountType }) {
variance, variance,
notes, notes,
}) })
setMsg({ text: 'Count saved.', ok: true }) const detail = await api.get<DetailRecord>(`/floats/${result.count_id}`)
setDenomQtys({}) setSavedRecord(detail)
setBagQtys({})
setReceipts([])
setNotes('')
} catch (e: unknown) { } catch (e: unknown) {
setMsg({ text: e instanceof Error ? e.message : 'Save failed.', ok: false }) setError(e instanceof Error ? e.message : 'Save failed.')
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
function reset() {
setSavedRecord(null)
setDenomQtys({})
setReceipts([])
setNotes('')
}
if (savedRecord) {
return (
<div style={{ maxWidth: '680px' }}>
<PageHeader title={`${TYPE_LABELS[type]} — Saved`} />
<FloatRecordPrint record={savedRecord} changeTinTargets={changeTinTargets} onClose={reset} />
</div>
)
}
return ( return (
<div style={{ maxWidth: '640px' }}> <div style={{ maxWidth: '600px' }}>
<PageHeader title={TYPE_LABELS[type]} subtitle="Enter denomination counts" /> <PageHeader title={TYPE_LABELS[type]} subtitle="Enter denomination counts" />
{msg && ( {error && (
<div style={{ <div style={{
background: msg.ok ? '#dcfce7' : '#fee2e2', color: msg.ok ? '#16a34a' : 'var(--danger)', background: '#fee2e2', color: 'var(--danger)',
border: `1px solid ${msg.ok ? '#bbf7d0' : '#fecaca'}`, borderRadius: '6px', border: '1px solid #fecaca', borderRadius: '6px',
padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem', padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
}}> }}>
{msg.text} {error}
</div> </div>
)} )}
<Card style={{ marginBottom: '1rem' }}> <Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>DENOMINATIONS</h2> <h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>DENOMINATIONS</h2>
{type === 'change_tin' ? ( {denoms.map(d => {
<> const qty = denomQtys[d.value] ?? 0
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 90px', gap: '0.4rem 0.5rem', alignItems: 'center', marginBottom: '0.35rem' }}> const rowTotal = d.value * qty
<span /> return (
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'center' }}>Bags / Qty</span> <div key={d.value} style={{ display: 'grid', gridTemplateColumns: '64px 1fr 90px', gap: '0.4rem 0.75rem', alignItems: 'center', marginBottom: '0.35rem' }}>
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'center' }}>Loose</span> <span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'right' }}>Total</span> <input type="number" min="0" step="1" value={qty || ''}
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
placeholder="0" style={inpSt} />
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
</div> </div>
{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 (
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 90px', gap: '0.4rem 0.5rem', alignItems: 'center', marginBottom: '0.35rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span>
{isNote ? (
<>
<input type="number" min="0" step="1" value={qty || ''}
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
placeholder="0 notes" style={inpSt} />
<span />
</>
) : (
<>
<div>
<input type="number" min="0" step="1" value={bags || ''}
onChange={e => setBagQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
placeholder="0" style={inpSt} />
<div style={{ fontSize: '0.65rem', color: 'var(--text-mid)', textAlign: 'center', marginTop: '0.1rem' }}>={fmtGBP(bagVal)} ea</div>
</div>
<input type="number" min="0" step="1" value={(denomQtys[d.value] ?? 0) || ''}
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
placeholder="0" style={inpSt} />
</>
)}
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
</div>
)
})}
</>
) : (
denoms.map(d => {
const qty = denomQtys[d.value] ?? 0
const rowTotal = d.value * qty
return (
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '60px 1fr 80px', gap: '0.4rem 0.75rem', alignItems: 'center', marginBottom: '0.35rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span>
<input type="number" min="0" step="1" value={qty || ''}
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))}
placeholder="0" style={inpSt} />
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span>
</div>
)
})
)}
<div style={{ borderTop: '2px solid var(--card-border)', paddingTop: '0.75rem', marginTop: '0.5rem', display: 'flex', justifyContent: 'space-between', fontWeight: 700 }}> <div style={{ borderTop: '2px solid var(--card-border)', paddingTop: '0.75rem', marginTop: '0.5rem', display: 'flex', justifyContent: 'space-between', fontWeight: 700 }}>
<span>Total Counted</span> <span>Total Counted</span>
<span>{fmtGBP(totalCounted)}</span> <span>{fmtGBP(totalCounted)}</span>
@ -210,8 +353,14 @@ export function FloatCountForm({ type }: { type: CountType }) {
{type !== 'safe_cash' && ( {type !== 'safe_cash' && (
<Card style={{ marginBottom: '1rem' }}> <Card style={{ marginBottom: '1rem' }}>
{type === 'petty_cash' && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', marginBottom: '0.4rem' }}>
<span style={{ color: 'var(--text-mid)' }}>Cash + Receipts</span>
<span>{fmtGBP(totalCounted + totalReceipts)}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', marginBottom: '0.4rem' }}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', marginBottom: '0.4rem' }}>
<span style={{ color: 'var(--text-mid)' }}>Target Amount</span> <span style={{ color: 'var(--text-mid)' }}>Target</span>
<span>{fmtGBP(targetAmount)}</span> <span>{fmtGBP(targetAmount)}</span>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', fontWeight: 700 }}> <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', fontWeight: 700 }}>
@ -223,53 +372,51 @@ export function FloatCountForm({ type }: { type: CountType }) {
</Card> </Card>
)} )}
{type === 'change_tin' && Object.values(changeTinTargets).some(v => v > 0) && ( {type === 'change_tin' && Object.values(changeTinTargets).some(v => v > 0) && (() => {
<Card style={{ marginBottom: '1rem' }}> const orderRows = CHANGE_TIN_DENOMS.map(d => {
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>CHANGE ORDER</h2> const bagVal = BAG_VALUES[d.value] ?? 0
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}> const unitVal = bagVal > 0 ? bagVal : d.value
<thead> const unitLabel = bagVal > 0 ? 'bag' : 'note'
<tr style={{ borderBottom: '2px solid var(--card-border)' }}> const target = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0))
<th style={{ textAlign: 'left', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Denom</th> if (target <= 0) return null
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Target</th> const targetUnits = unitVal > 0 ? Math.round(target / unitVal) : 0
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Counted</th> const counted = d.value * (denomQtys[d.value] ?? 0)
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Order</th> const needed = target - counted
</tr> const orderUnits = needed > 0.005 ? Math.ceil(needed / unitVal) : 0
</thead> return { d, target, targetUnits, counted, orderUnits, unitLabel }
<tbody> }).filter(Boolean)
{denoms.map(d => { if (!orderRows.length) return null
const isNote = !BAG_VALUES[d.value] return (
const bagVal = BAG_VALUES[d.value] ?? 0 <Card style={{ marginBottom: '1rem' }}>
const unitVal = isNote ? d.value : bagVal <h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>CHANGE ORDER</h2>
const target = parseFloat(String(changeTinTargets[d.value.toFixed(2)] ?? 0)) <table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
if (target <= 0) return null <thead>
const targetUnits = unitVal > 0 ? Math.round(target / unitVal) : 0 <tr style={{ borderBottom: '2px solid var(--card-border)' }}>
const bags = bagQtys[d.value] ?? 0 {['Denom', 'Target', 'Counted', 'Order'].map(h => (
const looseOrNotes = denomQtys[d.value] ?? 0 <th key={h} style={{ padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)', textAlign: h === 'Denom' ? 'left' : 'right' }}>{h}</th>
const countedVal = isNote ? d.value * looseOrNotes : bagVal * bags + d.value * looseOrNotes ))}
const needed = target - countedVal </tr>
const orderUnits = needed > 0.005 ? Math.ceil(needed / unitVal) : 0 </thead>
const unitLabel = isNote ? 'note' : 'bag' <tbody>
return ( {orderRows.map(r => r && (
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}> <tr key={r.d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.3rem 0.4rem', fontWeight: 600 }}>{d.label}</td> <td style={{ padding: '0.3rem 0.4rem', fontWeight: 600 }}>{r.d.label}</td>
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', color: 'var(--text-mid)' }}> <td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', color: 'var(--text-mid)' }}>
{targetUnits} {unitLabel}{targetUnits !== 1 ? 's' : ''} {r.targetUnits} {r.unitLabel}{r.targetUnits !== 1 ? 's' : ''}
</td> </td>
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right' }}> <td style={{ padding: '0.3rem 0.4rem', textAlign: 'right' }}>
{isNote {r.counted > 0 ? fmtGBP(r.counted) : '—'}
? (looseOrNotes > 0 ? `${looseOrNotes}` : '—')
: (bags > 0 ? `${bags}bg` : '') + (bags > 0 && looseOrNotes > 0 ? ' + ' : '') + (looseOrNotes > 0 ? `${looseOrNotes}×` : '') + (bags === 0 && looseOrNotes === 0 ? '—' : '')}
</td> </td>
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', fontWeight: 700, color: orderUnits > 0 ? 'var(--danger)' : '#16a34a' }}> <td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', fontWeight: 700, color: r.orderUnits > 0 ? 'var(--danger)' : '#16a34a' }}>
{orderUnits > 0 ? `+${orderUnits} ${unitLabel}${orderUnits !== 1 ? 's' : ''}` : 'OK'} {r.orderUnits > 0 ? `+${r.orderUnits} ${r.unitLabel}${r.orderUnits !== 1 ? 's' : ''}` : 'OK'}
</td> </td>
</tr> </tr>
) ))}
})} </tbody>
</tbody> </table>
</table> </Card>
</Card> )
)} })()}
<Card style={{ marginBottom: '1rem' }}> <Card style={{ marginBottom: '1rem' }}>
<label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label> <label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label>
@ -285,13 +432,24 @@ export function FloatCountForm({ type }: { type: CountType }) {
) )
} }
// ── History list + detail ──────────────────────────────────────────────────────
export function FloatHistory({ type }: { type: CountType }) { export function FloatHistory({ type }: { type: CountType }) {
const [rows, setRows] = useState<FloatCount[]>([]) const [rows, setRows] = useState<FloatCount[]>([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0)
const [detail, setDetail] = useState<(FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }) | null>(null) const [detail, setDetail] = useState<DetailRecord | null>(null)
const [changeTinTargets, setChangeTinTargets] = useState<Record<string, number>>({})
const limit = 10 const limit = 10
useEffect(() => {
if (type === 'change_tin') {
api.get<Record<string, string>>('/settings').then(s => {
try { setChangeTinTargets(JSON.parse(s.change_tin_breakdown || '{}')) } catch {}
}).catch(() => {})
}
}, [type])
useEffect(() => { useEffect(() => {
api.get<{ rows: FloatCount[]; total: number }>(`/floats?type=${type}&offset=${offset}&limit=${limit}`) api.get<{ rows: FloatCount[]; total: number }>(`/floats?type=${type}&offset=${offset}&limit=${limit}`)
.then(d => { setRows(d.rows); setTotal(d.total) }) .then(d => { setRows(d.rows); setTotal(d.total) })
@ -299,55 +457,23 @@ export function FloatHistory({ type }: { type: CountType }) {
}, [type, offset]) }, [type, offset])
async function loadDetail(id: number) { async function loadDetail(id: number) {
const d = await api.get<FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }>(`/floats/${id}`) const d = await api.get<DetailRecord>(`/floats/${id}`)
setDetail(d) setDetail(d)
} }
const isPetty = type === 'petty_cash'
return ( return (
<div> <div>
<PageHeader title={`${TYPE_LABELS[type]} History`} /> <PageHeader title={`${TYPE_LABELS[type]} History`} />
{detail ? ( {detail ? (
<Card> <Card>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}> <FloatRecordPrint
<h2 style={{ fontSize: '0.875rem', fontWeight: 700 }}>{new Date(detail.count_date).toLocaleString('en-GB')}</h2> record={detail}
<Btn small variant="ghost" onClick={() => setDetail(null)}>Back</Btn> changeTinTargets={changeTinTargets}
</div> onClose={() => setDetail(null)}
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1rem', fontSize: '0.875rem' }}> closeLabel="Back to History"
<span>Total: <strong>{fmtGBP(detail.total_counted)}</strong></span> />
{detail.count_type !== 'safe_cash' && <span>Variance: <strong>{fmtGBP(detail.variance)}</strong></span>}
{detail.count_type === 'petty_cash' && <span>Receipts: <strong>{fmtGBP(detail.total_receipts)}</strong></span>}
</div>
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
<tbody>
{detail.denominations.map(d => {
const bags = d.bag_quantity ?? 0
return (
<tr key={String(d.denomination_value)} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.3rem 0.5rem' }}>{fmtGBP(d.denomination_value)}</td>
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>
{bags > 0 ? `${bags} bag${bags !== 1 ? 's' : ''}` : ''}{bags > 0 && d.quantity > 0 ? ' + ' : ''}{d.quantity > 0 ? `×${d.quantity}` : ''}{bags === 0 && d.quantity === 0 ? '—' : ''}
</td>
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right' }}>{fmtGBP(d.total_amount)}</td>
{d.target !== undefined && d.target > 0 && (
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>tgt {fmtGBP(d.target)}</td>
)}
</tr>
)
})}
</tbody>
</table>
{detail.receipts.length > 0 && (
<>
<h3 style={{ fontSize: '0.8rem', fontWeight: 700, margin: '0.75rem 0 0.4rem', color: 'var(--text-mid)' }}>RECEIPTS</h3>
{detail.receipts.map(r => (
<div key={r.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8rem', borderBottom: '1px solid var(--card-border)', padding: '0.3rem 0.5rem' }}>
<span>{r.receipt_description || '—'}</span>
<span>{fmtGBP(r.receipt_value)}</span>
</div>
))}
</>
)}
{detail.notes && <p style={{ marginTop: '0.75rem', fontSize: '0.8rem', color: 'var(--text-mid)' }}>{detail.notes}</p>}
</Card> </Card>
) : ( ) : (
<> <>
@ -359,24 +485,34 @@ export function FloatHistory({ type }: { type: CountType }) {
<thead> <thead>
<tr style={{ borderBottom: '2px solid var(--card-border)', background: 'var(--body-bg)' }}> <tr style={{ borderBottom: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<th style={{ ...thS, textAlign: 'left' }}>Date / Time</th> <th style={{ ...thS, textAlign: 'left' }}>Date / Time</th>
{isPetty && <th style={thS}>Cash</th>}
{isPetty && <th style={thS}>Receipts</th>}
<th style={thS}>Total</th> <th style={thS}>Total</th>
{type !== 'safe_cash' && <th style={thS}>Variance</th>} {type !== 'safe_cash' && <th style={thS}>Variance</th>}
<th style={thS}></th> <th style={thS}></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map(row => ( {rows.map(row => {
<tr key={row.id} style={{ borderBottom: '1px solid var(--card-border)' }}> const cash = parseFloat(row.total_counted)
<td style={tdS}>{new Date(row.count_date).toLocaleString('en-GB')}</td> const rec = parseFloat(row.total_receipts)
<td style={{ ...tdS, textAlign: 'right', fontWeight: 600 }}>{fmtGBP(row.total_counted)}</td> const combined = cash + rec
{type !== 'safe_cash' && ( const v = parseFloat(row.variance)
<td style={{ ...tdS, textAlign: 'right', color: Math.abs(parseFloat(row.variance)) < 0.01 ? 'var(--text-mid)' : parseFloat(row.variance) > 0 ? 'var(--success)' : 'var(--danger)' }}> return (
{Math.abs(parseFloat(row.variance)) < 0.01 ? '£0.00' : (parseFloat(row.variance) > 0 ? '+' : '') + fmtGBP(Math.abs(parseFloat(row.variance)))} <tr key={row.id} style={{ borderBottom: '1px solid var(--card-border)' }}>
</td> <td style={tdS}>{new Date(row.count_date).toLocaleString('en-GB')}</td>
)} {isPetty && <td style={{ ...tdS, textAlign: 'right' }}>{fmtGBP(cash)}</td>}
<td style={tdS}><Btn small variant="ghost" onClick={() => loadDetail(row.id)}>View</Btn></td> {isPetty && <td style={{ ...tdS, textAlign: 'right' }}>{rec > 0 ? fmtGBP(rec) : '—'}</td>}
</tr> <td style={{ ...tdS, textAlign: 'right', fontWeight: 600 }}>{fmtGBP(isPetty ? combined : cash)}</td>
))} {type !== 'safe_cash' && (
<td style={{ ...tdS, textAlign: 'right', color: Math.abs(v) < 0.01 ? 'var(--text-mid)' : v > 0 ? 'var(--success)' : 'var(--danger)' }}>
{Math.abs(v) < 0.01 ? '£0.00' : (v > 0 ? '+' : '') + fmtGBP(Math.abs(v))}
</td>
)}
<td style={tdS}><Btn small variant="ghost" onClick={() => loadDetail(row.id)}>View</Btn></td>
</tr>
)
})}
</tbody> </tbody>
</table> </table>
</Card> </Card>
@ -394,6 +530,8 @@ export function FloatHistory({ type }: { type: CountType }) {
) )
} }
// ── Shell ──────────────────────────────────────────────────────────────────────
export function FloatManagement() { export function FloatManagement() {
const tabs: Array<{ path: string; label: string; type: CountType }> = [ const tabs: Array<{ path: string; label: string; type: CountType }> = [
{ path: 'petty-cash', label: 'Petty Cash', type: 'petty_cash' }, { path: 'petty-cash', label: 'Petty Cash', type: 'petty_cash' },