diff --git a/backend/src/db.js b/backend/src/db.js index befc5008..2505c766 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -17,7 +17,48 @@ export async function initDb() { error TEXT, ran_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); - CREATE INDEX IF NOT EXISTS report_runs_ran_at_idx ON report_runs (ran_at DESC); + CREATE INDEX IF NOT EXISTS report_runs_ran_at_idx ON report_runs (ran_at DESC); CREATE INDEX IF NOT EXISTS report_runs_report_id_idx ON report_runs (report_id); + + CREATE TABLE IF NOT EXISTS forecast_sessions ( + id SERIAL PRIMARY KEY, + year INTEGER NOT NULL, + month INTEGER NOT NULL, + pickup_avg_rate DECIMAL(10,2) NOT NULL DEFAULT 135.00, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(year, month) + ); + + CREATE TABLE IF NOT EXISTS day_overrides ( + id SERIAL PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES forecast_sessions(id) ON DELETE CASCADE, + date DATE NOT NULL, + pickup_rooms INTEGER, + pickup_avg_rate DECIMAL(10,2), + dry_override DECIMAL(12,2), + wet_override DECIMAL(12,2), + actual_accomm DECIMAL(12,2), + UNIQUE(session_id, date) + ); + CREATE INDEX IF NOT EXISTS idx_day_overrides_session ON day_overrides(session_id); + CREATE INDEX IF NOT EXISTS idx_day_overrides_date ON day_overrides(date); + + CREATE TABLE IF NOT EXISTS forecast_snapshots ( + id SERIAL PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES forecast_sessions(id) ON DELETE CASCADE, + snapshot_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + label TEXT, + forecast_rooms INTEGER, + forecast_accomm DECIMAL(12,2), + forecast_dry DECIMAL(12,2), + forecast_wet DECIMAL(12,2), + forecast_total DECIMAL(12,2), + otb_rooms INTEGER, + pickup_rooms INTEGER, + occ_pct DECIMAL(5,2) + ); + CREATE INDEX IF NOT EXISTS idx_forecast_snapshots_session ON forecast_snapshots(session_id); `) } diff --git a/backend/src/forecast-db.js b/backend/src/forecast-db.js new file mode 100644 index 00000000..4df43446 --- /dev/null +++ b/backend/src/forecast-db.js @@ -0,0 +1,13 @@ +import pg from 'pg' + +const { Pool } = pg + +export const forecastPool = process.env.FORECAST_DATABASE_URL + ? new Pool({ connectionString: process.env.FORECAST_DATABASE_URL }) + : null + +export async function queryForecast(sql, params) { + if (!forecastPool) throw new Error('FORECAST_DATABASE_URL not configured') + const res = await forecastPool.query(sql, params) + return res.rows +} diff --git a/backend/src/index.js b/backend/src/index.js index e890a3d8..4fe0f017 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -3,8 +3,10 @@ import cookie from '@fastify/cookie' import cors from '@fastify/cors' import { initDb } from './db.js' import { reportRoutes } from './routes/reports.js' +import { directorsForecastRoutes } from './routes/directors-forecast.js' const app = Fastify({ logger: true, trustProxy: true }) +const startedAt = Date.now() await app.register(cookie) await app.register(cors, { @@ -12,9 +14,10 @@ await app.register(cors, { credentials: true, }) -app.get('/health', async () => ({ status: 'healthy' })) +app.get('/health', async () => ({ status: 'healthy', version: process.env.BUILD_VERSION || String(startedAt) })) await app.register(reportRoutes) +await app.register(directorsForecastRoutes) try { await initDb() diff --git a/backend/src/routes/directors-forecast.js b/backend/src/routes/directors-forecast.js new file mode 100644 index 00000000..954f8c9e --- /dev/null +++ b/backend/src/routes/directors-forecast.js @@ -0,0 +1,354 @@ +import { requireAuth, hasCap } from '../auth.js' +import { pool } from '../db.js' +import { queryForecast } from '../forecast-db.js' + +function weekBand(day) { + if (day <= 7) return '1–7' + if (day <= 14) return '8–14' + if (day <= 21) return '15–21' + if (day <= 28) return '22–28' + return '29–end' +} + +async function getSessionAndOverrides(year, month) { + const sessionRes = await pool.query( + `INSERT INTO forecast_sessions (year, month) + VALUES ($1, $2) + ON CONFLICT (year, month) DO UPDATE SET updated_at = NOW() + RETURNING *`, + [year, month] + ) + const session = sessionRes.rows[0] + + const overridesRes = await pool.query( + `SELECT date::text, pickup_rooms, pickup_avg_rate, dry_override, wet_override, actual_accomm + FROM day_overrides WHERE session_id = $1`, + [session.id] + ) + const overrideMap = {} + for (const row of overridesRes.rows) overrideMap[row.date] = row + + return { session, overrideMap } +} + +async function getDowHistory(firstDayOfMonth) { + const lookbackDate = new Date(firstDayOfMonth) + lookbackDate.setDate(lookbackDate.getDate() - 70) + const lookbackStr = lookbackDate.toISOString().split('T')[0] + const yesterdayDate = new Date() + yesterdayDate.setDate(yesterdayDate.getDate() - 1) + const yesterday = yesterdayDate.toISOString().split('T')[0] + + let rows = [] + try { + rows = await queryForecast( + `SELECT date::text, dry, wet FROM newbook_net_revenue_data + WHERE date >= $1 AND date <= $2 ORDER BY date DESC`, + [lookbackStr, yesterday] + ) + } catch {} + + const dowHistory = { dry: {}, wet: {} } + for (const row of rows) { + const dow = new Date(row.date + 'T00:00:00').getDay() + if (!dowHistory.dry[dow]) { dowHistory.dry[dow] = []; dowHistory.wet[dow] = [] } + if (dowHistory.dry[dow].length < 7) { + dowHistory.dry[dow].push(parseFloat(row.dry || 0)) + dowHistory.wet[dow].push(parseFloat(row.wet || 0)) + } + } + return dowHistory +} + +function dowAvg(dowHistory, field, dow) { + const vals = dowHistory[field][dow] + if (!vals || vals.length === 0) return 0 + return vals.reduce((a, b) => a + b, 0) / vals.length +} + +export async function directorsForecastRoutes(fastify) { + fastify.addHook('preHandler', requireAuth) + + // GET /api/directors-forecast/worksheet/:year/:month + fastify.get('/api/directors-forecast/worksheet/:year/:month', async (request, reply) => { + const year = parseInt(request.params.year) + const month = parseInt(request.params.month) + if (isNaN(year) || isNaN(month) || month < 1 || month > 12) { + return reply.status(400).send({ error: 'Invalid year/month' }) + } + + const firstDay = `${year}-${String(month).padStart(2, '0')}-01` + const lastDayNum = new Date(year, month, 0).getDate() + const lastDay = `${year}-${String(month).padStart(2, '0')}-${String(lastDayNum).padStart(2, '0')}` + const today = new Date().toISOString().split('T')[0] + + const { session, overrideMap } = await getSessionAndOverrides(year, month) + + let otbMap = {} + try { + const rows = await queryForecast( + `SELECT date::text, rooms_count, booking_count, net_booking_rev_total + FROM newbook_bookings_stats WHERE date >= $1 AND date <= $2`, + [firstDay, lastDay] + ) + for (const r of rows) otbMap[r.date] = r + } catch (e) { fastify.log.warn('OTB fetch failed:', e.message) } + + let revenueMap = {} + try { + const rows = await queryForecast( + `SELECT date::text, accommodation, dry, wet + FROM newbook_net_revenue_data WHERE date >= $1 AND date <= $2`, + [firstDay, lastDay] + ) + for (const r of rows) revenueMap[r.date] = r + } catch (e) { fastify.log.warn('Revenue fetch failed:', e.message) } + + let mlMap = {} + try { + const rows = await queryForecast( + `SELECT DISTINCT ON (forecast_date) forecast_date::text, predicted_value + FROM forecasts + WHERE forecast_date >= $1 AND forecast_date <= $2 AND forecast_type = 'hotel_room_nights' + ORDER BY forecast_date, generated_at DESC`, + [firstDay, lastDay] + ) + for (const r of rows) mlMap[r.forecast_date] = parseFloat(r.predicted_value) + } catch {} + + const dowHistory = await getDowHistory(new Date(firstDay)) + const sessionRate = parseFloat(session.pickup_avg_rate || 135) + + const days = [] + for (let d = 1; d <= lastDayNum; d++) { + const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}` + const isPast = dateStr < today + const isToday = dateStr === today + const dow = new Date(dateStr + 'T00:00:00').getDay() + + const otb = otbMap[dateStr] || {} + const rev = revenueMap[dateStr] || {} + const ovr = overrideMap[dateStr] || {} + + const available = parseInt(otb.rooms_count || 0) + const otbRooms = parseInt(otb.booking_count || 0) + const otbNetRev = parseFloat(otb.net_booking_rev_total || 0) + const avgRateNet = otbRooms > 0 ? otbNetRev / otbRooms : null + + const actualAccomm = ovr.actual_accomm != null ? parseFloat(ovr.actual_accomm) + : (rev.accommodation != null ? parseFloat(rev.accommodation) : null) + const actualDry = rev.dry != null ? parseFloat(rev.dry) : null + const actualWet = rev.wet != null ? parseFloat(rev.wet) : null + + const pickupRooms = ovr.pickup_rooms != null ? parseInt(ovr.pickup_rooms) : 0 + const dayRate = ovr.pickup_avg_rate != null ? parseFloat(ovr.pickup_avg_rate) : sessionRate + const totalRooms = otbRooms + pickupRooms + + const mlSuggestion = mlMap[dateStr] != null + ? Math.max(0, Math.round(mlMap[dateStr] - otbRooms)) : null + + let forecastAccomm + if (isPast && actualAccomm != null) forecastAccomm = actualAccomm + else forecastAccomm = otbNetRev + pickupRooms * dayRate + + const forecastDry = ovr.dry_override != null ? parseFloat(ovr.dry_override) + : (isPast && actualDry != null ? actualDry : dowAvg(dowHistory, 'dry', dow)) + + const forecastWet = ovr.wet_override != null ? parseFloat(ovr.wet_override) + : (isPast && actualWet != null ? actualWet : dowAvg(dowHistory, 'wet', dow)) + + days.push({ + date: dateStr, dow, available, otb_rooms: otbRooms, + otb_net_rev: otbNetRev, avg_rate_net: avgRateNet, + is_past: isPast, is_today: isToday, + actual_accomm: actualAccomm, actual_dry: actualDry, actual_wet: actualWet, + ml_suggestion: mlSuggestion, + pickup_rooms: pickupRooms, + pickup_avg_rate: ovr.pickup_avg_rate != null ? parseFloat(ovr.pickup_avg_rate) : null, + dry_override: ovr.dry_override != null ? parseFloat(ovr.dry_override) : null, + wet_override: ovr.wet_override != null ? parseFloat(ovr.wet_override) : null, + total_rooms: totalRooms, + forecast_accomm: forecastAccomm, + forecast_occ_pct: available > 0 ? (totalRooms / available) * 100 : null, + forecast_dry: forecastDry, + forecast_wet: forecastWet, + }) + } + + return { session: { ...session, pickup_avg_rate: sessionRate }, days } + }) + + // PUT /api/directors-forecast/worksheet/:year/:month — save overrides + fastify.put('/api/directors-forecast/worksheet/:year/:month', async (request, reply) => { + if (!hasCap(request, 'edit')) return reply.status(403).send({ error: 'Missing capability: edit' }) + + const year = parseInt(request.params.year) + const month = parseInt(request.params.month) + const { pickup_avg_rate, overrides } = request.body + + const sessionRes = await pool.query( + `INSERT INTO forecast_sessions (year, month, pickup_avg_rate, updated_at) + VALUES ($1, $2, $3, NOW()) + ON CONFLICT (year, month) DO UPDATE + SET pickup_avg_rate = EXCLUDED.pickup_avg_rate, updated_at = NOW() + RETURNING *`, + [year, month, pickup_avg_rate ?? 135] + ) + const session = sessionRes.rows[0] + + if (Array.isArray(overrides)) { + for (const o of overrides) { + if (!o.date) continue + await pool.query( + `INSERT INTO day_overrides (session_id, date, pickup_rooms, pickup_avg_rate, dry_override, wet_override, actual_accomm) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (session_id, date) DO UPDATE + SET pickup_rooms = EXCLUDED.pickup_rooms, + pickup_avg_rate = EXCLUDED.pickup_avg_rate, + dry_override = EXCLUDED.dry_override, + wet_override = EXCLUDED.wet_override, + actual_accomm = EXCLUDED.actual_accomm`, + [session.id, o.date, + o.pickup_rooms ?? null, o.pickup_avg_rate ?? null, + o.dry_override ?? null, o.wet_override ?? null, o.actual_accomm ?? null] + ) + } + } + + return { ok: true } + }) + + // GET /api/directors-forecast/report/:year/:month + fastify.get('/api/directors-forecast/report/:year/:month', async (request, reply) => { + const year = parseInt(request.params.year) + const month = parseInt(request.params.month) + + const firstDay = `${year}-${String(month).padStart(2, '0')}-01` + const lastDayNum = new Date(year, month, 0).getDate() + const lastDay = `${year}-${String(month).padStart(2, '0')}-${String(lastDayNum).padStart(2, '0')}` + const today = new Date().toISOString().split('T')[0] + + const { session, overrideMap } = await getSessionAndOverrides(year, month) + + let otbRows = [], revRows = [] + try { otbRows = await queryForecast(`SELECT date::text, rooms_count, booking_count, net_booking_rev_total FROM newbook_bookings_stats WHERE date >= $1 AND date <= $2`, [firstDay, lastDay]) } catch {} + try { revRows = await queryForecast(`SELECT date::text, accommodation, dry, wet FROM newbook_net_revenue_data WHERE date >= $1 AND date <= $2`, [firstDay, lastDay]) } catch {} + + let budget = { accomm: 0, dry: 0, wet: 0 } + try { + const rows = await queryForecast(`SELECT budget_type, budget_value FROM monthly_budgets WHERE year = $1 AND month = $2`, [year, month]) + for (const b of rows) { + if (b.budget_type === 'net_accom') budget.accomm = parseFloat(b.budget_value || 0) + if (b.budget_type === 'net_dry') budget.dry = parseFloat(b.budget_value || 0) + if (b.budget_type === 'net_wet') budget.wet = parseFloat(b.budget_value || 0) + } + } catch {} + + let lastYear = { accomm: 0, dry: 0, wet: 0, rooms: 0 } + try { + const lyFirst = `${year - 1}-${String(month).padStart(2, '0')}-01` + const lyLastNum = new Date(year - 1, month, 0).getDate() + const lyLast = `${year - 1}-${String(month).padStart(2, '0')}-${String(lyLastNum).padStart(2, '0')}` + const [lyRev] = await queryForecast(`SELECT SUM(accommodation) as accomm, SUM(dry) as dry, SUM(wet) as wet FROM newbook_net_revenue_data WHERE date >= $1 AND date <= $2`, [lyFirst, lyLast]) + if (lyRev) { lastYear.accomm = parseFloat(lyRev.accomm || 0); lastYear.dry = parseFloat(lyRev.dry || 0); lastYear.wet = parseFloat(lyRev.wet || 0) } + const [lyOtb] = await queryForecast(`SELECT SUM(booking_count) as rooms FROM newbook_bookings_stats WHERE date >= $1 AND date <= $2`, [lyFirst, lyLast]) + if (lyOtb) lastYear.rooms = parseInt(lyOtb.rooms || 0) + } catch {} + + const dowHistory = await getDowHistory(new Date(firstDay)) + const sessionRate = parseFloat(session.pickup_avg_rate || 135) + + const otbMap = {}; for (const r of otbRows) otbMap[r.date] = r + const revMap = {}; for (const r of revRows) revMap[r.date] = r + + const weekBands = { '1–7': { accomm: 0, dry: 0, wet: 0, rooms: 0 }, '8–14': { accomm: 0, dry: 0, wet: 0, rooms: 0 }, '15–21': { accomm: 0, dry: 0, wet: 0, rooms: 0 }, '22–28': { accomm: 0, dry: 0, wet: 0, rooms: 0 }, '29–end': { accomm: 0, dry: 0, wet: 0, rooms: 0 } } + + let totalAvailable = 0, totalOtbRooms = 0, totalOtbNetRev = 0, totalPickup = 0 + let totalAccomm = 0, totalDry = 0, totalWet = 0 + + for (let d = 1; d <= lastDayNum; d++) { + const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(d).padStart(2, '0')}` + const isPast = dateStr < today + const dow = new Date(dateStr + 'T00:00:00').getDay() + + const otb = otbMap[dateStr] || {}; const rev = revMap[dateStr] || {}; const ovr = overrideMap[dateStr] || {} + const available = parseInt(otb.rooms_count || 0) + const otbRooms = parseInt(otb.booking_count || 0) + const otbNetRev = parseFloat(otb.net_booking_rev_total || 0) + const actualAccomm = ovr.actual_accomm != null ? parseFloat(ovr.actual_accomm) : (rev.accommodation != null ? parseFloat(rev.accommodation) : null) + const actualDry = rev.dry != null ? parseFloat(rev.dry) : null + const actualWet = rev.wet != null ? parseFloat(rev.wet) : null + const pickupRooms = ovr.pickup_rooms != null ? parseInt(ovr.pickup_rooms) : 0 + const dayRate = ovr.pickup_avg_rate != null ? parseFloat(ovr.pickup_avg_rate) : sessionRate + const totalRooms = otbRooms + pickupRooms + + const fAccomm = (isPast && actualAccomm != null) ? actualAccomm : otbNetRev + pickupRooms * dayRate + const fDry = ovr.dry_override != null ? parseFloat(ovr.dry_override) : (isPast && actualDry != null ? actualDry : dowAvg(dowHistory, 'dry', dow)) + const fWet = ovr.wet_override != null ? parseFloat(ovr.wet_override) : (isPast && actualWet != null ? actualWet : dowAvg(dowHistory, 'wet', dow)) + + totalAvailable += available; totalOtbRooms += otbRooms; totalOtbNetRev += otbNetRev + totalPickup += pickupRooms; totalAccomm += fAccomm; totalDry += fDry; totalWet += fWet + + const band = weekBand(d) + weekBands[band].accomm += fAccomm; weekBands[band].dry += fDry + weekBands[band].wet += fWet; weekBands[band].rooms += totalRooms + } + + const totalForecastRooms = totalOtbRooms + totalPickup + + const snapshotsRes = await pool.query( + `SELECT id, snapshot_at, label, forecast_rooms, forecast_accomm, forecast_dry, forecast_wet, forecast_total, otb_rooms, pickup_rooms, occ_pct + FROM forecast_snapshots WHERE session_id = $1 ORDER BY snapshot_at ASC`, + [session.id] + ) + + return { + year, month, + forecast: { + rooms: totalForecastRooms, accomm: totalAccomm, dry: totalDry, wet: totalWet, + total: totalAccomm + totalDry + totalWet, + occ_pct: totalAvailable > 0 ? (totalForecastRooms / totalAvailable) * 100 : null, + arr: totalForecastRooms > 0 ? totalAccomm / totalForecastRooms : null, + revpar: totalAvailable > 0 ? totalAccomm / totalAvailable : null, + available: totalAvailable, + }, + budget: { ...budget, total: budget.accomm + budget.dry + budget.wet }, + last_year: { ...lastYear, total: lastYear.accomm + lastYear.dry + lastYear.wet }, + otb: { + rooms: totalOtbRooms, accomm: totalOtbNetRev, available: totalAvailable, + occ_pct: totalAvailable > 0 ? (totalOtbRooms / totalAvailable) * 100 : null, + arr: totalOtbRooms > 0 ? totalOtbNetRev / totalOtbRooms : null, + revpar: totalAvailable > 0 ? totalOtbNetRev / totalAvailable : null, + }, + pickup: { rooms: totalPickup, accomm: totalPickup * sessionRate, avg_rate: sessionRate }, + weekly: Object.entries(weekBands).map(([label, v]) => ({ label, ...v })), + snapshots: snapshotsRes.rows, + } + }) + + // POST /api/directors-forecast/report/:year/:month/snapshot + fastify.post('/api/directors-forecast/report/:year/:month/snapshot', async (request, reply) => { + if (!hasCap(request, 'edit')) return reply.status(403).send({ error: 'Missing capability: edit' }) + + const year = parseInt(request.params.year) + const month = parseInt(request.params.month) + const { label, forecast_rooms, forecast_accomm, forecast_dry, forecast_wet, forecast_total, otb_rooms, pickup_rooms, occ_pct } = request.body + + const sessionRes = await pool.query(`SELECT id FROM forecast_sessions WHERE year = $1 AND month = $2`, [year, month]) + if (sessionRes.rows.length === 0) return reply.status(404).send({ error: 'Session not found' }) + + const res = await pool.query( + `INSERT INTO forecast_snapshots (session_id, label, forecast_rooms, forecast_accomm, forecast_dry, forecast_wet, forecast_total, otb_rooms, pickup_rooms, occ_pct) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, + [sessionRes.rows[0].id, label, forecast_rooms, forecast_accomm, forecast_dry, forecast_wet, forecast_total, otb_rooms, pickup_rooms, occ_pct] + ) + return res.rows[0] + }) + + // DELETE /api/directors-forecast/report/:year/:month/snapshot/:id + fastify.delete('/api/directors-forecast/report/:year/:month/snapshot/:id', async (request, reply) => { + if (!hasCap(request, 'edit')) return reply.status(403).send({ error: 'Missing capability: edit' }) + await pool.query(`DELETE FROM forecast_snapshots WHERE id = $1`, [parseInt(request.params.id)]) + return { ok: true } + }) +} diff --git a/docker-compose.yml b/docker-compose.yml index 4af59259..e461f3de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,13 +5,12 @@ services: - apparmor=unconfined environment: - DATABASE_URL=${DATABASE_URL} + - FORECAST_DATABASE_URL=${FORECAST_DATABASE_URL:-} - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} - SETTINGS_URL=${SETTINGS_URL} - SETTINGS_SECRET=${SETTINGS_SECRET} - APP_SLUG=reports - OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled} - - RESOS_API_KEY=${RESOS_API_KEY:-} - - SAMBA_DATABASE_URL=${SAMBA_DATABASE_URL:-} healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"] interval: 10s diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 62c4b75d..8660fd67 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -29,3 +29,31 @@ export function runReport(id: string, dateFrom: string, dateTo: string): Promise body: JSON.stringify({ dateFrom, dateTo }), }) } + +// ── Directors Forecast API ────────────────────────────────────────────────── + +export function dfGetWorksheet(year: number, month: number) { + return request(`/directors-forecast/worksheet/${year}/${month}`) +} + +export function dfSaveWorksheet(year: number, month: number, data: { pickup_avg_rate: number; overrides: object[] }) { + return request(`/directors-forecast/worksheet/${year}/${month}`, { + method: 'PUT', body: JSON.stringify(data), + }) +} + +export function dfGetReport(year: number, month: number) { + return request(`/directors-forecast/report/${year}/${month}`) +} + +export function dfSaveSnapshot(year: number, month: number, data: object) { + return request(`/directors-forecast/report/${year}/${month}/snapshot`, { + method: 'POST', body: JSON.stringify(data), + }) +} + +export function dfDeleteSnapshot(year: number, month: number, id: number) { + return request(`/directors-forecast/report/${year}/${month}/snapshot/${id}`, { + method: 'DELETE', + }) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index d33b9c67..191b25a9 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -410,3 +410,127 @@ html, body, #root { .sidebar::-webkit-scrollbar-thumb:hover, .sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); } .nav-scroll, .sidebar, .sidebar-nav { scrollbar-width: thin; scrollbar-color: rgba(201,168,76,0.35) transparent; } + +/* ── Directors Forecast ──────────────────────────────────────────── */ +.df-root { display: flex; flex-direction: column; height: 100%; overflow: hidden; } + +.df-header { + display: flex; align-items: center; gap: 16px; + padding: 12px 24px; border-bottom: 1px solid var(--border); + background: var(--card-bg); flex-shrink: 0; +} +.df-month-nav { display: flex; align-items: center; gap: 10px; } +.df-nav-btn { + background: none; border: 1px solid var(--border); border-radius: 4px; + width: 26px; height: 26px; cursor: pointer; font-size: 16px; line-height: 1; + color: var(--text-primary); +} +.df-nav-btn:hover { background: var(--body-bg); } +.df-month-label { font-weight: 600; font-size: 14px; min-width: 140px; text-align: center; } +.df-tabs { display: flex; gap: 4px; } +.df-tab { + padding: 5px 14px; border-radius: 4px; border: 1px solid var(--border); + background: var(--card-bg); cursor: pointer; font-size: 12px; color: var(--text-muted); +} +.df-tab:hover { background: var(--body-bg); } +.df-tab.active { background: var(--navy); color: #fff; border-color: var(--navy); } + +/* Worksheet */ +.df-worksheet { display: flex; flex-direction: column; flex: 1; overflow: hidden; } +.df-toolbar { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 24px; border-bottom: 1px solid var(--border); + background: var(--card-bg); flex-shrink: 0; gap: 12px; +} +.df-toolbar-left, .df-toolbar-right { display: flex; align-items: center; gap: 10px; } +.df-rate-label { font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 6px; } +.df-rate-input { width: 70px; padding: 4px 6px; border: 1px solid var(--border); border-radius: 4px; font-size: 13px; } +.df-save-btn { display: flex; align-items: center; gap: 6px; font-size: 12px; } +.df-btn-icon { + background: none; border: 1px solid var(--border); border-radius: 4px; + width: 26px; height: 26px; display: flex; align-items: center; justify-content: center; + cursor: pointer; color: var(--text-muted); +} +.df-btn-icon:hover { background: var(--body-bg); } +.df-error-inline { font-size: 12px; color: #dc2626; } + +.df-table-wrap { flex: 1; overflow: auto; padding: 0 24px 24px; } +.df-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 12px; } +.df-table th { + position: sticky; top: 0; background: var(--navy); color: #fff; + padding: 6px 8px; text-align: right; white-space: nowrap; font-weight: 500; font-size: 11px; +} +.df-table th:first-child, .df-table th:nth-child(2) { text-align: left; } +.df-table td { padding: 5px 8px; border-bottom: 1px solid var(--border); } +.df-table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.df-table tr:hover td { background: #f8f9fa; } +.df-row-past td { color: var(--text-muted); } +.df-row-today td { background: #fffbeb !important; font-weight: 500; } + +.df-ml { color: var(--gold); font-weight: 600; } +.df-forecast { font-weight: 600; color: #1d4ed8; } +.df-bold { font-weight: 600; } + +.df-input-cell { padding: 2px 4px !important; } +.df-cell-input { + width: 70px; padding: 3px 5px; border: 1px solid var(--border); border-radius: 3px; + font-size: 11px; text-align: right; background: #fff; +} +.df-cell-input:focus { outline: none; border-color: var(--gold); } + +.df-totals td { background: #f4f5f7; font-weight: 600; border-top: 2px solid var(--border); } + +/* Report tab */ +.df-report { flex: 1; overflow: auto; padding: 24px; display: flex; flex-direction: column; gap: 20px; } +.df-report-title { font-size: 18px; font-weight: 700; color: var(--navy); text-align: center; margin-bottom: 4px; } +.df-report-toolbar { + display: flex; align-items: center; gap: 10px; flex-wrap: wrap; + padding-bottom: 12px; border-bottom: 1px solid var(--border); +} +.df-snap-row { display: flex; align-items: center; gap: 6px; } +.df-snap-input { padding: 5px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px; width: 200px; } + +.df-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } +.df-card-title { padding: 10px 14px; font-weight: 600; font-size: 12px; background: #f8f9fa; border-bottom: 1px solid var(--border); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; } + +.df-report-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.df-report-table th { padding: 8px 12px; background: #f8f9fa; border-bottom: 1px solid var(--border); text-align: right; font-weight: 600; font-size: 11px; color: var(--text-muted); } +.df-report-table th:first-child { text-align: left; } +.df-report-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); } +.df-report-table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.df-report-table tr:last-child td { border-bottom: none; } +.df-row-label { font-size: 12px; color: var(--text-primary); white-space: nowrap; } +.df-total-row td { font-weight: 700; background: #f4f5f7; border-top: 2px solid var(--border); } +.df-pos { color: #16a34a; text-align: right; font-variant-numeric: tabular-nums; } +.df-neg { color: #dc2626; text-align: right; font-variant-numeric: tabular-nums; } + +.df-snap-col { position: relative; font-size: 10px; padding: 4px 20px 4px 8px !important; color: var(--text-muted); } +.df-snap-val { text-align: right; font-variant-numeric: tabular-nums; font-size: 11px; color: var(--text-muted); } +.df-del-snap { + position: absolute; right: 4px; top: 50%; transform: translateY(-50%); + background: none; border: none; cursor: pointer; color: var(--text-muted); padding: 2px; +} +.df-del-snap:hover { color: #dc2626; } + +.df-weekly-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } + +.df-loading { display: flex; justify-content: center; align-items: center; padding: 64px; } +.df-spinner { + width: 32px; height: 32px; border: 3px solid var(--border); + border-top-color: var(--gold); border-radius: 50%; + animation: spin .7s linear infinite; +} +.df-error { padding: 24px; color: #dc2626; font-size: 13px; } + +@media print { + .no-print { display: none !important; } + .sidebar, .top-bar, .df-header { display: none !important; } + .df-root, .df-report { overflow: visible; } + #df-printable { padding: 0; } + .df-weekly-grid { grid-template-columns: repeat(3, 1fr); } +} + +@media (max-width: 900px) { + .df-weekly-grid { grid-template-columns: 1fr; } + .df-table { font-size: 11px; } +} diff --git a/frontend/src/pages/DirectorsForecast.tsx b/frontend/src/pages/DirectorsForecast.tsx new file mode 100644 index 00000000..2a3ceda7 --- /dev/null +++ b/frontend/src/pages/DirectorsForecast.tsx @@ -0,0 +1,478 @@ +import { useCallback, useEffect, useState } from 'react' +import { Save, RefreshCw, Camera, Trash2, Printer } from 'lucide-react' +import { useAuth } from '../components/AuthGate' +import { dfGetWorksheet, dfSaveWorksheet, dfGetReport, dfSaveSnapshot, dfDeleteSnapshot } from '../api' +import type { WorksheetDay, WorksheetData, ForecastReportData, ForecastSnapshot } from '../types' +import { can, DOW_NAMES, MONTH_NAMES } from '../types' + +// ── formatters ──────────────────────────────────────────────────────────────── + +const fmtCcy = (n: number | null | undefined) => + n == null ? '—' : `£${n.toLocaleString('en-GB', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` + +const fmtPct = (n: number | null | undefined) => + n == null ? '—' : `${n.toFixed(1)}%` + +const fmtNum = (n: number | null | undefined, d = 0) => + n == null ? '—' : n.toLocaleString('en-GB', { minimumFractionDigits: d, maximumFractionDigits: d }) + +// ── MonthNav ────────────────────────────────────────────────────────────────── + +function MonthNav({ year, month, onChange }: { year: number; month: number; onChange: (y: number, m: number) => void }) { + const prev = () => month === 1 ? onChange(year - 1, 12) : onChange(year, month - 1) + const next = () => month === 12 ? onChange(year + 1, 1) : onChange(year, month + 1) + return ( +
+ + {MONTH_NAMES[month - 1]} {year} + +
+ ) +} + +// ── Override state helpers ──────────────────────────────────────────────────── + +type DayOverride = { + pickup_rooms: string + pickup_avg_rate: string + dry_override: string + wet_override: string +} + +// ── Worksheet tab ───────────────────────────────────────────────────────────── + +function WorksheetTab({ year, month }: { year: number; month: number }) { + const { user } = useAuth() + const canEdit = can(user, 'edit') + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [sessionRate, setRate] = useState('135') + const [overrides, setOverrides] = useState>({}) + const [dirty, setDirty] = useState(false) + + const load = useCallback(async () => { + setLoading(true); setError(null) + try { + const d: WorksheetData = await dfGetWorksheet(year, month) + setData(d) + setRate(String(d.session.pickup_avg_rate ?? 135)) + const ovrs: Record = {} + for (const day of d.days) { + ovrs[day.date] = { + pickup_rooms: day.pickup_rooms > 0 ? String(day.pickup_rooms) : '', + pickup_avg_rate: day.pickup_avg_rate != null ? String(day.pickup_avg_rate) : '', + dry_override: day.dry_override != null ? String(day.dry_override) : '', + wet_override: day.wet_override != null ? String(day.wet_override) : '', + } + } + setOverrides(ovrs) + setDirty(false) + } catch { setError('Failed to load worksheet data.') } + finally { setLoading(false) } + }, [year, month]) + + useEffect(() => { load() }, [load]) + + function setOvr(date: string, field: keyof DayOverride, val: string) { + setOverrides(prev => ({ ...prev, [date]: { ...prev[date], [field]: val } })) + setDirty(true) + } + + async function handleSave() { + if (!canEdit) return + setSaving(true) + try { + const ovrList = Object.entries(overrides).map(([date, o]) => ({ + date, + pickup_rooms: o.pickup_rooms !== '' ? parseInt(o.pickup_rooms) : null, + pickup_avg_rate: o.pickup_avg_rate !== '' ? parseFloat(o.pickup_avg_rate) : null, + dry_override: o.dry_override !== '' ? parseFloat(o.dry_override) : null, + wet_override: o.wet_override !== '' ? parseFloat(o.wet_override) : null, + })) + await dfSaveWorksheet(year, month, { pickup_avg_rate: parseFloat(sessionRate) || 135, overrides: ovrList }) + setDirty(false) + await load() + } catch { setError('Save failed.') } + finally { setSaving(false) } + } + + function computedDay(day: WorksheetDay) { + const ovr = overrides[day.date] || {} + const pickup = parseInt(ovr.pickup_rooms || '0') || 0 + const rate = parseFloat(ovr.pickup_avg_rate || '') || parseFloat(sessionRate) || 135 + const total = day.otb_rooms + pickup + const occ = day.available > 0 ? (total / day.available) * 100 : null + let accomm = day.otb_net_rev + pickup * rate + if (day.is_past && day.actual_accomm != null) accomm = day.actual_accomm + const dry = ovr.dry_override !== '' ? parseFloat(ovr.dry_override) : day.forecast_dry + const wet = ovr.wet_override !== '' ? parseFloat(ovr.wet_override) : day.forecast_wet + return { total, occ, accomm, dry: dry || 0, wet: wet || 0 } + } + + if (loading) return
+ if (!data) return
{error || 'No data.'}
+ + const totals = data.days.reduce((acc, day) => { + const c = computedDay(day) + const p = parseInt(overrides[day.date]?.pickup_rooms || '0') || 0 + return { + available: acc.available + day.available, + otb: acc.otb + day.otb_rooms, + pickup: acc.pickup + p, + total: acc.total + c.total, + accomm: acc.accomm + c.accomm, + dry: acc.dry + c.dry, + wet: acc.wet + c.wet, + } + }, { available: 0, otb: 0, pickup: 0, total: 0, accomm: 0, dry: 0, wet: 0 }) + + return ( +
+
+
+ +
+
+ {error && {error}} + + {canEdit && ( + + )} +
+
+ +
+ + + + + + + + + + + + {data.days.map(day => { + const c = computedDay(day) + const ovr = overrides[day.date] || { pickup_rooms: '', pickup_avg_rate: '', dry_override: '', wet_override: '' } + const rowCls = day.is_today ? 'df-row-today' : day.is_past ? 'df-row-past' : '' + return ( + + + + + + + + + + + + + + + + + + + ) + })} + + + + + + + + + + + + + + +
DayDateAvailOTBOTB RevAvg £ML±PickupTotalAct AccAct DryAct WetOcc%Fcast AccFcast DryFcast Wet
{DOW_NAMES[day.dow]}{day.date.slice(5)}{day.available || '—'}{day.otb_rooms}{fmtCcy(day.otb_net_rev || null)}{fmtCcy(day.avg_rate_net)} + {day.ml_suggestion != null ? (day.ml_suggestion > 0 ? `+${day.ml_suggestion}` : day.ml_suggestion) : '—'} + + {canEdit && !day.is_past ? ( + setOvr(day.date, 'pickup_rooms', e.target.value)} + className="df-cell-input" /> + ) : ( + {parseInt(ovr.pickup_rooms || '0') || '—'} + )} + {c.total}{fmtCcy(day.actual_accomm)}{fmtCcy(day.actual_dry)}{fmtCcy(day.actual_wet)}{c.occ != null ? `${c.occ.toFixed(1)}%` : '—'}{fmtCcy(c.accomm)} + {canEdit ? ( + setOvr(day.date, 'dry_override', e.target.value)} + className="df-cell-input" /> + ) : {fmtCcy(c.dry)}} + + {canEdit ? ( + setOvr(day.date, 'wet_override', e.target.value)} + className="df-cell-input" /> + ) : {fmtCcy(c.wet)}} +
Totals{totals.available}{totals.otb} + {totals.pickup}{totals.total} + {totals.available > 0 ? `${((totals.total / totals.available) * 100).toFixed(1)}%` : '—'}{fmtCcy(totals.accomm)}{fmtCcy(totals.dry)}{fmtCcy(totals.wet)}
+
+
+ ) +} + +// ── Report tab ──────────────────────────────────────────────────────────────── + +function VarCell({ a, b }: { a: number; b: number }) { + const v = a - b + return = 0 ? 'df-pos' : 'df-neg'}>{v >= 0 ? '+' : ''}{fmtCcy(v)} +} + +function ReportTab({ year, month }: { year: number; month: number }) { + const { user } = useAuth() + const canEdit = can(user, 'edit') + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [snapshotLabel, setLabel] = useState('') + const [saving, setSaving] = useState(false) + + const load = useCallback(async () => { + setLoading(true); setError(null) + try { setData(await dfGetReport(year, month)) } + catch { setError('Failed to load report data.') } + finally { setLoading(false) } + }, [year, month]) + + useEffect(() => { load() }, [load]) + + async function handleSnapshot() { + if (!data || !canEdit) return + setSaving(true) + try { + await dfSaveSnapshot(year, month, { + label: snapshotLabel || `${new Date().toLocaleDateString('en-GB')}`, + forecast_rooms: data.forecast.rooms, + forecast_accomm: data.forecast.accomm, + forecast_dry: data.forecast.dry, + forecast_wet: data.forecast.wet, + forecast_total: data.forecast.total, + otb_rooms: data.otb.rooms, + pickup_rooms: data.pickup.rooms, + occ_pct: data.forecast.occ_pct, + }) + setLabel('') + await load() + } catch { setError('Snapshot failed.') } + finally { setSaving(false) } + } + + async function handleDeleteSnap(snap: ForecastSnapshot) { + if (!canEdit) return + try { await dfDeleteSnapshot(year, month, snap.id); await load() } + catch { setError('Delete failed.') } + } + + if (loading) return
+ if (!data) return
{error || 'No data.'}
+ + const { forecast, budget, last_year, otb, pickup, weekly, snapshots } = data + + return ( +
+
+ + + {canEdit && ( +
+ setLabel(e.target.value)} className="df-snap-input" /> + +
+ )} + {error && {error}} +
+ +

+ DIRECTORS FORECAST — {MONTH_NAMES[month - 1].toUpperCase()} {year} +

+ + {/* Forecast vs Budget vs Last Year */} +
+ + + + + + + {snapshots.map(s => ( + + ))} + + + + + + + + + + {snapshots.map(s => )} + + {([['Accommodation', 'accomm'], ['Dry', 'dry'], ['Wet', 'wet']] as const).map(([label, key]) => ( + + + + + + + + {snapshots.map(s => ( + + ))} + + ))} + + + + + + + + {snapshots.map(s => )} + + +
ForecastBudgetBudget VarLast YearLY Var + {s.label || new Date(s.snapshot_at).toLocaleDateString('en-GB')} + {canEdit && ( + + )} +
Rooms{forecast.rooms.toLocaleString()}{last_year.rooms.toLocaleString()}= last_year.rooms ? 'num df-pos' : 'num df-neg'}> + {forecast.rooms - last_year.rooms >= 0 ? '+' : ''}{(forecast.rooms - last_year.rooms).toLocaleString()} + {s.forecast_rooms?.toLocaleString()}
{label}{fmtCcy(forecast[key])}{fmtCcy(budget[key])}{fmtCcy(last_year[key])} + {fmtCcy(s[`forecast_${key}` as keyof ForecastSnapshot] as number)} +
Total{fmtCcy(forecast.total)}{fmtCcy(budget.total)}{fmtCcy(last_year.total)}{fmtCcy(s.forecast_total)}
+
+ + {/* OTB Occupancy Summary */} +
+
Occupancy Report
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OTB (Actuals)Probable PickupEstimate for Month
Room Nights{otb.rooms.toLocaleString()}{pickup.rooms.toLocaleString()}{forecast.rooms.toLocaleString()}
Revenue{fmtCcy(otb.accomm)}{fmtCcy(pickup.accomm)}{fmtCcy(forecast.accomm)}
ARR{fmtCcy(otb.arr)}{fmtCcy(pickup.avg_rate)}{fmtCcy(forecast.arr)}
Occupancy %{fmtPct(otb.occ_pct)}{pickup.rooms > 0 && forecast.available > 0 ? fmtPct((pickup.rooms / forecast.available) * 100) : '—'}{fmtPct(forecast.occ_pct)}
REVPAR{fmtCcy(otb.revpar)}{fmtCcy(forecast.revpar)}
Available Rooms{forecast.available.toLocaleString()}{(forecast.available - otb.rooms).toLocaleString()}{forecast.available.toLocaleString()}
+
+ + {/* Weekly Breakdown */} +
+ {(['accomm', 'dry', 'wet'] as const).map(cat => ( +
+
{cat === 'accomm' ? 'Accommodation' : cat === 'dry' ? 'Dry (Food)' : 'Wet (Beverage)'}
+ + + {weekly.map(w => ( + + + + + ))} + + + + + +
{w.label}{fmtCcy(w[cat])}
Total{fmtCcy(weekly.reduce((s, w) => s + w[cat], 0))}
+
+ ))} +
+
+ ) +} + +// ── Main component ──────────────────────────────────────────────────────────── + +type Tab = 'worksheet' | 'report' + +export default function DirectorsForecast() { + const now = new Date() + const [year, setYear] = useState(now.getFullYear()) + const [month, setMonth] = useState(now.getMonth() + 1) + const [tab, setTab] = useState('worksheet') + + return ( +
+
+ { setYear(y); setMonth(m) }} /> +
+ + +
+
+ + {tab === 'worksheet' + ? + : } +
+ ) +} diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index 06fe9896..9e23688a 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -2,11 +2,23 @@ import { useEffect, useState, useCallback } from 'react' import { BarChart2, Building2, UtensilsCrossed, ShoppingCart, Database, ChevronRight, ChevronDown, Play, Download, Loader2, AlertCircle, - FileBarChart, + FileBarChart, TrendingUp, } from 'lucide-react' import { useAuth } from '../components/AuthGate' import { fetchReports, runReport } from '../api' import type { ReportMeta, ReportResult } from '../types' +import DirectorsForecast from './DirectorsForecast' + +const DIRECTORS_REPORTS: ReportMeta[] = [ + { + id: 'directors-forecast', + name: 'Directors Forecast', + category: 'directors', + categoryLabel: 'Directors Reports', + subcategory: null, + description: 'Monthly OTB, pickup and revenue forecast with budget and last-year comparison.', + }, +] // ── helpers ─────────────────────────────────────────────────────────────────── @@ -99,6 +111,7 @@ function Sidebar({ tree, selectedId, onSelect }: SidebarProps) { const { user } = useAuth() const [openCats, setOpenCats] = useState>(new Set(tree.map(t => t.category))) const [openSubs, setOpenSubs] = useState>(new Set()) + const [directorsOpen, setDirectorsOpen] = useState(true) const toggleCat = (key: string) => setOpenCats(prev => { const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n }) @@ -114,6 +127,30 @@ function Sidebar({ tree, selectedId, onSelect }: SidebarProps) {
+ ) : selected.category === 'directors' ? ( + ) : (
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 04fd2e7a..9e74317a 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -29,3 +29,47 @@ export interface ReportResult { rows: Record[] summary?: { label: string; value: string }[] } + +// ── Directors Forecast types ──────────────────────────────────────────────── + +export const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] +export const MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December'] + +export interface ForecastSession { + id: number; year: number; month: number + pickup_avg_rate: number; notes: string | null +} + +export interface WorksheetDay { + date: string; dow: number; available: number + otb_rooms: number; otb_net_rev: number; avg_rate_net: number | null + is_past: boolean; is_today: boolean + actual_accomm: number | null; actual_dry: number | null; actual_wet: number | null + ml_suggestion: number | null + pickup_rooms: number; pickup_avg_rate: number | null + dry_override: number | null; wet_override: number | null + total_rooms: number; forecast_accomm: number + forecast_occ_pct: number | null; forecast_dry: number; forecast_wet: number +} + +export interface WorksheetData { session: ForecastSession; days: WorksheetDay[] } + +export interface WeekBand { label: string; accomm: number; dry: number; wet: number; rooms: number } + +export interface ForecastSnapshot { + id: number; snapshot_at: string; label: string | null + forecast_rooms: number; forecast_accomm: number; forecast_dry: number + forecast_wet: number; forecast_total: number + otb_rooms: number; pickup_rooms: number; occ_pct: number | null +} + +export interface ForecastReportData { + year: number; month: number + forecast: { rooms: number; accomm: number; dry: number; wet: number; total: number; occ_pct: number | null; arr: number | null; revpar: number | null; available: number } + budget: { accomm: number; dry: number; wet: number; total: number } + last_year: { accomm: number; dry: number; wet: number; rooms: number; total: number } + otb: { rooms: number; accomm: number; occ_pct: number | null; arr: number | null; revpar: number | null; available: number } + pickup: { rooms: number; accomm: number; avg_rate: number } + weekly: WeekBand[] + snapshots: ForecastSnapshot[] +}