Wire Newbook credentials to settings service

This commit is contained in:
jtricerolph 2026-07-01 19:33:16 +00:00
commit 63a5a72fa3
32 changed files with 3386 additions and 0 deletions

View file

@ -0,0 +1,455 @@
import { useState, useCallback } from 'react'
import { RefreshCw, Save, CheckCircle, Loader } from 'lucide-react'
import { api } from '../api'
import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout'
import {
GBP_DENOMINATIONS, fmtGBP, today,
type User, type CashUp, type Denomination, type CardMachine,
type PaymentTotals, type ReconciliationRow, type TillPayment, type Attachment,
} 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 + d.total_amount, 0)
}
interface Props { user: User }
export function DailyCashUp({ user: _user }: Props) {
const [date, setDate] = useState(today())
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 [fetching, setFetching] = useState(false)
const [saving, setSaving] = useState(false)
const [loading, setLoading] = useState(false)
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
const [showFloat, setShowFloat] = useState(false)
const isFinal = cashUp?.status === 'final'
function flash(text: string, ok = true) {
setMsg({ text, ok })
setTimeout(() => setMsg(null), 4000)
}
async function loadExisting() {
setLoading(true)
try {
const data = await api.get<{
cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[];
reconciliation: ReconciliationRow[]; attachments: Attachment[]
}>(`/cashup?date=${date}`)
setCashUp(data.cash_up)
setNotes(data.cash_up.notes || '')
setAttachments(data.attachments || [])
// Rebuild denomination grids from saved data
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 }
: { 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 }
}))
}
flash('Loaded existing cash up.')
} catch (e: unknown) {
if (e instanceof Error && e.message === 'Not found') flash('No cash up for this date.', false)
else flash('Failed to load.', false)
} finally {
setLoading(false)
}
}
function reset() {
setCashUp(null)
setTakings(initDenominations('takings'))
setFloat(initDenominations('float'))
setMachines(initMachines())
setNotes('')
setAttachments([])
setNewbookTotals(null)
setTillPayments([])
}
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[]
}>('/newbook/payments', { date })
setNewbookTotals(data.totals)
setTillPayments(data.till_payments || [])
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)
}
} 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" subtitle={cashUp ? `Status: ` : undefined} />
{cashUp && <div style={{ marginTop: '-1rem', marginBottom: '1rem' }}><StatusBadge status={cashUp.status} /></div>}
{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} disabled={isFinal}
onChange={e => { setDate(e.target.value); reset() }}
style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.45rem 0.75rem', fontSize: '0.9rem' }}
/>
</div>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end', paddingBottom: '1px' }}>
<Btn onClick={loadExisting} disabled={loading || isFinal} variant="secondary" small>
{loading ? 'Loading…' : 'Load Existing'}
</Btn>
{cashUp && <Btn onClick={reset} variant="ghost" small>New</Btn>}
</div>
</Card>
{/* 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} />
</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} />
</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>
{/* 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>
)}
</>
)}
</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 */}
{!isFinal && (
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Btn onClick={() => save('draft')} disabled={saving} variant="secondary">
<Save size={14} style={{ marginRight: '0.4rem' }} />
{saving ? 'Saving…' : 'Save Draft'}
</Btn>
<Btn onClick={() => save('final')} disabled={saving}>
<CheckCircle size={14} style={{ marginRight: '0.4rem' }} />
{saving ? 'Submitting…' : 'Submit Final'}
</Btn>
</div>
)}
{isFinal && (
<div style={{ color: 'var(--text-mid)', fontSize: '0.875rem', fontStyle: 'italic' }}>
This cash up has been finalised and cannot be edited.
</div>
)}
<style>{`@keyframes spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }`}</style>
</div>
)
}
function DenomGrid({ denoms, onChange, disabled }: {
denoms: Denomination[]
onChange: (idx: number, field: 'quantity' | 'value_entered', val: string) => void
disabled: boolean
}) {
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"
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="—"
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>
)
}
const inputSt: React.CSSProperties = {
border: '1px solid var(--card-border)', borderRadius: '4px',
padding: '0.3rem 0.5rem', fontSize: '0.875rem', width: '100%',
background: 'white',
}
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>
)
}