Add Directors Forecast interactive report section

Embeds a Directors Forecast section in the Reports sidebar (hard-coded
under "Directors Reports"). Worksheet tab: day-by-day OTB grid with
editable pickup rooms, dry/wet overrides, ML suggestion column and live
forecast recalculation. Report tab: forecast vs budget vs last-year
comparison, occupancy summary, weekly revenue bands and snapshot tracking.

Backend: forecast-db.js (read-only pool to forecasting_db), new tables
(forecast_sessions, day_overrides, forecast_snapshots) and full REST
routes under /api/directors-forecast. docker-compose FORECAST_DATABASE_URL
added.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-20 10:24:47 +00:00
parent 92e2c48f48
commit 018957123f
10 changed files with 1128 additions and 5 deletions

View file

@ -0,0 +1,478 @@
import { useCallback, useEffect, useState } from 'react'
import { Save, RefreshCw, Camera, Trash2, Printer } 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)
}
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-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 => {
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' : ''
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"
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"
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"
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() {
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>
)
}

View file

@ -2,11 +2,23 @@ import { useEffect, useState, useCallback } from 'react'
import {
BarChart2, Building2, UtensilsCrossed, ShoppingCart, Database,
ChevronRight, ChevronDown, Play, Download, Loader2, AlertCircle,
FileBarChart,
FileBarChart, TrendingUp,
} from 'lucide-react'
import { useAuth } from '../components/AuthGate'
import { fetchReports, runReport } from '../api'
import type { ReportMeta, ReportResult } from '../types'
import DirectorsForecast from './DirectorsForecast'
const DIRECTORS_REPORTS: ReportMeta[] = [
{
id: 'directors-forecast',
name: 'Directors Forecast',
category: 'directors',
categoryLabel: 'Directors Reports',
subcategory: null,
description: 'Monthly OTB, pickup and revenue forecast with budget and last-year comparison.',
},
]
// ── helpers ───────────────────────────────────────────────────────────────────
@ -99,6 +111,7 @@ function Sidebar({ tree, selectedId, onSelect }: SidebarProps) {
const { user } = useAuth()
const [openCats, setOpenCats] = useState<Set<string>>(new Set(tree.map(t => t.category)))
const [openSubs, setOpenSubs] = useState<Set<string>>(new Set())
const [directorsOpen, setDirectorsOpen] = useState(true)
const toggleCat = (key: string) =>
setOpenCats(prev => { const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n })
@ -114,6 +127,30 @@ function Sidebar({ tree, selectedId, onSelect }: SidebarProps) {
</div>
<nav className="sidebar-nav" style={{ padding: 0 }}>
{/* Hard-coded Directors Reports section */}
<div className="report-category">
<button className="report-cat-header" onClick={() => setDirectorsOpen(o => !o)}>
<span className="report-cat-icon"><TrendingUp size={14} strokeWidth={1.75} /></span>
<span className="report-cat-label">Directors Reports</span>
{directorsOpen
? <ChevronDown size={12} strokeWidth={2} />
: <ChevronRight size={12} strokeWidth={2} />}
</button>
{directorsOpen && (
<div className="report-cat-body">
{DIRECTORS_REPORTS.map(r => (
<button
key={r.id}
className={`report-item${selectedId === r.id ? ' active' : ''}`}
onClick={() => onSelect(r)}
>
{r.name}
</button>
))}
</div>
)}
</div>
{tree.map(cat => {
const isOpen = openCats.has(cat.category)
return (
@ -267,6 +304,8 @@ export default function ReportsPage() {
<h2>Custom Reports</h2>
<p>Select a report from the sidebar to get started.</p>
</div>
) : selected.category === 'directors' ? (
<DirectorsForecast />
) : (
<div className="report-view">
<div className="report-header">