From b53df5b6a529fd47e776af78d30f46191a55beb5 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Wed, 1 Jul 2026 22:40:12 +0000 Subject: [PATCH] Weekly report: full occupancy stats + VAT column in sales Occupancy table now calculates from raw Newbook data: - Rooms occupied (from per-category occupancy API) - Occ % (rooms occupied / total rooms from sites_data) - Guests (adults + children + infants from bookings_data) - Net accommodation revenue (from earned_revenue, ACC GL group) - Avg net per room, REVPAR - GGR (average guest rate from tariffs_quoted) - Avg lead time (arriving bookings only) Sales breakdown now shows Net, VAT, Gross as separate total columns. Debtors/Creditors adds Period Close row. Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/MultiDayReport.tsx | 405 ++++++++++++++++++++------ 1 file changed, 318 insertions(+), 87 deletions(-) diff --git a/frontend/src/pages/MultiDayReport.tsx b/frontend/src/pages/MultiDayReport.tsx index 5cfc068..609a7d0 100644 --- a/frontend/src/pages/MultiDayReport.tsx +++ b/frontend/src/pages/MultiDayReport.tsx @@ -12,19 +12,65 @@ interface DayData { daily_stats: { gross_sales: number; transaction_count: number } | null sales_breakdown: SalesCol[] } -interface OccupancyItem { period?: string; date?: string; rooms_sold?: number; total_rooms?: number; total_people?: number } + +// Newbook occupancy_data: per-category with per-date occupancy map +interface OccupancyCategoryRaw { + 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 + 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: OccupancyItem[] - bookings_data: Array<{ date?: string; period?: string; arrivals?: number; departures?: number; [k: string]: unknown }> + occupancy_data: OccupancyCategoryRaw[] + bookings_data: BookingRaw[] + sites_data: SiteRaw[] + earned_revenue: EarnedRevenueItem[] + 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 DayOccStats { + roomsOccupied: number + people: number; adults: number; children: number; infants: number + netAccom: number + ggr: number; ggrRoomCount: number + avgLeadTime: number; arrivingCount: number +} + const RECON_LABELS: Record = { cash: 'Cash', gateway_visa_mc: 'Gateway V/MC', @@ -33,7 +79,6 @@ const RECON_LABELS: Record = { pdq_amex: 'PDQ Amex', bacs: 'BACS', } - const VARIANCE_CATEGORIES = new Set(['cash', 'pdq_visa_mc', 'pdq_amex']) function fmtDate(d: string) { @@ -42,13 +87,116 @@ function fmtDate(d: string) { return new Date(s + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short' }) } -function variance(banked: number, reported: number) { - return banked - reported -} +function buildOccupancyStats( + dates: string[], + occupancyData: OccupancyCategoryRaw[], + bookingsData: BookingRaw[], + sitesData: SiteRaw[], + earnedRevenue: EarnedRevenueItem[], + glAccounts: Array<{ gl_group_id?: string; gl_group_name?: string }>, +): { byDate: Record; totalRooms: number } { -function varStyle(v: number): React.CSSProperties { - if (Math.abs(v) < 0.005) return { textAlign: 'right', color: 'var(--text-mid)' } - return { textAlign: 'right', color: v < 0 ? 'var(--danger)' : '#16a34a', fontWeight: 600 } + // Total rooms (exclude overflow) + const totalRooms = sitesData.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) { + 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) + } + } + } + + // 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 accomByDate: Record = {} + for (const item of earnedRevenue) { + 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) + } + } + + // Per-day stats from bookings + const bookingStats: Record = {} + + for (const b of bookingsData) { + const catName = (b.category_name ?? '').toLowerCase() + if (catName.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 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 } + 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++ } + 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] = { + 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, + arrivingCount: bk?.arrivingCount ?? 0, + } + } + return { byDate, totalRooms } } export function MultiDayReport() { @@ -86,6 +234,15 @@ export function MultiDayReport() { const dates = result?.report_data.map(d => d.date) ?? [] 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 ?? [], + ) : null + return (
@@ -147,11 +304,7 @@ export function MultiDayReport() { Category - {dates.map(d => ( - - {fmtDate(d)} - - ))} + {dates.map(d => {fmtDate(d)})} Total @@ -163,40 +316,28 @@ export function MultiDayReport() { 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 [ - /* Reported row */ hasVariance && ( - - - {label} — Newbook - + + {label} — Newbook {reportedVals.map((v, i) => ( - - {v ? fmtGBP(v) : '—'} - + {v ? fmtGBP(v) : '—'} ))} - - {fmtGBP(totalReported)} - + {fmtGBP(totalReported)} ), - /* Banked / main row */ - - - {hasVariance ? `${label} — Banked` : label} - + + {hasVariance ? `${label} — Banked` : label} {bankedVals.map((v, i) => { const rep = reportedVals[i] - const v2 = hasVariance ? variance(v, rep) : null + const vari = hasVariance ? (v - rep) : null return ( {v ? fmtGBP(v) : '—'} - {v2 !== null && Math.abs(v2) > 0.005 && ( -
- {v2 > 0 ? '+' : ''}{fmtGBP(v2)} + {vari !== null && Math.abs(vari) > 0.005 && ( +
+ {vari > 0 ? '+' : ''}{fmtGBP(vari)}
)} @@ -205,15 +346,14 @@ export function MultiDayReport() { {fmtGBP(totalBanked)} {hasVariance && Math.abs(totalBanked - totalReported) > 0.005 && ( -
+
{totalBanked - totalReported > 0 ? '+' : ''}{fmtGBP(totalBanked - totalReported)}
)} - + , ].filter(Boolean) })} - {/* Total row */} Total {result.report_data.map((day, i) => { @@ -237,7 +377,9 @@ export function MultiDayReport() { Category {dates.map(d => {fmtDate(d)})} - Total + Total Net + Total VAT + Total Gross @@ -246,44 +388,46 @@ export function MultiDayReport() { 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 totalGross = vals.reduce((s, v) => s + v.gross, 0) - if (vals.every(v => v.gross === 0)) return null + 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 ( {col.display_name} {vals.map((v, i) => ( {v.gross ? fmtGBP(v.gross) : '—'} - {v.vat > 0 && ( -
- net {fmtGBP(v.net)} -
- )} + {v.vat > 0 &&
net {fmtGBP(v.net)}
} ))} - {fmtGBP(totalGross)} + {fmtGBP(totNet)} + {fmtGBP(totVat)} + {fmtGBP(totGross)} ) })} - - Total Gross + + TOTAL {result.report_data.map((day, i) => { - const t = day.sales_breakdown.reduce((s, sb) => s + (sb.gross_amount ?? 0), 0) - return {t ? fmtGBP(t) : '—'} + 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 ( + + {g ? fmtGBP(g) : '—'} + {n > 0 &&
net {fmtGBP(n)}
} + + ) })} - - {fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.gross_amount ?? 0), 0), 0))} - - - - Total Net - {result.report_data.map((day, i) => { - const t = day.sales_breakdown.reduce((s, sb) => s + (sb.net_amount ?? 0), 0) - return {t ? fmtGBP(t) : '—'} - })} - + {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))} + @@ -291,35 +435,106 @@ export function MultiDayReport() { )} {/* Table 3: Occupancy */} - {result.occupancy_data.length > 0 && ( + {occStats && (

Occupancy

- + {occStats.totalRooms > 0 && ( +

+ Total rooms: {occStats.totalRooms} +

+ )} +
- - - - - + + {dates.map(d => )} + - {result.occupancy_data.map((row, i) => { - const date = row.period ?? row.date ?? '' - const occ = row.total_rooms && row.rooms_sold - ? ((row.rooms_sold / row.total_rooms) * 100).toFixed(1) - : '—' - return ( - - - - - - - - ) - })} + {[ + { + 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) => ( + + ))} + + + ))}
DateRooms SoldTotal RoomsGuestsOcc %Metric{fmtDate(d)}Total / Avg
{fmtDate(date)}{row.rooms_sold ?? '—'}{row.total_rooms ?? '—'}{row.total_people ?? '—'}{occ}{occ !== '—' ? '%' : ''}
{row.label}{row.fmt(v, i)} + {row.total(row.vals)} +
@@ -355,10 +570,26 @@ export function MultiDayReport() { {fmtDate(d)} {fmtGBP(b.debtors)} {fmtGBP(b.creditors)} - {fmtGBP(b.overall)} + = 0 ? '#16a34a' : 'var(--danger)' }}> + {fmtGBP(b.overall)} + ) })} + {(() => { + const last = debtors.balances_by_date[dates[dates.length - 1]] + if (!last) return null + return ( + + Period Close + {fmtGBP(last.debtors)} + {fmtGBP(last.creditors)} + = 0 ? '#16a34a' : 'var(--danger)' }}> + {fmtGBP(last.overall)} + + + ) + })()} ) : (