cashup/frontend/src/pages/DailyCashUp.tsx
jtricerolph 2b04d99fd6 Fix mobile overflow on reconciliation, safe count, and settings tables
Tables with multiple columns now scroll horizontally within their cards
instead of overflowing off-screen on mobile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 11:59:15 +00:00

947 lines
44 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 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={() => setPageState('editing')}>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>
)
}