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'
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 })

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 { api } from '../api'
import { PageHeader, Card, Btn } from '../components/Layout'
@ -13,34 +13,219 @@ const TYPE_LABELS: Record<CountType, string> = {
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<number, number> = {
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<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 }) {
const navigate = useNavigate()
const [denomQtys, setDenomQtys] = useState<Record<number, number>>({})
const [bagQtys, setBagQtys] = useState<Record<number, number>>({})
const [receipts, setReceipts] = useState<Array<{ amount: string; description: string }>>([])
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<string | null>(null)
const [savedRecord, setSavedRecord] = useState<DetailRecord | null>(null)
const [changeTinTargets, setChangeTinTargets] = useState<Record<string, number>>({})
const [pettyTarget, setPettyTarget] = useState(200)
const denoms = type === 'change_tin' ? CHANGE_TIN_DENOMS : GBP_DENOMINATIONS
useEffect(() => {
api.get<Record<string, string>>('/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<DetailRecord>(`/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 (
<div style={{ maxWidth: '680px' }}>
<PageHeader title={`${TYPE_LABELS[type]} — Saved`} />
<FloatRecordPrint record={savedRecord} changeTinTargets={changeTinTargets} onClose={reset} />
</div>
)
}
return (
<div style={{ maxWidth: '640px' }}>
<div style={{ maxWidth: '600px' }}>
<PageHeader title={TYPE_LABELS[type]} subtitle="Enter denomination counts" />
{msg && (
{error && (
<div style={{
background: msg.ok ? '#dcfce7' : '#fee2e2', color: msg.ok ? '#16a34a' : 'var(--danger)',
border: `1px solid ${msg.ok ? '#bbf7d0' : '#fecaca'}`, borderRadius: '6px',
background: '#fee2e2', color: 'var(--danger)',
border: '1px solid #fecaca', borderRadius: '6px',
padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
}}>
{msg.text}
{error}
</div>
)}
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>DENOMINATIONS</h2>
{type === 'change_tin' ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 90px', gap: '0.4rem 0.5rem', alignItems: 'center', marginBottom: '0.35rem' }}>
<span />
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'center' }}>Bags / Qty</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'center' }}>Loose</span>
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', textAlign: 'right' }}>Total</span>
{denoms.map(d => {
const qty = denomQtys[d.value] ?? 0
const rowTotal = d.value * qty
return (
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '64px 1fr 90px', 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>
{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 }}>
<span>Total Counted</span>
<span>{fmtGBP(totalCounted)}</span>
@ -210,8 +353,14 @@ export function FloatCountForm({ type }: { type: CountType }) {
{type !== 'safe_cash' && (
<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' }}>
<span style={{ color: 'var(--text-mid)' }}>Target Amount</span>
<span style={{ color: 'var(--text-mid)' }}>Target</span>
<span>{fmtGBP(targetAmount)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', fontWeight: 700 }}>
@ -223,53 +372,51 @@ export function FloatCountForm({ type }: { type: CountType }) {
</Card>
)}
{type === 'change_tin' && Object.values(changeTinTargets).some(v => v > 0) && (
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>CHANGE ORDER</h2>
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ textAlign: 'left', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Denom</th>
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Target</th>
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Counted</th>
<th style={{ textAlign: 'right', padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)' }}>Order</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.3rem 0.4rem', fontWeight: 600 }}>{d.label}</td>
{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 (
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>CHANGE ORDER</h2>
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
{['Denom', 'Target', 'Counted', 'Order'].map(h => (
<th key={h} style={{ padding: '0.3rem 0.4rem', fontWeight: 600, color: 'var(--text-mid)', textAlign: h === 'Denom' ? 'left' : 'right' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{orderRows.map(r => r && (
<tr key={r.d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
<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)' }}>
{targetUnits} {unitLabel}{targetUnits !== 1 ? 's' : ''}
{r.targetUnits} {r.unitLabel}{r.targetUnits !== 1 ? 's' : ''}
</td>
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right' }}>
{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) : '—'}
</td>
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', fontWeight: 700, color: orderUnits > 0 ? 'var(--danger)' : '#16a34a' }}>
{orderUnits > 0 ? `+${orderUnits} ${unitLabel}${orderUnits !== 1 ? 's' : ''}` : 'OK'}
<td style={{ padding: '0.3rem 0.4rem', textAlign: 'right', fontWeight: 700, color: r.orderUnits > 0 ? 'var(--danger)' : '#16a34a' }}>
{r.orderUnits > 0 ? `+${r.orderUnits} ${r.unitLabel}${r.orderUnits !== 1 ? 's' : ''}` : 'OK'}
</td>
</tr>
)
})}
</tbody>
</table>
</Card>
)}
))}
</tbody>
</table>
</Card>
)
})()}
<Card style={{ marginBottom: '1rem' }}>
<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 }) {
const [rows, setRows] = useState<FloatCount[]>([])
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<DetailRecord | null>(null)
const [changeTinTargets, setChangeTinTargets] = useState<Record<string, number>>({})
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(() => {
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<FloatCount & { denominations: FloatDenomination[]; receipts: FloatReceipt[] }>(`/floats/${id}`)
const d = await api.get<DetailRecord>(`/floats/${id}`)
setDetail(d)
}
const isPetty = type === 'petty_cash'
return (
<div>
<PageHeader title={`${TYPE_LABELS[type]} History`} />
{detail ? (
<Card>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700 }}>{new Date(detail.count_date).toLocaleString('en-GB')}</h2>
<Btn small variant="ghost" onClick={() => setDetail(null)}>Back</Btn>
</div>
<div style={{ display: 'flex', gap: '2rem', marginBottom: '1rem', fontSize: '0.875rem' }}>
<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>}
<FloatRecordPrint
record={detail}
changeTinTargets={changeTinTargets}
onClose={() => setDetail(null)}
closeLabel="Back to History"
/>
</Card>
) : (
<>
@ -359,24 +485,34 @@ export function FloatHistory({ type }: { type: CountType }) {
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<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>
{type !== 'safe_cash' && <th style={thS}>Variance</th>}
<th style={thS}></th>
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.id} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={tdS}>{new Date(row.count_date).toLocaleString('en-GB')}</td>
<td style={{ ...tdS, textAlign: 'right', fontWeight: 600 }}>{fmtGBP(row.total_counted)}</td>
{type !== 'safe_cash' && (
<td style={{ ...tdS, textAlign: 'right', color: Math.abs(parseFloat(row.variance)) < 0.01 ? 'var(--text-mid)' : parseFloat(row.variance) > 0 ? 'var(--success)' : 'var(--danger)' }}>
{Math.abs(parseFloat(row.variance)) < 0.01 ? '£0.00' : (parseFloat(row.variance) > 0 ? '+' : '') + fmtGBP(Math.abs(parseFloat(row.variance)))}
</td>
)}
<td style={tdS}><Btn small variant="ghost" onClick={() => loadDetail(row.id)}>View</Btn></td>
</tr>
))}
{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 (
<tr key={row.id} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={tdS}>{new Date(row.count_date).toLocaleString('en-GB')}</td>
{isPetty && <td style={{ ...tdS, textAlign: 'right' }}>{fmtGBP(cash)}</td>}
{isPetty && <td style={{ ...tdS, textAlign: 'right' }}>{rec > 0 ? fmtGBP(rec) : '—'}</td>}
<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>
</table>
</Card>
@ -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' },