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
|
|
@ -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()
|
||||
|
|
|
|||
253
backend/src/routes/weekly-actual.js
Normal file
253
backend/src/routes/weekly-actual.js
Normal file
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
405
frontend/package-lock.json
generated
405
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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