cashup/frontend/src/pages/DailyCashUp.tsx
jtricerolph 5aafc0e99f Start cash up creates DB record immediately so photo uploads are available
Clicking 'Start Cash Up' now POSTs an empty draft to the backend straight
away, then reloads the record to populate cashUp.id — so PDQ Z-report and
receipt evidence uploaders are available without having to save a draft first.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-20 22:43:11 +00:00

968 lines
45 KiB
TypeScript

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<PageState>('checking')
const [cashUp, setCashUp] = useState<CashUp | null>(null)
const [takings, setTakings] = useState<Denomination[]>(initDenominations('takings'))
const [float, setFloat] = useState<Denomination[]>(initDenominations('float'))
const machineNamesRef = useRef<string[]>(DEFAULT_MACHINE_NAMES)
const [machines, setMachines] = useState<CardMachine[]>(() => initMachines(DEFAULT_MACHINE_NAMES))
const [notes, setNotes] = useState('')
const [attachments, setAttachments] = useState<Attachment[]>([])
const [newbookTotals, setNewbookTotals] = useState<PaymentTotals | null>(null)
const [tillPayments, setTillPayments] = useState<TillPayment[]>([])
const [transactionBreakdown, setTransactionBreakdown] = useState<TransactionBreakdown | null>(null)
const [checkedItems, setCheckedItems] = useState<Set<string>>(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 (
<div style={{ padding: '1.5rem', maxWidth: '900px' }}>
<PageHeader title="Daily Cash Up" />
{msg && (
<div style={{
background: msg.ok ? '#dcfce7' : '#fee2e2', color: msg.ok ? '#16a34a' : 'var(--danger)',
border: `1px solid ${msg.ok ? '#bbf7d0' : '#fecaca'}`, borderRadius: '6px',
padding: '0.625rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
}}>
{msg.text}
</div>
)}
{/* Date selector */}
<Card style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div>
<label style={{ fontSize: '0.8rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' }}>Business Date</label>
<input type="date" value={date}
onChange={e => setDate(e.target.value)}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.9rem' }}
/>
</div>
{cashUp && <div style={{ paddingBottom: '1px' }}><StatusBadge status={cashUp.status} /></div>}
</Card>
{/* Checking */}
{pageState === 'checking' && (
<div style={{ color: 'var(--text-mid)', padding: '2rem 0', textAlign: 'center' }}>Loading</div>
)}
{/* No cash up for this date */}
{pageState === 'empty' && (
<Card style={{ textAlign: 'center', padding: '2.5rem 1.5rem' }}>
<p style={{ color: 'var(--text-mid)', marginBottom: '1.25rem' }}>No cash up recorded for this date.</p>
<Btn onClick={startCashUp} disabled={saving}>{saving ? 'Starting…' : 'Start Cash Up'}</Btn>
</Card>
)}
{/* Locked banner */}
{pageState === 'locked' && (
<div style={{
background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: '6px',
padding: '0.75rem 1rem', marginBottom: '1rem', fontSize: '0.875rem',
color: '#16a34a', display: 'flex', alignItems: 'center', gap: '0.5rem',
}}>
<CheckCircle size={15} /> This cash up has been finalised and is locked.
</div>
)}
{(pageState === 'editing' || pageState === 'locked') && <>
{/* 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',
width: '100%', background: 'none', border: 'none', padding: 0, cursor: 'pointer',
}}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Float Count</h2>
<span style={{ fontSize: '0.9rem', color: 'var(--text-mid)' }}>
{fmtGBP(floatCounted)} {showFloat ? '▲' : '▼'}
</span>
</button>
{floatVariance !== null && (
<div style={{ display: 'flex', gap: '1.25rem', fontSize: '0.8rem', marginTop: '0.35rem' }}>
<span style={{ color: 'var(--text-mid)' }}>Target: {fmtGBP(tillFloatTarget)}</span>
<span style={{ fontWeight: 600, color: Math.abs(floatVariance) < 0.01 ? 'var(--text-mid)' : floatVariance > 0 ? 'var(--success)' : 'var(--danger)' }}>
{Math.abs(floatVariance) < 0.01
? 'In balance'
: (floatVariance > 0 ? '+' : '') + fmtGBP(Math.abs(floatVariance)) + ' variance'}
</span>
</div>
)}
{showFloat && (
<div style={{ marginTop: '1rem' }}>
<DenomGrid denoms={float} onChange={(i, f, v) => updateDenom(float, setFloat, i, f, v)} disabled={isFinal} tabBase={24} />
</div>
)}
</Card>
{/* Cash denomination — Takings */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: newbookTotals ? '0.35rem' : '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Cash Takings</h2>
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>{fmtGBP(denomTotal(takings))}</span>
</div>
{newbookTotals && (() => {
const cashVariance = denomTotal(takings) - newbookTotals.cash
return (
<div style={{ display: 'flex', gap: '1.25rem', fontSize: '0.8rem', marginBottom: '1rem' }}>
<span style={{ color: 'var(--text-mid)' }}>Reported: {fmtGBP(newbookTotals.cash)}</span>
<span style={{ fontWeight: 600, color: Math.abs(cashVariance) < 0.01 ? 'var(--text-mid)' : cashVariance > 0 ? 'var(--success)' : 'var(--danger)' }}>
{Math.abs(cashVariance) < 0.01
? 'In balance'
: (cashVariance > 0 ? '+' : '') + fmtGBP(Math.abs(cashVariance)) + ' variance'}
</span>
</div>
)
})()}
<DenomGrid denoms={takings} onChange={(i, f, v) => updateDenom(takings, setTakings, i, f, v)} disabled={isFinal} tabBase={0} />
</Card>
{/* Card Machines (PDQ) — inputs + Z-report uploads side by side per machine */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Card Machines (PDQ)</h2>
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>Total: {fmtGBP(totalPdq)}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{machines.map((m, i) => {
const isOrphaned = !machineNamesRef.current.includes(m.machine_name)
const inputDisabled = isFinal || isOrphaned
return (
<div key={m.machine_name} style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem',
border: `1px solid ${isOrphaned ? '#fca5a5' : 'var(--card-border)'}`,
borderRadius: '8px', padding: '1rem',
background: isOrphaned ? '#fff8f8' : undefined,
}}>
{/* Left: amount inputs */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.75rem' }}>
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--text-mid)' }}>
{m.machine_name}
</h3>
{isOrphaned && (
<span style={{ fontSize: '0.65rem', fontWeight: 700, color: '#b91c1c', background: '#fee2e2', padding: '0.1rem 0.4rem', borderRadius: '3px', whiteSpace: 'nowrap' }}>
Removed from settings
</span>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
<MoneyInput label="Total" value={m.total_amount}
onChange={v => updateMachine(i, 'total_amount', v)} disabled={inputDisabled} />
<MoneyInput label="Amex" value={m.amex_amount}
onChange={v => updateMachine(i, 'amex_amount', v)} disabled={inputDisabled} />
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', paddingTop: '0.25rem' }}>
<span style={{ color: 'var(--text-mid)' }}>Visa / MC</span>
<span style={{ fontWeight: 600 }}>{fmtGBP(m.visa_mc_amount)}</span>
</div>
</div>
</div>
{/* Right: PDQ Z-report upload */}
<div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '0.5rem', fontWeight: 600 }}>
Z-Report
</div>
{cashUp ? (
<PhotoUploader
cashUpId={cashUp.id}
attachmentType="pdq_z_report"
label={m.machine_name}
attachments={attachments}
onAdded={a => setAttachments(prev => [...prev, a])}
onRemoved={id => setAttachments(prev => prev.filter(a => a.id !== id))}
disabled={isFinal}
/>
) : (
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', fontStyle: 'italic' }}>
Save a draft first to upload photos.
</p>
)}
</div>
</div>
)
})}
</div>
</Card>
{/* Newbook + Reconciliation */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Newbook Reconciliation</h2>
<Btn onClick={fetchNewbook} disabled={fetching || isFinal} small>
{fetching ? <><Loader size={13} style={{ animation: 'spin 1s linear infinite' }} /> Fetching</> : <><RefreshCw size={13} /> Fetch Payments</>}
</Btn>
</div>
{newbookTotals && (
<>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', fontSize: '0.875rem', borderCollapse: 'collapse', minWidth: '360px' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
<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, 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={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>
)
})}
</tbody>
</table>
</div>
{tillPayments.length > 0 && (
<div style={{ marginTop: '1rem' }}>
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-mid)' }}>
Till / Restaurant Transactions
</h3>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', fontSize: '0.8rem', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--card-border)' }}>
<th style={{ padding: '0.35rem 0.5rem', textAlign: 'left', color: 'var(--text-mid)' }}>Type</th>
<th style={{ padding: '0.35rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>Qty</th>
<th style={{ padding: '0.35rem 0.5rem', textAlign: 'right', color: 'var(--text-mid)' }}>Total</th>
</tr>
</thead>
<tbody>
{tillPayments.map(t => (
<tr key={t.payment_type} style={{ borderBottom: '1px solid var(--card-border)' }}>
<td style={{ padding: '0.35rem 0.5rem' }}>{t.payment_type}</td>
<td style={{ padding: '0.35rem 0.5rem', textAlign: 'right' }}>{t.quantity}</td>
<td style={{ padding: '0.35rem 0.5rem', textAlign: 'right' }}>{fmtGBP(t.total_value)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{transactionBreakdown && (
<TransactionChecklist
breakdown={transactionBreakdown}
checked={checkedItems}
onToggle={key => setCheckedItems(prev => {
const next = new Set(prev)
next.has(key) ? next.delete(key) : next.add(key)
return next
})}
/>
)}
</>
)}
</Card>
{/* Receipt / discrepancy evidence */}
{cashUp && (
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '0.5rem' }}>Receipt Evidence &amp; Error Photos</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '0.75rem' }}>
Upload photos of receipts with errors, discrepancies or anything needing a record.
</p>
<PhotoUploader
cashUpId={cashUp.id}
attachmentType="receipt_error"
label={null}
attachments={attachments}
onAdded={a => setAttachments(prev => [...prev, a])}
onRemoved={id => setAttachments(prev => prev.filter(a => a.id !== id))}
disabled={false}
/>
</Card>
)}
{/* Notes */}
<Card style={{ marginBottom: '1rem' }}>
<label style={{ fontSize: '0.875rem', fontWeight: 600, display: 'block', marginBottom: '0.5rem' }}>Notes</label>
<textarea
value={notes} onChange={e => setNotes(e.target.value)}
disabled={isFinal}
rows={3}
placeholder="Explain any variances or issues…"
style={{
width: '100%', border: '1px solid var(--card-border)', borderRadius: '6px',
padding: '0.6rem 0.75rem', fontSize: '0.875rem', resize: 'vertical',
background: isFinal ? 'var(--body-bg)' : 'white',
}}
/>
</Card>
{/* Action buttons */}
{pageState === 'editing' && (
<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'}
</Btn>
{canFinalise ? (
<Btn onClick={() => save('final')} disabled={saving}>
<CheckCircle size={14} style={{ marginRight: '0.4rem' }} />
{saving ? 'Submitting…' : 'Submit Final'}
</Btn>
) : (
<span style={{ fontSize: '0.8rem', color: 'var(--text-mid)' }}>
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>
)}
</> /* end editing | locked */}
<style>{`@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }`}</style>
</div>
)
}
function DenomGrid({ denoms, onChange, disabled, tabBase = 0 }: {
denoms: Denomination[]
onChange: (idx: number, field: 'quantity' | 'value_entered', val: string) => void
disabled: boolean
tabBase?: number
}) {
const n = denoms.length
return (
<div>
<div style={{ display: 'grid', gridTemplateColumns: '80px 1fr 1fr 80px', gap: '0.25rem 0.5rem', marginBottom: '0.35rem' }}>
{['Denom', 'Qty', 'Value Override', 'Total'].map(h => (
<span key={h} style={{ fontSize: '0.75rem', color: 'var(--text-mid)', fontWeight: 600 }}>{h}</span>
))}
</div>
{GBP_DENOMINATIONS.map((d, i) => {
const row = denoms[i]
return (
<div key={d.value} style={{ display: 'grid', gridTemplateColumns: '80px 1fr 1fr 80px', gap: '0.25rem 0.5rem', marginBottom: '0.2rem', alignItems: 'center' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600 }}>{d.label}</span>
<input
type="number" min="0" step="1"
value={row.quantity ?? ''}
onChange={e => onChange(i, 'quantity', e.target.value)}
disabled={disabled}
placeholder="0"
tabIndex={tabBase + i + 1}
style={inputSt}
/>
<input
type="number" min="0" step="0.01"
value={row.value_entered ?? ''}
onChange={e => onChange(i, 'value_entered', e.target.value)}
disabled={disabled || row.quantity !== null}
placeholder="—"
tabIndex={tabBase + n + i + 1}
style={{ ...inputSt, opacity: row.quantity !== null ? 0.4 : 1 }}
/>
<span style={{ fontSize: '0.875rem', textAlign: 'right' }}>
{row.total_amount > 0 ? fmtGBP(row.total_amount) : '—'}
</span>
</div>
)
})}
</div>
)
}
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 700)
useEffect(() => {
const mq = window.matchMedia('(max-width: 699px)')
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches)
mq.addEventListener('change', handler)
return () => mq.removeEventListener('change', handler)
}, [])
return isMobile
}
async function compressImage(file: File, maxWidth = 1600, quality = 0.85): Promise<File> {
if (!file.type.startsWith('image/')) return file
return new Promise(resolve => {
const img = new Image()
const url = URL.createObjectURL(file)
img.onload = () => {
URL.revokeObjectURL(url)
const scale = img.width > maxWidth ? maxWidth / img.width : 1
const canvas = document.createElement('canvas')
canvas.width = Math.round(img.width * scale)
canvas.height = Math.round(img.height * scale)
canvas.getContext('2d')!.drawImage(img, 0, 0, canvas.width, canvas.height)
canvas.toBlob(blob => {
if (!blob) { resolve(file); return }
const name = file.name.replace(/\.[^.]+$/, '.jpg')
const compressed = new File([blob], name, { type: 'image/jpeg' })
resolve(compressed.size < file.size ? compressed : file)
}, 'image/jpeg', quality)
}
img.onerror = () => { URL.revokeObjectURL(url); resolve(file) }
img.src = url
})
}
function PhotoUploader({
cashUpId, attachmentType, label, attachments, onAdded, onRemoved, disabled,
}: {
cashUpId: number
attachmentType: 'pdq_z_report' | 'receipt_error' | 'other'
label: string | null
attachments: Attachment[]
onAdded: (a: Attachment) => void
onRemoved: (id: number) => void
disabled: boolean
}) {
const [uploading, setUploading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const cameraRef = useRef<HTMLInputElement>(null)
const isMobile = useIsMobile()
const mine = attachments.filter(a =>
a.attachment_type === attachmentType &&
(label === null ? !a.label : a.label === label)
)
const handleFiles = useCallback(async (files: FileList, inputEl?: HTMLInputElement | null) => {
setUploading(true)
for (const file of Array.from(files)) {
try {
const toUpload = await compressImage(file)
const a = await uploadAttachment(cashUpId, toUpload, attachmentType, label ?? undefined)
onAdded(a as Attachment)
} catch (e) {
console.error('Upload failed', e)
}
}
setUploading(false)
if (inputEl) inputEl.value = ''
}, [cashUpId, attachmentType, label, onAdded])
async function remove(id: number) {
await api.delete(`/attachments/${id}`)
onRemoved(id)
}
const thumbBtn: React.CSSProperties = {
width: '88px', height: '88px', border: '2px dashed var(--card-border)', borderRadius: '6px',
background: 'var(--body-bg)', cursor: 'pointer', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: '4px', color: 'var(--text-mid)', fontSize: '0.7rem',
}
return (
<div style={{ display: 'flex', gap: '0.625rem', flexWrap: 'wrap', alignItems: 'flex-start' }}>
{mine.map(a => (
<div key={a.id} style={{ position: 'relative', width: '88px', height: '88px', flexShrink: 0 }}>
{a.mime_type.startsWith('image/') ? (
<a href={`/cashup/api/uploads${a.file_path}`} target="_blank" rel="noopener noreferrer">
<img src={`/cashup/api/uploads${a.file_path}`} alt={a.file_name}
style={{ width: '88px', height: '88px', objectFit: 'cover', borderRadius: '6px', border: '1px solid var(--card-border)', display: 'block' }} />
</a>
) : (
<a href={`/cashup/api/uploads${a.file_path}`} target="_blank" rel="noopener noreferrer"
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '88px', height: '88px', border: '1px solid var(--card-border)', borderRadius: '6px', background: 'var(--body-bg)', textDecoration: 'none', color: 'var(--text-mid)', gap: '4px' }}>
<FileText size={24} />
<span style={{ fontSize: '0.6rem', textAlign: 'center', padding: '0 4px', wordBreak: 'break-all' }}>
{a.file_name.slice(0, 14)}
</span>
</a>
)}
{!disabled && (
<button onClick={() => remove(a.id)}
style={{ position: 'absolute', top: '-8px', right: '-8px', width: '20px', height: '20px', borderRadius: '50%', background: '#dc2626', color: '#fff', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1 }}>
<X size={10} />
</button>
)}
</div>
))}
{!disabled && !isMobile && (
<button onClick={() => inputRef.current?.click()} style={thumbBtn}>
{uploading ? <Loader size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <><Camera size={18} /><span>Add photo</span></>}
</button>
)}
{!disabled && isMobile && (
<>
<button onClick={() => cameraRef.current?.click()} style={thumbBtn}>
{uploading ? <Loader size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <><Camera size={18} /><span>Camera</span></>}
</button>
<button onClick={() => inputRef.current?.click()} style={thumbBtn}>
<FileText size={18} /><span>Library</span>
</button>
</>
)}
{/* Desktop / library picker — supports multiple files */}
<input ref={inputRef} type="file" accept="image/*" multiple hidden
onChange={e => e.target.files?.length && handleFiles(e.target.files, inputRef.current)} />
{/* Mobile camera — single capture, goes straight to rear camera */}
<input ref={cameraRef} type="file" accept="image/*" capture="environment" hidden
onChange={e => e.target.files?.length && handleFiles(e.target.files, cameraRef.current)} />
</div>
)
}
const inputSt: React.CSSProperties = {
border: '1px solid var(--card-border)', borderRadius: '4px',
padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%',
background: 'white',
}
const SECTION_LABELS: Record<keyof TransactionBreakdown, string> = {
reception_manual: 'Reception — Manual',
reception_gateway: 'Reception — Gateway',
restaurant_bar: 'Restaurant / Bar',
}
function TransactionChecklist({ breakdown, checked, onToggle }: {
breakdown: TransactionBreakdown
checked: Set<string>
onToggle: (key: string) => void
}) {
const [openSections, setOpenSections] = useState<Set<string>>(new Set())
const allItems: Array<{ key: string; section: string; category: string; item: TransactionItem }> = []
for (const [section, categories] of Object.entries(breakdown) as [keyof TransactionBreakdown, Record<string, TransactionItem[]>][]) {
for (const [category, items] of Object.entries(categories)) {
items.forEach((item, i) => allItems.push({ key: `${section}:${category}:${i}`, section, category, item }))
}
}
const totalItems = allItems.length
const checkedCount = allItems.filter(a => checked.has(a.key)).length
if (totalItems === 0) return null
const toggleSection = (s: string) => setOpenSections(prev => {
const next = new Set(prev); next.has(s) ? next.delete(s) : next.add(s); return next
})
return (
<div style={{ marginTop: '1.25rem', borderTop: '1px solid var(--card-border)', paddingTop: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<h3 style={{ fontSize: '0.875rem', fontWeight: 700 }}>Transaction Breakdown</h3>
<span style={{ fontSize: '0.75rem', color: checkedCount === totalItems ? 'var(--success)' : 'var(--text-mid)' }}>
{checkedCount}/{totalItems} checked
</span>
</div>
{(Object.entries(breakdown) as [keyof TransactionBreakdown, Record<string, TransactionItem[]>][]).map(([section, categories]) => {
const sectionItems = Object.values(categories).flat()
if (sectionItems.length === 0) return null
const sectionKeys = Object.entries(categories).flatMap(([cat, items]) =>
items.map((_, i) => `${section}:${cat}:${i}`)
)
const sectionChecked = sectionKeys.filter(k => checked.has(k)).length
const isOpen = openSections.has(section)
return (
<div key={section} style={{ marginBottom: '0.5rem', border: '1px solid var(--card-border)', borderRadius: '6px', overflow: 'hidden' }}>
<button onClick={() => toggleSection(section)} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
width: '100%', padding: '0.5rem 0.75rem', background: 'var(--body-bg)',
border: 'none', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600,
}}>
<span>{SECTION_LABELS[section]}</span>
<span style={{ color: sectionChecked === sectionKeys.length ? 'var(--success)' : 'var(--text-mid)', fontSize: '0.75rem' }}>
{sectionChecked}/{sectionKeys.length} {isOpen ? '▲' : '▼'}
</span>
</button>
{isOpen && (
<div style={{ padding: '0.25rem 0' }}>
{Object.entries(categories).map(([category, items]) => (
<div key={category}>
<div style={{ padding: '0.25rem 0.75rem', fontSize: '0.7rem', fontWeight: 700, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.05em', background: 'var(--card-bg)' }}>
{category}
</div>
{items.map((item, i) => {
const key = `${section}:${category}:${i}`
const isDone = checked.has(key)
return (
<div key={key} onClick={() => onToggle(key)} style={{
display: 'grid', gridTemplateColumns: '20px 1fr auto auto',
gap: '0.5rem', alignItems: 'center',
padding: '0.35rem 0.75rem', cursor: 'pointer',
background: isDone ? '#f0fdf4' : 'white',
borderBottom: '1px solid var(--card-border)',
opacity: isDone ? 0.6 : 1,
}}>
<span style={{ fontSize: '0.9rem', color: isDone ? 'var(--success)' : 'var(--text-mid)' }}>
{isDone ? '✓' : '○'}
</span>
<div>
<span style={{ fontSize: '0.8rem', textDecoration: isDone ? 'line-through' : 'none' }}>
{item.details || item.payment_type}
</span>
{item.time && (
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)', marginLeft: '0.5rem' }}>{item.time.slice(11, 16)}</span>
)}
</div>
<span style={{ fontSize: '0.7rem', color: 'var(--text-mid)' }}>{item.payment_type}</span>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: item.is_voided ? 'var(--danger)' : undefined }}>
{item.is_voided ? '-' : ''}{fmtGBP(Math.abs(item.amount))}
</span>
</div>
)
})}
</div>
))}
</div>
)}
</div>
)
})}
</div>
)
}
function MoneyInput({ label, value, onChange, disabled }: {
label: string; value: number; onChange: (v: string) => void; disabled: boolean
}) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.5rem' }}>
<label style={{ fontSize: '0.8rem', color: 'var(--text-mid)', minWidth: '60px' }}>{label}</label>
<input
type="number" min="0" step="0.01"
value={value || ''}
onChange={e => onChange(e.target.value)}
disabled={disabled}
placeholder="0.00"
style={{ ...inputSt, width: '110px', textAlign: 'right' }}
/>
</div>
)
}