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,
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']
@ -45,6 +46,8 @@ export function DailyCashUp({ user: _user }: Props) {
const [, 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)
@ -92,7 +95,7 @@ export function DailyCashUp({ user: _user }: Props) {
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(() => {
setPageState('checking')
setCashUp(null)
@ -103,6 +106,8 @@ export function DailyCashUp({ user: _user }: Props) {
setAttachments([])
setNewbookTotals(null)
setTillPayments([])
setTransactionBreakdown(null)
setCheckedItems(new Set())
api.get<{
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')
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) {
@ -144,10 +158,12 @@ export function DailyCashUp({ user: _user }: Props) {
setFetching(true)
try {
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 })
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)
@ -371,6 +387,17 @@ export function DailyCashUp({ user: _user }: Props) {
</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>
@ -465,6 +492,114 @@ const inputSt: React.CSSProperties = {
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
}) {

View file

@ -60,6 +60,20 @@ export interface TillPayment {
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 {
id: number
cash_up_id: number