import { useState, useEffect, useRef } 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 MACHINES = ['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(): CardMachine[] { return MACHINES.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 [machines, setMachines] = useState(initMachines()) 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 [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 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 || []) 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) { setMachines(MACHINES.map(name => { const m = data.card_machines.find(c => c.machine_name === name) return m ?? { machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 } })) } setPageState(data.cash_up.status === 'final' ? 'locked' : 'editing') } // 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()) setNotes('') setAttachments([]) setNewbookTotals(null) setTillPayments([]) setTransactionBreakdown(null) setCheckedItems(new Set()) api.get<{ cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[]; reconciliation: ReconciliationRow[]; attachments: Attachment[] }>(`/cashup?date=${date}`) .then(data => applyLoaded(data)) .catch(e => { if (e instanceof Error && e.message === 'Not found') setPageState('empty') else { 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) setCheckedItems(new Set()) 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 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, 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') } } 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) 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.

setPageState('editing')}>Start Cash Up
)} {/* Locked banner */} {pageState === 'locked' && (
This cash up has been finalised and is locked.
)} {(pageState === 'editing' || pageState === 'locked') && <> {/* Cash denomination — Takings */}

Cash Takings

{fmtGBP(denomTotal(takings))}
updateDenom(takings, setTakings, i, f, v)} disabled={isFinal} tabBase={0} />
{/* Float (collapsible) */} {showFloat && (
updateDenom(float, setFloat, i, f, v)} disabled={isFinal} tabBase={24} />
)}
{/* Card Machines */}

Card Machines (PDQ)

Total: {fmtGBP(totalPdq)}
{machines.map((m, i) => (

{m.machine_name}

updateMachine(i, 'total_amount', v)} disabled={isFinal} /> updateMachine(i, 'amex_amount', v)} disabled={isFinal} />
Visa / MC {fmtGBP(m.visa_mc_amount)}
))}
{/* PDQ Z-Reports — one upload area per machine */} {cashUp && (

PDQ Z-Reports

Upload the end-of-day Z-report printout for each card machine.

{MACHINES.map(name => (
{name}
setAttachments(prev => [...prev, a])} onRemoved={id => setAttachments(prev => prev.filter(a => a.id !== id))} disabled={isFinal} />
))}
)} {/* Newbook + Reconciliation */}

Newbook Reconciliation

{fetching ? <> Fetching… : <> Fetch Payments}
{newbookTotals && ( <> {['Category', 'Banked', 'Reported', 'Variance'].map(h => ( ))} {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)' return ( ) })}
{h}
{row.category} {fmtGBP(row.banked_amount)} {fmtGBP(row.reported_amount)} {Math.abs(variance) < 0.01 ? '—' : (variance > 0 ? '+' : '') + fmtGBP(Math.abs(variance))}
{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 */}