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:
parent
53c8938c2f
commit
4caf307fd1
2 changed files with 151 additions and 2 deletions
|
|
@ -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
|
||||
}) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue