From b97358e9fb9593966b6a4419ee23d2929de745bf Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Mon, 20 Jul 2026 13:07:19 +0000 Subject: [PATCH] Add Weekly Actuals directors report with charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/src/index.js | 2 + backend/src/routes/weekly-actual.js | 253 +++++++++++++++ frontend/package-lock.json | 405 ++++++++++++++++++++++- frontend/package.json | 3 +- frontend/src/api.ts | 8 +- frontend/src/index.css | 185 +++++++++++ frontend/src/pages/ReportsPage.tsx | 11 + frontend/src/pages/WeeklyActual.tsx | 480 ++++++++++++++++++++++++++++ frontend/src/types.ts | 92 ++++++ 9 files changed, 1433 insertions(+), 6 deletions(-) create mode 100644 backend/src/routes/weekly-actual.js create mode 100644 frontend/src/pages/WeeklyActual.tsx diff --git a/backend/src/index.js b/backend/src/index.js index 4fe0f017..327ab469 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -4,6 +4,7 @@ import cors from '@fastify/cors' import { initDb } from './db.js' import { reportRoutes } from './routes/reports.js' import { directorsForecastRoutes } from './routes/directors-forecast.js' +import { weeklyActualRoutes } from './routes/weekly-actual.js' const app = Fastify({ logger: true, trustProxy: true }) const startedAt = Date.now() @@ -18,6 +19,7 @@ app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_ await app.register(reportRoutes) await app.register(directorsForecastRoutes) +await app.register(weeklyActualRoutes) try { await initDb() diff --git a/backend/src/routes/weekly-actual.js b/backend/src/routes/weekly-actual.js new file mode 100644 index 00000000..97701205 --- /dev/null +++ b/backend/src/routes/weekly-actual.js @@ -0,0 +1,253 @@ +import { requireAuth } from '../auth.js' + +const FORECASTING_URL = process.env.FORECASTING_URL || 'http://10.10.10.113:3080' + +async function fcFetch(path) { + const apiKey = process.env.FORECASTING_API_KEY + if (!apiKey) throw new Error('FORECASTING_API_KEY not configured') + const res = await fetch(`${FORECASTING_URL}/forecasting/api/public${path}`, { + headers: { 'X-API-Key': apiKey }, + }) + if (!res.ok) throw new Error(`Forecasting API ${res.status} — ${path}`) + return res.json() +} + +function toDateStr(d) { + return d.toISOString().split('T')[0] +} + +function addDays(d, n) { + const r = new Date(d) + r.setDate(r.getDate() + n) + return r +} + +function buildDayMap(data) { + const m = {} + for (const d of (data?.data ?? [])) m[d.date] = d + return m +} + +const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] +const MONTH_LABELS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] + +function weekSummary(revDayMap, weekSundayDate, weekEndingDateStr) { + let net = 0, budget = 0, ly = 0 + let net_rooms = 0, net_dry = 0, net_wet = 0 + let budget_rooms = 0, budget_dry = 0, budget_wet = 0 + let ly_rooms = 0, ly_dry = 0, ly_wet = 0 + + for (let i = 0; i < 7; i++) { + const dateStr = toDateStr(addDays(weekSundayDate, i)) + const rev = revDayMap[dateStr] || {} + const r = parseFloat(rev.accom?.otb ?? 0) + const d = parseFloat(rev.dry?.otb ?? 0) + const w = parseFloat(rev.wet?.otb ?? 0) + const br = parseFloat(rev.accom?.budget ?? 0) + const bd = parseFloat(rev.dry?.budget ?? 0) + const bw = parseFloat(rev.wet?.budget ?? 0) + const lr = parseFloat(rev.accom?.prior_final ?? 0) + const ld = parseFloat(rev.dry?.prior_final ?? 0) + const lw = parseFloat(rev.wet?.prior_final ?? 0) + + net_rooms += r; net_dry += d; net_wet += w + budget_rooms += br; budget_dry += bd; budget_wet += bw + ly_rooms += lr; ly_dry += ld; ly_wet += lw + net += r + d + w; budget += br + bd + bw; ly += lr + ld + lw + } + + return { + week_ending: weekEndingDateStr, + gross: net * 1.20, + net, + pct_to_budget: budget > 0 ? ((net - budget) / budget) * 100 : null, + budget_net: budget, + pct_to_ly: ly > 0 ? ((net - ly) / ly) * 100 : null, + ly_net: ly, + dept: { + rooms: { net: net_rooms, budget: budget_rooms, ly: ly_rooms }, + dry: { net: net_dry, budget: budget_dry, ly: ly_dry }, + wet: { net: net_wet, budget: budget_wet, ly: ly_wet }, + }, + } +} + +export async function weeklyActualRoutes(fastify) { + fastify.addHook('preHandler', requireAuth) + + // GET /api/weekly-actual/:date (date = week ending Saturday, YYYY-MM-DD) + fastify.get('/api/weekly-actual/:date', async (request, reply) => { + const weekEndDate = new Date(request.params.date + 'T00:00:00') + if (isNaN(weekEndDate.getTime())) return reply.status(400).send({ error: 'Invalid date' }) + + // Week runs Sun → Sat + const weekStartDate = addDays(weekEndDate, -6) + const year = weekEndDate.getFullYear() + const month = weekEndDate.getMonth() + 1 + + // 5-week window: start 4 weeks before this week's Sunday + const fiveWeekStart = addDays(weekStartDate, -28) + + // Month to date: 1st of month → week ending date + const monthStart = new Date(year, month - 1, 1) + const monthDays = Math.round((weekEndDate - monthStart) / 86400000) + 1 + + // 12-month trend months (most recent last) + const trendMonths = [] + for (let i = 11; i >= 0; i--) { + const d = new Date(year, month - 1 - i, 1) + trendMonths.push({ year: d.getFullYear(), month: d.getMonth() + 1 }) + } + + // All API calls in parallel + const results = await Promise.all([ + // 35 days of revenue (5 × 7 weeks) + fcFetch(`/forecast/revenue?start_date=${toDateStr(fiveWeekStart)}&days=35&type=all`), + // This week rooms (for occupancy table) + fcFetch(`/forecast/rooms?start_date=${toDateStr(weekStartDate)}&days=7`), + // Month to date revenue + monthDays > 0 + ? fcFetch(`/forecast/revenue?start_date=${toDateStr(monthStart)}&days=${monthDays}&type=all`) + : Promise.resolve({ data: [] }), + // 12 monthly trend revenue calls + ...trendMonths.map(({ year: y, month: m }) => { + const firstDay = new Date(y, m - 1, 1) + const daysInMon = new Date(y, m, 0).getDate() + return fcFetch(`/forecast/revenue?start_date=${toDateStr(firstDay)}&days=${daysInMon}&type=all`) + }), + ]) + + const [fiveWeekRevRaw, thisWeekRoomsRaw, monthRevRaw, ...trendRevRaw] = results + + const fiveWeekRevMap = buildDayMap(fiveWeekRevRaw) + const thisWeekRoomsMap = buildDayMap(thisWeekRoomsRaw) + const monthRevMap = buildDayMap(monthRevRaw) + + // ── Five week summaries ──────────────────────────────────────────────────── + const fiveWeeks = [0, 1, 2, 3, 4].map(w => { + const sundayDate = addDays(weekStartDate, -7 * w) + const saturdayDate = addDays(sundayDate, 6) + return weekSummary(fiveWeekRevMap, sundayDate, toDateStr(saturdayDate)) + }) + + const thisWeek = fiveWeeks[0] + const lastWeek = fiveWeeks[1] + + // ── Occupancy by DOW ────────────────────────────────────────────────────── + let totalAvail = 0, totalSold = 0, totalAccomm = 0 + const daily = [] + + for (let i = 0; i < 7; i++) { + const d = addDays(weekStartDate, i) + const dateStr = toDateStr(d) + const rev = fiveWeekRevMap[dateStr] || {} + const rm = thisWeekRoomsMap[dateStr] || {} + + const available = parseInt(rm.available_rooms ?? 0) + const rooms_sold = parseInt(rm.otb_rooms ?? 0) + const accomm = parseFloat(rev.accom?.otb ?? 0) + + totalAvail += available + totalSold += rooms_sold + totalAccomm += accomm + + daily.push({ + date: dateStr, + dow: DOW_NAMES[d.getDay()], + available, + rooms_sold, + accomm, + arr: rooms_sold > 0 ? accomm / rooms_sold : null, + occ_pct: available > 0 ? (rooms_sold / available) * 100 : null, + revpar: available > 0 ? accomm / available : null, + }) + } + + const occupancy = { + daily, + totals: { + available: totalAvail, + rooms_sold: totalSold, + accomm: totalAccomm, + arr: totalSold > 0 ? totalAccomm / totalSold : null, + occ_pct: totalAvail > 0 ? (totalSold / totalAvail) * 100 : null, + revpar: totalAvail > 0 ? totalAccomm / totalAvail : null, + }, + } + + // ── Month progress ───────────────────────────────────────────────────────── + let mNet = { rooms: 0, dry: 0, wet: 0 } + let mBud = { rooms: 0, dry: 0, wet: 0 } + let mLy = { rooms: 0, dry: 0, wet: 0 } + + for (let i = 0; i < monthDays; i++) { + const d = addDays(monthStart, i) + if (d > weekEndDate) break + const dateStr = toDateStr(d) + const rev = monthRevMap[dateStr] || {} + + mNet.rooms += parseFloat(rev.accom?.otb ?? 0) + mNet.dry += parseFloat(rev.dry?.otb ?? 0) + mNet.wet += parseFloat(rev.wet?.otb ?? 0) + mBud.rooms += parseFloat(rev.accom?.budget ?? 0) + mBud.dry += parseFloat(rev.dry?.budget ?? 0) + mBud.wet += parseFloat(rev.wet?.budget ?? 0) + mLy.rooms += parseFloat(rev.accom?.prior_final ?? 0) + mLy.dry += parseFloat(rev.dry?.prior_final ?? 0) + mLy.wet += parseFloat(rev.wet?.prior_final ?? 0) + } + + const mNetTotal = mNet.rooms + mNet.dry + mNet.wet + const mBudTotal = mBud.rooms + mBud.dry + mBud.wet + const mLyTotal = mLy.rooms + mLy.dry + mLy.wet + + const month_progress = { + year, month, + date_to: toDateStr(weekEndDate), + gross: mNetTotal * 1.20, + net: mNetTotal, + budget_net: mBudTotal, + ly_net: mLyTotal, + pct_to_budget: mBudTotal > 0 ? ((mNetTotal - mBudTotal) / mBudTotal) * 100 : null, + pct_to_ly: mLyTotal > 0 ? ((mNetTotal - mLyTotal) / mLyTotal) * 100 : null, + split: { + rooms: { net: mNet.rooms, budget_net: mBud.rooms, ly_net: mLy.rooms, + pct_to_budget: mBud.rooms > 0 ? ((mNet.rooms - mBud.rooms) / mBud.rooms) * 100 : null, + pct_to_ly: mLy.rooms > 0 ? ((mNet.rooms - mLy.rooms) / mLy.rooms) * 100 : null }, + dry: { net: mNet.dry, budget_net: mBud.dry, ly_net: mLy.dry, + pct_to_budget: mBud.dry > 0 ? ((mNet.dry - mBud.dry) / mBud.dry) * 100 : null, + pct_to_ly: mLy.dry > 0 ? ((mNet.dry - mLy.dry) / mLy.dry) * 100 : null }, + wet: { net: mNet.wet, budget_net: mBud.wet, ly_net: mLy.wet, + pct_to_budget: mBud.wet > 0 ? ((mNet.wet - mBud.wet) / mBud.wet) * 100 : null, + pct_to_ly: mLy.wet > 0 ? ((mNet.wet - mLy.wet) / mLy.wet) * 100 : null }, + }, + } + + // ── Monthly trend ───────────────────────────────────────────────────────── + const monthly_trend = trendMonths.map((tm, i) => { + const data = trendRevRaw[i]?.data ?? [] + let rooms = 0, dry = 0, wet = 0 + for (const d of data) { + rooms += parseFloat(d.accom?.otb ?? 0) + dry += parseFloat(d.dry?.otb ?? 0) + wet += parseFloat(d.wet?.otb ?? 0) + } + return { + label: `${MONTH_LABELS[tm.month - 1]}-${String(tm.year).slice(2)}`, + rooms, dry, wet, + total: rooms + dry + wet, + } + }) + + return { + week_ending: toDateStr(weekEndDate), + week_start: toDateStr(weekStartDate), + this_week: thisWeek, + last_week: lastWeek.dept, + five_weeks: fiveWeeks, + occupancy, + month_progress, + monthly_trend, + } + }) +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4e811d1a..cdb0bdbe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "lucide-react": "^0.468.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "recharts": "^3.9.2" }, "devDependencies": { "@types/react": "^18.3.1", @@ -2125,6 +2126,32 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2602,6 +2629,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { "version": "3.0.0-pre1", "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", @@ -2663,6 +2702,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2674,14 +2776,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -2712,6 +2814,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -3035,6 +3143,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", @@ -3102,9 +3219,130 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3177,6 +3415,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3419,6 +3663,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz", + "integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -3501,6 +3755,12 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3935,6 +4195,16 @@ "dev": true, "license": "ISC" }, + "node_modules/immer": { + "version": "11.1.15", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.15.tgz", + "integrity": "sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3950,6 +4220,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -4832,6 +5111,36 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", + "integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -4842,6 +5151,51 @@ "node": ">=0.10.0" } }, + "node_modules/recharts": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz", + "integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^11.1.8", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.2.0", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -4954,6 +5308,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -5509,6 +5869,12 @@ "node": ">=10" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -5769,6 +6135,37 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 909b700b..dad6e79c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "dependencies": { "lucide-react": "^0.468.0", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "recharts": "^3.9.2" }, "devDependencies": { "@types/react": "^18.3.1", diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d1e554e4..0ed94f6d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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 { + return request(`/weekly-actual/${weekEnding}`) +} + // ── Directors Forecast API ────────────────────────────────────────────────── export function dfGetWorksheet(year: number, month: number): Promise { diff --git a/frontend/src/index.css b/frontend/src/index.css index 191b25a9..07029927 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } +} diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index 9e23688a..2d4640d0 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -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() {

Custom Reports

Select a report from the sidebar to get started.

+ ) : selected.id === 'weekly-actual' ? ( + ) : selected.category === 'directors' ? ( ) : ( diff --git a/frontend/src/pages/WeeklyActual.tsx b/frontend/src/pages/WeeklyActual.tsx new file mode 100644 index 00000000..018de0c1 --- /dev/null +++ b/frontend/src/pages/WeeklyActual.tsx @@ -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 ( +
+ + + + + + + + + + + + + + {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 ( + + + + + + + + + + ) + })} + +
This Week% to BudgetBudget% to Last YearLast YearLast Week
{r.label}{fmtCcy(r.tw_net)}{fmtPct(pctBud)}{fmtCcy(r.budget)}{fmtPct(pctLy)}{fmtCcy(r.ly)}{fmtCcy(r.lw_net)}
+
+ ) +} + +// ── 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 ( +
+
+

This Week vs Budget vs Last Year

+ + + + + `£${(v/1000).toFixed(0)}k`} tick={{ fontSize: 10 }} width={52} /> + + + + + + + +
+ +
+

This Week — Dept Split

+ + + `${name} ${((percent ?? 0) * 100).toFixed(0)}%`} + labelLine={false} + > + {pieData.map((_, idx) => ( + + ))} + + + + +
+
+ ) +} + +// ── Section: 5 Week Summary ──────────────────────────────────────────────── + +function FiveWeekTable({ weeks }: { weeks: WeekSummaryRow[] }) { + return ( +
+ + + + + + + + + + + + + + {weeks.map((w, i) => ( + + + + + + + + + + ))} + +
Week EndingGross SalesNet Sales% to BudgetBudget Net% to Last YearLast Year Net
{fmtShortDate(w.week_ending)}{fmtCcy(w.gross, 2)}{fmtCcy(w.net, 2)}{fmtPct(w.pct_to_budget)}{fmtCcy(w.budget_net, 2)}{fmtPct(w.pct_to_ly)}{fmtCcy(w.ly_net, 2)}
+
+ ) +} + +// ── Section: Occupancy ───────────────────────────────────────────────────── + +function OccupancyTable({ occupancy }: { occupancy: WeeklyActualData['occupancy'] }) { + const { daily, totals } = occupancy + + return ( +
+ + + + + + + + + + + + + + {daily.map(d => ( + + + + + + + + + + ))} + + + + + + + + + + +
Rooms Avail.Rooms SoldRevenueAv RateOccupancy %RevPAR
{d.dow}{d.available}{d.rooms_sold}{fmtCcy(d.accomm)}{fmtCcy(d.arr)}{d.occ_pct == null ? '—' : `${d.occ_pct.toFixed(2)}%`}{fmtCcy(d.revpar)}
Totals{totals.available}{totals.rooms_sold}{fmtCcy(totals.accomm)}{fmtCcy(totals.arr)}{totals.occ_pct == null ? '—' : `${totals.occ_pct.toFixed(2)}%`}{fmtCcy(totals.revpar)}
+
+ ) +} + +// ── 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 ( +
+ {/* Progress to date summary */} +
+ + + + + + + + + + + + + + + + + + + + + + + +
Date toGross SalesNet Sales% to Budget NetBudget Net% to Last YearLast Year Net
{fmtShortDate(mp.date_to)}{fmtCcy(mp.gross)}{fmtCcy(mp.net)}{fmtPct(mp.pct_to_budget)}{fmtCcy(mp.budget_net)}{fmtPct(mp.pct_to_ly)}{fmtCcy(mp.ly_net)}
+
+ + {/* Dept split */} +
+ + + + + + + + + + + + + {deptSplit.map(d => ( + + + + + + + + + ))} + +
Net Sales% to BudgetBudget Net% to Last YearLast Year Net
{d.label}{fmtCcy(d.net)}{fmtPct(d.pct_to_budget)}{fmtCcy(d.budget_net)}{fmtPct(d.pct_to_ly)}{fmtCcy(d.ly_net)}
+
+ + {/* Charts row */} +
+
+

Month Progress — Dept vs Budget vs Last Year

+ + + + + `£${(v/1000).toFixed(0)}k`} tick={{ fontSize: 10 }} width={52} /> + + + + + + + +
+ +
+

Monthly Average Turnover — Last 12 Months

+ + + + + `£${(v/1000).toFixed(0)}k`} tick={{ fontSize: 10 }} width={52} /> + + + + + + + +
+
+
+ ) +} + +// ── Section: Utilities Placeholder ───────────────────────────────────────── + +function UtilitiesPlaceholder() { + return ( +
+

Utilities report — pending meter readings app integration.

+
+ ) +} + +// ── Main component ───────────────────────────────────────────────────────── + +export default function WeeklyActual() { + const [weekEnding, setWeekEnding] = useState(defaultWeekEnding) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( +
+ {/* Toolbar */} +
+
+ +
+ Week ending {fmtShortDate(weekEnding)} +
+ + setWeekEnding(e.target.value)} + /> +
+
+ + +
+
+ + {error &&
{error}
} + {loading &&
} + + {data && ( +
+ {/* ── Week Report ─────────────────────────────────────────── */} +
+

Week Report — {fmtShortDate(weekEnding)}

+ + +
+ + {/* ── 5 Week Summary ──────────────────────────────────────── */} +
+

5 Week Summary

+ +
+ + {/* ── Occupancy ────────────────────────────────────────────── */} +
+

Occupancy %

+

Overflow room types excluded from available room counts.

+ +
+ + {/* ── Month Progress ──────────────────────────────────────── */} +
+

+ {MONTH_NAMES[data.month_progress.month - 1]} {data.month_progress.year} — Month Progress to Date +

+ +
+ + {/* ── Utilities ────────────────────────────────────────────── */} +
+

Utilities

+ +
+
+ )} +
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9e74317a..4e263b2a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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']