Auto-fetch Newbook on date load; add transaction breakdown checklist

- Newbook payments now fetched automatically when a date loads (in
  parallel with the cash up fetch), not just on button click
- Refresh button still available for manual re-fetch
- Transaction breakdown section below reconciliation table: three
  collapsible groups (Reception Manual, Reception Gateway, Restaurant/Bar)
  each showing individual transactions with click-to-tick checkboxes
  for tracking down discrepancies — local state, clears on refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-01 22:15:42 +00:00
parent 53c8938c2f
commit 4caf307fd1
2 changed files with 151 additions and 2 deletions

View file

@ -6,6 +6,7 @@ import {
GBP_DENOMINATIONS, fmtGBP, today, GBP_DENOMINATIONS, fmtGBP, today,
type User, type CashUp, type Denomination, type CardMachine, type User, type CashUp, type Denomination, type CardMachine,
type PaymentTotals, type ReconciliationRow, type TillPayment, type Attachment, type PaymentTotals, type ReconciliationRow, type TillPayment, type Attachment,
type TransactionBreakdown, type TransactionItem,
} from '../types' } from '../types'
const MACHINES = ['Front Desk', 'Restaurant / Bar'] const MACHINES = ['Front Desk', 'Restaurant / Bar']
@ -45,6 +46,8 @@ export function DailyCashUp({ user: _user }: Props) {
const [, setAttachments] = useState<Attachment[]>([]) const [, setAttachments] = useState<Attachment[]>([])
const [newbookTotals, setNewbookTotals] = useState<PaymentTotals | null>(null) const [newbookTotals, setNewbookTotals] = useState<PaymentTotals | null>(null)
const [tillPayments, setTillPayments] = useState<TillPayment[]>([]) 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 [fetching, setFetching] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null) const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null)
@ -92,7 +95,7 @@ export function DailyCashUp({ user: _user }: Props) {
setPageState(data.cash_up.status === 'final' ? 'locked' : 'editing') setPageState(data.cash_up.status === 'final' ? 'locked' : 'editing')
} }
// Auto-check on mount and whenever date changes // Auto-check on mount and whenever date changes; auto-fetch Newbook in parallel
useEffect(() => { useEffect(() => {
setPageState('checking') setPageState('checking')
setCashUp(null) setCashUp(null)
@ -103,6 +106,8 @@ export function DailyCashUp({ user: _user }: Props) {
setAttachments([]) setAttachments([])
setNewbookTotals(null) setNewbookTotals(null)
setTillPayments([]) setTillPayments([])
setTransactionBreakdown(null)
setCheckedItems(new Set())
api.get<{ api.get<{
cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[]; cash_up: CashUp; denominations: Denomination[]; card_machines: CardMachine[];
@ -113,6 +118,15 @@ export function DailyCashUp({ user: _user }: Props) {
if (e instanceof Error && e.message === 'Not found') setPageState('empty') if (e instanceof Error && e.message === 'Not found') setPageState('empty')
else { flash('Failed to load data for this date.', false); 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]) }, [date])
function updateDenom(list: Denomination[], setList: (d: Denomination[]) => void, idx: number, field: 'quantity' | 'value_entered', raw: string) { function updateDenom(list: Denomination[], setList: (d: Denomination[]) => void, idx: number, field: 'quantity' | 'value_entered', raw: string) {
@ -144,10 +158,12 @@ export function DailyCashUp({ user: _user }: Props) {
setFetching(true) setFetching(true)
try { try {
const data = await api.post<{ const data = await api.post<{
count: number; totals: PaymentTotals; till_payments: TillPayment[] count: number; totals: PaymentTotals; till_payments: TillPayment[]; transaction_breakdown: TransactionBreakdown
}>('/newbook/payments', { date }) }>('/newbook/payments', { date })
setNewbookTotals(data.totals) setNewbookTotals(data.totals)
setTillPayments(data.till_payments || []) setTillPayments(data.till_payments || [])
setTransactionBreakdown(data.transaction_breakdown || null)
setCheckedItems(new Set())
flash(`Fetched ${data.count} payment(s) from Newbook.`) flash(`Fetched ${data.count} payment(s) from Newbook.`)
} catch (e: unknown) { } catch (e: unknown) {
flash(e instanceof Error ? e.message : 'Failed to fetch Newbook data.', false) flash(e instanceof Error ? e.message : 'Failed to fetch Newbook data.', false)
@ -371,6 +387,17 @@ export function DailyCashUp({ user: _user }: Props) {
</table> </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> </Card>
@ -465,6 +492,114 @@ const inputSt: React.CSSProperties = {
background: 'white', 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 }: { function MoneyInput({ label, value, onChange, disabled }: {
label: string; value: number; onChange: (v: string) => void; disabled: boolean label: string; value: number; onChange: (v: string) => void; disabled: boolean
}) { }) {

View file

@ -60,6 +60,20 @@ export interface TillPayment {
total_value: number total_value: number
} }
export interface TransactionItem {
time: string
payment_type: string
details: string
amount: number
is_voided: boolean
}
export interface TransactionBreakdown {
reception_manual: Record<string, TransactionItem[]>
reception_gateway: Record<string, TransactionItem[]>
restaurant_bar: Record<string, TransactionItem[]>
}
export interface Attachment { export interface Attachment {
id: number id: number
cash_up_id: number cash_up_id: number