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 <table>
  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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-07 11:56:45 +00:00
parent 8b2dc4ad78
commit ed9cce1d76
4 changed files with 392 additions and 103 deletions

View file

@ -211,7 +211,7 @@ export async function reportRoutes(app) {
const { from, to } = req.query const { from, to } = req.query
if (!from || !to) return reply.status(400).send({ error: 'from and to required' }) 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( pool.query(
`SELECT d.denomination_value, `SELECT d.denomination_value,
ROUND(SUM(d.total_amount) / NULLIF(d.denomination_value, 0)) AS total_quantity, 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`, ORDER BY session_date ASC`,
[from, to] [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 { return {
denominations: denomResult.rows, denominations: denomResult.rows,
by_date: byDateResult.rows, by_date: byDateResult.rows,
by_date_denom: byDateDenomResult.rows,
period: { from, to }, period: { from, to },
} }
}) })

View file

@ -1,22 +1,39 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
import { GBP_DENOMINATIONS, fmtGBP, today } from '../types' import { GBP_DENOMINATIONS, fmtGBP, today } from '../types'
interface DenomRow { denomination_value: string; total_quantity: string; total_value: string } interface DayRow {
interface DayRow { session_date: string; status: 'draft' | 'final'; total_cash_counted: string; submitted_by: string | null } 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 { interface SummaryResult {
denominations: DenomRow[]
by_date: DayRow[] by_date: DayRow[]
by_date_denom: DenomDateRow[]
period: { from: string; to: string } period: { from: string; to: string }
} }
function fmtDate(d: string) { function fmtDateShort(d: string) {
return new Date(d.slice(0, 10) + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }) return new Date(d.slice(0, 10) + 'T12:00:00').toLocaleDateString('en-GB', {
weekday: 'short', day: '2-digit', month: 'short',
})
} }
export function CashSummary() { 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 [to, setTo] = useState(today)
const [result, setResult] = useState<SummaryResult | null>(null) const [result, setResult] = useState<SummaryResult | null>(null)
const [loading, setLoading] = useState(false) 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<string, number> = {}
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<string, Record<number, number>> = {}
const rowTotals: Record<number, number> = {}
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 ( return (
<div style={{ padding: '1.5rem', maxWidth: '760px' }}> <div style={{ padding: '1.5rem' }}>
<PageHeader title="Cash Denomination Summary" subtitle="Aggregate cash count across a date range" /> <PageHeader title="Cash Summary" subtitle="Daily cash denomination breakdown" />
<Card style={{ marginBottom: '1rem', display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}> <Card style={{ marginBottom: '1rem', display: 'flex', gap: '1rem', alignItems: 'flex-end', flexWrap: 'wrap' }}>
<div> <div>
@ -50,6 +88,11 @@ export function CashSummary() {
<input type="date" value={to} onChange={e => setTo(e.target.value)} style={inpSt} /> <input type="date" value={to} onChange={e => setTo(e.target.value)} style={inpSt} />
</div> </div>
<Btn onClick={generate} disabled={loading}>{loading ? 'Loading…' : 'Generate'}</Btn> <Btn onClick={generate} disabled={loading}>{loading ? 'Loading…' : 'Generate'}</Btn>
{result && result.by_date.length > 0 && (
<Btn variant="secondary" onClick={addToSafe} style={{ marginLeft: 'auto' }}>
+ Add to Safe
</Btn>
)}
</Card> </Card>
{error && ( {error && (
@ -58,91 +101,86 @@ export function CashSummary() {
</div> </div>
)} )}
{result && (<> {result && (
result.by_date.length === 0 ? (
{/* Daily breakdown */}
<Card style={{ marginBottom: '1rem' }}>
<h2 style={secSt}>{result.period.from} {result.period.to}</h2>
{result.by_date.length === 0 ? (
<p style={{ fontSize: '0.875rem', color: 'var(--text-mid)' }}>No cash ups recorded in this period.</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...thSt, textAlign: 'left' }}>Date</th>
<th style={{ ...thSt, textAlign: 'left' }}>Status</th>
<th style={thSt}>Cash Counted</th>
<th style={{ ...thSt, textAlign: 'left' }}>Submitted By</th>
</tr>
</thead>
<tbody>
{result.by_date.map(row => (
<tr key={row.session_date} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.45rem 0.5rem', fontWeight: 600 }}>{fmtDate(row.session_date)}</td>
<td style={{ padding: '0.45rem 0.5rem' }}><StatusBadge status={row.status} /></td>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right', fontWeight: 600 }}>{fmtGBP(row.total_cash_counted)}</td>
<td style={{ padding: '0.45rem 0.5rem', color: 'var(--text-mid)', fontSize: '0.8rem' }}>{row.submitted_by ?? '—'}</td>
</tr>
))}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<td colSpan={2} style={{ padding: '0.6rem 0.5rem', fontWeight: 700 }}>Period Total</td>
<td style={{ padding: '0.6rem 0.5rem', textAlign: 'right', fontWeight: 700 }}>
{fmtGBP(result.by_date.reduce((s, r) => s + parseFloat(r.total_cash_counted), 0))}
</td>
<td />
</tr>
</tfoot>
</table>
)}
</Card>
{/* Denomination breakdown */}
{result.denominations.length > 0 && (
<Card> <Card>
<h2 style={secSt}>Denomination Breakdown</h2> <p style={{ fontSize: '0.875rem', color: 'var(--text-mid)', textAlign: 'center', padding: '1rem' }}>
<p style={{ fontSize: '0.8rem', color: 'var(--text-mid)', marginBottom: '1rem' }}> No cash ups recorded in this period.
Takings only (excludes float counts)
</p> </p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}> </Card>
) : (
<Card style={{ padding: 0, overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead> <thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}> <tr style={{ background: 'var(--body-bg)', borderBottom: '1px solid var(--card-border)' }}>
<th style={{ ...thSt, textAlign: 'left' }}>Denomination</th> <th style={{ ...th, textAlign: 'left', paddingLeft: '0.75rem', minWidth: '90px', position: 'sticky', left: 0, background: 'var(--body-bg)' }}>
<th style={thSt}>Total Qty</th> Status
<th style={thSt}>Total Value</th> </th>
{result.by_date.map(d => (
<th key={d.session_date} style={{ ...th, padding: '0.4rem 0.5rem' }}>
<StatusBadge status={d.status} />
</th>
))}
<th style={{ ...th, borderLeft: '2px solid var(--card-border)' }}></th>
</tr>
<tr style={{ background: 'var(--body-bg)', borderBottom: '2px solid var(--card-border)' }}>
<th style={{ ...th, textAlign: 'left', paddingLeft: '0.75rem', fontWeight: 700, position: 'sticky', left: 0, background: 'var(--body-bg)' }}>
Denomination
</th>
{result.by_date.map(d => (
<th key={d.session_date} style={{ ...th, whiteSpace: 'nowrap', fontWeight: 700 }}>
{fmtDateShort(d.session_date)}
</th>
))}
<th style={{ ...th, fontWeight: 700, borderLeft: '2px solid var(--card-border)' }}>Total</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{GBP_DENOMINATIONS.map(d => { {GBP_DENOMINATIONS.map(d => {
const row = result.denominations.find(r => Math.abs(parseFloat(r.denomination_value) - d.value) < 0.001) if (!rowTotals[d.value]) return null
if (!row) return null
return ( return (
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}> <tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.45rem 0.5rem', fontWeight: 600 }}>{d.label}</td> <td style={{ ...td, fontWeight: 700, paddingLeft: '0.75rem', position: 'sticky', left: 0, background: 'white' }}>
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{row.total_quantity}</td> {d.label}
<td style={{ padding: '0.45rem 0.5rem', textAlign: 'right' }}>{fmtGBP(row.total_value)}</td> </td>
{result.by_date.map(row => {
const date = row.session_date.slice(0, 10)
const amt = denomMap[date]?.[d.value] || 0
return (
<td key={date} style={td}>{amt > 0 ? fmtGBP(amt) : '—'}</td>
)
})}
<td style={{ ...td, fontWeight: 700, borderLeft: '2px solid var(--card-border)' }}>
{fmtGBP(rowTotals[d.value])}
</td>
</tr> </tr>
) )
})} })}
</tbody> </tbody>
<tfoot> <tfoot>
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}> <tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)', fontWeight: 700 }}>
<td style={{ padding: '0.6rem 0.5rem', fontWeight: 700 }}>Grand Total</td> <td style={{ ...td, fontWeight: 700, paddingLeft: '0.75rem', position: 'sticky', left: 0, background: 'var(--body-bg)' }}>
<td></td> Cash Total
<td style={{ padding: '0.6rem 0.5rem', textAlign: 'right', fontWeight: 700, fontSize: '1rem' }}>{fmtGBP(grandTotal)}</td> </td>
{result.by_date.map(row => (
<td key={row.session_date} style={td}>
{fmtGBP(row.total_cash_counted)}
</td>
))}
<td style={{ ...td, fontWeight: 700, fontSize: '0.9rem', borderLeft: '2px solid var(--card-border)' }}>
{fmtGBP(grandTotal)}
</td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</Card> </Card>
)} )
)}
</>)}
</div> </div>
) )
} }
const lbl: React.CSSProperties = { fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' } 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 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 th: React.CSSProperties = { padding: '0.45rem 0.5rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)' }
const thSt: React.CSSProperties = { padding: '0.5rem 0.5rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)' } const td: React.CSSProperties = { padding: '0.4rem 0.5rem', textAlign: 'right' }

View file

@ -28,9 +28,9 @@ const BAG_VALUES: Record<number, number> = {
// ── Shared record detail + print view ───────────────────────────────────────── // ── 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 record: DetailRecord
changeTinTargets: Record<string, number> changeTinTargets: Record<string, number>
onClose: () => void onClose: () => void
@ -324,30 +324,48 @@ export function FloatCountForm({ type }: { type: CountType }) {
</div> </div>
)} )}
<Card style={{ marginBottom: '1rem' }}> <Card style={{ marginBottom: '1rem', padding: 0, overflow: 'hidden' }}>
<h2 style={{ fontSize: '0.875rem', fontWeight: 700, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>DENOMINATIONS</h2> <h2 style={{ fontSize: '0.875rem', fontWeight: 700, color: 'var(--text-mid)', padding: '0.75rem 0.75rem 0.5rem' }}>DENOMINATIONS</h2>
{denoms.map(d => { <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
const qty = denomQtys[d.value] ?? 0 <thead>
const uv = unitVal(d) <tr style={{ borderBottom: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
const rowTotal = uv * qty <th style={fthL}>Denomination</th>
const isBag = type === 'change_tin' && BAG_VALUES[d.value] !== undefined <th style={fthR}>{type === 'change_tin' ? 'Bags' : 'Qty'}</th>
return ( <th style={fthR}>Amount</th>
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '80px 1fr 90px', gap: '0.4rem 0.75rem', alignItems: 'center', marginBottom: '0.35rem' }}> </tr>
<div> </thead>
<span style={{ fontWeight: 600, fontSize: '0.875rem' }}>{d.label}</span> <tbody>
{isBag && <div style={{ fontSize: '0.65rem', color: 'var(--text-mid)' }}>{fmtGBP(uv)}/bag</div>} {denoms.map(d => {
</div> const qty = denomQtys[d.value] ?? 0
<input type="number" min="0" step="1" value={qty || ''} const uv = unitVal(d)
onChange={e => setDenomQtys(prev => ({ ...prev, [d.value]: parseInt(e.target.value) || 0 }))} const rowTotal = uv * qty
placeholder="0" style={inpSt} /> const isBag = type === 'change_tin' && BAG_VALUES[d.value] !== undefined
<span style={{ textAlign: 'right', fontSize: '0.875rem' }}>{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}</span> return (
</div> <tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
) <td style={{ padding: '0.3rem 0.75rem' }}>
})} <span style={{ fontWeight: 600 }}>{d.label}</span>
<div style={{ borderTop: '2px solid var(--card-border)', paddingTop: '0.75rem', marginTop: '0.5rem', display: 'flex', justifyContent: 'space-between', fontWeight: 700 }}> {isBag && <div style={{ fontSize: '0.65rem', color: 'var(--text-mid)' }}>{fmtGBP(uv)}/bag</div>}
<span>Total Counted</span> </td>
<span>{fmtGBP(totalCounted)}</span> <td style={{ padding: '0.2rem 0.5rem', textAlign: 'right' }}>
</div> <input type="number" min="0" step="1" value={qty || ''}
onChange={e => 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' }} />
</td>
<td style={{ padding: '0.3rem 0.75rem', textAlign: 'right', fontWeight: 600 }}>
{rowTotal > 0 ? fmtGBP(rowTotal) : '—'}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)' }}>
<td colSpan={2} style={{ padding: '0.5rem 0.75rem', fontWeight: 700 }}>Total Counted</td>
<td style={{ padding: '0.5rem 0.75rem', textAlign: 'right', fontWeight: 700 }}>{fmtGBP(totalCounted)}</td>
</tr>
</tfoot>
</table>
</Card> </Card>
{type === 'petty_cash' && ( {type === 'petty_cash' && (
@ -601,5 +619,7 @@ const inpSt: React.CSSProperties = {
border: '1px solid var(--card-border)', borderRadius: '4px', border: '1px solid var(--card-border)', borderRadius: '4px',
padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%', 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 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' } const tdS: React.CSSProperties = { padding: '0.6rem 0.75rem' }

View file

@ -1,14 +1,235 @@
import { Routes, Route, Navigate } from 'react-router-dom' import { useState, useEffect } from 'react'
import { FloatCountForm, FloatHistory } from './FloatManagement' 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<string, number> } | null)?.adjustDenoms
const [current, setCurrent] = useState<Record<number, number>>({})
const [lastCount, setLastCount] = useState<FloatCount | null>(null)
const [loading, setLoading] = useState(true)
const [adjust, setAdjust] = useState<Record<number, number>>(() => {
if (!prefill) return {}
const pf: Record<number, number> = {}
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<string | null>(null)
const [savedRecord, setSavedRecord] = useState<DetailRecord | null>(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<DetailRecord>(`/floats/${last.id}`)
const curr: Record<number, number> = {}
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<number, number> = {}
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<DetailRecord>(`/floats/${res.count_id}`)
setSavedRecord(detail)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Save failed.')
} finally {
setSaving(false)
}
}
if (savedRecord) {
return (
<div style={{ maxWidth: '680px' }}>
<PageHeader title="Safe Count — Saved" />
<FloatRecordPrint
record={savedRecord}
changeTinTargets={{}}
onClose={() => {
setSavedRecord(null)
setAdjust({})
setNotes('')
setReloadKey(k => k + 1)
}}
closeLabel="New Adjustment"
/>
</div>
)
}
const lastCountDate = lastCount
? new Date(lastCount.count_date).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
: null
return (
<div style={{ maxWidth: '700px' }}>
<PageHeader
title="Safe Count"
subtitle={
loading ? 'Loading last count…'
: lastCount ? `Last count: ${lastCountDate}${fmtGBP(lastCount.total_counted)}`
: 'No previous count on record'
}
/>
{error && (
<div style={{ background: '#fee2e2', color: 'var(--danger)', border: '1px solid #fecaca', borderRadius: '6px', padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem' }}>
{error}
</div>
)}
{prefill && (
<div style={{ background: '#eff6ff', border: '1px solid #bfdbfe', borderRadius: '6px', padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem', color: '#1e40af' }}>
Adjustment pre-filled from cash summary takings. Review and adjust as needed before saving.
</div>
)}
<Card style={{ marginBottom: '1rem', padding: 0, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.875rem' }}>
<thead>
<tr style={{ background: 'var(--body-bg)', borderBottom: '2px solid var(--card-border)' }}>
<th style={thL}>Denomination</th>
<th style={thR}>Current in Safe</th>
<th style={thR}>Adjustment</th>
<th style={thR}>New Total</th>
</tr>
</thead>
<tbody>
{GBP_DENOMINATIONS.map(d => {
const curr = current[d.value] || 0
const adj = adjust[d.value] || 0
const newT = newTotals[d.value]
return (
<tr key={d.value} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.3rem 0.75rem', fontWeight: 600 }}>{d.label}</td>
<td style={{ padding: '0.3rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>
{curr > 0 ? fmtGBP(curr) : '—'}
</td>
<td style={{ padding: '0.2rem 0.5rem', textAlign: 'right' }}>
<input
type="number" step="0.01"
value={adjust[d.value] !== undefined && adjust[d.value] !== 0 ? adjust[d.value] : ''}
onChange={e => {
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,
}}
/>
</td>
<td style={{
padding: '0.3rem 0.75rem', textAlign: 'right', fontWeight: 600,
color: newT === 0 && (curr > 0 || adj < 0) ? 'var(--danger)' : undefined,
}}>
{newT > 0 ? fmtGBP(newT) : (curr > 0 || adj !== 0) ? fmtGBP(0) : '—'}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr style={{ borderTop: '2px solid var(--card-border)', background: 'var(--body-bg)', fontWeight: 700 }}>
<td style={{ padding: '0.5rem 0.75rem' }}>Total</td>
<td style={{ padding: '0.5rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>
{fmtGBP(prevTotal)}
</td>
<td style={{ padding: '0.5rem 0.5rem', textAlign: 'right', color: adjustTotal < 0 ? 'var(--danger)' : adjustTotal > 0 ? 'var(--success)' : 'var(--text-mid)' }}>
{adjustTotal !== 0
? (adjustTotal > 0 ? '+' : '-') + fmtGBP(Math.abs(adjustTotal))
: '—'}
</td>
<td style={{ padding: '0.5rem 0.75rem', textAlign: 'right', fontSize: '1rem' }}>
{fmtGBP(newTotal)}
</td>
</tr>
</tfoot>
</table>
</Card>
<Card style={{ marginBottom: '1rem' }}>
<label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label>
<textarea
value={notes} onChange={e => setNotes(e.target.value)} rows={2}
placeholder="Reason for adjustment (e.g. weekly banking, coin exchange, uplift…)"
style={{ width: '100%', border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.5rem', fontSize: '0.875rem', resize: 'vertical' }}
/>
</Card>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Btn onClick={save} disabled={saving || loading}>
{saving ? 'Saving…' : 'Save Count'}
</Btn>
<Btn variant="ghost" onClick={() => navigate('/safe/history')}>View History</Btn>
</div>
</div>
)
}
export function SafeCount() { export function SafeCount() {
return ( return (
<div style={{ padding: '1.5rem' }}> <div style={{ padding: '1.5rem' }}>
<Routes> <Routes>
<Route index element={<FloatCountForm type="safe_cash" />} /> <Route index element={<SafeCountAdjust />} />
<Route path="history" element={<FloatHistory type="safe_cash" />} /> <Route path="history" element={<FloatHistory type="safe_cash" />} />
<Route path="*" element={<Navigate to="" replace />} /> <Route path="*" element={<Navigate to="" replace />} />
</Routes> </Routes>
</div> </div>
) )
} }
const thL: React.CSSProperties = { padding: '0.45rem 0.75rem', textAlign: 'left', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem' }
const thR: React.CSSProperties = { padding: '0.45rem 0.75rem', textAlign: 'right', fontWeight: 600, color: 'var(--text-mid)', fontSize: '0.8rem' }