reports/frontend/src/pages/DirectorsForecast.tsx
jtricerolph e9899ce7d5 Fix worksheet past-date display: pickup, dry/wet, and room cap
Past dates: zero out pickup (override ignored), use actuals for dry/wet
(fall back to override/forecast only if no actual exists), lock dry/wet
inputs read-only. Pickup + OTB total is now capped at available rooms
so occupancy can't exceed 100%. Totals row also excludes past pickup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 17:33:09 +00:00

520 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 = day.is_past ? 0 : (parseInt(ovr.pickup_rooms || '0') || 0)
const rate = parseFloat(ovr.pickup_avg_rate || '') || parseFloat(sessionRate) || 135
const rawTotal = day.otb_rooms + pickup
const total = day.available > 0 ? Math.min(rawTotal, day.available) : rawTotal
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 = day.is_past && day.actual_dry != null
? day.actual_dry
: (ovr.dry_override !== '' ? parseFloat(ovr.dry_override) : day.forecast_dry)
const wet = day.is_past && day.actual_wet != null
? day.actual_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 = day.is_past ? 0 : (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 && !day.is_past ? (
<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 && !day.is_past ? (
<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>
)
}