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>
947 lines
44 KiB
TypeScript
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 & 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>
|
|
)
|
|
}
|