import { useState, useEffect } from 'react' import { api } from '../api' import { PageHeader, Card, Btn, StatusBadge } from '../components/Layout' import { fmtGBP } from '../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 { date: string cash_up: { id: number; status: 'draft' | 'final'; total_cash_counted: string } | null reconciliation: ReconRow[] 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 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: 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', 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']) function fmtDate(d: string) { const s = (d ?? '').slice(0, 10) if (!s) return '—' 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 }>, ): { byDate: Record; totalRooms: number } { // 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() { 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) 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) } } 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)) }, [result, startDate, numDays]) 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 (
{/* 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'}
{error && (
{error}
)} {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

{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 })}
Category{fmtDate(d)}Total
{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))}
{/* 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 Net Total VAT Total 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))}
)} {/* Table 3: Occupancy */} {occStats && (

Occupancy

{occStats.totalRooms > 0 && (

Total rooms: {occStats.totalRooms}

)} {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) => ( ))} ))}
Metric{fmtDate(d)}Total / Avg
{row.label}{row.fmt(v, i)} {row.total(row.vals)}
)} {/* 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 ( ) })()}
Date Debtors Creditors Net
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.

)}
)}
) } 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' }