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:
parent
29a119f51d
commit
b97358e9fb
9 changed files with 1433 additions and 6 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
|
|
|
|||
480
frontend/src/pages/WeeklyActual.tsx
Normal file
480
frontend/src/pages/WeeklyActual.tsx
Normal 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 <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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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']
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue