Add Directors Forecast interactive report section
Embeds a Directors Forecast section in the Reports sidebar (hard-coded under "Directors Reports"). Worksheet tab: day-by-day OTB grid with editable pickup rooms, dry/wet overrides, ML suggestion column and live forecast recalculation. Report tab: forecast vs budget vs last-year comparison, occupancy summary, weekly revenue bands and snapshot tracking. Backend: forecast-db.js (read-only pool to forecasting_db), new tables (forecast_sessions, day_overrides, forecast_snapshots) and full REST routes under /api/directors-forecast. docker-compose FORECAST_DATABASE_URL added. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
92e2c48f48
commit
018957123f
10 changed files with 1128 additions and 5 deletions
|
|
@ -19,5 +19,46 @@ export async function initDb() {
|
||||||
);
|
);
|
||||||
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 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);
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
backend/src/forecast-db.js
Normal file
13
backend/src/forecast-db.js
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -3,8 +3,10 @@ import cookie from '@fastify/cookie'
|
||||||
import cors from '@fastify/cors'
|
import cors from '@fastify/cors'
|
||||||
import { initDb } from './db.js'
|
import { initDb } from './db.js'
|
||||||
import { reportRoutes } from './routes/reports.js'
|
import { reportRoutes } from './routes/reports.js'
|
||||||
|
import { directorsForecastRoutes } from './routes/directors-forecast.js'
|
||||||
|
|
||||||
const app = Fastify({ logger: true, trustProxy: true })
|
const app = Fastify({ logger: true, trustProxy: true })
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
await app.register(cookie)
|
await app.register(cookie)
|
||||||
await app.register(cors, {
|
await app.register(cors, {
|
||||||
|
|
@ -12,9 +14,10 @@ await app.register(cors, {
|
||||||
credentials: true,
|
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(reportRoutes)
|
||||||
|
await app.register(directorsForecastRoutes)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initDb()
|
await initDb()
|
||||||
|
|
|
||||||
354
backend/src/routes/directors-forecast.js
Normal file
354
backend/src/routes/directors-forecast.js
Normal file
|
|
@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -5,13 +5,12 @@ services:
|
||||||
- apparmor=unconfined
|
- apparmor=unconfined
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=${DATABASE_URL}
|
- DATABASE_URL=${DATABASE_URL}
|
||||||
|
- FORECAST_DATABASE_URL=${FORECAST_DATABASE_URL:-}
|
||||||
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
- CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET}
|
||||||
- SETTINGS_URL=${SETTINGS_URL}
|
- SETTINGS_URL=${SETTINGS_URL}
|
||||||
- SETTINGS_SECRET=${SETTINGS_SECRET}
|
- SETTINGS_SECRET=${SETTINGS_SECRET}
|
||||||
- APP_SLUG=reports
|
- APP_SLUG=reports
|
||||||
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
|
- OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled}
|
||||||
- RESOS_API_KEY=${RESOS_API_KEY:-}
|
|
||||||
- SAMBA_DATABASE_URL=${SAMBA_DATABASE_URL:-}
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,31 @@ export function runReport(id: string, dateFrom: string, dateTo: string): Promise
|
||||||
body: JSON.stringify({ dateFrom, dateTo }),
|
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',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -410,3 +410,127 @@ html, body, #root {
|
||||||
.sidebar::-webkit-scrollbar-thumb:hover,
|
.sidebar::-webkit-scrollbar-thumb:hover,
|
||||||
.sidebar-nav::-webkit-scrollbar-thumb:hover { background: rgba(201,168,76,0.65); }
|
.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; }
|
.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; }
|
||||||
|
}
|
||||||
|
|
|
||||||
478
frontend/src/pages/DirectorsForecast.tsx
Normal file
478
frontend/src/pages/DirectorsForecast.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="df-month-nav">
|
||||||
|
<button className="df-nav-btn" onClick={prev}>‹</button>
|
||||||
|
<span className="df-month-label">{MONTH_NAMES[month - 1]} {year}</span>
|
||||||
|
<button className="df-nav-btn" onClick={next}>›</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<WorksheetData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [sessionRate, setRate] = useState('135')
|
||||||
|
const [overrides, setOverrides] = useState<Record<string, DayOverride>>({})
|
||||||
|
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<string, DayOverride> = {}
|
||||||
|
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 <div className="df-loading"><div className="df-spinner" /></div>
|
||||||
|
if (!data) return <div className="df-error">{error || 'No data.'}</div>
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="df-worksheet">
|
||||||
|
<div className="df-toolbar">
|
||||||
|
<div className="df-toolbar-left">
|
||||||
|
<label className="df-rate-label">
|
||||||
|
Default Pickup Rate (£)
|
||||||
|
<input
|
||||||
|
type="number" value={sessionRate} min="0" step="1"
|
||||||
|
onChange={e => { setRate(e.target.value); setDirty(true) }}
|
||||||
|
className="df-rate-input" disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="df-toolbar-right">
|
||||||
|
{error && <span className="df-error-inline">{error}</span>}
|
||||||
|
<button className="btn-icon df-btn-icon" onClick={load} title="Refresh">
|
||||||
|
<RefreshCw size={13} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
{canEdit && (
|
||||||
|
<button className="btn-run df-save-btn" onClick={handleSave} disabled={saving || !dirty}>
|
||||||
|
<Save size={13} strokeWidth={1.75} />
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="df-table-wrap">
|
||||||
|
<table className="df-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Day</th><th>Date</th><th>Avail</th><th>OTB</th>
|
||||||
|
<th>OTB Rev</th><th>Avg £</th><th title="ML model suggestion">ML±</th>
|
||||||
|
<th>Pickup</th><th>Total</th>
|
||||||
|
<th>Act Acc</th><th>Act Dry</th><th>Act Wet</th>
|
||||||
|
<th>Occ%</th><th>Fcast Acc</th><th>Fcast Dry</th><th>Fcast Wet</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr key={day.date} className={rowCls}>
|
||||||
|
<td>{DOW_NAMES[day.dow]}</td>
|
||||||
|
<td>{day.date.slice(5)}</td>
|
||||||
|
<td className="num">{day.available || '—'}</td>
|
||||||
|
<td className="num">{day.otb_rooms}</td>
|
||||||
|
<td className="num">{fmtCcy(day.otb_net_rev || null)}</td>
|
||||||
|
<td className="num">{fmtCcy(day.avg_rate_net)}</td>
|
||||||
|
<td className="num df-ml">
|
||||||
|
{day.ml_suggestion != null ? (day.ml_suggestion > 0 ? `+${day.ml_suggestion}` : day.ml_suggestion) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="df-input-cell">
|
||||||
|
{canEdit && !day.is_past ? (
|
||||||
|
<input type="number" value={ovr.pickup_rooms} min="0" placeholder="0"
|
||||||
|
onChange={e => setOvr(day.date, 'pickup_rooms', e.target.value)}
|
||||||
|
className="df-cell-input" />
|
||||||
|
) : (
|
||||||
|
<span className="num">{parseInt(ovr.pickup_rooms || '0') || '—'}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="num df-bold">{c.total}</td>
|
||||||
|
<td className="num">{fmtCcy(day.actual_accomm)}</td>
|
||||||
|
<td className="num">{fmtCcy(day.actual_dry)}</td>
|
||||||
|
<td className="num">{fmtCcy(day.actual_wet)}</td>
|
||||||
|
<td className="num">{c.occ != null ? `${c.occ.toFixed(1)}%` : '—'}</td>
|
||||||
|
<td className="num df-forecast">{fmtCcy(c.accomm)}</td>
|
||||||
|
<td className="df-input-cell">
|
||||||
|
{canEdit ? (
|
||||||
|
<input type="number" value={ovr.dry_override} step="0.01"
|
||||||
|
placeholder={fmtNum(day.forecast_dry, 0)}
|
||||||
|
onChange={e => setOvr(day.date, 'dry_override', e.target.value)}
|
||||||
|
className="df-cell-input" />
|
||||||
|
) : <span className="num">{fmtCcy(c.dry)}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="df-input-cell">
|
||||||
|
{canEdit ? (
|
||||||
|
<input type="number" value={ovr.wet_override} step="0.01"
|
||||||
|
placeholder={fmtNum(day.forecast_wet, 0)}
|
||||||
|
onChange={e => setOvr(day.date, 'wet_override', e.target.value)}
|
||||||
|
className="df-cell-input" />
|
||||||
|
) : <span className="num">{fmtCcy(c.wet)}</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="df-totals">
|
||||||
|
<td colSpan={2}>Totals</td>
|
||||||
|
<td className="num">{totals.available}</td>
|
||||||
|
<td className="num">{totals.otb}</td>
|
||||||
|
<td /><td /><td />
|
||||||
|
<td className="num">{totals.pickup}</td>
|
||||||
|
<td className="num df-bold">{totals.total}</td>
|
||||||
|
<td /><td /><td />
|
||||||
|
<td className="num">{totals.available > 0 ? `${((totals.total / totals.available) * 100).toFixed(1)}%` : '—'}</td>
|
||||||
|
<td className="num df-bold">{fmtCcy(totals.accomm)}</td>
|
||||||
|
<td className="num">{fmtCcy(totals.dry)}</td>
|
||||||
|
<td className="num">{fmtCcy(totals.wet)}</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Report tab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function VarCell({ a, b }: { a: number; b: number }) {
|
||||||
|
const v = a - b
|
||||||
|
return <td className={v >= 0 ? 'df-pos' : 'df-neg'}>{v >= 0 ? '+' : ''}{fmtCcy(v)}</td>
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportTab({ year, month }: { year: number; month: number }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const canEdit = can(user, 'edit')
|
||||||
|
|
||||||
|
const [data, setData] = useState<ForecastReportData | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(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 <div className="df-loading"><div className="df-spinner" /></div>
|
||||||
|
if (!data) return <div className="df-error">{error || 'No data.'}</div>
|
||||||
|
|
||||||
|
const { forecast, budget, last_year, otb, pickup, weekly, snapshots } = data
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="df-report" id="df-printable">
|
||||||
|
<div className="df-report-toolbar no-print">
|
||||||
|
<button className="btn-icon df-btn-icon" onClick={() => window.print()} title="Print">
|
||||||
|
<Printer size={13} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
<button className="btn-icon df-btn-icon" onClick={load} title="Refresh">
|
||||||
|
<RefreshCw size={13} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="df-snap-row">
|
||||||
|
<input type="text" value={snapshotLabel} placeholder="Snapshot label (optional)"
|
||||||
|
onChange={e => setLabel(e.target.value)} className="df-snap-input" />
|
||||||
|
<button className="btn-run df-save-btn" onClick={handleSnapshot} disabled={saving}>
|
||||||
|
<Camera size={13} strokeWidth={1.75} />
|
||||||
|
{saving ? 'Saving…' : 'Save Snapshot'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <span className="df-error-inline">{error}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="df-report-title">
|
||||||
|
DIRECTORS FORECAST — {MONTH_NAMES[month - 1].toUpperCase()} {year}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Forecast vs Budget vs Last Year */}
|
||||||
|
<div className="df-card">
|
||||||
|
<table className="df-report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Forecast</th><th>Budget</th><th>Budget Var</th>
|
||||||
|
<th>Last Year</th><th>LY Var</th>
|
||||||
|
{snapshots.map(s => (
|
||||||
|
<th key={s.id} className="df-snap-col">
|
||||||
|
{s.label || new Date(s.snapshot_at).toLocaleDateString('en-GB')}
|
||||||
|
{canEdit && (
|
||||||
|
<button className="df-del-snap" onClick={() => handleDeleteSnap(s)} title="Delete">
|
||||||
|
<Trash2 size={10} strokeWidth={1.75} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className="df-row-label">Rooms</td>
|
||||||
|
<td className="num">{forecast.rooms.toLocaleString()}</td>
|
||||||
|
<td className="num">—</td><td className="num">—</td>
|
||||||
|
<td className="num">{last_year.rooms.toLocaleString()}</td>
|
||||||
|
<td className={forecast.rooms >= last_year.rooms ? 'num df-pos' : 'num df-neg'}>
|
||||||
|
{forecast.rooms - last_year.rooms >= 0 ? '+' : ''}{(forecast.rooms - last_year.rooms).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
{snapshots.map(s => <td key={s.id} className="num df-snap-val">{s.forecast_rooms?.toLocaleString()}</td>)}
|
||||||
|
</tr>
|
||||||
|
{([['Accommodation', 'accomm'], ['Dry', 'dry'], ['Wet', 'wet']] as const).map(([label, key]) => (
|
||||||
|
<tr key={key}>
|
||||||
|
<td className="df-row-label">{label}</td>
|
||||||
|
<td className="num">{fmtCcy(forecast[key])}</td>
|
||||||
|
<td className="num">{fmtCcy(budget[key])}</td>
|
||||||
|
<VarCell a={forecast[key]} b={budget[key]} />
|
||||||
|
<td className="num">{fmtCcy(last_year[key])}</td>
|
||||||
|
<VarCell a={forecast[key]} b={last_year[key]} />
|
||||||
|
{snapshots.map(s => (
|
||||||
|
<td key={s.id} className="num df-snap-val">
|
||||||
|
{fmtCcy(s[`forecast_${key}` as keyof ForecastSnapshot] as number)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="df-total-row">
|
||||||
|
<td className="df-row-label">Total</td>
|
||||||
|
<td className="num">{fmtCcy(forecast.total)}</td>
|
||||||
|
<td className="num">{fmtCcy(budget.total)}</td>
|
||||||
|
<VarCell a={forecast.total} b={budget.total} />
|
||||||
|
<td className="num">{fmtCcy(last_year.total)}</td>
|
||||||
|
<VarCell a={forecast.total} b={last_year.total} />
|
||||||
|
{snapshots.map(s => <td key={s.id} className="num df-snap-val">{fmtCcy(s.forecast_total)}</td>)}
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OTB Occupancy Summary */}
|
||||||
|
<div className="df-card">
|
||||||
|
<div className="df-card-title">Occupancy Report</div>
|
||||||
|
<table className="df-report-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th><th>OTB (Actuals)</th><th>Probable Pickup</th><th>Estimate for Month</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td className="df-row-label">Room Nights</td>
|
||||||
|
<td className="num">{otb.rooms.toLocaleString()}</td>
|
||||||
|
<td className="num">{pickup.rooms.toLocaleString()}</td>
|
||||||
|
<td className="num df-bold">{forecast.rooms.toLocaleString()}</td></tr>
|
||||||
|
<tr><td className="df-row-label">Revenue</td>
|
||||||
|
<td className="num">{fmtCcy(otb.accomm)}</td>
|
||||||
|
<td className="num">{fmtCcy(pickup.accomm)}</td>
|
||||||
|
<td className="num df-bold">{fmtCcy(forecast.accomm)}</td></tr>
|
||||||
|
<tr><td className="df-row-label">ARR</td>
|
||||||
|
<td className="num">{fmtCcy(otb.arr)}</td>
|
||||||
|
<td className="num">{fmtCcy(pickup.avg_rate)}</td>
|
||||||
|
<td className="num df-bold">{fmtCcy(forecast.arr)}</td></tr>
|
||||||
|
<tr><td className="df-row-label">Occupancy %</td>
|
||||||
|
<td className="num">{fmtPct(otb.occ_pct)}</td>
|
||||||
|
<td className="num">{pickup.rooms > 0 && forecast.available > 0 ? fmtPct((pickup.rooms / forecast.available) * 100) : '—'}</td>
|
||||||
|
<td className="num df-bold">{fmtPct(forecast.occ_pct)}</td></tr>
|
||||||
|
<tr><td className="df-row-label">REVPAR</td>
|
||||||
|
<td className="num">{fmtCcy(otb.revpar)}</td>
|
||||||
|
<td className="num">—</td>
|
||||||
|
<td className="num df-bold">{fmtCcy(forecast.revpar)}</td></tr>
|
||||||
|
<tr><td className="df-row-label">Available Rooms</td>
|
||||||
|
<td className="num">{forecast.available.toLocaleString()}</td>
|
||||||
|
<td className="num">{(forecast.available - otb.rooms).toLocaleString()}</td>
|
||||||
|
<td className="num">{forecast.available.toLocaleString()}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekly Breakdown */}
|
||||||
|
<div className="df-weekly-grid">
|
||||||
|
{(['accomm', 'dry', 'wet'] as const).map(cat => (
|
||||||
|
<div key={cat} className="df-card">
|
||||||
|
<div className="df-card-title">{cat === 'accomm' ? 'Accommodation' : cat === 'dry' ? 'Dry (Food)' : 'Wet (Beverage)'}</div>
|
||||||
|
<table className="df-report-table">
|
||||||
|
<tbody>
|
||||||
|
{weekly.map(w => (
|
||||||
|
<tr key={w.label}>
|
||||||
|
<td className="df-row-label">{w.label}</td>
|
||||||
|
<td className="num">{fmtCcy(w[cat])}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="df-total-row">
|
||||||
|
<td className="df-row-label">Total</td>
|
||||||
|
<td className="num">{fmtCcy(weekly.reduce((s, w) => s + w[cat], 0))}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<Tab>('worksheet')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="df-root">
|
||||||
|
<div className="df-header">
|
||||||
|
<MonthNav year={year} month={month} onChange={(y, m) => { setYear(y); setMonth(m) }} />
|
||||||
|
<div className="df-tabs">
|
||||||
|
<button className={`df-tab${tab === 'worksheet' ? ' active' : ''}`} onClick={() => setTab('worksheet')}>
|
||||||
|
Worksheet
|
||||||
|
</button>
|
||||||
|
<button className={`df-tab${tab === 'report' ? ' active' : ''}`} onClick={() => setTab('report')}>
|
||||||
|
Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'worksheet'
|
||||||
|
? <WorksheetTab key={`ws-${year}-${month}`} year={year} month={month} />
|
||||||
|
: <ReportTab key={`rp-${year}-${month}`} year={year} month={month} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -2,11 +2,23 @@ import { useEffect, useState, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
BarChart2, Building2, UtensilsCrossed, ShoppingCart, Database,
|
BarChart2, Building2, UtensilsCrossed, ShoppingCart, Database,
|
||||||
ChevronRight, ChevronDown, Play, Download, Loader2, AlertCircle,
|
ChevronRight, ChevronDown, Play, Download, Loader2, AlertCircle,
|
||||||
FileBarChart,
|
FileBarChart, TrendingUp,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useAuth } from '../components/AuthGate'
|
import { useAuth } from '../components/AuthGate'
|
||||||
import { fetchReports, runReport } from '../api'
|
import { fetchReports, runReport } from '../api'
|
||||||
import type { ReportMeta, ReportResult } from '../types'
|
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 ───────────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -99,6 +111,7 @@ function Sidebar({ tree, selectedId, onSelect }: SidebarProps) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set(tree.map(t => t.category)))
|
const [openCats, setOpenCats] = useState<Set<string>>(new Set(tree.map(t => t.category)))
|
||||||
const [openSubs, setOpenSubs] = useState<Set<string>>(new Set())
|
const [openSubs, setOpenSubs] = useState<Set<string>>(new Set())
|
||||||
|
const [directorsOpen, setDirectorsOpen] = useState(true)
|
||||||
|
|
||||||
const toggleCat = (key: string) =>
|
const toggleCat = (key: string) =>
|
||||||
setOpenCats(prev => { const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n })
|
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) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="sidebar-nav" style={{ padding: 0 }}>
|
<nav className="sidebar-nav" style={{ padding: 0 }}>
|
||||||
|
{/* Hard-coded Directors Reports section */}
|
||||||
|
<div className="report-category">
|
||||||
|
<button className="report-cat-header" onClick={() => setDirectorsOpen(o => !o)}>
|
||||||
|
<span className="report-cat-icon"><TrendingUp size={14} strokeWidth={1.75} /></span>
|
||||||
|
<span className="report-cat-label">Directors Reports</span>
|
||||||
|
{directorsOpen
|
||||||
|
? <ChevronDown size={12} strokeWidth={2} />
|
||||||
|
: <ChevronRight size={12} strokeWidth={2} />}
|
||||||
|
</button>
|
||||||
|
{directorsOpen && (
|
||||||
|
<div className="report-cat-body">
|
||||||
|
{DIRECTORS_REPORTS.map(r => (
|
||||||
|
<button
|
||||||
|
key={r.id}
|
||||||
|
className={`report-item${selectedId === r.id ? ' active' : ''}`}
|
||||||
|
onClick={() => onSelect(r)}
|
||||||
|
>
|
||||||
|
{r.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{tree.map(cat => {
|
{tree.map(cat => {
|
||||||
const isOpen = openCats.has(cat.category)
|
const isOpen = openCats.has(cat.category)
|
||||||
return (
|
return (
|
||||||
|
|
@ -267,6 +304,8 @@ export default function ReportsPage() {
|
||||||
<h2>Custom Reports</h2>
|
<h2>Custom Reports</h2>
|
||||||
<p>Select a report from the sidebar to get started.</p>
|
<p>Select a report from the sidebar to get started.</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : selected.category === 'directors' ? (
|
||||||
|
<DirectorsForecast />
|
||||||
) : (
|
) : (
|
||||||
<div className="report-view">
|
<div className="report-view">
|
||||||
<div className="report-header">
|
<div className="report-header">
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,47 @@ export interface ReportResult {
|
||||||
rows: Record<string, unknown>[]
|
rows: Record<string, unknown>[]
|
||||||
summary?: { label: string; value: string }[]
|
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[]
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue