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[] } interface OccupancyItem { period?: string; date?: string; rooms_sold?: number; total_rooms?: number; total_people?: 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 }> } interface DebtorBalance { creditors: number; debtors: number; overall: number } interface DebtorsResult { period_open_balance: DebtorBalance balances_by_date: 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']) 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 variance(banked: number, reported: number) { return banked - reported } 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 } } 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 ?? [] 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 [ /* Reported row */ hasVariance && ( {reportedVals.map((v, i) => ( ))} ), /* Banked / main row */ {bankedVals.map((v, i) => { const rep = reportedVals[i] const v2 = hasVariance ? variance(v, rep) : null return ( ) })} ].filter(Boolean) })} {/* Total row */} {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) : '—'} {v2 !== null && Math.abs(v2) > 0.005 && (
{v2 > 0 ? '+' : ''}{fmtGBP(v2)}
)}
{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 totalGross = vals.reduce((s, v) => s + v.gross, 0) if (vals.every(v => v.gross === 0)) return null return ( {vals.map((v, i) => ( ))} ) })} {result.report_data.map((day, i) => { const t = day.sales_breakdown.reduce((s, sb) => s + (sb.gross_amount ?? 0), 0) return })} {result.report_data.map((day, i) => { const t = day.sales_breakdown.reduce((s, sb) => s + (sb.net_amount ?? 0), 0) return })}
Category{fmtDate(d)}Total
{col.display_name} {v.gross ? fmtGBP(v.gross) : '—'} {v.vat > 0 && (
net {fmtGBP(v.net)}
)}
{fmtGBP(totalGross)}
Total Gross{t ? fmtGBP(t) : '—'} {fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.gross_amount ?? 0), 0), 0))}
Total Net{t ? fmtGBP(t) : '—'} {fmtGBP(result.report_data.reduce((s, d) => s + d.sales_breakdown.reduce((ss, sb) => ss + (sb.net_amount ?? 0), 0), 0))}
)} {/* Table 3: Occupancy */} {result.occupancy_data.length > 0 && (

Occupancy

{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 ( ) })}
Date Rooms Sold Total Rooms Guests Occ %
{fmtDate(date)} {row.rooms_sold ?? '—'} {row.total_rooms ?? '—'} {row.total_people ?? '—'} {occ}{occ !== '—' ? '%' : ''}
)} {/* 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 ( ) })}
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)} {fmtGBP(b.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' }