import { useCallback, useEffect, useState } from 'react' import { useFrameViewport } from '../hooks/useFrameViewport' import { Save, RefreshCw, Camera, Trash2, Printer, Repeat2 } from 'lucide-react' import { useAuth } from '../components/AuthGate' import { dfGetWorksheet, dfSaveWorksheet, dfGetReport, dfSaveSnapshot, dfDeleteSnapshot } from '../api' import type { WorksheetDay, WorksheetData, ForecastReportData, ForecastSnapshot } from '../types' import { can, DOW_NAMES, MONTH_NAMES } from '../types' // ── formatters ──────────────────────────────────────────────────────────────── const fmtCcy = (n: number | null | undefined) => n == null ? '—' : `£${n.toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` const fmtPct = (n: number | null | undefined) => n == null ? '—' : `${n.toFixed(1)}%` const fmtNum = (n: number | null | undefined, d = 0) => n == null ? '—' : n.toLocaleString('en-GB', { minimumFractionDigits: d, maximumFractionDigits: d }) // ── MonthNav ────────────────────────────────────────────────────────────────── function MonthNav({ year, month, onChange }: { year: number; month: number; onChange: (y: number, m: number) => void }) { const prev = () => month === 1 ? onChange(year - 1, 12) : onChange(year, month - 1) const next = () => month === 12 ? onChange(year + 1, 1) : onChange(year, month + 1) return (
{MONTH_NAMES[month - 1]} {year}
) } // ── Override state helpers ──────────────────────────────────────────────────── type DayOverride = { pickup_rooms: string pickup_avg_rate: string dry_override: string wet_override: string } // ── Worksheet tab ───────────────────────────────────────────────────────────── function WorksheetTab({ year, month }: { year: number; month: number }) { const { user } = useAuth() const canEdit = can(user, 'edit') const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [sessionRate, setRate] = useState('135') const [overrides, setOverrides] = useState>({}) const [dirty, setDirty] = useState(false) const load = useCallback(async () => { setLoading(true); setError(null) try { const d: WorksheetData = await dfGetWorksheet(year, month) setData(d) setRate(String(d.session.pickup_avg_rate ?? 135)) const ovrs: Record = {} for (const day of d.days) { ovrs[day.date] = { pickup_rooms: day.pickup_rooms > 0 ? String(day.pickup_rooms) : '', pickup_avg_rate: day.pickup_avg_rate != null ? String(day.pickup_avg_rate) : '', dry_override: day.dry_override != null ? String(day.dry_override) : '', wet_override: day.wet_override != null ? String(day.wet_override) : '', } } setOverrides(ovrs) setDirty(false) } catch { setError('Failed to load worksheet data.') } finally { setLoading(false) } }, [year, month]) useEffect(() => { load() }, [load]) function setOvr(date: string, field: keyof DayOverride, val: string) { setOverrides(prev => ({ ...prev, [date]: { ...prev[date], [field]: val } })) setDirty(true) } function fillFnBFromLast7() { if (!data) return const last7 = data.days .filter(d => d.is_past && (d.actual_dry != null || d.actual_wet != null)) .slice(-7) if (!last7.length) return setOverrides(prev => { const next = { ...prev } const futureDays = data.days.filter(d => !d.is_past) futureDays.forEach((day, i) => { const src = last7[i % last7.length] next[day.date] = { ...next[day.date], dry_override: String(src.actual_dry ?? src.forecast_dry ?? ''), wet_override: String(src.actual_wet ?? src.forecast_wet ?? ''), } }) return next }) setDirty(true) } async function handleSave() { if (!canEdit) return setSaving(true) try { const ovrList = Object.entries(overrides).map(([date, o]) => ({ date, pickup_rooms: o.pickup_rooms !== '' ? parseInt(o.pickup_rooms) : null, pickup_avg_rate: o.pickup_avg_rate !== '' ? parseFloat(o.pickup_avg_rate) : null, dry_override: o.dry_override !== '' ? parseFloat(o.dry_override) : null, wet_override: o.wet_override !== '' ? parseFloat(o.wet_override) : null, })) await dfSaveWorksheet(year, month, { pickup_avg_rate: parseFloat(sessionRate) || 135, overrides: ovrList }) setDirty(false) await load() } catch { setError('Save failed.') } finally { setSaving(false) } } function computedDay(day: WorksheetDay) { const ovr = overrides[day.date] || {} const pickup = parseInt(ovr.pickup_rooms || '0') || 0 const rate = parseFloat(ovr.pickup_avg_rate || '') || parseFloat(sessionRate) || 135 const total = day.otb_rooms + pickup const occ = day.available > 0 ? (total / day.available) * 100 : null let accomm = day.otb_net_rev + pickup * rate if (day.is_past && day.actual_accomm != null) accomm = day.actual_accomm const dry = ovr.dry_override !== '' ? parseFloat(ovr.dry_override) : day.forecast_dry const wet = ovr.wet_override !== '' ? parseFloat(ovr.wet_override) : day.forecast_wet return { total, occ, accomm, dry: dry || 0, wet: wet || 0 } } if (loading) return
if (!data) return
{error || 'No data.'}
const totals = data.days.reduce((acc, day) => { const c = computedDay(day) const p = parseInt(overrides[day.date]?.pickup_rooms || '0') || 0 return { available: acc.available + day.available, otb: acc.otb + day.otb_rooms, pickup: acc.pickup + p, total: acc.total + c.total, accomm: acc.accomm + c.accomm, dry: acc.dry + c.dry, wet: acc.wet + c.wet, } }, { available: 0, otb: 0, pickup: 0, total: 0, accomm: 0, dry: 0, wet: 0 }) return (
{error && {error}} {canEdit && ( )} {canEdit && ( )}
{data.days.map((day, i) => { const n = data.days.length const c = computedDay(day) const ovr = overrides[day.date] || { pickup_rooms: '', pickup_avg_rate: '', dry_override: '', wet_override: '' } const rowCls = day.is_today ? 'df-row-today' : day.is_past ? 'df-row-past' : '' // tabIndex groups: pickup col → dry col → wet col (vertical tab order) const tabPickup = i + 1 const tabDry = n + i + 1 const tabWet = n * 2 + i + 1 return ( ) })}
DayDateAvailOTB OTB RevAvg £ML± PickupTotal Act AccAct DryAct Wet Occ%Fcast AccFcast DryFcast Wet
{DOW_NAMES[day.dow]} {day.date.slice(5)} {day.available || '—'} {day.otb_rooms} {fmtCcy(day.otb_net_rev || null)} {fmtCcy(day.avg_rate_net)} {day.ml_suggestion != null ? (day.ml_suggestion > 0 ? `+${day.ml_suggestion}` : day.ml_suggestion) : '—'} {canEdit && !day.is_past ? ( setOvr(day.date, 'pickup_rooms', e.target.value)} className="df-cell-input" /> ) : ( {parseInt(ovr.pickup_rooms || '0') || '—'} )} {c.total} {fmtCcy(day.actual_accomm)} {fmtCcy(day.actual_dry)} {fmtCcy(day.actual_wet)} {c.occ != null ? `${c.occ.toFixed(1)}%` : '—'} {fmtCcy(c.accomm)} {canEdit ? ( setOvr(day.date, 'dry_override', e.target.value)} className="df-cell-input" /> ) : {fmtCcy(c.dry)}} {canEdit ? ( setOvr(day.date, 'wet_override', e.target.value)} className="df-cell-input" /> ) : {fmtCcy(c.wet)}}
Totals {totals.available} {totals.otb} {totals.pickup} {totals.total} {totals.available > 0 ? `${((totals.total / totals.available) * 100).toFixed(1)}%` : '—'} {fmtCcy(totals.accomm)} {fmtCcy(totals.dry)} {fmtCcy(totals.wet)}
) } // ── Report tab ──────────────────────────────────────────────────────────────── function VarCell({ a, b }: { a: number; b: number }) { const v = a - b return = 0 ? 'df-pos' : 'df-neg'}>{v >= 0 ? '+' : ''}{fmtCcy(v)} } function ReportTab({ year, month }: { year: number; month: number }) { const { user } = useAuth() const canEdit = can(user, 'edit') const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [snapshotLabel, setLabel] = useState('') const [saving, setSaving] = useState(false) const load = useCallback(async () => { setLoading(true); setError(null) try { setData(await dfGetReport(year, month)) } catch { setError('Failed to load report data.') } finally { setLoading(false) } }, [year, month]) useEffect(() => { load() }, [load]) async function handleSnapshot() { if (!data || !canEdit) return setSaving(true) try { await dfSaveSnapshot(year, month, { label: snapshotLabel || `${new Date().toLocaleDateString('en-GB')}`, forecast_rooms: data.forecast.rooms, forecast_accomm: data.forecast.accomm, forecast_dry: data.forecast.dry, forecast_wet: data.forecast.wet, forecast_total: data.forecast.total, otb_rooms: data.otb.rooms, pickup_rooms: data.pickup.rooms, occ_pct: data.forecast.occ_pct, }) setLabel('') await load() } catch { setError('Snapshot failed.') } finally { setSaving(false) } } async function handleDeleteSnap(snap: ForecastSnapshot) { if (!canEdit) return try { await dfDeleteSnapshot(year, month, snap.id); await load() } catch { setError('Delete failed.') } } if (loading) return
if (!data) return
{error || 'No data.'}
const { forecast, budget, last_year, otb, pickup, weekly, snapshots } = data return (
{canEdit && (
setLabel(e.target.value)} className="df-snap-input" />
)} {error && {error}}

DIRECTORS FORECAST — {MONTH_NAMES[month - 1].toUpperCase()} {year}

{/* Forecast vs Budget vs Last Year */}
{snapshots.map(s => ( ))} {snapshots.map(s => )} {([['Accommodation', 'accomm'], ['Dry', 'dry'], ['Wet', 'wet']] as const).map(([label, key]) => ( {snapshots.map(s => ( ))} ))} {snapshots.map(s => )}
ForecastBudgetBudget Var Last YearLY Var {s.label || new Date(s.snapshot_at).toLocaleDateString('en-GB')} {canEdit && ( )}
Rooms {forecast.rooms.toLocaleString()} {last_year.rooms.toLocaleString()} = last_year.rooms ? 'num df-pos' : 'num df-neg'}> {forecast.rooms - last_year.rooms >= 0 ? '+' : ''}{(forecast.rooms - last_year.rooms).toLocaleString()} {s.forecast_rooms?.toLocaleString()}
{label} {fmtCcy(forecast[key])} {fmtCcy(budget[key])} {fmtCcy(last_year[key])} {fmtCcy(s[`forecast_${key}` as keyof ForecastSnapshot] as number)}
Total {fmtCcy(forecast.total)} {fmtCcy(budget.total)} {fmtCcy(last_year.total)} {fmtCcy(s.forecast_total)}
{/* OTB Occupancy Summary */}
Occupancy Report
OTB (Actuals)Probable PickupEstimate for Month
Room Nights {otb.rooms.toLocaleString()} {pickup.rooms.toLocaleString()} {forecast.rooms.toLocaleString()}
Revenue {fmtCcy(otb.accomm)} {fmtCcy(pickup.accomm)} {fmtCcy(forecast.accomm)}
ARR {fmtCcy(otb.arr)} {fmtCcy(pickup.avg_rate)} {fmtCcy(forecast.arr)}
Occupancy % {fmtPct(otb.occ_pct)} {pickup.rooms > 0 && forecast.available > 0 ? fmtPct((pickup.rooms / forecast.available) * 100) : '—'} {fmtPct(forecast.occ_pct)}
REVPAR {fmtCcy(otb.revpar)} {fmtCcy(forecast.revpar)}
Available Rooms {forecast.available.toLocaleString()} {(forecast.available - otb.rooms).toLocaleString()} {forecast.available.toLocaleString()}
{/* Weekly Breakdown */}
{(['accomm', 'dry', 'wet'] as const).map(cat => (
{cat === 'accomm' ? 'Accommodation' : cat === 'dry' ? 'Dry (Food)' : 'Wet (Beverage)'}
{weekly.map(w => ( ))}
{w.label} {fmtCcy(w[cat])}
Total {fmtCcy(weekly.reduce((s, w) => s + w[cat], 0))}
))}
) } // ── Main component ──────────────────────────────────────────────────────────── type Tab = 'worksheet' | 'report' export default function DirectorsForecast() { useFrameViewport('desktop') const now = new Date() const [year, setYear] = useState(now.getFullYear()) const [month, setMonth] = useState(now.getMonth() + 1) const [tab, setTab] = useState('worksheet') return (
{ setYear(y); setMonth(m) }} />
{tab === 'worksheet' ? : }
) }