Auto-save drafts, reorder float/takings, 5-col reconciliation table

- Auto-save: every 60s while editing, silently saves draft if form has data.
  Status "Auto-saved at HH:MM" appears next to the Save Draft button briefly.
- Float Count moved above Cash Takings and defaults to open so staff count
  the float first before counting takings.
- Reconciliation table gains a fifth "Card Total" column: PDQ Visa/MC and
  PDQ Amex rows share a rowspan=2 cell showing the combined PDQ variance.
  If the per-category rows are red but the combined is green it means only
  the Visa/Amex split was mis-allocated, not an actual cash discrepancy
  (labelled "split only"). Gateway rows similarly grouped (always auto 0).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-02 14:55:05 +00:00
parent fb66dcd6bf
commit d03371dca1

View file

@ -53,7 +53,9 @@ export function DailyCashUp({ user }: Props) {
const [fetching, setFetching] = useState(false)
const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
const [showFloat, setShowFloat] = useState(false)
const [showFloat, setShowFloat] = useState(true)
const [autoSaveMsg, setAutoSaveMsg] = useState('')
const autoSaveFnRef = useRef<() => void>(() => {})
const isFinal = pageState === 'locked'
@ -98,6 +100,33 @@ export function DailyCashUp({ user }: Props) {
setPageState(data.cash_up.status === 'final' ? 'locked' : 'editing')
}
// Auto-save ref — updated every render so the interval always captures latest state
autoSaveFnRef.current = () => {
if (pageState !== 'editing' || saving) return
const allDenoms = [...takings, ...float].filter(d => d.total_amount > 0)
if (allDenoms.length === 0 && machines.every(m => m.total_amount === 0) && !notes) return
api.post('/cashup/save', {
session_date: date, status: 'draft', notes, checked_transactions: [...checkedItems],
denominations: allDenoms.map(d => ({
count_type: d.count_type, type: d.denomination_type, value: d.denomination_value,
quantity: d.quantity, value_entered: d.value_entered, total_amount: d.total_amount,
})),
card_machines: machines.map(m => ({
name: m.machine_name, total: m.total_amount, amex: m.amex_amount, visa_mc: m.visa_mc_amount,
})),
}).then(() => {
setAutoSaveMsg('Auto-saved at ' + new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }))
setTimeout(() => setAutoSaveMsg(''), 5000)
}).catch(() => {})
}
// Run auto-save every 60 s while editing
useEffect(() => {
if (pageState !== 'editing') return
const interval = setInterval(() => autoSaveFnRef.current(), 60_000)
return () => clearInterval(interval)
}, [pageState])
// Fetch till float target once on mount
useEffect(() => {
api.get<{ till_float_target?: string }>('/settings')
@ -230,6 +259,9 @@ export function DailyCashUp({ user }: Props) {
const totalPdq = machines.reduce((s, m) => s + m.total_amount, 0)
const floatCounted = denomTotal(float)
const floatVariance = tillFloatTarget > 0 ? floatCounted - tillFloatTarget : null
// Combined PDQ variance: accounts for Visa/Amex split being misallocated between machines
const pdqCombinedReported = newbookTotals ? newbookTotals.manual_visa_mc + newbookTotals.manual_amex : 0
const pdqCombinedVariance = totalPdq - pdqCombinedReported
return (
<div style={{ padding: '1.5rem', maxWidth: '900px' }}>
@ -283,16 +315,7 @@ export function DailyCashUp({ user }: Props) {
{(pageState === 'editing' || pageState === 'locked') && <>
{/* Cash denomination — Takings */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Cash Takings</h2>
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>{fmtGBP(denomTotal(takings))}</span>
</div>
<DenomGrid denoms={takings} onChange={(i, f, v) => updateDenom(takings, setTakings, i, f, v)} disabled={isFinal} tabBase={0} />
</Card>
{/* Float (collapsible) */}
{/* Float Count — shown first so staff confirm float before counting takings */}
<Card style={{ marginBottom: '1rem' }}>
<button onClick={() => setShowFloat(f => !f)} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
@ -320,6 +343,15 @@ export function DailyCashUp({ user }: Props) {
)}
</Card>
{/* Cash denomination — Takings */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Cash Takings</h2>
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>{fmtGBP(denomTotal(takings))}</span>
</div>
<DenomGrid denoms={takings} onChange={(i, f, v) => updateDenom(takings, setTakings, i, f, v)} disabled={isFinal} tabBase={0} />
</Card>
{/* Card Machines */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
@ -387,23 +419,44 @@ export function DailyCashUp({ user }: Props) {
<table style={{ width: '100%', fontSize: '0.875rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
{['Category', 'Banked', 'Reported', 'Variance'].map(h => (
<th key={h} style={{ padding: '0.4rem 0.5rem', textAlign: h === 'Category' ? 'left' : 'right', color: 'var(--text-mid)', fontWeight: 600 }}>{h}</th>
))}
<th style={{ padding: '0.4rem 0.5rem', textAlign: 'left', color: 'var(--text-mid)', fontWeight: 600 }}>Category</th>
<th style={{ padding: '0.4rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)', fontWeight: 600 }}>Banked</th>
<th style={{ padding: '0.4rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)', fontWeight: 600 }}>Reported</th>
<th style={{ padding: '0.4rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)', fontWeight: 600 }}>Variance</th>
<th style={{ padding: '0.4rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)', fontWeight: 600, borderLeft: '2px solid var(--card-border)', whiteSpace: 'nowrap' }}>Card Total</th>
</tr>
</thead>
<tbody>
{recon.map(row => {
const variance = row.banked_amount - row.reported_amount
const varColor = Math.abs(variance) < 0.01 ? 'var(--text-mid)' : variance > 0 ? 'var(--success)' : 'var(--danger)'
{recon.map((row, i) => {
const v = row.banked_amount - row.reported_amount
const fmtV = (n: number) => Math.abs(n) < 0.01 ? '—' : (n > 0 ? '+' : '') + fmtGBP(Math.abs(n))
const vCss = (n: number): React.CSSProperties => ({
padding: '0.5rem', textAlign: 'right', fontWeight: 600,
color: Math.abs(n) < 0.01 ? '#16a34a' : n < 0 ? '#dc2626' : '#16a34a',
background: Math.abs(n) < 0.01 ? '#f0fdf4' : n < 0 ? '#fef2f2' : '#f0fdf4',
})
// Second row of each card group — Total Variance cell covered by rowSpan above
const isGroupSecond = i === 2 || i === 4
// First row of PDQ group (i=1) → rowSpan=2 showing combined PDQ variance
const isPDQFirst = i === 1
// First row of Gateway group (i=3) → rowSpan=2 (Gateway always auto-matches)
const isGWFirst = i === 3
const combinedV = isPDQFirst ? pdqCombinedVariance : 0
return (
<tr key={row.category} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.5rem' }}>{row.category}</td>
<td style={{ padding: '0.5rem', textAlign: 'right' }}>{fmtGBP(row.banked_amount)}</td>
<td style={{ padding: '0.5rem', textAlign: 'right' }}>{fmtGBP(row.reported_amount)}</td>
<td style={{ padding: '0.5rem', textAlign: 'right', color: varColor, fontWeight: 600 }}>
{Math.abs(variance) < 0.01 ? '—' : (variance > 0 ? '+' : '') + fmtGBP(Math.abs(variance))}
</td>
<td style={vCss(v)}>{fmtV(v)}</td>
{!isGroupSecond && (
<td rowSpan={isPDQFirst || isGWFirst ? 2 : 1}
style={{ ...vCss(isPDQFirst ? pdqCombinedVariance : v), borderLeft: '2px solid var(--card-border)', verticalAlign: 'middle' }}>
{fmtV(isPDQFirst ? pdqCombinedVariance : v)}
{isPDQFirst && Math.abs(combinedV) < 0.01 && Math.abs(v) >= 0.01 && (
<div style={{ fontSize: '0.65rem', fontWeight: 400, color: '#16a34a', marginTop: '0.2rem' }}>split only</div>
)}
</td>
)}
</tr>
)
})}
@ -487,7 +540,7 @@ export function DailyCashUp({ user }: Props) {
{/* Action buttons */}
{pageState === 'editing' && (
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<Btn onClick={() => save('draft')} disabled={saving} variant="secondary">
<Save size={14} style={{ marginRight: '0.4rem' }} />
{saving ? 'Saving…' : 'Save Draft'}
@ -502,6 +555,11 @@ export function DailyCashUp({ user }: Props) {
Save as draft a manager with finalise permission will submit it.
</span>
)}
{autoSaveMsg && (
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginLeft: 'auto' }}>
{autoSaveMsg}
</span>
)}
</div>
)}