Add Weekly Actuals directors report with charts

Full weekly actuals report matching the spreadsheet layout:
- Week summary table (Rooms/Dry/Wet vs Budget vs Last Year vs Last Week)
- Grouped bar chart + dept split pie chart
- 5-week rolling summary table
- Occupancy table by day of week (rooms, occ%, ARR, RevPAR)
- Month progress to date (totals + dept split) with progress bar chart
- 12-month turnover trend line chart by dept
- Utilities placeholder (pending meter readings app)

Budget values come from the forecasting API which distributes monthly
budget proportionally to daily sales — so high-demand weeks (e.g.
Christmas) carry a higher budget share. Gross shown as net × 1.20.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-20 13:07:19 +00:00
parent 29a119f51d
commit b97358e9fb
9 changed files with 1433 additions and 6 deletions

View file

@ -1,4 +1,4 @@
import type { ReportMeta, ReportResult, WorksheetData, ForecastReportData } from './types'
import type { ReportMeta, ReportResult, WorksheetData, ForecastReportData, WeeklyActualData } from './types'
const BASE = '/reports/api'
@ -30,6 +30,12 @@ export function runReport(id: string, dateFrom: string, dateTo: string): Promise
})
}
// ── Weekly Actuals API ──────────────────────────────────────────────────────
export function getWeeklyActual(weekEnding: string): Promise<WeeklyActualData> {
return request<WeeklyActualData>(`/weekly-actual/${weekEnding}`)
}
// ── Directors Forecast API ──────────────────────────────────────────────────
export function dfGetWorksheet(year: number, month: number): Promise<WorksheetData> {

View file

@ -534,3 +534,188 @@ html, body, #root {
.df-weekly-grid { grid-template-columns: 1fr; }
.df-table { font-size: 11px; }
}
/* ── Weekly Actuals ─────────────────────────────────────────────────────── */
.wa-page {
height: 100%;
overflow-y: auto;
background: var(--body-bg);
}
.wa-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--card-bg);
gap: 12px;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 10;
}
.wa-week-nav {
display: flex;
align-items: center;
gap: 8px;
}
.wa-week-label {
font-size: 13px;
color: var(--text-primary);
min-width: 160px;
text-align: center;
}
.wa-toolbar-actions {
display: flex;
align-items: center;
gap: 6px;
}
.wa-content {
padding: 20px;
display: flex;
flex-direction: column;
gap: 28px;
max-width: 1400px;
}
.wa-section {
background: var(--card-bg);
border-radius: var(--radius);
border: 1px solid var(--border);
padding: 20px;
box-shadow: var(--shadow-sm);
}
.wa-section-title {
font-size: 13px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text-primary);
margin: 0 0 14px;
padding-bottom: 10px;
border-bottom: 2px solid var(--gold);
}
.wa-note {
font-size: 11px;
color: var(--text-muted);
margin: 0 0 10px;
font-style: italic;
}
.wa-table-wrap {
overflow-x: auto;
}
.wa-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.wa-table th {
background: #f8f9fa;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-muted);
padding: 8px 10px;
border-bottom: 2px solid var(--border);
text-align: right;
white-space: nowrap;
}
.wa-table th:first-child { text-align: left; }
.wa-table td {
padding: 7px 10px;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.wa-table tbody tr:last-child td { border-bottom: none; }
.wa-table tbody tr:hover { background: #fafafa; }
.wa-row-label {
font-size: 12px;
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
}
.wa-num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.wa-muted { color: var(--text-muted); }
.wa-pos { color: #16a34a; font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; }
.wa-neg { color: #dc2626; font-weight: 600; text-align: right; font-variant-numeric: tabular-nums; }
.wa-total-row td {
font-weight: 700;
background: #f4f5f7;
border-top: 2px solid var(--border);
border-bottom: none;
}
.wa-charts-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.wa-chart-card {
background: #fafafa;
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px 10px 10px;
}
.wa-chart-card h4 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
color: var(--text-muted);
margin: 0 0 10px;
text-align: center;
}
.wa-month-section {
display: flex;
flex-direction: column;
gap: 0;
}
.wa-utilities-placeholder {
background: #f8f9fa;
border: 1px dashed var(--border);
border-radius: 6px;
padding: 24px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
@media print {
.wa-toolbar { display: none !important; }
.wa-page { overflow: visible; }
.wa-charts-row { break-inside: avoid; }
}
@media (max-width: 900px) {
.wa-charts-row { grid-template-columns: 1fr; }
.wa-table { font-size: 11px; }
}

View file

@ -8,6 +8,7 @@ import { useAuth } from '../components/AuthGate'
import { fetchReports, runReport } from '../api'
import type { ReportMeta, ReportResult } from '../types'
import DirectorsForecast from './DirectorsForecast'
import WeeklyActual from './WeeklyActual'
const DIRECTORS_REPORTS: ReportMeta[] = [
{
@ -18,6 +19,14 @@ const DIRECTORS_REPORTS: ReportMeta[] = [
subcategory: null,
description: 'Monthly OTB, pickup and revenue forecast with budget and last-year comparison.',
},
{
id: 'weekly-actual',
name: 'Weekly Actuals',
category: 'directors',
categoryLabel: 'Directors Reports',
subcategory: null,
description: 'Weekly actuals report with dept split, occupancy, month progress and 12-month trend.',
},
]
// ── helpers ───────────────────────────────────────────────────────────────────
@ -304,6 +313,8 @@ export default function ReportsPage() {
<h2>Custom Reports</h2>
<p>Select a report from the sidebar to get started.</p>
</div>
) : selected.id === 'weekly-actual' ? (
<WeeklyActual />
) : selected.category === 'directors' ? (
<DirectorsForecast />
) : (

View file

@ -0,0 +1,480 @@
import { useCallback, useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, RefreshCw, Printer } from 'lucide-react'
import {
ResponsiveContainer,
BarChart, Bar,
XAxis, YAxis, Tooltip, Legend,
PieChart, Pie, Cell,
LineChart, Line,
CartesianGrid,
} from 'recharts'
import { getWeeklyActual } from '../api'
import type { WeeklyActualData, WeekSummaryRow } from '../types'
// ── Formatters ─────────────────────────────────────────────────────────────
const fmtCcy = (n: number | null | undefined, dp = 2) =>
n == null ? '—' : `£${n.toLocaleString('en-GB', { minimumFractionDigits: dp, maximumFractionDigits: dp })}`
const fmtPct = (n: number | null | undefined) =>
n == null ? '—' : `${n >= 0 ? '+' : ''}${n.toFixed(2)}%`
const fmtPctClass = (n: number | null | undefined) =>
n == null ? '' : n >= 0 ? 'wa-pos' : 'wa-neg'
const fmtShortDate = (s: string) => {
const d = new Date(s + 'T00:00:00')
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`
}
const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December']
// ── Week navigation ────────────────────────────────────────────────────────
function defaultWeekEnding(): string {
const d = new Date()
const day = d.getDay()
// Go back to the most recent Saturday (day=6→today, else go back)
const daysBack = day === 6 ? 0 : day + 1
d.setDate(d.getDate() - daysBack)
return d.toISOString().split('T')[0]
}
function shiftWeek(dateStr: string, weeks: number): string {
const d = new Date(dateStr + 'T00:00:00')
d.setDate(d.getDate() + weeks * 7)
return d.toISOString().split('T')[0]
}
// ── Chart colours ──────────────────────────────────────────────────────────
const CHART_COLORS = {
rooms: '#c9a84c',
dry: '#2563eb',
wet: '#059669',
thisWeek: '#c9a84c',
budget: '#9ca3af',
lastYear: '#d1d5db',
}
const PIE_COLORS = [CHART_COLORS.rooms, CHART_COLORS.dry, CHART_COLORS.wet]
// ── Tooltip formatter ──────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fmtChartCcy = (v: any): string => {
if (v == null || Array.isArray(v)) return '—'
const n = Number(v)
return isNaN(n) ? '—' : `£${n.toLocaleString('en-GB', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`
}
// ── Section: Week Summary Table ────────────────────────────────────────────
function WeekTable({ tw, lw }: { tw: WeekSummaryRow; lw: WeekSummaryRow['dept'] }) {
const rows = [
{ label: 'Rooms', tw_net: tw.dept.rooms.net, budget: tw.dept.rooms.budget, ly: tw.dept.rooms.ly, lw_net: lw.rooms.net },
{ label: 'Dry', tw_net: tw.dept.dry.net, budget: tw.dept.dry.budget, ly: tw.dept.dry.ly, lw_net: lw.dry.net },
{ label: 'Wet', tw_net: tw.dept.wet.net, budget: tw.dept.wet.budget, ly: tw.dept.wet.ly, lw_net: lw.wet.net },
{ label: 'Total Net', tw_net: tw.net, budget: tw.budget_net, ly: tw.ly_net, lw_net: lw.rooms.net + lw.dry.net + lw.wet.net },
]
return (
<div className="wa-table-wrap">
<table className="wa-table">
<thead>
<tr>
<th></th>
<th>This Week</th>
<th>% to Budget</th>
<th>Budget</th>
<th>% to Last Year</th>
<th>Last Year</th>
<th>Last Week</th>
</tr>
</thead>
<tbody>
{rows.map((r, i) => {
const pctBud = r.budget > 0 ? ((r.tw_net - r.budget) / r.budget) * 100 : null
const pctLy = r.ly > 0 ? ((r.tw_net - r.ly) / r.ly) * 100 : null
return (
<tr key={r.label} className={i === rows.length - 1 ? 'wa-total-row' : ''}>
<td className="wa-row-label">{r.label}</td>
<td className="wa-num">{fmtCcy(r.tw_net)}</td>
<td className={`wa-num ${fmtPctClass(pctBud)}`}>{fmtPct(pctBud)}</td>
<td className="wa-num wa-muted">{fmtCcy(r.budget)}</td>
<td className={`wa-num ${fmtPctClass(pctLy)}`}>{fmtPct(pctLy)}</td>
<td className="wa-num wa-muted">{fmtCcy(r.ly)}</td>
<td className="wa-num wa-muted">{fmtCcy(r.lw_net)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
// ── Section: Week Charts ───────────────────────────────────────────────────
function WeekCharts({ tw }: { tw: WeekSummaryRow }) {
const barData = [
{ name: 'Rooms', 'This Week': tw.dept.rooms.net, 'Budget': tw.dept.rooms.budget, 'Last Year': tw.dept.rooms.ly },
{ name: 'Dry', 'This Week': tw.dept.dry.net, 'Budget': tw.dept.dry.budget, 'Last Year': tw.dept.dry.ly },
{ name: 'Wet', 'This Week': tw.dept.wet.net, 'Budget': tw.dept.wet.budget, 'Last Year': tw.dept.wet.ly },
]
const pieData = [
{ name: 'Rooms', value: tw.dept.rooms.net },
{ name: 'Dry', value: tw.dept.dry.net },
{ name: 'Wet', value: tw.dept.wet.net },
]
return (
<div className="wa-charts-row">
<div className="wa-chart-card">
<h4>This Week vs Budget vs Last Year</h4>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={barData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={v => `£${(v/1000).toFixed(0)}k`} tick={{ fontSize: 10 }} width={52} />
<Tooltip formatter={fmtChartCcy} />
<Legend iconSize={10} wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="This Week" fill={CHART_COLORS.thisWeek} radius={[2,2,0,0]} />
<Bar dataKey="Budget" fill={CHART_COLORS.budget} radius={[2,2,0,0]} />
<Bar dataKey="Last Year" fill={CHART_COLORS.lastYear} radius={[2,2,0,0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="wa-chart-card">
<h4>This Week Dept Split</h4>
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={pieData}
cx="50%" cy="50%"
outerRadius={80}
dataKey="value"
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
labelLine={false}
>
{pieData.map((_, idx) => (
<Cell key={idx} fill={PIE_COLORS[idx]} />
))}
</Pie>
<Tooltip formatter={fmtChartCcy} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
)
}
// ── Section: 5 Week Summary ────────────────────────────────────────────────
function FiveWeekTable({ weeks }: { weeks: WeekSummaryRow[] }) {
return (
<div className="wa-table-wrap">
<table className="wa-table">
<thead>
<tr>
<th>Week Ending</th>
<th>Gross Sales</th>
<th>Net Sales</th>
<th>% to Budget</th>
<th>Budget Net</th>
<th>% to Last Year</th>
<th>Last Year Net</th>
</tr>
</thead>
<tbody>
{weeks.map((w, i) => (
<tr key={w.week_ending} className={i === 0 ? 'wa-total-row' : ''}>
<td className="wa-row-label">{fmtShortDate(w.week_ending)}</td>
<td className="wa-num">{fmtCcy(w.gross, 2)}</td>
<td className="wa-num">{fmtCcy(w.net, 2)}</td>
<td className={`wa-num ${fmtPctClass(w.pct_to_budget)}`}>{fmtPct(w.pct_to_budget)}</td>
<td className="wa-num wa-muted">{fmtCcy(w.budget_net, 2)}</td>
<td className={`wa-num ${fmtPctClass(w.pct_to_ly)}`}>{fmtPct(w.pct_to_ly)}</td>
<td className="wa-num wa-muted">{fmtCcy(w.ly_net, 2)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ── Section: Occupancy ─────────────────────────────────────────────────────
function OccupancyTable({ occupancy }: { occupancy: WeeklyActualData['occupancy'] }) {
const { daily, totals } = occupancy
return (
<div className="wa-table-wrap">
<table className="wa-table">
<thead>
<tr>
<th></th>
<th>Rooms Avail.</th>
<th>Rooms Sold</th>
<th>Revenue</th>
<th>Av Rate</th>
<th>Occupancy %</th>
<th>RevPAR</th>
</tr>
</thead>
<tbody>
{daily.map(d => (
<tr key={d.date}>
<td className="wa-row-label">{d.dow}</td>
<td className="wa-num">{d.available}</td>
<td className="wa-num">{d.rooms_sold}</td>
<td className="wa-num">{fmtCcy(d.accomm)}</td>
<td className="wa-num">{fmtCcy(d.arr)}</td>
<td className="wa-num">{d.occ_pct == null ? '—' : `${d.occ_pct.toFixed(2)}%`}</td>
<td className="wa-num">{fmtCcy(d.revpar)}</td>
</tr>
))}
<tr className="wa-total-row">
<td className="wa-row-label">Totals</td>
<td className="wa-num">{totals.available}</td>
<td className="wa-num">{totals.rooms_sold}</td>
<td className="wa-num">{fmtCcy(totals.accomm)}</td>
<td className="wa-num">{fmtCcy(totals.arr)}</td>
<td className="wa-num">{totals.occ_pct == null ? '—' : `${totals.occ_pct.toFixed(2)}%`}</td>
<td className="wa-num">{fmtCcy(totals.revpar)}</td>
</tr>
</tbody>
</table>
</div>
)
}
// ── Section: Month Progress ────────────────────────────────────────────────
function MonthProgressSection({
mp, trend,
}: {
mp: WeeklyActualData['month_progress']
trend: WeeklyActualData['monthly_trend']
}) {
const deptBarData = [
{ name: 'Rooms', 'Net Sales': mp.split.rooms.net, 'Budget Net': mp.split.rooms.budget_net, 'Last Year': mp.split.rooms.ly_net },
{ name: 'Dry', 'Net Sales': mp.split.dry.net, 'Budget Net': mp.split.dry.budget_net, 'Last Year': mp.split.dry.ly_net },
{ name: 'Wet', 'Net Sales': mp.split.wet.net, 'Budget Net': mp.split.wet.budget_net, 'Last Year': mp.split.wet.ly_net },
]
const deptSplit = [
{ label: 'Rooms', ...mp.split.rooms },
{ label: 'Dry', ...mp.split.dry },
{ label: 'Wet', ...mp.split.wet },
]
return (
<div className="wa-month-section">
{/* Progress to date summary */}
<div className="wa-table-wrap">
<table className="wa-table">
<thead>
<tr>
<th>Date to</th>
<th>Gross Sales</th>
<th>Net Sales</th>
<th>% to Budget Net</th>
<th>Budget Net</th>
<th>% to Last Year</th>
<th>Last Year Net</th>
</tr>
</thead>
<tbody>
<tr className="wa-total-row">
<td className="wa-row-label">{fmtShortDate(mp.date_to)}</td>
<td className="wa-num">{fmtCcy(mp.gross)}</td>
<td className="wa-num">{fmtCcy(mp.net)}</td>
<td className={`wa-num ${fmtPctClass(mp.pct_to_budget)}`}>{fmtPct(mp.pct_to_budget)}</td>
<td className="wa-num wa-muted">{fmtCcy(mp.budget_net)}</td>
<td className={`wa-num ${fmtPctClass(mp.pct_to_ly)}`}>{fmtPct(mp.pct_to_ly)}</td>
<td className="wa-num wa-muted">{fmtCcy(mp.ly_net)}</td>
</tr>
</tbody>
</table>
</div>
{/* Dept split */}
<div className="wa-table-wrap" style={{ marginTop: 8 }}>
<table className="wa-table">
<thead>
<tr>
<th></th>
<th>Net Sales</th>
<th>% to Budget</th>
<th>Budget Net</th>
<th>% to Last Year</th>
<th>Last Year Net</th>
</tr>
</thead>
<tbody>
{deptSplit.map(d => (
<tr key={d.label}>
<td className="wa-row-label">{d.label}</td>
<td className="wa-num">{fmtCcy(d.net)}</td>
<td className={`wa-num ${fmtPctClass(d.pct_to_budget)}`}>{fmtPct(d.pct_to_budget)}</td>
<td className="wa-num wa-muted">{fmtCcy(d.budget_net)}</td>
<td className={`wa-num ${fmtPctClass(d.pct_to_ly)}`}>{fmtPct(d.pct_to_ly)}</td>
<td className="wa-num wa-muted">{fmtCcy(d.ly_net)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Charts row */}
<div className="wa-charts-row" style={{ marginTop: 16 }}>
<div className="wa-chart-card">
<h4>Month Progress Dept vs Budget vs Last Year</h4>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={deptBarData} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tickFormatter={v => `£${(v/1000).toFixed(0)}k`} tick={{ fontSize: 10 }} width={52} />
<Tooltip formatter={fmtChartCcy} />
<Legend iconSize={10} wrapperStyle={{ fontSize: 11 }} />
<Bar dataKey="Net Sales" fill={CHART_COLORS.thisWeek} radius={[2,2,0,0]} />
<Bar dataKey="Budget Net" fill={CHART_COLORS.budget} radius={[2,2,0,0]} />
<Bar dataKey="Last Year" fill={CHART_COLORS.lastYear} radius={[2,2,0,0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="wa-chart-card">
<h4>Monthly Average Turnover Last 12 Months</h4>
<ResponsiveContainer width="100%" height={220}>
<LineChart data={trend} margin={{ top: 8, right: 8, left: 8, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="label" tick={{ fontSize: 9 }} interval={1} />
<YAxis tickFormatter={v => `£${(v/1000).toFixed(0)}k`} tick={{ fontSize: 10 }} width={52} />
<Tooltip formatter={fmtChartCcy} />
<Legend iconSize={10} wrapperStyle={{ fontSize: 11 }} />
<Line type="monotone" dataKey="rooms" stroke={CHART_COLORS.rooms} strokeWidth={2} dot={false} name="Rooms" />
<Line type="monotone" dataKey="dry" stroke={CHART_COLORS.dry} strokeWidth={2} dot={false} name="Dry" />
<Line type="monotone" dataKey="wet" stroke={CHART_COLORS.wet} strokeWidth={2} dot={false} name="Wet" />
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
)
}
// ── Section: Utilities Placeholder ─────────────────────────────────────────
function UtilitiesPlaceholder() {
return (
<div className="wa-utilities-placeholder">
<p>Utilities report pending meter readings app integration.</p>
</div>
)
}
// ── Main component ─────────────────────────────────────────────────────────
export default function WeeklyActual() {
const [weekEnding, setWeekEnding] = useState(defaultWeekEnding)
const [data, setData] = useState<WeeklyActualData | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async (date: string) => {
setLoading(true)
setError(null)
setData(null)
try {
setData(await getWeeklyActual(date))
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load(weekEnding) }, [load, weekEnding])
const prevWeek = () => setWeekEnding(w => shiftWeek(w, -1))
const nextWeek = () => setWeekEnding(w => shiftWeek(w, +1))
return (
<div className="wa-page" id="wa-printable">
{/* Toolbar */}
<div className="wa-toolbar no-print">
<div className="wa-week-nav">
<button className="btn-icon" onClick={prevWeek} title="Previous week">
<ChevronLeft size={16} strokeWidth={1.75} />
</button>
<div className="wa-week-label">
Week ending&nbsp;<strong>{fmtShortDate(weekEnding)}</strong>
</div>
<button className="btn-icon" onClick={nextWeek} title="Next week">
<ChevronRight size={16} strokeWidth={1.75} />
</button>
<input
type="date"
className="date-input"
value={weekEnding}
onChange={e => setWeekEnding(e.target.value)}
/>
</div>
<div className="wa-toolbar-actions">
<button className="btn-icon" onClick={() => load(weekEnding)} title="Refresh">
<RefreshCw size={15} strokeWidth={1.75} />
</button>
<button className="btn-icon" onClick={() => window.print()} title="Print">
<Printer size={15} strokeWidth={1.75} />
</button>
</div>
</div>
{error && <div className="report-error" style={{ margin: '16px 24px' }}>{error}</div>}
{loading && <div className="loading"><div className="spinner" /></div>}
{data && (
<div className="wa-content">
{/* ── Week Report ─────────────────────────────────────────── */}
<section className="wa-section">
<h2 className="wa-section-title">Week Report {fmtShortDate(weekEnding)}</h2>
<WeekTable tw={data.this_week} lw={data.last_week} />
<WeekCharts tw={data.this_week} />
</section>
{/* ── 5 Week Summary ──────────────────────────────────────── */}
<section className="wa-section">
<h2 className="wa-section-title">5 Week Summary</h2>
<FiveWeekTable weeks={data.five_weeks} />
</section>
{/* ── Occupancy ────────────────────────────────────────────── */}
<section className="wa-section">
<h2 className="wa-section-title">Occupancy %</h2>
<p className="wa-note">Overflow room types excluded from available room counts.</p>
<OccupancyTable occupancy={data.occupancy} />
</section>
{/* ── Month Progress ──────────────────────────────────────── */}
<section className="wa-section">
<h2 className="wa-section-title">
{MONTH_NAMES[data.month_progress.month - 1]} {data.month_progress.year} Month Progress to Date
</h2>
<MonthProgressSection mp={data.month_progress} trend={data.monthly_trend} />
</section>
{/* ── Utilities ────────────────────────────────────────────── */}
<section className="wa-section">
<h2 className="wa-section-title">Utilities</h2>
<UtilitiesPlaceholder />
</section>
</div>
)}
</div>
)
}

View file

@ -30,6 +30,98 @@ export interface ReportResult {
summary?: { label: string; value: string }[]
}
// ── Weekly Actuals types ────────────────────────────────────────────────────
export interface WeekDeptTotals {
net: number
budget: number
ly: number
}
export interface WeekSummaryDept {
rooms: WeekDeptTotals
dry: WeekDeptTotals
wet: WeekDeptTotals
}
export interface WeekSummaryRow {
week_ending: string
gross: number
net: number
pct_to_budget: number | null
budget_net: number
pct_to_ly: number | null
ly_net: number
dept: WeekSummaryDept
}
export interface OccDay {
date: string
dow: string
available: number
rooms_sold: number
accomm: number
arr: number | null
occ_pct: number | null
revpar: number | null
}
export interface OccTotals {
available: number
rooms_sold: number
accomm: number
arr: number | null
occ_pct: number | null
revpar: number | null
}
export interface MonthSplit {
net: number
budget_net: number
ly_net: number
pct_to_budget: number | null
pct_to_ly: number | null
}
export interface MonthProgress {
year: number
month: number
date_to: string
gross: number
net: number
budget_net: number
ly_net: number
pct_to_budget: number | null
pct_to_ly: number | null
split: {
rooms: MonthSplit
dry: MonthSplit
wet: MonthSplit
}
}
export interface MonthTrend {
label: string
rooms: number
dry: number
wet: number
total: number
}
export interface WeeklyActualData {
week_ending: string
week_start: string
this_week: WeekSummaryRow
last_week: WeekSummaryDept
five_weeks: WeekSummaryRow[]
occupancy: {
daily: OccDay[]
totals: OccTotals
}
month_progress: MonthProgress
monthly_trend: MonthTrend[]
}
// ── Directors Forecast types ────────────────────────────────────────────────
export const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']