diff --git a/frontend/src/pages/MultiDayReport.tsx b/frontend/src/pages/MultiDayReport.tsx index cab0ea3..fb349bd 100644 --- a/frontend/src/pages/MultiDayReport.tsx +++ b/frontend/src/pages/MultiDayReport.tsx @@ -1,8 +1,10 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { api } from '../api' import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { fmtGBP } from '../types' +// ── Types ───────────────────────────────────────────────────────────────────── + interface SalesCol { gl_code: string; category: string; net_amount: number; vat_amount: number; gross_amount: number } interface ReconRow { category: string; banked_amount: number; reported_amount: number } interface DayData { @@ -12,74 +14,34 @@ interface DayData { daily_stats: { gross_sales: number; transaction_count: number } | null sales_breakdown: SalesCol[] } - -// Newbook occupancy_data: per-category with per-date occupancy map interface OccupancyCategoryRaw { - category_id?: string | number - category_name?: string + category_id?: string | number; category_name?: string occupancy?: Record - // fallback flat format - period?: string; date?: string; rooms_sold?: number; total_rooms?: number; total_people?: number } - interface BookingRaw { - booking_arrival?: string - booking_departure?: string - booking_adults?: string | number - booking_children?: string | number - booking_infants?: string | number - category_id?: string | number - category_name?: string - booking_placed?: string + booking_arrival?: string; booking_departure?: string + booking_adults?: string | number; booking_children?: string | number; booking_infants?: string | number + category_id?: string | number; category_name?: string; booking_placed?: string tariffs_quoted?: Array<{ stay_date?: string; calculated_amount?: string | number }> } - -interface SiteRaw { - category_id?: string | number - category_name?: string -} - -interface EarnedRevenueItem { - period?: string - gl_group_id?: string - earned_revenue_ex?: number - earned_revenue_tax?: number - earned_revenue?: number -} - interface ReportResult { report_data: DayData[] sales_columns: Array<{ gl_code: string; display_name: string }> occupancy_data: OccupancyCategoryRaw[] bookings_data: BookingRaw[] - sites_data: SiteRaw[] - earned_revenue: EarnedRevenueItem[] + sites_data: Array<{ category_id?: string | number; category_name?: string }> + earned_revenue: Array<{ period?: string; gl_group_id?: string; earned_revenue_ex?: number; earned_revenue_tax?: number; earned_revenue?: number }> gl_accounts: Array<{ gl_group_id?: string; gl_group_name?: string }> } - interface DebtorBalance { creditors: number; debtors: number; overall: number } -interface DebtorsResult { - period_open_balance: DebtorBalance - balances_by_date: Record -} - +interface DebtorsResult { period_open_balance: DebtorBalance; balances_by_date: Record } interface DayOccStats { - roomsOccupied: number - people: number; adults: number; children: number; infants: number - netAccom: number - ggr: number; ggrRoomCount: number - avgLeadTime: number; arrivingCount: number + roomsOccupied: number; people: number; adults: number; children: number; infants: number + netAccom: number; ggr: number; avgLeadTime: number; arrivingCount: number + byCategory: Record } -const RECON_LABELS: Record = { - cash: 'Cash', - gateway_visa_mc: 'Gateway V/MC', - gateway_amex: 'Gateway Amex', - pdq_visa_mc: 'PDQ V/MC', - pdq_amex: 'PDQ Amex', - bacs: 'BACS', -} -const VARIANCE_CATEGORIES = new Set(['cash', 'pdq_visa_mc', 'pdq_amex']) +// ── Helpers ─────────────────────────────────────────────────────────────────── function fmtDate(d: string) { const s = (d ?? '').slice(0, 10) @@ -87,523 +49,692 @@ function fmtDate(d: string) { return new Date(s + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' }) } -function buildOccupancyStats( - dates: string[], - occupancyData: OccupancyCategoryRaw[], - bookingsData: BookingRaw[], - sitesData: SiteRaw[], - earnedRevenue: EarnedRevenueItem[], - glAccounts: Array<{ gl_group_id?: string; gl_group_name?: string }>, +function recon(day: DayData, cat: string): ReconRow { + return day.reconciliation.find(r => r.category === cat) ?? { category: cat, banked_amount: 0, reported_amount: 0 } +} + +function dayGrossSales(day: DayData) { + return day.sales_breakdown.reduce((s, sb) => s + (sb.gross_amount ?? 0), 0) +} + +const BANKED_COLS: { key: string; label: string; auto: boolean }[] = [ + { key: 'cash', label: 'Cash', auto: false }, + { key: 'gateway_visa_mc',label: 'Gateway V/MC', auto: true }, + { key: 'pdq_visa_mc', label: 'PDQ V/MC', auto: false }, + { key: 'gateway_amex', label: 'Gateway Amex', auto: true }, + { key: 'pdq_amex', label: 'PDQ Amex', auto: false }, + { key: 'bacs', label: 'BACS', auto: true }, +] + +function varClass(banked: number, reported: number): React.CSSProperties { + const diff = banked - reported + if (Math.abs(diff) < 0.005) return {} + return diff < 0 ? { background: '#ffcccc' } : { background: '#ffe6b3' } +} + +function varLabel(banked: number, reported: number) { + const diff = banked - reported + if (Math.abs(diff) < 0.005) return null + return ( + + {diff < 0 ? '▼' : '▲'} {fmtGBP(diff)} + + ) +} + +// ── Excel-like cell selection ───────────────────────────────────────────────── + +function useExcelSelection() { + type Sel = { tableId: string; r1: number; c1: number; r2: number; c2: number } + const [sel, setSel] = useState(null) + const [anchor, setAnchor] = useState<{ tableId: string; row: number; col: number } | null>(null) + const selRef = useRef(sel) + selRef.current = sel + + const onClick = useCallback((tableId: string, e: React.MouseEvent) => { + const cell = (e.target as Element).closest('td,th') as HTMLElement | null + if (!cell) return + const tableEl = e.currentTarget + const allRows = Array.from(tableEl.querySelectorAll('tr')) + const row = cell.closest('tr') as HTMLTableRowElement + const ri = allRows.indexOf(row) + const ci = Array.from(row.querySelectorAll('td,th')).indexOf(cell as HTMLTableCellElement) + if (ri < 0 || ci < 0) return + if (e.shiftKey && anchor?.tableId === tableId) { + setSel({ tableId, r1: Math.min(anchor.row, ri), c1: Math.min(anchor.col, ci), r2: Math.max(anchor.row, ri), c2: Math.max(anchor.col, ci) }) + } else { + setSel({ tableId, r1: ri, c1: ci, r2: ri, c2: ci }) + setAnchor({ tableId, row: ri, col: ci }) + } + }, [anchor]) + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (!(e.ctrlKey || e.metaKey) || e.key !== 'c') return + const s = selRef.current + if (!s) return + const tbl = document.getElementById(s.tableId) as HTMLTableElement | null + if (!tbl) return + const rows = Array.from(tbl.querySelectorAll('tr')) + const lines: string[] = [] + for (let r = s.r1; r <= s.r2; r++) { + const cells = Array.from(rows[r]?.querySelectorAll('td,th') ?? []) + lines.push(cells.slice(s.c1, s.c2 + 1).map(c => (c.textContent ?? '').trim()).join('\t')) + } + navigator.clipboard.writeText(lines.join('\n')).catch(() => {}) + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, []) + + function cs(tableId: string, r: number, c: number): React.CSSProperties { + if (!sel || sel.tableId !== tableId || r < sel.r1 || r > sel.r2 || c < sel.c1 || c > sel.c2) return {} + return { outline: '2px solid #0078d4', outlineOffset: '-2px', background: '#cce4ff' } + } + + return { onClick, cs } +} + +// ── Occupancy stats ─────────────────────────────────────────────────────────── + +function buildOccStats( + dates: string[], ocData: OccupancyCategoryRaw[], bkData: BookingRaw[], + sites: Array<{ category_id?: string | number; category_name?: string }>, + earnedRev: ReportResult['earned_revenue'], + glAccounts: ReportResult['gl_accounts'], ): { byDate: Record; totalRooms: number } { - // Total rooms (exclude overflow) - const totalRooms = sitesData.filter(s => - !(s.category_name ?? '').toLowerCase().includes('overflow') - ).length + const totalRooms = sites.filter(s => !(s.category_name ?? '').toLowerCase().includes('overflow')).length - // Rooms occupied by date from per-category occupancy data const roomsByDate: Record = {} - for (const cat of occupancyData) { + const byCatByDate: Record> = {} + for (const cat of ocData) { if ((cat.category_name ?? '').toLowerCase().includes('overflow')) continue - if (cat.occupancy && typeof cat.occupancy === 'object') { - for (const [date, occ] of Object.entries(cat.occupancy)) { - const d = date.slice(0, 10) - roomsByDate[d] = (roomsByDate[d] ?? 0) + (Number(occ?.occupied) || 0) - } + if (!cat.occupancy) continue + for (const [date, occ] of Object.entries(cat.occupancy)) { + const d = date.slice(0, 10) + const n = Number(occ?.occupied) || 0 + roomsByDate[d] = (roomsByDate[d] ?? 0) + n + if (!byCatByDate[d]) byCatByDate[d] = {} + byCatByDate[d][String(cat.category_id ?? '')] = { name: cat.category_name ?? '', occupied: n } } } - // Net accommodation revenue by date (identify ACC group from GL accounts) const accomGroupIds = new Set() for (const a of glAccounts) { - const name = (a.gl_group_name ?? '').toLowerCase() - if (name.startsWith('acc') || name.includes('accommodation')) { - if (a.gl_group_id) accomGroupIds.add(a.gl_group_id) - } + const nm = (a.gl_group_name ?? '').toLowerCase() + if ((nm.startsWith('acc') || nm.includes('accommodation')) && a.gl_group_id) accomGroupIds.add(a.gl_group_id) } const accomByDate: Record = {} - for (const item of earnedRevenue) { + for (const item of earnedRev) { const d = (item.period ?? '').slice(0, 10) - if (!d || !item.gl_group_id) continue - if (accomGroupIds.has(item.gl_group_id)) { - accomByDate[d] = (accomByDate[d] ?? 0) + (item.earned_revenue_ex ?? 0) - } + if (!d || !item.gl_group_id || !accomGroupIds.has(item.gl_group_id)) continue + accomByDate[d] = (accomByDate[d] ?? 0) + (item.earned_revenue_ex ?? 0) } - // Per-day stats from bookings - const bookingStats: Record = {} - - for (const b of bookingsData) { - const catName = (b.category_name ?? '').toLowerCase() - if (catName.includes('overflow')) continue + type BkStats = { people: number; adults: number; children: number; infants: number; totalRate: number; rateRooms: number; leadDaysSum: number; arrivingCount: number; byCat: Record } + const bs: Record = {} + for (const b of bkData) { + if ((b.category_name ?? '').toLowerCase().includes('overflow')) continue const arrival = (b.booking_arrival ?? '').slice(0, 10) const departure = (b.booking_departure ?? '').slice(0, 10) if (!arrival || !departure) continue - const adults = Number(b.booking_adults) || 0 const children = Number(b.booking_children) || 0 const infants = Number(b.booking_infants) || 0 const people = adults + children + infants - + const catId = String(b.category_id ?? '') const placedStr = (b.booking_placed ?? '').slice(0, 10) let leadDays = 0 - if (placedStr) { - const placedMs = new Date(placedStr + 'T12:00:00').getTime() - const arrivalMs = new Date(arrival + 'T12:00:00').getTime() - leadDays = Math.max(0, Math.floor((arrivalMs - placedMs) / 86400000)) - } - - // Build tariff lookup: { stayDate → amount } + if (placedStr) leadDays = Math.max(0, Math.floor((new Date(arrival + 'T12:00:00').getTime() - new Date(placedStr + 'T12:00:00').getTime()) / 86400000)) const tariffMap: Record = {} for (const t of (b.tariffs_quoted ?? [])) { const td = (t.stay_date ?? '').slice(0, 10) if (td) tariffMap[td] = (tariffMap[td] ?? 0) + (Number(t.calculated_amount) || 0) } - - // Iterate each stay day let cur = new Date(arrival + 'T12:00:00') const dep = new Date(departure + 'T12:00:00') while (cur < dep) { const d = cur.toISOString().slice(0, 10) - if (!bookingStats[d]) bookingStats[d] = { people: 0, adults: 0, children: 0, infants: 0, totalRate: 0, rateRooms: 0, leadDaysSum: 0, arrivingCount: 0 } - const s = bookingStats[d] - s.people += people; s.adults += adults; s.children += children; s.infants += infants - if (tariffMap[d] != null) { s.totalRate += tariffMap[d]; s.rateRooms++ } - if (d === arrival) { s.leadDaysSum += leadDays; s.arrivingCount++ } + if (!bs[d]) bs[d] = { people: 0, adults: 0, children: 0, infants: 0, totalRate: 0, rateRooms: 0, leadDaysSum: 0, arrivingCount: 0, byCat: {} } + bs[d].people += people; bs[d].adults += adults; bs[d].children += children; bs[d].infants += infants + if (tariffMap[d] != null) { bs[d].totalRate += tariffMap[d]; bs[d].rateRooms++ } + if (d === arrival) { bs[d].leadDaysSum += leadDays; bs[d].arrivingCount++ } + if (!bs[d].byCat[catId]) bs[d].byCat[catId] = { people: 0, adults: 0, children: 0, infants: 0, totalRate: 0, rateRooms: 0 } + bs[d].byCat[catId].people += people; bs[d].byCat[catId].adults += adults; bs[d].byCat[catId].children += children; bs[d].byCat[catId].infants += infants + if (tariffMap[d] != null) { bs[d].byCat[catId].totalRate += tariffMap[d]; bs[d].byCat[catId].rateRooms++ } + if (!bs[d].byCat[catId]) bs[d].byCat[catId] = { people: 0, adults: 0, children: 0, infants: 0, totalRate: 0, rateRooms: 0 } cur.setDate(cur.getDate() + 1) } } const byDate: Record = {} - for (const date of dates) { - const bk = bookingStats[date] - const roomsOccupied = roomsByDate[date] ?? 0 - const netAccom = accomByDate[date] ?? 0 - byDate[date] = { + for (const d of dates) { + const bk = bs[d] + const roomsOccupied = roomsByDate[d] ?? 0 + const byCatOcc = byCatByDate[d] ?? {} + const byCategory: DayOccStats['byCategory'] = {} + const allCatIds = new Set([...Object.keys(byCatOcc), ...Object.keys(bk?.byCat ?? {})]) + for (const catId of allCatIds) { + const occInfo = byCatOcc[catId] + const bkCat = bk?.byCat[catId] + byCategory[catId] = { + name: occInfo?.name ?? bkCat ? '' : '', + occupied: occInfo?.occupied ?? 0, + people: bkCat?.people ?? 0, adults: bkCat?.adults ?? 0, children: bkCat?.children ?? 0, infants: bkCat?.infants ?? 0, + avgRate: bkCat && bkCat.rateRooms > 0 ? bkCat.totalRate / bkCat.rateRooms : 0, + rooms: bkCat?.rateRooms ?? 0, + } + if (!byCategory[catId].name) byCategory[catId].name = occInfo?.name ?? '' + } + byDate[d] = { roomsOccupied, - people: bk?.people ?? 0, - adults: bk?.adults ?? 0, - children: bk?.children ?? 0, - infants: bk?.infants ?? 0, - netAccom, - ggr: bk && bk.rateRooms > 0 ? bk.totalRate / bk.rateRooms : 0, - ggrRoomCount: bk?.rateRooms ?? 0, - avgLeadTime: bk && bk.arrivingCount > 0 ? Math.round(bk.leadDaysSum / bk.arrivingCount) : 0, + people: bk?.people ?? 0, adults: bk?.adults ?? 0, children: bk?.children ?? 0, infants: bk?.infants ?? 0, + netAccom: accomByDate[d] ?? 0, + ggr: bk && bk.rateRooms > 0 ? bk.totalRate / bk.rateRooms : 0, + avgLeadTime: bk && bk.arrivingCount > 0 ? Math.round(bk.leadDaysSum / bk.arrivingCount) : 0, arrivingCount: bk?.arrivingCount ?? 0, + byCategory, } } return { byDate, totalRooms } } +// ── Tooltip ─────────────────────────────────────────────────────────────────── + +function Tooltip({ text, children }: { text: string; children: React.ReactNode }) { + const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + return ( + setPos({ x: (e.target as HTMLElement).getBoundingClientRect().left, y: (e.target as HTMLElement).getBoundingClientRect().bottom + window.scrollY })} + onMouseLeave={() => setPos(null)}> + {children} + {pos && ( +
+ {text} +
+ )} +
+ ) +} + +// ── Component ───────────────────────────────────────────────────────────────── + export function MultiDayReport() { - const [startDate, setStartDate] = useState(() => { - const d = new Date(); d.setDate(d.getDate() - 6); return d.toISOString().slice(0, 10) - }) + const [startDate, setStartDate] = useState(() => { const d = new Date(); d.setDate(d.getDate() - 6); return d.toISOString().slice(0, 10) }) const [numDays, setNumDays] = useState(7) const [result, setResult] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [debtors, setDebtors] = useState(null) const [debtorsLoading, setDebtorsLoading] = useState(false) + const { onClick, cs } = useExcelSelection() async function generate() { setLoading(true); setError(''); setResult(null); setDebtors(null) - try { - const data = await api.post('/reports/multiday', { start_date: startDate, num_days: numDays }) - setResult(data) - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Failed to generate report') - } finally { - setLoading(false) - } + try { setResult(await api.post('/reports/multiday', { start_date: startDate, num_days: numDays })) } + catch (e: unknown) { setError(e instanceof Error ? e.message : 'Failed') } + finally { setLoading(false) } } useEffect(() => { if (!result) return setDebtorsLoading(true) api.post('/reports/debtors-creditors', { start_date: startDate, num_days: numDays }) - .then(d => setDebtors(d)) - .catch(() => {}) - .finally(() => setDebtorsLoading(false)) + .then(d => setDebtors(d)).catch(() => {}).finally(() => setDebtorsLoading(false)) }, [result, startDate, numDays]) - const dates = result?.report_data.map(d => d.date) ?? [] + const days = result?.report_data ?? [] const salesCols = result?.sales_columns ?? [] - - const occStats = result ? buildOccupancyStats( - dates, - result.occupancy_data, - result.bookings_data, - result.sites_data, - result.earned_revenue ?? [], - result.gl_accounts ?? [], + const occStats = result ? buildOccStats( + days.map(d => d.date), result.occupancy_data, result.bookings_data, + result.sites_data, result.earned_revenue ?? [], result.gl_accounts ?? [], ) : null return (
- {/* Controls */} -
- - setStartDate(e.target.value)} style={inpSt} /> -
-
- - setNumDays(Math.max(1, Math.min(365, parseInt(e.target.value) || 7)))} - style={{ ...inpSt, width: '80px' }} /> -
- - {loading ? 'Generating…' : 'Generate Report'} - +
setStartDate(e.target.value)} style={inp} />
+
setNumDays(Math.max(1, Math.min(365, parseInt(e.target.value) || 7)))} style={{ ...inp, width: '80px' }} />
+ {loading ? 'Generating…' : 'Generate Report'}
- {error && ( -
- {error} -
- )} + {error &&
{error}
} + + {result && (<> + + {/* Status row */} + +

Daily Cash Up Status

+

+ Click cell, then Shift+Click to select range. Ctrl+C to copy. +

+
+ {days.map(day => ( +
+
{fmtDate(day.date)}
+ {day.cash_up ? : None} + {day.cash_up &&
{fmtGBP(day.cash_up.total_cash_counted)}
} +
+ ))} +
+
+ + {/* Section 1: Reconciliation — two side-by-side tables */} + +

Daily Cash Up & Reconciliation Summary

+

+ BANKED colors: + Green = manual + Orange = auto + Red = short ▼ + Amber = over ▲ +

+
+
+ + {/* BANKED table */} + onClick('tbl-banked', e)}> + + + + + {BANKED_COLS.map(c => )} + + + + + {days.map((day, ri) => { + const vals = BANKED_COLS.map(c => recon(day, c.key)) + const bankedTotal = vals.reduce((s, v) => s + v.banked_amount, 0) + const reportedTotal = vals.reduce((s, v) => s + v.reported_amount, 0) + return ( + + + {BANKED_COLS.map((c, ci) => { + const v = vals[ci] + const baseStyle: React.CSSProperties = { background: c.auto ? '#fffbf5' : '#f1f8f4' } + const vStyle = varClass(v.banked_amount, v.reported_amount) + return ( + + ) + })} + + + ) + })} + + + + + {BANKED_COLS.map((c, ci) => { + const tot = days.reduce((s, d) => s + recon(d, c.key).banked_amount, 0) + return + })} + + + + + {BANKED_COLS.map((c, ci) => { + const v = days.reduce((s, d) => s + recon(d, c.key).banked_amount, 0) - days.reduce((s, d) => s + recon(d, c.key).reported_amount, 0) + const vstyle: React.CSSProperties = Math.abs(v) < 0.005 ? { color: '#155724', background: '#d4edda' } : v < 0 ? { color: '#721c24', background: '#f8d7da' } : { color: '#856404', background: '#fff3cd' } + return + })} + {(() => { + const v = days.reduce((s, d) => s + BANKED_COLS.reduce((ss, c) => ss + recon(d, c.key).banked_amount, 0), 0) - days.reduce((s, d) => s + BANKED_COLS.reduce((ss, c) => ss + recon(d, c.key).reported_amount, 0), 0) + const vstyle: React.CSSProperties = Math.abs(v) < 0.005 ? { color: '#155724', background: '#d4edda' } : v < 0 ? { color: '#721c24', background: '#f8d7da' } : { color: '#856404', background: '#fff3cd' } + return + })()} + + +
BANKED
Date{c.label}Total
{fmtDate(day.date)} + {v.banked_amount ? fmtGBP(v.banked_amount) : '£0.00'} + {varLabel(v.banked_amount, v.reported_amount)} + + {fmtGBP(bankedTotal)}{varLabel(bankedTotal, reportedTotal)} +
TOTALS{fmtGBP(tot)}{fmtGBP(days.reduce((s, d) => s + BANKED_COLS.reduce((ss, c) => ss + recon(d, c.key).banked_amount, 0), 0))}
VARIANCE{v >= 0 ? '▲ +' : '▼ '}{fmtGBP(v)}{v >= 0 ? '▲ +' : '▼ '}{fmtGBP(v)}
+ + {/* REPORTED table */} + onClick('tbl-reported', e)}> + + + + + {BANKED_COLS.map(c => )} + + + + + + {days.map((day, ri) => { + const vals = BANKED_COLS.map(c => recon(day, c.key)) + const reportedTotal = vals.reduce((s, v) => s + v.reported_amount, 0) + const audit = dayGrossSales(day) + const auditVar = audit - reportedTotal + const auditStyle: React.CSSProperties = Math.abs(auditVar) < 0.005 ? { background: '#d4edda', fontWeight: 700 } : auditVar < 0 ? { background: '#f8d7da', fontWeight: 700 } : { background: '#fff3cd', fontWeight: 700 } + return ( + + + {BANKED_COLS.map((c, ci) => { + const v = vals[ci] + return + })} + + + + ) + })} + + + + + {BANKED_COLS.map((c, ci) => { + const tot = days.reduce((s, d) => s + recon(d, c.key).reported_amount, 0) + return + })} + + {(() => { + const tot = days.reduce((s, d) => s + dayGrossSales(d), 0) + const rep = days.reduce((s, d) => s + BANKED_COLS.reduce((ss, c) => ss + recon(d, c.key).reported_amount, 0), 0) + const vstyle: React.CSSProperties = Math.abs(tot - rep) < 0.005 ? { background: '#d4edda' } : {} + return + })()} + + +
REPORTED
Date{c.label}TotalAudit
{fmtDate(day.date)}{v.reported_amount ? fmtGBP(v.reported_amount) : '£0.00'}{fmtGBP(reportedTotal)}{fmtGBP(audit)}
TOTALS{fmtGBP(tot)}{fmtGBP(days.reduce((s, d) => s + BANKED_COLS.reduce((ss, c) => ss + recon(d, c.key).reported_amount, 0), 0))}{fmtGBP(tot)}
- {result && ( - <> - {/* Daily Status Row */} - -

Daily Cash Up Status

-
- {result.report_data.map(day => ( -
-
{fmtDate(day.date)}
- {day.cash_up - ? - : None - } - {day.cash_up && ( -
- {fmtGBP(day.cash_up.total_cash_counted)} -
- )} -
- ))}
-
+
- {/* Table 1: Reconciliation */} - -

Payment Reconciliation

- + {/* Gross Sales */} +
+

Gross Sales (from Earned Revenue)

+
onClick('tbl-grosssales', e)}> - - - {dates.map(d => )} - + + + - {Object.entries(RECON_LABELS).map(([key, label]) => { - const rows = result.report_data.map(day => day.reconciliation.find(r => r.category === key)) - const bankedVals = rows.map(r => r?.banked_amount ?? 0) - const reportedVals = rows.map(r => r?.reported_amount ?? 0) - const hasVariance = VARIANCE_CATEGORIES.has(key) - const totalBanked = bankedVals.reduce((s, v) => s + v, 0) - const totalReported = reportedVals.reduce((s, v) => s + v, 0) - if (bankedVals.every(v => v === 0) && reportedVals.every(v => v === 0)) return null - return [ - hasVariance && ( - - - {reportedVals.map((v, i) => ( - - ))} - - - ), - - - {bankedVals.map((v, i) => { - const rep = reportedVals[i] - const vari = hasVariance ? (v - rep) : null - return ( - - ) - })} - - , - ].filter(Boolean) - })} - - - {result.report_data.map((day, i) => { - const t = day.reconciliation.reduce((s, r) => s + r.banked_amount, 0) - return - })} - - + {days.map((day, ri) => ( + + + + + ))} + + + + + + +
Category{fmtDate(d)}Total
DateGross Sales
{label} — Newbook{v ? fmtGBP(v) : '—'}{fmtGBP(totalReported)}
{hasVariance ? `${label} — Banked` : label} - {v ? fmtGBP(v) : '—'} - {vari !== null && Math.abs(vari) > 0.005 && ( -
- {vari > 0 ? '+' : ''}{fmtGBP(vari)} -
- )} -
- {fmtGBP(totalBanked)} - {hasVariance && Math.abs(totalBanked - totalReported) > 0.005 && ( -
- {totalBanked - totalReported > 0 ? '+' : ''}{fmtGBP(totalBanked - totalReported)} -
- )} -
Total{t ? fmtGBP(t) : '—'} - {fmtGBP(result.report_data.reduce((s, d) => s + d.reconciliation.reduce((ss, r) => ss + r.banked_amount, 0), 0))} -
{fmtDate(day.date)}{fmtGBP(dayGrossSales(day))}
TOTAL{fmtGBP(days.reduce((s, d) => s + dayGrossSales(d), 0))}
+
+
+ + {/* Section 2: Sales Breakdown — dates on rows, categories on columns */} + {salesCols.length > 0 && ( + +

Sales Breakdown (Net Values)

+

Dates on rows, GL categories on columns. Click/Shift+Click to select, Ctrl+C to copy.

+ onClick('tbl-sales', e)}> + + + + {salesCols.map(col => ( + + ))} + + + + + + + + {days.map((day, ri) => { + const map = Object.fromEntries(day.sales_breakdown.map(s => [s.gl_code, s])) + const totalNet = day.sales_breakdown.reduce((s, sb) => s + (sb.net_amount ?? 0), 0) + const totalVat = day.sales_breakdown.reduce((s, sb) => s + (sb.vat_amount ?? 0), 0) + const totalGross = dayGrossSales(day) + const auditStyle: React.CSSProperties = { background: '#d4edda', fontWeight: 700 } + return ( + + + {salesCols.map((col, ci) => { + const v = map[col.gl_code]?.net_amount ?? 0 + return + })} + + + + + + ) + })} + + + + + {salesCols.map((col, ci) => { + const tot = days.reduce((s, d) => s + (d.sales_breakdown.find(sb => sb.gl_code === col.gl_code)?.net_amount ?? 0), 0) + return + })} + + + + + +
Date +
{col.display_name}
+
Total NetVATGross TotalAudit
{fmtDate(day.date)}{v ? fmtGBP(v) : '£0.00'}{fmtGBP(totalNet)}{fmtGBP(totalVat)}{fmtGBP(totalGross)}{fmtGBP(totalGross)}
TOTAL{fmtGBP(tot)}{fmtGBP(days.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.net_amount ?? 0), 0), 0))}{fmtGBP(days.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.vat_amount ?? 0), 0), 0))}{fmtGBP(days.reduce((s, d) => s + dayGrossSales(d), 0))}{fmtGBP(days.reduce((s, d) => s + dayGrossSales(d), 0))}
+ )} - {/* Table 2: Sales Breakdown */} - {salesCols.length > 0 && ( - -

Sales Breakdown

- - - - - {dates.map(d => )} - - - - - - - {salesCols.map(col => { - const vals = result.report_data.map(day => { - const sb = day.sales_breakdown.find(s => s.gl_code === col.gl_code) - return { net: sb?.net_amount ?? 0, gross: sb?.gross_amount ?? 0, vat: sb?.vat_amount ?? 0 } - }) - const totNet = vals.reduce((s, v) => s + v.net, 0) - const totVat = vals.reduce((s, v) => s + v.vat, 0) - const totGross = vals.reduce((s, v) => s + v.gross, 0) - if (vals.every(v => v.gross === 0 && v.net === 0)) return null - return ( - - - {vals.map((v, i) => ( - - ))} - - - - - ) - })} - - - {result.report_data.map((day, i) => { - const g = day.sales_breakdown.reduce((s, sb) => s + (sb.gross_amount ?? 0), 0) - const n = day.sales_breakdown.reduce((s, sb) => s + (sb.net_amount ?? 0), 0) - return ( - - ) - })} - - - - - -
Category{fmtDate(d)}Total NetTotal VATTotal Gross
{col.display_name} - {v.gross ? fmtGBP(v.gross) : '—'} - {v.vat > 0 &&
net {fmtGBP(v.net)}
} -
{fmtGBP(totNet)}{fmtGBP(totVat)}{fmtGBP(totGross)}
TOTAL - {g ? fmtGBP(g) : '—'} - {n > 0 &&
net {fmtGBP(n)}
} -
- {fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.net_amount ?? 0), 0), 0))} - - {fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.vat_amount ?? 0), 0), 0))} - - {fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.gross_amount ?? 0), 0), 0))} -
-
- )} + {/* Section 3: Occupancy — dates on rows, metrics on columns */} + {occStats && ( + +

Occupancy Statistics

+ {occStats.totalRooms > 0 &&

Total rooms: {occStats.totalRooms} (excluding overflow)

} + onClick('tbl-occ', e)}> + + + + + + + + + + {days.map((day, ri) => { + const s = occStats.byDate[day.date] + if (!s) return + const occ = occStats.totalRooms > 0 ? (s.roomsOccupied / occStats.totalRooms * 100) : 0 + const avgNet = s.roomsOccupied > 0 ? s.netAccom / s.roomsOccupied : 0 + const revpar = occStats.totalRooms > 0 ? s.netAccom / occStats.totalRooms : 0 - {/* Table 3: Occupancy */} - {occStats && ( - -

Occupancy

- {occStats.totalRooms > 0 && ( -

- Total rooms: {occStats.totalRooms} -

- )} -
DateRoomsGuestsNet AccomAvg Net/RoomREVPAROcc %GGRAvg Lead Time
{fmtDate(day.date)}
- - - - {dates.map(d => )} - - - - - {[ - { - label: 'Rooms Occupied', - vals: dates.map(d => occStats.byDate[d]?.roomsOccupied ?? 0), - fmt: (v: number) => String(v), - total: (vs: number[]) => String(vs.reduce((s, v) => s + v, 0)), - }, - { - label: 'Occ %', - vals: dates.map(d => occStats.totalRooms > 0 ? (occStats.byDate[d]?.roomsOccupied ?? 0) / occStats.totalRooms * 100 : 0), - fmt: (v: number) => v ? v.toFixed(1) + '%' : '—', - total: (vs: number[]) => { const avg = vs.filter(v => v > 0); return avg.length ? (avg.reduce((s, v) => s + v, 0) / avg.length).toFixed(1) + '% avg' : '—' }, - }, - { - label: 'Guests', - vals: dates.map(d => occStats.byDate[d]?.people ?? 0), - fmt: (v: number) => v ? String(v) : '—', - total: (vs: number[]) => String(vs.reduce((s, v) => s + v, 0)), - }, - { - label: 'Net Accom', - vals: dates.map(d => occStats.byDate[d]?.netAccom ?? 0), - fmt: (v: number) => v ? fmtGBP(v) : '—', - total: (vs: number[]) => fmtGBP(vs.reduce((s, v) => s + v, 0)), - }, - { - label: 'Avg Net / Room', - vals: dates.map(d => { - const s = occStats.byDate[d]; if (!s) return 0 - return s.roomsOccupied > 0 ? s.netAccom / s.roomsOccupied : 0 - }), - fmt: (v: number) => v ? fmtGBP(v) : '—', - total: (_vs: number[]) => { - const totalNetAccom = dates.reduce((s, d) => s + (occStats.byDate[d]?.netAccom ?? 0), 0) - const totalRooms = dates.reduce((s, d) => s + (occStats.byDate[d]?.roomsOccupied ?? 0), 0) - return totalRooms > 0 ? fmtGBP(totalNetAccom / totalRooms) + ' avg' : '—' - }, - }, - { - label: 'REVPAR', - vals: dates.map(d => { - if (!occStats.totalRooms) return 0 - return (occStats.byDate[d]?.netAccom ?? 0) / occStats.totalRooms - }), - fmt: (v: number) => v ? fmtGBP(v) : '—', - total: (vs: number[]) => { - const avg = vs.filter(v => v > 0) - return avg.length ? fmtGBP(avg.reduce((s, v) => s + v, 0) / avg.length) + ' avg' : '—' - }, - }, - { - label: 'GGR (Avg Rate)', - vals: dates.map(d => occStats.byDate[d]?.ggr ?? 0), - fmt: (v: number) => v ? fmtGBP(v) : '—', - total: (vs: number[]) => { - const active = vs.filter(v => v > 0) - return active.length ? fmtGBP(active.reduce((s, v) => s + v, 0) / active.length) + ' avg' : '—' - }, - }, - { - label: 'Avg Lead Time', - vals: dates.map(d => occStats.byDate[d]?.avgLeadTime ?? 0), - fmt: (v: number, i: number) => { - const s = occStats.byDate[dates[i]] - if (!s || !s.arrivingCount) return '—' - return v + ' days' - }, - total: (vs: number[]) => { - const active = vs.filter(v => v > 0) - return active.length ? Math.round(active.reduce((s, v) => s + v, 0) / active.length) + ' days avg' : '—' - }, - }, - ].map(row => ( - - - {row.vals.map((v, i) => ( - - ))} - + + + + + + + + + + + ) + })} + + + {(() => { + const totRooms = days.reduce((s, d) => s + (occStats.byDate[d.date]?.roomsOccupied ?? 0), 0) + const totPeople = days.reduce((s, d) => s + (occStats.byDate[d.date]?.people ?? 0), 0) + const totAccom = days.reduce((s, d) => s + (occStats.byDate[d.date]?.netAccom ?? 0), 0) + const n = days.filter(d => occStats.byDate[d.date]?.roomsOccupied).length || 1 + const avgOcc = days.reduce((s, d) => s + (occStats.totalRooms > 0 ? (occStats.byDate[d.date]?.roomsOccupied ?? 0) / occStats.totalRooms * 100 : 0), 0) / n + const avgGgr = days.reduce((s, d) => s + (occStats.byDate[d.date]?.ggr ?? 0), 0) / n + const avgLead = days.filter(d => occStats.byDate[d.date]?.arrivingCount).reduce((s, d) => s + (occStats.byDate[d.date]?.avgLeadTime ?? 0), 0) / (days.filter(d => occStats.byDate[d.date]?.arrivingCount).length || 1) + return ( + + + + + + + + + + + + ) + })()} + +
Metric{fmtDate(d)}Total / Avg
{row.label}{row.fmt(v, i)} - {row.total(row.vals)} + const roomsTip = `Occupied: ${s.roomsOccupied}\n\nBy Category:\n` + Object.values(s.byCategory).filter(c => c.occupied > 0).map(c => `${c.name}: ${c.occupied} occupied`).join('\n') + const guestsTip = `Total: ${s.people}\nAdults: ${s.adults}, Children: ${s.children}, Infants: ${s.infants}\n\nBy Category:\n` + Object.values(s.byCategory).filter(c => c.people > 0).map(c => `${c.name}: ${c.people} (${c.adults}A, ${c.children}C, ${c.infants}I)`).join('\n') + const occTip = `${occ.toFixed(1)}% (${s.roomsOccupied}/${occStats.totalRooms})\n\nBy Category:\n` + Object.values(s.byCategory).filter(c => c.occupied > 0).map(c => `${c.name}: ${c.occupied} rooms`).join('\n') + const ggrTip = `Avg Guest Rate: ${fmtGBP(s.ggr)}\n\nBy Category:\n` + Object.values(s.byCategory).filter(c => c.rooms > 0).map(c => `${c.name}: ${fmtGBP(c.avgRate)} (${c.rooms} rooms)`).join('\n') + const leadTip = s.arrivingCount > 0 ? `Avg Lead Time: ${s.avgLeadTime} days\nArrivals: ${s.arrivingCount}` : 'No arrivals' + + return ( +
{fmtDate(day.date)}{s.roomsOccupied}{s.people || '—'}{s.netAccom ? fmtGBP(s.netAccom) : '—'}{avgNet ? fmtGBP(avgNet) : '—'}{revpar ? fmtGBP(revpar) : '—'}{occ ? occ.toFixed(1) + '%' : '—'}{s.ggr ? fmtGBP(s.ggr) : '—'}{s.arrivingCount ? `${s.avgLeadTime} days` : '—'}
TOTAL (AVG){totRooms} ({Math.round(totRooms / (days.length || 1))}){totPeople} ({Math.round(totPeople / (days.length || 1))}){fmtGBP(totAccom)}({fmtGBP(occStats.totalRooms > 0 ? totAccom / totRooms : 0)})({fmtGBP(occStats.totalRooms > 0 ? totAccom / occStats.totalRooms / (days.length || 1) : 0)})({avgOcc.toFixed(1)}%)({fmtGBP(avgGgr)})({Math.round(avgLead)} days)
+
+ )} + + {/* Section 4: Debtors/Creditors */} + +

Creditors and Debtors

+

Daily account balances. Creditors = we owe, Debtors = they owe us.

+ {debtorsLoading ? ( +

Loading balances…

+ ) : debtors ? ( + onClick('tbl-balances', e)}> + + + + + + + + {[{ date: 'Period Open', b: debtors.period_open_balance }, ...Object.entries(debtors.balances_by_date).map(([date, b]) => ({ date, b }))].map(({ date, b }, ri) => { + const balStyle: React.CSSProperties = b.overall >= 0 ? { color: '#155724' } : { color: '#721c24' } + const isOpen = date === 'Period Open' + return ( + + + + + - ))} - -
DateCreditorsDebtorsOverall Balance
{isOpen ? date : fmtDate(date)}£-{fmtGBP(b.creditors).slice(1)}{fmtGBP(b.debtors)} + {b.overall >= 0 ? fmtGBP(b.overall) : `£-${fmtGBP(Math.abs(b.overall)).slice(1)}`}
-
- )} + ) + })} + + + {(() => { + const entries = Object.entries(debtors.balances_by_date) + const last = entries[entries.length - 1]?.[1] + if (!last) return null + const balStyle: React.CSSProperties = last.overall >= 0 ? { color: '#155724' } : { color: '#721c24' } + return ( + + Period Close + £-{fmtGBP(last.creditors).slice(1)} + {fmtGBP(last.debtors)} + {last.overall >= 0 ? fmtGBP(last.overall) : `£-${fmtGBP(Math.abs(last.overall)).slice(1)}`} + + ) + })()} + + + ) :

Balances unavailable.

} + - {/* Table 4: Debtors / Creditors */} - -

Debtors / Creditors

- {debtorsLoading ? ( -

Loading balances…

- ) : debtors ? ( - - - - - - - - - - - - - - - - - {dates.map(d => { - const b = debtors.balances_by_date[d] - if (!b) return null - return ( - - - - - - - ) - })} - {(() => { - const last = debtors.balances_by_date[dates[dates.length - 1]] - if (!last) return null - return ( - - - - - - - ) - })()} - -
DateDebtorsCreditorsNet
Opening Balance{fmtGBP(debtors.period_open_balance.debtors)}{fmtGBP(debtors.period_open_balance.creditors)}{fmtGBP(debtors.period_open_balance.overall)}
{fmtDate(d)}{fmtGBP(b.debtors)}{fmtGBP(b.creditors)}= 0 ? '#16a34a' : 'var(--danger)' }}> - {fmtGBP(b.overall)} -
Period Close{fmtGBP(last.debtors)}{fmtGBP(last.creditors)}= 0 ? '#16a34a' : 'var(--danger)' }}> - {fmtGBP(last.overall)} -
- ) : ( -

Balances unavailable.

- )} -
- - )} + {/* Section 5: Copy/Paste (spreadsheet-ready) */} + +

Copy/Paste Format (for Spreadsheet)

+

2-row format: Gateway Amex on second row. Select all and Ctrl+C.

+

Cash Up & Reconciliation

+ onClick('tbl-paste-recon', e)}> + + + + + + + {['Date','Cash','Gateway V/MC','PDQ V/MC','PDQ Amex','BACS',''].map((l, i) => )} + {['Cash','Total V/MC','Total BACS','Total Amex'].map((l, i) => )} + + + + {days.flatMap((day, ri) => { + const r = BANKED_COLS.reduce((m, c) => { m[c.key] = recon(day, c.key); return m }, {} as Record) + const totalVMC = r['gateway_visa_mc'].reported_amount + r['pdq_visa_mc'].reported_amount + const totalAmex = r['gateway_amex'].reported_amount + r['pdq_amex'].reported_amount + return [ + + + + + + + + + + + + + , + + {[0,1,2,3,4,5,6,7,8,9,10].map(ci => ( + + ))} + , + ] + })} + +
BANKEDREPORTED
{l}{l}
{fmtDate(day.date)}{fmtGBP(r['cash'].banked_amount)}{fmtGBP(r['gateway_visa_mc'].banked_amount)}{fmtGBP(r['pdq_visa_mc'].banked_amount)}{fmtGBP(r['pdq_amex'].banked_amount)}{fmtGBP(r['bacs'].banked_amount)}{fmtGBP(r['cash'].reported_amount)}{fmtGBP(totalVMC)}{fmtGBP(r['bacs'].reported_amount)}{fmtGBP(totalAmex)}
+ {ci === 2 ? fmtGBP(r['gateway_amex'].banked_amount) : ''} +
+ +

Gross Sales

+ onClick('tbl-paste-gross', e)}> + + + {['Date','','Gross Sales'].map((l, i) => )} + + + + {days.flatMap((day, ri) => [ + + + + + , + + {[0,1,2].map(ci => )} + , + ])} + +
{l}
{fmtDate(day.date)}{fmtGBP(dayGrossSales(day))}
+
+ + )}
) } -const labelSt: React.CSSProperties = { fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' } -const inpSt: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' } -const sectionTitle: React.CSSProperties = { fontSize: '1rem', fontWeight: 700, marginBottom: '1rem' } -const th: React.CSSProperties = { padding: '0.5rem 0.625rem', textAlign: 'right', color: 'var(--text-mid)', fontWeight: 600, whiteSpace: 'nowrap' } -const td: React.CSSProperties = { padding: '0.45rem 0.625rem' } +// ── Styles ──────────────────────────────────────────────────────────────────── +const lbl: React.CSSProperties = { fontSize: '0.75rem', color: 'var(--text-mid)', display: 'block', marginBottom: '0.25rem' } +const inp: React.CSSProperties = { border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.4rem 0.6rem', fontSize: '0.875rem' } +const sec: React.CSSProperties = { fontSize: '1rem', fontWeight: 700, marginBottom: '0.75rem' } +const tblSt: React.CSSProperties = { borderCollapse: 'collapse', fontSize: '0.8rem', width: '100%', cursor: 'cell', userSelect: 'text' } +const th: React.CSSProperties = { padding: '0.5rem 0.625rem', textAlign: 'right', fontWeight: 600, color: '#333', whiteSpace: 'nowrap', border: '1px solid #ccc' } +const td: React.CSSProperties = { padding: '0.45rem 0.625rem', border: '1px solid #ddd' }