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,
|
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
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue