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:
parent
13f1b524b0
commit
dcb450bd99
3 changed files with 113 additions and 49 deletions
|
|
@ -5,6 +5,7 @@ import { testConnection, fetchGlAccountsGrouped } from '../lib/newbook.js'
|
||||||
const ALL_KEYS = [
|
const ALL_KEYS = [
|
||||||
'default_report_days', 'petty_cash_float', 'till_float_target',
|
'default_report_days', 'petty_cash_float', 'till_float_target',
|
||||||
'sales_breakdown_columns', 'change_tin_breakdown', 'change_tin_notes',
|
'sales_breakdown_columns', 'change_tin_breakdown', 'change_tin_notes',
|
||||||
|
'card_machines',
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function settingsRoutes(app) {
|
export async function settingsRoutes(app) {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
type TransactionBreakdown, type TransactionItem,
|
type TransactionBreakdown, type TransactionItem,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
|
||||||
const MACHINES = ['Front Desk', 'Restaurant / Bar']
|
const DEFAULT_MACHINE_NAMES = ['Front Desk', 'Restaurant / Bar']
|
||||||
|
|
||||||
function initDenominations(countType: 'takings' | 'float'): Denomination[] {
|
function initDenominations(countType: 'takings' | 'float'): Denomination[] {
|
||||||
return GBP_DENOMINATIONS.map(d => ({
|
return GBP_DENOMINATIONS.map(d => ({
|
||||||
|
|
@ -22,8 +22,8 @@ function initDenominations(countType: 'takings' | 'float'): Denomination[] {
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function initMachines(): CardMachine[] {
|
function initMachines(names: string[]): CardMachine[] {
|
||||||
return MACHINES.map(name => ({ machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 }))
|
return names.map(name => ({ machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 }))
|
||||||
}
|
}
|
||||||
|
|
||||||
function denomTotal(denoms: Denomination[]) {
|
function denomTotal(denoms: Denomination[]) {
|
||||||
|
|
@ -42,7 +42,8 @@ export function DailyCashUp({ user }: Props) {
|
||||||
const [cashUp, setCashUp] = useState<CashUp | null>(null)
|
const [cashUp, setCashUp] = useState<CashUp | null>(null)
|
||||||
const [takings, setTakings] = useState<Denomination[]>(initDenominations('takings'))
|
const [takings, setTakings] = useState<Denomination[]>(initDenominations('takings'))
|
||||||
const [float, setFloat] = useState<Denomination[]>(initDenominations('float'))
|
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 [notes, setNotes] = useState('')
|
||||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||||
const [newbookTotals, setNewbookTotals] = useState<PaymentTotals | null>(null)
|
const [newbookTotals, setNewbookTotals] = useState<PaymentTotals | null>(null)
|
||||||
|
|
@ -91,7 +92,7 @@ export function DailyCashUp({ user }: Props) {
|
||||||
setFloat(rebuild('float'))
|
setFloat(rebuild('float'))
|
||||||
|
|
||||||
if (data.card_machines.length) {
|
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)
|
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 }
|
if (!m) return { machine_name: name, total_amount: 0, amex_amount: 0, visa_mc_amount: 0 }
|
||||||
return {
|
return {
|
||||||
|
|
@ -133,10 +134,24 @@ export function DailyCashUp({ user }: Props) {
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [pageState])
|
}, [pageState])
|
||||||
|
|
||||||
// Fetch till float target once on mount
|
// Fetch settings once on mount (float target + machine names)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<{ till_float_target?: string }>('/settings')
|
api.get<{ till_float_target?: string; card_machines?: string }>('/settings')
|
||||||
.then(s => setTillFloatTarget(parseFloat(s.till_float_target || '0') || 0))
|
.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(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
@ -146,7 +161,7 @@ export function DailyCashUp({ user }: Props) {
|
||||||
setCashUp(null)
|
setCashUp(null)
|
||||||
setTakings(initDenominations('takings'))
|
setTakings(initDenominations('takings'))
|
||||||
setFloat(initDenominations('float'))
|
setFloat(initDenominations('float'))
|
||||||
setMachines(initMachines())
|
setMachines(initMachines(machineNamesRef.current))
|
||||||
setNotes('')
|
setNotes('')
|
||||||
setAttachments([])
|
setAttachments([])
|
||||||
setNewbookTotals(null)
|
setNewbookTotals(null)
|
||||||
|
|
@ -376,59 +391,60 @@ export function DailyCashUp({ user }: Props) {
|
||||||
<DenomGrid denoms={takings} onChange={(i, f, v) => updateDenom(takings, setTakings, i, f, v)} disabled={isFinal} tabBase={0} />
|
<DenomGrid denoms={takings} onChange={(i, f, v) => updateDenom(takings, setTakings, i, f, v)} disabled={isFinal} tabBase={0} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Card Machines */}
|
{/* Card Machines (PDQ) — inputs + Z-report uploads side by side per machine */}
|
||||||
<Card style={{ marginBottom: '1rem' }}>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Card Machines (PDQ)</h2>
|
<h2 style={{ fontSize: '1rem', fontWeight: 700 }}>Card Machines (PDQ)</h2>
|
||||||
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>Total: {fmtGBP(totalPdq)}</span>
|
<span style={{ fontWeight: 700, fontSize: '1.1rem' }}>Total: {fmtGBP(totalPdq)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||||
{machines.map((m, i) => (
|
{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={{
|
||||||
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>
|
display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem',
|
||||||
{m.machine_name}
|
border: '1px solid var(--card-border)', borderRadius: '8px', padding: '1rem',
|
||||||
</h3>
|
}}>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
{/* Left: amount inputs */}
|
||||||
<MoneyInput label="Total" value={m.total_amount}
|
<div>
|
||||||
onChange={v => updateMachine(i, 'total_amount', v)} disabled={isFinal} />
|
<h3 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text-mid)' }}>
|
||||||
<MoneyInput label="Amex" value={m.amex_amount}
|
{m.machine_name}
|
||||||
onChange={v => updateMachine(i, 'amex_amount', v)} disabled={isFinal} />
|
</h3>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.875rem', paddingTop: '0.25rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
<span style={{ color: 'var(--text-mid)' }}>Visa / MC</span>
|
<MoneyInput label="Total" value={m.total_amount}
|
||||||
<span style={{ fontWeight: 600 }}>{fmtGBP(m.visa_mc_amount)}</span>
|
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>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* Newbook + Reconciliation */}
|
||||||
<Card style={{ marginBottom: '1rem' }}>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { api } from '../api'
|
||||||
import { PageHeader, Card, Btn } from '../components/Layout'
|
import { PageHeader, Card, Btn } from '../components/Layout'
|
||||||
import { can, fmtGBP, type User } from '../types'
|
import { can, fmtGBP, type User } from '../types'
|
||||||
|
|
||||||
|
const DEFAULT_MACHINE_NAMES = ['Front Desk', 'Restaurant / Bar']
|
||||||
|
|
||||||
interface SettingsData {
|
interface SettingsData {
|
||||||
default_report_days: string
|
default_report_days: string
|
||||||
petty_cash_float: string
|
petty_cash_float: string
|
||||||
|
|
@ -10,6 +12,7 @@ interface SettingsData {
|
||||||
sales_breakdown_columns: string
|
sales_breakdown_columns: string
|
||||||
change_tin_breakdown: string
|
change_tin_breakdown: string
|
||||||
change_tin_notes: string
|
change_tin_notes: string
|
||||||
|
card_machines: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Banknotes use individual note qty; coins use sealed bag qty
|
// 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 }) {
|
export function SettingsPage({ user }: { user: User }) {
|
||||||
const [settings, setSettings] = useState<Partial<SettingsData>>({})
|
const [settings, setSettings] = useState<Partial<SettingsData>>({})
|
||||||
const [columns, setColumns] = useState<GlColumn[]>([])
|
const [columns, setColumns] = useState<GlColumn[]>([])
|
||||||
|
const [machineNames, setMachineNames] = useState<string[]>(DEFAULT_MACHINE_NAMES)
|
||||||
const [tinTargetUnits, setTinTargetUnits] = useState<Record<string, number>>({})
|
const [tinTargetUnits, setTinTargetUnits] = useState<Record<string, number>>({})
|
||||||
const [tinDenomNotes, setTinDenomNotes] = useState<Record<string, string>>({})
|
const [tinDenomNotes, setTinDenomNotes] = useState<Record<string, string>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -48,6 +52,10 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
api.get<SettingsData>('/settings').then(s => {
|
api.get<SettingsData>('/settings').then(s => {
|
||||||
setSettings(s)
|
setSettings(s)
|
||||||
try { setColumns(JSON.parse(s.sales_breakdown_columns || '[]')) } catch { setColumns([]) }
|
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 {
|
try {
|
||||||
const breakdown = JSON.parse(s.change_tin_breakdown || '{}')
|
const breakdown = JSON.parse(s.change_tin_breakdown || '{}')
|
||||||
const units: Record<string, number> = {}
|
const units: Record<string, number> = {}
|
||||||
|
|
@ -78,6 +86,7 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
sales_breakdown_columns: JSON.stringify(columns),
|
sales_breakdown_columns: JSON.stringify(columns),
|
||||||
change_tin_breakdown: JSON.stringify(tinBreakdown),
|
change_tin_breakdown: JSON.stringify(tinBreakdown),
|
||||||
change_tin_notes: JSON.stringify(tinDenomNotes),
|
change_tin_notes: JSON.stringify(tinDenomNotes),
|
||||||
|
card_machines: JSON.stringify(machineNames),
|
||||||
})
|
})
|
||||||
flash('Settings saved.')
|
flash('Settings saved.')
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|
@ -186,6 +195,44 @@ export function SettingsPage({ user }: { user: User }) {
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* Change tin target breakdown */}
|
||||||
<Card style={{ marginBottom: '1rem' }}>
|
<Card style={{ marginBottom: '1rem' }}>
|
||||||
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '0.5rem' }}>Change Tin Target</h2>
|
<h2 style={{ fontSize: '1rem', fontWeight: 700, marginBottom: '0.5rem' }}>Change Tin Target</h2>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue