Backend (server-side enforcement, not just UI): - auth.js: read caps from JWT; hasCap() + requireCap() helpers; legacy-token fallback (full access minus settings) so existing sessions keep working until re-login - finalise: submit final, delete draft, bulk-finalise, attachments - reports: multiday report, cash summary, debtors - floats: float management + safe count - settings: settings mutations (was is_admin) - count: draft save, newbook fetch Frontend: - can(user, cap) helper; User.caps from /verify - Nav items, routes and actions (Submit Final, delete, bulk-finalise) gated on capabilities; non-finalisers see a draft-only hint Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
747 lines
33 KiB
TypeScript
747 lines
33 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { RefreshCw, Save, CheckCircle, Loader, Camera, FileText, X } from 'lucide-react'
|
|
import { api, uploadAttachment } from '../api'
|
|
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
|
|
import {
|
|
GBP_DENOMINATIONS, fmtGBP, today, can,
|
|
type User, type CashUp, type Denomination, type CardMachine,
|
|
type PaymentTotals, type ReconciliationRow, type TillPayment, type Attachment,
|
|
type TransactionBreakdown, type TransactionItem,
|
|
} from '../types'
|
|
|
|
const MACHINES = ['Front Desk', 'Restaurant / Bar']
|
|
|
|
function initDenominations(countType: 'takings' | 'float'): Denomination[] {
|
|
return GBP_DENOMINATIONS.map(d => ({
|
|
count_type: countType,
|
|
denomination_type: d.type,
|
|
denomination_value: d.value,
|
|
quantity: null,
|
|
value_entered: null,
|
|
total_amount: 0,
|
|
}))
|
|
}
|
|
|
|
function initMachines(): CardMachine[] {
|
|
return MACHINES.map(name => ({ machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 }))
|
|
}
|
|
|
|
function denomTotal(denoms: Denomination[]) {
|
|
return denoms.reduce((s, d) => s + (Number(d.total_amount) || 0), 0)
|
|
}
|
|
|
|
interface Props { user: User }
|
|
|
|
// flow: checking → empty (no record) | editing (draft) | locked (final)
|
|
type PageState = 'checking' | 'empty' | 'editing' | 'locked'
|
|
|
|
export function DailyCashUp({ user }: Props) {
|
|
const canFinalise = can(user, 'finalise')
|
|
const [date, setDate] = useState(today())
|
|
const [pageState, setPageState] = useState<PageState>('checking')
|
|
const [cashUp, setCashUp] = useState<CashUp | null>(null)
|
|
const [takings, setTakings] = useState<Denomination[]>(initDenominations('takings'))
|
|
const [float, setFloat] = useState<Denomination[]>(initDenominations('float'))
|
|
const [machines, setMachines] = useState<CardMachine[]>(initMachines())
|
|
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 [fetching, setFetching] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
|
const [showFloat, setShowFloat] = useState(false)
|
|
|
|
const isFinal = pageState === 'locked'
|
|
|
|
function flash(text: string, ok = true) {
|
|
setMsg({ text, ok })
|
|
setTimeout(() => setMsg(null), 4000)
|
|
}
|
|
|
|
function applyLoaded(data: {
|
|
cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[]; attachments: Attachment[]
|
|
}) {
|
|
setCashUp(data.cash_up)
|
|
setNotes(data.cash_up.notes || '')
|
|
setAttachments(data.attachments || [])
|
|
|
|
const rebuild = (ct: 'takings' | 'float') =>
|
|
GBP_DENOMINATIONS.map(d => {
|
|
const saved = data.denominations.find(
|
|
s => s.count_type === ct && parseFloat(String(s.denomination_value)) === d.value
|
|
)
|
|
return saved
|
|
? {
|
|
...saved,
|
|
denomination_value: d.value,
|
|
total_amount: parseFloat(String(saved.total_amount)) || 0,
|
|
value_entered: saved.value_entered != null ? parseFloat(String(saved.value_entered)) : null,
|
|
}
|
|
: { count_type: ct, denomination_type: d.type, denomination_value: d.value, quantity: null, value_entered: null, total_amount: 0 }
|
|
})
|
|
|
|
setTakings(rebuild('takings'))
|
|
setFloat(rebuild('float'))
|
|
|
|
if (data.card_machines.length) {
|
|
setMachines(MACHINES.map(name => {
|
|
const m = data.card_machines.find(c => c.machine_name === name)
|
|
return m ?? { machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 }
|
|
}))
|
|
}
|
|
|
|
setPageState(data.cash_up.status === 'final' ? 'locked' : 'editing')
|
|
}
|
|
|
|
// Auto-check on mount and whenever date changes; auto-fetch Newbook in parallel
|
|
useEffect(() => {
|
|
setPageState('checking')
|
|
setCashUp(null)
|
|
setTakings(initDenominations('takings'))
|
|
setFloat(initDenominations('float'))
|
|
setMachines(initMachines())
|
|
setNotes('')
|
|
setAttachments([])
|
|
setNewbookTotals(null)
|
|
setTillPayments([])
|
|
setTransactionBreakdown(null)
|
|
setCheckedItems(new Set())
|
|
|
|
api.get<{
|
|
cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[];
|
|
reconciliation: ReconciliationRow[]; attachments: Attachment[]
|
|
}>(`/cashup?date=${date}`)
|
|
.then(data => applyLoaded(data))
|
|
.catch(e => {
|
|
if (e instanceof Error && e.message === 'Not found') setPageState('empty')
|
|
else { flash('Failed to load data for this date.', false); setPageState('empty') }
|
|
})
|
|
|
|
// Auto-fetch Newbook — soft failure, user can retry with the button
|
|
api.post<{ count: number; totals: PaymentTotals; till_payments: TillPayment[]; transaction_breakdown: TransactionBreakdown }>(
|
|
'/newbook/payments', { date }
|
|
).then(data => {
|
|
setNewbookTotals(data.totals)
|
|
setTillPayments(data.till_payments || [])
|
|
setTransactionBreakdown(data.transaction_breakdown || null)
|
|
}).catch(() => { /* silently ignore — button still available */ })
|
|
}, [date])
|
|
|
|
function updateDenom(list: Denomination[], setList: (d: Denomination[]) => void, idx: number, field: 'quantity' | 'value_entered', raw: string) {
|
|
const val = raw === '' ? null : parseFloat(raw)
|
|
const updated = list.map((d, i) => {
|
|
if (i !== idx) return d
|
|
if (field === 'quantity') {
|
|
const qty = val === null ? null : Math.max(0, Math.round(val))
|
|
return { ...d, quantity: qty, value_entered: null, total_amount: qty === null ? 0 : qty * d.denomination_value }
|
|
} else {
|
|
const ve = val === null ? null : Math.max(0, val)
|
|
return { ...d, value_entered: ve, quantity: null, total_amount: ve ?? 0 }
|
|
}
|
|
})
|
|
setList(updated)
|
|
}
|
|
|
|
function updateMachine(idx: number, field: 'total_amount' | 'amex_amount', raw: string) {
|
|
const val = raw === '' ? 0 : parseFloat(raw) || 0
|
|
setMachines(machines.map((m, i) => {
|
|
if (i !== idx) return m
|
|
const total = field === 'total_amount' ? val : m.total_amount
|
|
const amex = field === 'amex_amount' ? val : m.amex_amount
|
|
return { ...m, total_amount: total, amex_amount: amex, visa_mc_amount: Math.max(0, total - amex) }
|
|
}))
|
|
}
|
|
|
|
async function fetchNewbook() {
|
|
setFetching(true)
|
|
try {
|
|
const data = await api.post<{
|
|
count: number; totals: PaymentTotals; till_payments: TillPayment[]; transaction_breakdown: TransactionBreakdown
|
|
}>('/newbook/payments', { date })
|
|
setNewbookTotals(data.totals)
|
|
setTillPayments(data.till_payments || [])
|
|
setTransactionBreakdown(data.transaction_breakdown || null)
|
|
setCheckedItems(new Set())
|
|
flash(`Fetched ${data.count} payment(s) from Newbook.`)
|
|
} catch (e: unknown) {
|
|
flash(e instanceof Error ? e.message : 'Failed to fetch Newbook data.', false)
|
|
} finally {
|
|
setFetching(false)
|
|
}
|
|
}
|
|
|
|
async function save(status: 'draft' | 'final') {
|
|
setSaving(true)
|
|
try {
|
|
const allDenoms = [...takings, ...float].filter(d => d.total_amount > 0)
|
|
const result = await api.post<{ cash_up_id: number; message: string }>('/cashup/save', {
|
|
session_date: date,
|
|
status,
|
|
notes,
|
|
denominations: allDenoms.map(d => ({
|
|
count_type: d.count_type,
|
|
type: d.denomination_type,
|
|
value: d.denomination_value,
|
|
quantity: d.quantity,
|
|
value_entered: d.value_entered,
|
|
total_amount: d.total_amount,
|
|
})),
|
|
card_machines: machines.map(m => ({
|
|
name: m.machine_name,
|
|
total: m.total_amount,
|
|
amex: m.amex_amount,
|
|
visa_mc: m.visa_mc_amount,
|
|
})),
|
|
})
|
|
flash(result.message)
|
|
if (status === 'final') {
|
|
setCashUp(prev => prev ? { ...prev, status: 'final' } : null)
|
|
setPageState('locked')
|
|
}
|
|
} catch (e: unknown) {
|
|
flash(e instanceof Error ? e.message : 'Save failed.', false)
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
// Build reconciliation rows from local + Newbook data
|
|
const recon: ReconciliationRow[] = newbookTotals ? [
|
|
{ category: 'Cash', banked_amount: denomTotal(takings), reported_amount: newbookTotals.cash },
|
|
{ category: 'PDQ Visa/MC', banked_amount: machines.reduce((s, m) => s + m.visa_mc_amount, 0), reported_amount: newbookTotals.manual_visa_mc },
|
|
{ category: 'PDQ Amex', banked_amount: machines.reduce((s, m) => s + m.amex_amount, 0), reported_amount: newbookTotals.manual_amex },
|
|
{ category: 'Gateway Visa/MC', banked_amount: newbookTotals.gateway_visa_mc, reported_amount: newbookTotals.gateway_visa_mc },
|
|
{ category: 'Gateway Amex', banked_amount: newbookTotals.gateway_amex, reported_amount: newbookTotals.gateway_amex },
|
|
{ category: 'BACS', banked_amount: newbookTotals.bacs, reported_amount: newbookTotals.bacs },
|
|
] : []
|
|
|
|
const totalPdq = machines.reduce((s, m) => s + m.total_amount, 0)
|
|
|
|
return (
|
|
<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') && <>
|
|
|
|
{/* Cash denomination — Takings */}
|
|
<Card style={{ marginBottom: '1rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
|
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Cash Takings</h2>
|
|
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>{fmtGBP(denomTotal(takings))}</span>
|
|
</div>
|
|
<DenomGrid denoms={takings} onChange={(i, f, v) => updateDenom(takings, setTakings, i, f, v)} disabled={isFinal} tabBase={0} />
|
|
</Card>
|
|
|
|
{/* Float (collapsible) */}
|
|
<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(denomTotal(float))} {showFloat ? '▲' : '▼'}
|
|
</span>
|
|
</button>
|
|
{showFloat && (
|
|
<div style={{ marginTop: '1rem' }}>
|
|
<DenomGrid denoms={float} onChange={(i, f, v) => updateDenom(float, setFloat, i, f, v)} disabled={isFinal} tabBase={24} />
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Card Machines */}
|
|
<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: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
|
{machines.map((m, i) => (
|
|
<div key={m.machine_name} style={{ border: '1px solid var(--card-border)', borderRadius: '8px', padding: '1rem' }}>
|
|
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>
|
|
{m.machine_name}
|
|
</h3>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
<MoneyInput label="Total" value={m.total_amount}
|
|
onChange={v => updateMachine(i, 'total_amount', v)} disabled={isFinal} />
|
|
<MoneyInput label="Amex" value={m.amex_amount}
|
|
onChange={v => updateMachine(i, 'amex_amount', v)} disabled={isFinal} />
|
|
<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>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* PDQ Z-Reports — one upload area per machine */}
|
|
{cashUp && (
|
|
<Card style={{ marginBottom: '1rem' }}>
|
|
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '0.75rem' }}>PDQ Z-Reports</h2>
|
|
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginBottom: '1rem' }}>
|
|
Upload the end-of-day Z-report printout for each card machine.
|
|
</p>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
|
{MACHINES.map(name => (
|
|
<div key={name}>
|
|
<div style={{ fontSize: '0.8rem', fontWeight: 600, marginBottom: '0.5rem' }}>{name}</div>
|
|
<PhotoUploader
|
|
cashUpId={cashUp.id}
|
|
attachmentType="pdq_z_report"
|
|
label={name}
|
|
attachments={attachments}
|
|
onAdded={a => setAttachments(prev => [...prev, a])}
|
|
onRemoved={id => setAttachments(prev => prev.filter(a => a.id !== id))}
|
|
disabled={isFinal}
|
|
/>
|
|
</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 && (
|
|
<>
|
|
<table style={{ width: '100%', fontSize: '0.875rem', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: '2px solid var(--card-border)' }}>
|
|
{['Category', 'Banked', 'Reported', 'Variance'].map(h => (
|
|
<th key={h} style={{ padding: '0.4rem 0.5rem', textAlign: h === 'Category' ? 'left' : 'right', color: 'var(--text-mid)', fontWeight: 600 }}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recon.map(row => {
|
|
const variance = row.banked_amount - row.reported_amount
|
|
const varColor = Math.abs(variance) < 0.01 ? 'var(--text-mid)' : variance > 0 ? 'var(--success)' : 'var(--danger)'
|
|
return (
|
|
<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={{ padding: '0.5rem', textAlign: 'right', color: varColor, fontWeight: 600 }}>
|
|
{Math.abs(variance) < 0.01 ? '—' : (variance > 0 ? '+' : '') + fmtGBP(Math.abs(variance))}
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
|
|
{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>
|
|
<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>
|
|
)}
|
|
{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' }}>
|
|
<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>
|
|
)}
|
|
</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 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 mine = attachments.filter(a =>
|
|
a.attachment_type === attachmentType &&
|
|
(label === null ? !a.label : a.label === label)
|
|
)
|
|
|
|
async function handleFiles(files: FileList) {
|
|
setUploading(true)
|
|
for (const file of Array.from(files)) {
|
|
try {
|
|
const a = await uploadAttachment(cashUpId, file, attachmentType, label ?? undefined)
|
|
onAdded(a as Attachment)
|
|
} catch (e) {
|
|
console.error('Upload failed', e)
|
|
}
|
|
}
|
|
setUploading(false)
|
|
if (inputRef.current) inputRef.current.value = ''
|
|
}
|
|
|
|
async function remove(id: number) {
|
|
await api.delete(`/attachments/${id}`)
|
|
onRemoved(id)
|
|
}
|
|
|
|
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 && (
|
|
<button onClick={() => inputRef.current?.click()}
|
|
style={{ 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' }}>
|
|
{uploading ? <Loader size={18} style={{ animation: 'spin 1s linear infinite' }} /> : <><Camera size={18} /><span>Add photo</span></>}
|
|
</button>
|
|
)}
|
|
<input ref={inputRef} type="file" accept="image/*,application/pdf" multiple hidden
|
|
onChange={e => e.target.files?.length && handleFiles(e.target.files)} />
|
|
</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>
|
|
)
|
|
}
|