import { useState, useEffect, useRef, useCallback } from 'react' import { RefreshCw, Save, CheckCircle, Loader, Camera, FileText, X } from 'lucide-react' import { api, uploadAttachment } from '../api' import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { GBP_DENOMINATIONS, fmtGBP, today, can, type User, type CashUp, type Denomination, type CardMachine, type PaymentTotals, type ReconciliationRow, type TillPayment, type Attachment, type TransactionBreakdown, type TransactionItem, } from '../types' const DEFAULT_MACHINE_NAMES = ['Front Desk', 'Restaurant / Bar'] function initDenominations(countType: 'takings' | 'float'): Denomination[] { return GBP_DENOMINATIONS.map(d => ({ count_type: countType, denomination_type: d.type, denomination_value: d.value, quantity: null, value_entered: null, total_amount: 0, })) } function initMachines(names: string[]): CardMachine[] { return names.map(name => ({ machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 })) } function denomTotal(denoms: Denomination[]) { return denoms.reduce((s, d) => s + (Number(d.total_amount) || 0), 0) } interface Props { user: User } // flow: checking → empty (no record) | editing (draft) | locked (final) type PageState = 'checking' | 'empty' | 'editing' | 'locked' export function DailyCashUp({ user }: Props) { const canFinalise = can(user, 'finalise') const [date, setDate] = useState(today()) const [pageState, setPageState] = useState('checking') const [cashUp, setCashUp] = useState(null) const [takings, setTakings] = useState(initDenominations('takings')) const [float, setFloat] = useState(initDenominations('float')) const machineNamesRef = useRef(DEFAULT_MACHINE_NAMES) const [machines, setMachines] = useState(() => initMachines(DEFAULT_MACHINE_NAMES)) const [notes, setNotes] = useState('') const [attachments, setAttachments] = useState([]) const [newbookTotals, setNewbookTotals] = useState(null) const [tillPayments, setTillPayments] = useState([]) const [transactionBreakdown, setTransactionBreakdown] = useState(null) const [checkedItems, setCheckedItems] = useState>(new Set()) const [tillFloatTarget, setTillFloatTarget] = useState(0) const [fetching, setFetching] = useState(false) const [saving, setSaving] = useState(false) const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null) const [showFloat, setShowFloat] = useState(true) const [autoSaveMsg, setAutoSaveMsg] = useState('') const autoSaveFnRef = useRef<() => void>(() => {}) const isFinal = pageState === 'locked' function flash(text: string, ok = true) { setMsg({ text, ok }) setTimeout(() => setMsg(null), 4000) } function applyLoaded(data: { cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[]; attachments: Attachment[] }) { setCashUp(data.cash_up) setNotes(data.cash_up.notes || '') setAttachments(data.attachments || []) setCheckedItems(new Set(data.cash_up.checked_transactions || [])) const rebuild = (ct: 'takings' | 'float') => GBP_DENOMINATIONS.map(d => { const saved = data.denominations.find( s => s.count_type === ct && parseFloat(String(s.denomination_value)) === d.value ) return saved ? { ...saved, denomination_value: d.value, total_amount: parseFloat(String(saved.total_amount)) || 0, value_entered: saved.value_entered != null ? parseFloat(String(saved.value_entered)) : null, } : { count_type: ct, denomination_type: d.type, denomination_value: d.value, quantity: null, value_entered: null, total_amount: 0 } }) setTakings(rebuild('takings')) setFloat(rebuild('float')) if (data.card_machines.length) { const settingsNames = machineNamesRef.current // Any DB machine whose name is no longer in settings — keep it visible and in // the save payload so its data isn't silently deleted when the record is re-saved. const orphanedNames = data.card_machines .map(m => m.machine_name) .filter(n => !settingsNames.includes(n)) const allNames = [...settingsNames, ...orphanedNames] setMachines(allNames.map(name => { const m = data.card_machines.find(c => c.machine_name === name) if (!m) return { machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 } return { ...m, total_amount: parseFloat(String(m.total_amount)) || 0, amex_amount: parseFloat(String(m.amex_amount)) || 0, visa_mc_amount: parseFloat(String(m.visa_mc_amount)) || 0, } })) } 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 settings once on mount (float target + machine names) useEffect(() => { api.get<{ till_float_target?: string; card_machines?: string }>('/settings') .then(s => { setTillFloatTarget(parseFloat(s.till_float_target || '0') || 0) try { const names = JSON.parse(s.card_machines || '[]') as string[] if (names.length > 0) { machineNamesRef.current = names // Only reinit machines if they're still all-zero (not yet touched) setMachines(prev => prev.every(m => m.total_amount === 0 && m.amex_amount === 0) ? initMachines(names) : prev ) } } catch {} }) .catch(() => {}) }, []) // Auto-check on mount and whenever date changes; auto-fetch Newbook in parallel useEffect(() => { setPageState('checking') setCashUp(null) setTakings(initDenominations('takings')) setFloat(initDenominations('float')) setMachines(initMachines(machineNamesRef.current)) setNotes('') setAttachments([]) setNewbookTotals(null) setTillPayments([]) setTransactionBreakdown(null) setCheckedItems(new Set()) api.get<{ cash_up: CashUp | null; denominations: Denomination[]; card_machines: CardMachine[]; reconciliation: ReconciliationRow[]; attachments: Attachment[] }>(`/cashup?date=${date}`) .then(data => { if (!data.cash_up) { setPageState('empty'); return } applyLoaded(data as { cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[]; attachments: Attachment[] }) }) .catch(() => { flash('Failed to load data for this date.', false); setPageState('empty') }) // Auto-fetch Newbook — soft failure, user can retry with the button api.post<{ count: number; totals: PaymentTotals; till_payments: TillPayment[]; transaction_breakdown: TransactionBreakdown }>( '/newbook/payments', { date } ).then(data => { setNewbookTotals(data.totals) setTillPayments(data.till_payments || []) setTransactionBreakdown(data.transaction_breakdown || null) }).catch(() => { /* silently ignore — button still available */ }) }, [date]) function updateDenom(list: Denomination[], setList: (d: Denomination[]) => void, idx: number, field: 'quantity' | 'value_entered', raw: string) { const val = raw === '' ? null : parseFloat(raw) const updated = list.map((d, i) => { if (i !== idx) return d if (field === 'quantity') { const qty = val === null ? null : Math.max(0, Math.round(val)) return { ...d, quantity: qty, value_entered: null, total_amount: qty === null ? 0 : qty * d.denomination_value } } else { const ve = val === null ? null : Math.max(0, val) return { ...d, value_entered: ve, quantity: null, total_amount: ve ?? 0 } } }) setList(updated) } function updateMachine(idx: number, field: 'total_amount' | 'amex_amount', raw: string) { const val = raw === '' ? 0 : parseFloat(raw) || 0 setMachines(machines.map((m, i) => { if (i !== idx) return m const total = field === 'total_amount' ? val : m.total_amount const amex = field === 'amex_amount' ? val : m.amex_amount return { ...m, total_amount: total, amex_amount: amex, visa_mc_amount: Math.max(0, total - amex) } })) } async function fetchNewbook() { setFetching(true) try { const data = await api.post<{ count: number; totals: PaymentTotals; till_payments: TillPayment[]; transaction_breakdown: TransactionBreakdown }>('/newbook/payments', { date }) setNewbookTotals(data.totals) setTillPayments(data.till_payments || []) setTransactionBreakdown(data.transaction_breakdown || null) flash(`Fetched ${data.count} payment(s) from Newbook.`) } catch (e: unknown) { flash(e instanceof Error ? e.message : 'Failed to fetch Newbook data.', false) } finally { setFetching(false) } } async function startCashUp() { setSaving(true) try { await api.post<{ cash_up_id: number }>('/cashup/save', { session_date: date, status: 'draft', notes: '', checked_transactions: [], denominations: [], card_machines: machines.map(m => ({ name: m.machine_name, total: 0, amex: 0, visa_mc: 0 })), }) const data = await api.get<{ cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[]; reconciliation: ReconciliationRow[]; attachments: Attachment[] }>(`/cashup?date=${date}`) applyLoaded(data as { cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[]; attachments: Attachment[] }) } catch (e: unknown) { flash(e instanceof Error ? e.message : 'Failed to start cash up.', false) setPageState('editing') } finally { setSaving(false) } } async function save(status: 'draft' | 'final') { setSaving(true) try { const allDenoms = [...takings, ...float].filter(d => d.total_amount > 0) const result = await api.post<{ cash_up_id: number; message: string }>('/cashup/save', { session_date: date, status, 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, })), }) flash(result.message) if (status === 'final') { setCashUp(prev => prev ? { ...prev, status: 'final' } : null) setPageState('locked') } else { // Reload the cashUp record to get the persisted ID (needed for attachment uploads) api.get<{ cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[]; reconciliation: ReconciliationRow[]; attachments: Attachment[] }>(`/cashup?date=${date}`) .then(data => setCashUp(data.cash_up)) .catch(() => {}) } } catch (e: unknown) { flash(e instanceof Error ? e.message : 'Save failed.', false) } finally { setSaving(false) } } // Build reconciliation rows from local + Newbook data const recon: ReconciliationRow[] = newbookTotals ? [ { category: 'Cash', banked_amount: denomTotal(takings), reported_amount: newbookTotals.cash }, { category: 'PDQ Visa/MC', banked_amount: machines.reduce((s, m) => s + m.visa_mc_amount, 0), reported_amount: newbookTotals.manual_visa_mc }, { category: 'PDQ Amex', banked_amount: machines.reduce((s, m) => s + m.amex_amount, 0), reported_amount: newbookTotals.manual_amex }, { category: 'Gateway Visa/MC', banked_amount: newbookTotals.gateway_visa_mc, reported_amount: newbookTotals.gateway_visa_mc }, { category: 'Gateway Amex', banked_amount: newbookTotals.gateway_amex, reported_amount: newbookTotals.gateway_amex }, { category: 'BACS', banked_amount: newbookTotals.bacs, reported_amount: newbookTotals.bacs }, ] : [] 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 (
{msg && (
{msg.text}
)} {/* Date selector */}
setDate(e.target.value)} style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.9rem' }} />
{cashUp &&
}
{/* Checking */} {pageState === 'checking' && (
Loading…
)} {/* No cash up for this date */} {pageState === 'empty' && (

No cash up recorded for this date.

{saving ? 'Starting…' : 'Start Cash Up'}
)} {/* Locked banner */} {pageState === 'locked' && (
This cash up has been finalised and is locked.
)} {(pageState === 'editing' || pageState === 'locked') && <> {/* Float Count — shown first so staff confirm float before counting takings */} {floatVariance !== null && (
Target: {fmtGBP(tillFloatTarget)} 0 ? 'var(--success)' : 'var(--danger)' }}> {Math.abs(floatVariance) < 0.01 ? 'In balance' : (floatVariance > 0 ? '+' : '') + fmtGBP(Math.abs(floatVariance)) + ' variance'}
)} {showFloat && (
updateDenom(float, setFloat, i, f, v)} disabled={isFinal} tabBase={24} />
)}
{/* Cash denomination — Takings */}

Cash Takings

{fmtGBP(denomTotal(takings))}
{newbookTotals && (() => { const cashVariance = denomTotal(takings) - newbookTotals.cash return (
Reported: {fmtGBP(newbookTotals.cash)} 0 ? 'var(--success)' : 'var(--danger)' }}> {Math.abs(cashVariance) < 0.01 ? 'In balance' : (cashVariance > 0 ? '+' : '') + fmtGBP(Math.abs(cashVariance)) + ' variance'}
) })()} updateDenom(takings, setTakings, i, f, v)} disabled={isFinal} tabBase={0} />
{/* Card Machines (PDQ) — inputs + Z-report uploads side by side per machine */}

Card Machines (PDQ)

Total: {fmtGBP(totalPdq)}
{machines.map((m, i) => { const isOrphaned = !machineNamesRef.current.includes(m.machine_name) const inputDisabled = isFinal || isOrphaned return (
{/* Left: amount inputs */}

{m.machine_name}

{isOrphaned && ( Removed from settings )}
updateMachine(i, 'total_amount', v)} disabled={inputDisabled} /> updateMachine(i, 'amex_amount', v)} disabled={inputDisabled} />
Visa / MC {fmtGBP(m.visa_mc_amount)}
{/* Right: PDQ Z-report upload */}
Z-Report
{cashUp ? ( setAttachments(prev => [...prev, a])} onRemoved={id => setAttachments(prev => prev.filter(a => a.id !== id))} disabled={isFinal} /> ) : (

Save a draft first to upload photos.

)}
) })}
{/* Newbook + Reconciliation */}

Newbook Reconciliation

{fetching ? <> Fetching… : <> Fetch Payments}
{newbookTotals && ( <>
{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 ( {!isGroupSecond && ( )} ) })}
Category Banked Reported Variance Card Total
{row.category} {fmtGBP(row.banked_amount)} {fmtGBP(row.reported_amount)} {fmtV(v)} {fmtV(isPDQFirst ? pdqCombinedVariance : v)} {isPDQFirst && Math.abs(combinedV) < 0.01 && Math.abs(v) >= 0.01 && (
split only
)}
{tillPayments.length > 0 && (

Till / Restaurant Transactions

{tillPayments.map(t => ( ))}
Type Qty Total
{t.payment_type} {t.quantity} {fmtGBP(t.total_value)}
)} {transactionBreakdown && ( setCheckedItems(prev => { const next = new Set(prev) next.has(key) ? next.delete(key) : next.add(key) return next })} /> )} )}
{/* Receipt / discrepancy evidence */} {cashUp && (

Receipt Evidence & Error Photos

Upload photos of receipts with errors, discrepancies or anything needing a record.

setAttachments(prev => [...prev, a])} onRemoved={id => setAttachments(prev => prev.filter(a => a.id !== id))} disabled={false} />
)} {/* Notes */}