reports/frontend/src/pages/DirectorsForecast.tsx
jtricerolph 3a8661127a Add useFrameViewport hook; apply desktop mode to DirectorsForecast
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 09:00:35 +00:00

515 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<div className="df-month-nav">
<button className="df-nav-btn" onClick={prev}></button>
<span className="df-month-label">{MONTH_NAMES[month - 1]} {year}</span>
<button className="df-nav-btn" onClick={next}></button>
</div>
)
}
// ── 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<WorksheetData | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sessionRate, setRate] = useState('135')
const [overrides, setOverrides] = useState<Record<string, DayOverride>>({})
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<string, DayOverride> = {}
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 <div className="df-loading"><div className="df-spinner" /></div>
if (!data) return <div className="df-error">{error || 'No data.'}</div>
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 (
<div className="df-worksheet">
<div className="df-toolbar">
<div className="df-toolbar-left">
<label className="df-rate-label">
Default Pickup Rate (£)
<input
type="number" value={sessionRate} min="0" step="1"
onChange={e => { setRate(e.target.value); setDirty(true) }}
className="df-rate-input" disabled={!canEdit}
/>
</label>
</div>
<div className="df-toolbar-right">
{error && <span className="df-error-inline">{error}</span>}
<button className="btn-icon df-btn-icon" onClick={load} title="Refresh">
<RefreshCw size={13} strokeWidth={1.75} />
</button>
{canEdit && (
<button className="btn-icon df-btn-icon" onClick={fillFnBFromLast7} title="Fill F&B from last 7 days">
<Repeat2 size={13} strokeWidth={1.75} />
</button>
)}
{canEdit && (
<button className="btn-run df-save-btn" onClick={handleSave} disabled={saving || !dirty}>
<Save size={13} strokeWidth={1.75} />
{saving ? 'Saving…' : 'Save'}
</button>
)}
</div>
</div>
<div className="df-table-wrap">
<table className="df-table">
<thead>
<tr>
<th>Day</th><th>Date</th><th>Avail</th><th>OTB</th>
<th>OTB Rev</th><th>Avg £</th><th title="ML model suggestion">ML±</th>
<th>Pickup</th><th>Total</th>
<th>Act Acc</th><th>Act Dry</th><th>Act Wet</th>
<th>Occ%</th><th>Fcast Acc</th><th>Fcast Dry</th><th>Fcast Wet</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={day.date} className={rowCls}>
<td>{DOW_NAMES[day.dow]}</td>
<td>{day.date.slice(5)}</td>
<td className="num">{day.available || '—'}</td>
<td className="num">{day.otb_rooms}</td>
<td className="num">{fmtCcy(day.otb_net_rev || null)}</td>
<td className="num">{fmtCcy(day.avg_rate_net)}</td>
<td className="num df-ml">
{day.ml_suggestion != null ? (day.ml_suggestion > 0 ? `+${day.ml_suggestion}` : day.ml_suggestion) : '—'}
</td>
<td className="df-input-cell">
{canEdit && !day.is_past ? (
<input type="number" value={ovr.pickup_rooms} min="0" placeholder="0"
tabIndex={tabPickup}
onChange={e => setOvr(day.date, 'pickup_rooms', e.target.value)}
className="df-cell-input" />
) : (
<span className="num">{parseInt(ovr.pickup_rooms || '0') || '—'}</span>
)}
</td>
<td className="num df-bold">{c.total}</td>
<td className="num">{fmtCcy(day.actual_accomm)}</td>
<td className="num">{fmtCcy(day.actual_dry)}</td>
<td className="num">{fmtCcy(day.actual_wet)}</td>
<td className="num">{c.occ != null ? `${c.occ.toFixed(1)}%` : '—'}</td>
<td className="num df-forecast">{fmtCcy(c.accomm)}</td>
<td className="df-input-cell">
{canEdit ? (
<input type="number" value={ovr.dry_override} step="0.01"
tabIndex={tabDry}
placeholder={fmtNum(day.forecast_dry, 0)}
onChange={e => setOvr(day.date, 'dry_override', e.target.value)}
className="df-cell-input" />
) : <span className="num">{fmtCcy(c.dry)}</span>}
</td>
<td className="df-input-cell">
{canEdit ? (
<input type="number" value={ovr.wet_override} step="0.01"
tabIndex={tabWet}
placeholder={fmtNum(day.forecast_wet, 0)}
onChange={e => setOvr(day.date, 'wet_override', e.target.value)}
className="df-cell-input" />
) : <span className="num">{fmtCcy(c.wet)}</span>}
</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr className="df-totals">
<td colSpan={2}>Totals</td>
<td className="num">{totals.available}</td>
<td className="num">{totals.otb}</td>
<td /><td /><td />
<td className="num">{totals.pickup}</td>
<td className="num df-bold">{totals.total}</td>
<td /><td /><td />
<td className="num">{totals.available > 0 ? `${((totals.total / totals.available) * 100).toFixed(1)}%` : '—'}</td>
<td className="num df-bold">{fmtCcy(totals.accomm)}</td>
<td className="num">{fmtCcy(totals.dry)}</td>
<td className="num">{fmtCcy(totals.wet)}</td>
</tr>
</tfoot>
</table>
</div>
</div>
)
}
// ── Report tab ────────────────────────────────────────────────────────────────
function VarCell({ a, b }: { a: number; b: number }) {
const v = a - b
return <td className={v >= 0 ? 'df-pos' : 'df-neg'}>{v >= 0 ? '+' : ''}{fmtCcy(v)}</td>
}
function ReportTab({ year, month }: { year: number; month: number }) {
const { user } = useAuth()
const canEdit = can(user, 'edit')
const [data, setData] = useState<ForecastReportData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 <div className="df-loading"><div className="df-spinner" /></div>
if (!data) return <div className="df-error">{error || 'No data.'}</div>
const { forecast, budget, last_year, otb, pickup, weekly, snapshots } = data
return (
<div className="df-report" id="df-printable">
<div className="df-report-toolbar no-print">
<button className="btn-icon df-btn-icon" onClick={() => window.print()} title="Print">
<Printer size={13} strokeWidth={1.75} />
</button>
<button className="btn-icon df-btn-icon" onClick={load} title="Refresh">
<RefreshCw size={13} strokeWidth={1.75} />
</button>
{canEdit && (
<div className="df-snap-row">
<input type="text" value={snapshotLabel} placeholder="Snapshot label (optional)"
onChange={e => setLabel(e.target.value)} className="df-snap-input" />
<button className="btn-run df-save-btn" onClick={handleSnapshot} disabled={saving}>
<Camera size={13} strokeWidth={1.75} />
{saving ? 'Saving…' : 'Save Snapshot'}
</button>
</div>
)}
{error && <span className="df-error-inline">{error}</span>}
</div>
<h2 className="df-report-title">
DIRECTORS FORECAST {MONTH_NAMES[month - 1].toUpperCase()} {year}
</h2>
{/* Forecast vs Budget vs Last Year */}
<div className="df-card">
<table className="df-report-table">
<thead>
<tr>
<th></th>
<th>Forecast</th><th>Budget</th><th>Budget Var</th>
<th>Last Year</th><th>LY Var</th>
{snapshots.map(s => (
<th key={s.id} className="df-snap-col">
{s.label || new Date(s.snapshot_at).toLocaleDateString('en-GB')}
{canEdit && (
<button className="df-del-snap" onClick={() => handleDeleteSnap(s)} title="Delete">
<Trash2 size={10} strokeWidth={1.75} />
</button>
)}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td className="df-row-label">Rooms</td>
<td className="num">{forecast.rooms.toLocaleString()}</td>
<td className="num"></td><td className="num"></td>
<td className="num">{last_year.rooms.toLocaleString()}</td>
<td className={forecast.rooms >= last_year.rooms ? 'num df-pos' : 'num df-neg'}>
{forecast.rooms - last_year.rooms >= 0 ? '+' : ''}{(forecast.rooms - last_year.rooms).toLocaleString()}
</td>
{snapshots.map(s => <td key={s.id} className="num df-snap-val">{s.forecast_rooms?.toLocaleString()}</td>)}
</tr>
{([['Accommodation', 'accomm'], ['Dry', 'dry'], ['Wet', 'wet']] as const).map(([label, key]) => (
<tr key={key}>
<td className="df-row-label">{label}</td>
<td className="num">{fmtCcy(forecast[key])}</td>
<td className="num">{fmtCcy(budget[key])}</td>
<VarCell a={forecast[key]} b={budget[key]} />
<td className="num">{fmtCcy(last_year[key])}</td>
<VarCell a={forecast[key]} b={last_year[key]} />
{snapshots.map(s => (
<td key={s.id} className="num df-snap-val">
{fmtCcy(s[`forecast_${key}` as keyof ForecastSnapshot] as number)}
</td>
))}
</tr>
))}
<tr className="df-total-row">
<td className="df-row-label">Total</td>
<td className="num">{fmtCcy(forecast.total)}</td>
<td className="num">{fmtCcy(budget.total)}</td>
<VarCell a={forecast.total} b={budget.total} />
<td className="num">{fmtCcy(last_year.total)}</td>
<VarCell a={forecast.total} b={last_year.total} />
{snapshots.map(s => <td key={s.id} className="num df-snap-val">{fmtCcy(s.forecast_total)}</td>)}
</tr>
</tbody>
</table>
</div>
{/* OTB Occupancy Summary */}
<div className="df-card">
<div className="df-card-title">Occupancy Report</div>
<table className="df-report-table">
<thead>
<tr>
<th></th><th>OTB (Actuals)</th><th>Probable Pickup</th><th>Estimate for Month</th>
</tr>
</thead>
<tbody>
<tr><td className="df-row-label">Room Nights</td>
<td className="num">{otb.rooms.toLocaleString()}</td>
<td className="num">{pickup.rooms.toLocaleString()}</td>
<td className="num df-bold">{forecast.rooms.toLocaleString()}</td></tr>
<tr><td className="df-row-label">Revenue</td>
<td className="num">{fmtCcy(otb.accomm)}</td>
<td className="num">{fmtCcy(pickup.accomm)}</td>
<td className="num df-bold">{fmtCcy(forecast.accomm)}</td></tr>
<tr><td className="df-row-label">ARR</td>
<td className="num">{fmtCcy(otb.arr)}</td>
<td className="num">{fmtCcy(pickup.avg_rate)}</td>
<td className="num df-bold">{fmtCcy(forecast.arr)}</td></tr>
<tr><td className="df-row-label">Occupancy %</td>
<td className="num">{fmtPct(otb.occ_pct)}</td>
<td className="num">{pickup.rooms > 0 && forecast.available > 0 ? fmtPct((pickup.rooms / forecast.available) * 100) : '—'}</td>
<td className="num df-bold">{fmtPct(forecast.occ_pct)}</td></tr>
<tr><td className="df-row-label">REVPAR</td>
<td className="num">{fmtCcy(otb.revpar)}</td>
<td className="num"></td>
<td className="num df-bold">{fmtCcy(forecast.revpar)}</td></tr>
<tr><td className="df-row-label">Available Rooms</td>
<td className="num">{forecast.available.toLocaleString()}</td>
<td className="num">{(forecast.available - otb.rooms).toLocaleString()}</td>
<td className="num">{forecast.available.toLocaleString()}</td></tr>
</tbody>
</table>
</div>
{/* Weekly Breakdown */}
<div className="df-weekly-grid">
{(['accomm', 'dry', 'wet'] as const).map(cat => (
<div key={cat} className="df-card">
<div className="df-card-title">{cat === 'accomm' ? 'Accommodation' : cat === 'dry' ? 'Dry (Food)' : 'Wet (Beverage)'}</div>
<table className="df-report-table">
<tbody>
{weekly.map(w => (
<tr key={w.label}>
<td className="df-row-label">{w.label}</td>
<td className="num">{fmtCcy(w[cat])}</td>
</tr>
))}
<tr className="df-total-row">
<td className="df-row-label">Total</td>
<td className="num">{fmtCcy(weekly.reduce((s, w) => s + w[cat], 0))}</td>
</tr>
</tbody>
</table>
</div>
))}
</div>
</div>
)
}
// ── 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<Tab>('worksheet')
return (
<div className="df-root">
<div className="df-header">
<MonthNav year={year} month={month} onChange={(y, m) => { setYear(y); setMonth(m) }} />
<div className="df-tabs">
<button className={`df-tab${tab === 'worksheet' ? ' active' : ''}`} onClick={() => setTab('worksheet')}>
Worksheet
</button>
<button className={`df-tab${tab === 'report' ? ' active' : ''}`} onClick={() => setTab('report')}>
Report
</button>
</div>
</div>
{tab === 'worksheet'
? <WorksheetTab key={`ws-${year}-${month}`} year={year} month={month} />
: <ReportTab key={`rp-${year}-${month}`} year={year} month={month} />}
</div>
)
}