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 [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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue