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:
parent
fb66dcd6bf
commit
d03371dca1
1 changed files with 79 additions and 21 deletions
|
|
@ -53,7 +53,9 @@ export function DailyCashUp({ user }: Props) {
|
||||||
const [fetching, setFetching] = useState(false)
|
const [fetching, setFetching] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
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'
|
const isFinal = pageState === 'locked'
|
||||||
|
|
||||||
|
|
@ -98,6 +100,33 @@ export function DailyCashUp({ user }: Props) {
|
||||||
setPageState(data.cash_up.status === 'final' ? 'locked' : 'editing')
|
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
|
// Fetch till float target once on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<{ till_float_target?: string }>('/settings')
|
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 totalPdq = machines.reduce((s, m) => s + m.total_amount, 0)
|
||||||
const floatCounted = denomTotal(float)
|
const floatCounted = denomTotal(float)
|
||||||
const floatVariance = tillFloatTarget > 0 ? floatCounted - tillFloatTarget : null
|
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 (
|
return (
|
||||||
<div style={{ padding: '1.5rem', maxWidth: '900px' }}>
|
<div style={{ padding: '1.5rem', maxWidth: '900px' }}>
|
||||||
|
|
@ -283,16 +315,7 @@ export function DailyCashUp({ user }: Props) {
|
||||||
|
|
||||||
{(pageState === 'editing' || pageState === 'locked') && <>
|
{(pageState === 'editing' || pageState === 'locked') && <>
|
||||||
|
|
||||||
{/* Cash denomination — Takings */}
|
{/* Float Count — shown first so staff confirm float before counting 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) */}
|
|
||||||
<Card style={{ marginBottom: '1rem' }}>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
<button onClick={() => setShowFloat(f => !f)} style={{
|
<button onClick={() => setShowFloat(f => !f)} style={{
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
|
|
@ -320,6 +343,15 @@ export function DailyCashUp({ user }: Props) {
|
||||||
)}
|
)}
|
||||||
</Card>
|
</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 Machines */}
|
||||||
<Card style={{ marginBottom: '1rem' }}>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', 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' }}>
|
<table style={{ width: '100%', fontSize: '0.875rem', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
||||||
{['Category', 'Banked', 'Reported', 'Variance'].map(h => (
|
<th style={{ padding: '0.4rem 0.5rem', textAlign: 'left', color: 'var(--text-mid)', fontWeight: 600 }}>Category</th>
|
||||||
<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: '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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{recon.map(row => {
|
{recon.map((row, i) => {
|
||||||
const variance = row.banked_amount - row.reported_amount
|
const v = row.banked_amount - row.reported_amount
|
||||||
const varColor = Math.abs(variance) < 0.01 ? 'var(--text-mid)' : variance > 0 ? 'var(--success)' : 'var(--danger)'
|
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 (
|
return (
|
||||||
<tr key={row.category} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
<tr key={row.category} style={{ borderBottom: '1px solid var(--card-border)' }}>
|
||||||
<td style={{ padding: '0.5rem' }}>{row.category}</td>
|
<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.banked_amount)}</td>
|
||||||
<td style={{ padding: '0.5rem', textAlign: 'right' }}>{fmtGBP(row.reported_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 }}>
|
<td style={vCss(v)}>{fmtV(v)}</td>
|
||||||
{Math.abs(variance) < 0.01 ? '—' : (variance > 0 ? '+' : '') + fmtGBP(Math.abs(variance))}
|
{!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>
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -487,7 +540,7 @@ export function DailyCashUp({ user }: Props) {
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
{pageState === 'editing' && (
|
{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">
|
<Btn onClick={() => save('draft')} disabled={saving} variant="secondary">
|
||||||
<Save size={14} style={{ marginRight: '0.4rem' }} />
|
<Save size={14} style={{ marginRight: '0.4rem' }} />
|
||||||
{saving ? 'Saving…' : 'Save Draft'}
|
{saving ? 'Saving…' : 'Save Draft'}
|
||||||
|
|
@ -502,6 +555,11 @@ export function DailyCashUp({ user }: Props) {
|
||||||
Save as draft — a manager with finalise permission will submit it.
|
Save as draft — a manager with finalise permission will submit it.
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{autoSaveMsg && (
|
||||||
|
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginLeft: 'auto' }}>
|
||||||
|
✓ {autoSaveMsg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue