Cashup: configurable card machines — settings + inline Z-report layout

Settings: add Card Machines section to configure number of PDQ terminals
and their names (1–6 machines, defaults to Front Desk + Restaurant / Bar).
Names are stored under the 'card_machines' settings key.

DailyCashUp: machine names now load from settings instead of hardcoded
const. Card Machines section changed from a 2-column grid to a vertical
list; each machine row shows the amount inputs on the left and its PDQ
Z-report photo uploader on the right. Separate PDQ Z-Reports card removed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-07 11:32:37 +00:00
parent 13f1b524b0
commit dcb450bd99
3 changed files with 113 additions and 49 deletions

View file

@ -5,6 +5,7 @@ import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
const ALL_KEYS = [
'default_report_days', 'petty_cash_float', 'till_float_target',
'sales_breakdown_columns', 'change_tin_breakdown', 'change_tin_notes',
'card_machines',
]
export async function settingsRoutes(app) {

View file

@ -9,7 +9,7 @@ import {
type TransactionBreakdown, type TransactionItem,
} from '../types'
const MACHINES = ['Front Desk', 'Restaurant / Bar']
const DEFAULT_MACHINE_NAMES = ['Front Desk', 'Restaurant / Bar']
function initDenominations(countType: 'takings' | 'float'): Denomination[] {
return GBP_DENOMINATIONS.map(d => ({
@ -22,8 +22,8 @@ function initDenominations(countType: 'takings' | 'float'): Denomination[] {
}))
}
function initMachines(): CardMachine[] {
return MACHINES.map(name => ({ machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_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[]) {
@ -42,7 +42,8 @@ export function DailyCashUp({ user }: Props) {
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 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)
@ -91,7 +92,7 @@ export function DailyCashUp({ user }: Props) {
setFloat(rebuild('float'))
if (data.card_machines.length) {
setMachines(MACHINES.map(name => {
setMachines(machineNamesRef.current.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 {
@ -133,10 +134,24 @@ export function DailyCashUp({ user }: Props) {
return () => clearInterval(interval)
}, [pageState])
// Fetch till float target once on mount
// Fetch settings once on mount (float target + machine names)
useEffect(() => {
api.get<{ till_float_target?: string }>('/settings')
.then(s => setTillFloatTarget(parseFloat(s.till_float_target || '0') || 0))
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(() => {})
}, [])
@ -146,7 +161,7 @@ export function DailyCashUp({ user }: Props) {
setCashUp(null)
setTakings(initDenominations('takings'))
setFloat(initDenominations('float'))
setMachines(initMachines())
setMachines(initMachines(machineNamesRef.current))
setNotes('')
setAttachments([])
setNewbookTotals(null)
@ -376,15 +391,20 @@ export function DailyCashUp({ user }: Props) {
<DenomGrid denoms={takings} onChange={(i, f, v) => updateDenom(takings, setTakings, i, f, v)} disabled={isFinal} tabBase={0} />
</Card>
{/* Card Machines */}
{/* 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: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{machines.map((m, i) => (
<div key={m.machine_name} style={{ border: '1px solid var(--card-border)', borderRadius: '8px', padding: '1rem' }}>
<div key={m.machine_name} style={{
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem',
border: '1px solid var(--card-border)', borderRadius: '8px', padding: '1rem',
}}>
{/* Left: amount inputs */}
<div>
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>
{m.machine_name}
</h3>
@ -399,35 +419,31 @@ export function DailyCashUp({ user }: Props) {
</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>
</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>
{cashUp ? (
<PhotoUploader
cashUpId={cashUp.id}
attachmentType="pdq_z_report"
label={name}
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' }}>

View file

@ -3,6 +3,8 @@ import { api } from '../api'
import { PageHeader, Card, Btn } from '../components/Layout'
import { can, fmtGBP, type User } from '../types'
const DEFAULT_MACHINE_NAMES = ['Front Desk', 'Restaurant / Bar']
interface SettingsData {
default_report_days: string
petty_cash_float: string
@ -10,6 +12,7 @@ interface SettingsData {
sales_breakdown_columns: string
change_tin_breakdown: string
change_tin_notes: string
card_machines: string
}
// Banknotes use individual note qty; coins use sealed bag qty
@ -31,6 +34,7 @@ interface GlColumn { gl_code: string; display_name: string; enabled: boolean; so
export function SettingsPage({ user }: { user: User }) {
const [settings, setSettings] = useState<Partial<SettingsData>>({})
const [columns, setColumns] = useState<GlColumn[]>([])
const [machineNames, setMachineNames] = useState<string[]>(DEFAULT_MACHINE_NAMES)
const [tinTargetUnits, setTinTargetUnits] = useState<Record<string, number>>({})
const [tinDenomNotes, setTinDenomNotes] = useState<Record<string, string>>({})
const [loading, setLoading] = useState(true)
@ -48,6 +52,10 @@ export function SettingsPage({ user }: { user: User }) {
api.get<SettingsData>('/settings').then(s => {
setSettings(s)
try { setColumns(JSON.parse(s.sales_breakdown_columns || '[]')) } catch { setColumns([]) }
try {
const names = JSON.parse(s.card_machines || '[]') as string[]
setMachineNames(names.length ? names : DEFAULT_MACHINE_NAMES)
} catch { setMachineNames(DEFAULT_MACHINE_NAMES) }
try {
const breakdown = JSON.parse(s.change_tin_breakdown || '{}')
const units: Record<string, number> = {}
@ -78,6 +86,7 @@ export function SettingsPage({ user }: { user: User }) {
sales_breakdown_columns: JSON.stringify(columns),
change_tin_breakdown: JSON.stringify(tinBreakdown),
change_tin_notes: JSON.stringify(tinDenomNotes),
card_machines: JSON.stringify(machineNames),
})
flash('Settings saved.')
} catch (e: unknown) {
@ -186,6 +195,44 @@ export function SettingsPage({ user }: { user: User }) {
</div>
</Card>
{/* Card machines */}
<Card style={{ marginBottom: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<div>
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Card Machines (PDQ)</h2>
<p style={{ fontSize: '0.75rem', color: 'var(--text-mid)', marginTop: '0.2rem' }}>
Define the PDQ terminals used each day. Names appear on the cash up form.
</p>
</div>
{machineNames.length < 6 && (
<Btn small variant="ghost"
onClick={() => setMachineNames(prev => [...prev, `Machine ${prev.length + 1}`])}>
+ Add
</Btn>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{machineNames.map((name, i) => (
<div key={i} style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<span style={{ fontSize: '0.75rem', color: 'var(--text-mid)', minWidth: '20px', textAlign: 'right' }}>{i + 1}.</span>
<input
type="text"
value={name}
onChange={e => setMachineNames(prev => prev.map((n, j) => j === i ? e.target.value : n))}
placeholder={`Machine ${i + 1}`}
style={{ ...inpSt, flex: 1 }}
/>
{machineNames.length > 1 && (
<button
onClick={() => setMachineNames(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', color: '#9ca3af', fontSize: '1.1rem', cursor: 'pointer', padding: '0 4px', lineHeight: 1 }}
title="Remove machine">×</button>
)}
</div>
))}
</div>
</Card>
{/* Change tin target breakdown */}
<Card style={{ marginBottom: '1rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '0.5rem' }}>Change Tin Target</h2>