commit 4abae5eda19e4e239b6694823a6a5fd3f473df1e Author: jtricerolph Date: Thu Jul 2 09:24:14 2026 +0000 Initial commit: HK Planner app Housekeeping workload and hours planning app — port of the WP hotel housekeeping hours calculator plugin. 7-day occupancy planner with Newbook PMS integration, staff rota, time requirements, and pickup tracking. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bb617d0 --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# PostgreSQL connection string for the hk-planner DB +DATABASE_URL=postgres://user:pass@host:5432/hk_planner + +# Shared secret used to verify hnf_session JWTs (same as auth service) +CENTRAL_AUTH_SECRET=change-me + +# Settings service (for Newbook credentials) +SETTINGS_URL=http://settings-backend:3001 +SETTINGS_SECRET=change-me + +# Hotel name shown on the login page +VITE_HOTEL_NAME=Hotel Name + +# Host port to expose the frontend on (default 3080) +FRONTEND_PORT=3080 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..35a6156 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package.json . +RUN npm install --omit=dev +COPY src ./src +EXPOSE 3001 +CMD ["node", "src/index.js"] diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..7e404b3 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,16 @@ +{ + "name": "hnf-hk-planner-backend", + "version": "1.0.0", + "type": "module", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "@fastify/cookie": "^9.4.0", + "@fastify/cors": "^9.0.1", + "fastify": "^4.28.1", + "jose": "^5.9.6", + "pg": "^8.13.1" + } +} diff --git a/backend/src/auth.js b/backend/src/auth.js new file mode 100644 index 0000000..37ef96c --- /dev/null +++ b/backend/src/auth.js @@ -0,0 +1,35 @@ +import { jwtVerify } from 'jose' +import { isOnsite } from './ip-check.js' + +const APP_SLUG = process.env.APP_SLUG || 'hk-planner' +const secret = new TextEncoder().encode(process.env.CENTRAL_AUTH_SECRET || '') + +export async function requireAuth(request, reply) { + const token = request.cookies?.hnf_session + if (!token) return reply.status(401).send({ error: 'Not authenticated' }) + + let payload + try { + const { payload: p } = await jwtVerify(token, secret) + payload = p + } catch { + return reply.status(401).send({ error: 'Invalid session' }) + } + + if (!payload.apps?.includes(APP_SLUG)) { + return reply.status(403).send({ error: 'No permission for this app' }) + } + + if (!payload.offsite_allowed) { + const clientIP = request.headers['x-real-ip'] || request.ip + if (!(await isOnsite(clientIP))) { + return reply.status(403).send({ error: 'Access restricted to site network' }) + } + } + + request.user = { + email: payload.sub, + name: payload.name, + is_admin: payload.is_admin ?? false, + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..1d096ca --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,26 @@ +import pg from 'pg' + +const { Pool } = pg +export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +export async function initDb() { + await pool.query(` + CREATE TABLE IF NOT EXISTS hk_config ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL DEFAULT 'null'::jsonb + ) + `) +} + +export async function getConfig(key, defaultVal = null) { + const { rows } = await pool.query('SELECT value FROM hk_config WHERE key = $1', [key]) + return rows.length ? rows[0].value : defaultVal +} + +export async function setConfig(key, value) { + await pool.query( + `INSERT INTO hk_config (key, value) VALUES ($1, $2::jsonb) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + [key, JSON.stringify(value)] + ) +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..1645b5d --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,24 @@ +import Fastify from 'fastify' +import cookie from '@fastify/cookie' +import cors from '@fastify/cors' +import { initDb } from './db.js' +import { bookingRoutes } from './routes/bookings.js' +import { configRoutes } from './routes/config.js' + +const app = Fastify({ logger: true, trustProxy: true }) + +await app.register(cookie) +await app.register(cors, { origin: process.env.CORS_ORIGIN || false, credentials: true }) + +app.get('/health', async () => ({ status: 'healthy' })) + +await app.register(bookingRoutes) +await app.register(configRoutes) + +try { + await initDb() + await app.listen({ port: 3001, host: '0.0.0.0' }) +} catch (err) { + app.log.error(err) + process.exit(1) +} diff --git a/backend/src/ip-check.js b/backend/src/ip-check.js new file mode 100644 index 0000000..4d8cb19 --- /dev/null +++ b/backend/src/ip-check.js @@ -0,0 +1,80 @@ +import dns from 'dns/promises' + +const raw = (process.env.OFFICE_IP_CHECK || 'disabled').trim() +const matchers = raw.split(',').map(s => s.trim()).filter(Boolean) + +const TTL = 5 * 60 * 1000 +const cache = new Map() + +const PUBLIC_IP_URLS = [ + 'https://api.ipify.org', + 'https://ifconfig.co/ip', + 'https://icanhazip.com', +] + +function normalizeIP(ip) { + return ip?.startsWith('::ffff:') ? ip.slice(7) : ip +} + +function isIPv4(s) { + return /^\d{1,3}(\.\d{1,3}){3}$/.test(s) +} + +function ipInCidr(ip, cidr) { + const [range, bits] = cidr.split('/') + if (!isIPv4(ip) || !isIPv4(range)) return false + const mask = ~(2 ** (32 - parseInt(bits)) - 1) >>> 0 + const toInt = s => s.split('.').reduce((a, o) => (a << 8) + parseInt(o), 0) >>> 0 + return (toInt(ip) & mask) === (toInt(range) & mask) +} + +async function fetchPublicIP() { + for (const url of PUBLIC_IP_URLS) { + try { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 4000) + const res = await fetch(url, { signal: ctrl.signal }) + clearTimeout(timer) + if (!res.ok) continue + const ip = (await res.text()).trim() + if (isIPv4(ip)) return ip + } catch { + // try next + } + } + return null +} + +async function resolveDynamic(key, resolver) { + const hit = cache.get(key) + if (hit && Date.now() < hit.expiry) return hit.ip + const ip = await resolver() + if (ip) { + cache.set(key, { ip, expiry: Date.now() + TTL }) + return ip + } + return hit ? hit.ip : null +} + +export async function isOnsite(requestIP) { + if (matchers.length === 0 || matchers.includes('disabled')) return true + const ip = normalizeIP(requestIP) + if (!ip) return false + + for (const m of matchers) { + if (m === 'auto') { + const pub = await resolveDynamic('auto', fetchPublicIP) + if (pub && ip === pub) return true + } else if (m.includes('/')) { + if (ipInCidr(ip, m)) return true + } else if (/[a-zA-Z]/.test(m)) { + const resolved = await resolveDynamic(m, async () => { + try { return (await dns.resolve4(m))[0] } catch { return null } + }) + if (resolved && ip === resolved) return true + } else { + if (ip === m) return true + } + } + return false +} diff --git a/backend/src/lib/newbook.js b/backend/src/lib/newbook.js new file mode 100644 index 0000000..0273a45 --- /dev/null +++ b/backend/src/lib/newbook.js @@ -0,0 +1,355 @@ +const API_BASE = 'https://api.newbook.cloud/rest/' + +// In-process bookings cache: key → { data, expiry } +const bookingsCache = new Map() + +// ── Date helpers ─────────────────────────────────────────────────────────── + +export function todayStr() { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +export function offsetDate(dateStr, days) { + const [y, m, d] = dateStr.split('-').map(Number) + const dt = new Date(y, m - 1, d) + dt.setDate(dt.getDate() + days) + return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}` +} + +function dateToMs(dateStr) { + const [y, m, d] = dateStr.split('-').map(Number) + return new Date(y, m - 1, d).getTime() +} + +// ── API client ───────────────────────────────────────────────────────────── + +async function getCredentials() { + const url = `${process.env.SETTINGS_URL}/settings/api/internal/integration/newbook` + const res = await fetch(url, { + headers: { Authorization: `Bearer ${process.env.SETTINGS_SECRET}` }, + signal: AbortSignal.timeout(5000), + }) + if (!res.ok) throw new Error(`Settings service returned ${res.status}`) + const s = await res.json() + return { + username: s.username || '', + password: s.password || '', + apiKey: s.api_key || '', + region: s.region || 'eu', + } +} + +async function callApi(endpoint, data = {}) { + const creds = await getCredentials() + if (!creds.username || !creds.password || !creds.apiKey) { + throw new Error('Newbook API credentials not configured') + } + + const body = { ...data, region: creds.region, api_key: creds.apiKey } + const auth = Buffer.from(`${creds.username}:${creds.password}`).toString('base64') + + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), 30000) + + try { + const res = await fetch(API_BASE + endpoint, { + method: 'POST', + signal: ctrl.signal, + headers: { 'Content-Type': 'application/json', Authorization: `Basic ${auth}` }, + body: JSON.stringify(body), + }) + clearTimeout(timer) + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Newbook API ${res.status}: ${text.slice(0, 200)}`) + } + return await res.json() + } catch (err) { + clearTimeout(timer) + throw err + } +} + +export async function fetchBookingsRange(startDate, endDate) { + return callApi('bookings_list', { + period_from: startDate + ' 00:00:00', + period_to: endDate + ' 23:59:59', + list_type: 'staying', + data_offset: 0, + data_limit: 2000, + }) +} + +export async function fetchBookingsDelta(startDate, endDate) { + return callApi('bookings_list', { + period_from: startDate + ' 00:00:00', + period_to: endDate + ' 23:59:59', + list_type: 'all', + data_offset: 0, + data_limit: 2000, + }) +} + +export async function fetchSitesList() { + return callApi('sites_list', {}) +} + +export async function testConnection() { + try { + const resp = await fetchSitesList() + if (resp.data) { + return { ok: true, message: `Connected. Found ${resp.data.length} site(s).` } + } + return { ok: false, error: resp.error || 'No data returned' } + } catch (err) { + return { ok: false, error: err.message } + } +} + +// ── Booking data algorithm (port of PHP get_bookings_data) ───────────────── + +const ACTIVE_STATUSES = ['Confirmed', 'Unconfirmed', 'Arrived', 'Departed'] +const CANCELLED_STATUSES = ['Cancelled', 'No Show'] + +export async function getBookingsData(weekStart, lastViewed, forceRefresh, getConfig) { + const cacheKey = `bookings_${weekStart}_${lastViewed}` + + if (!forceRefresh) { + const cached = bookingsCache.get(cacheKey) + if (cached && Date.now() < cached.expiry) return cached.data + } + + const today = todayStr() + const todayMs = dateToMs(today) + const fetchFrom = offsetDate(weekStart, -8) + const endDate = offsetDate(weekStart, 6) + const lastViewedMs = dateToMs(lastViewed) + + const [bookingsResp, deltaResp, sitesResp] = await Promise.all([ + fetchBookingsRange(fetchFrom, endDate), + fetchBookingsDelta(lastViewed, endDate), + fetchSitesList(), + ]) + + if (!bookingsResp.data) throw new Error(bookingsResp.error || 'No booking data returned') + + const sites = sitesResp.data || [] + + // Build category_map and site → catKey index + const categoryMap = {} // catKey → {name, total_rooms} + const siteToKey = {} // siteId → catKey + + for (const site of sites) { + const siteId = site.site_id || '' + const catName = (site.category_name || 'Unknown').trim() + const catKey = catName.toLowerCase() + if (siteId) siteToKey[siteId] = catKey + if (!categoryMap[catKey]) categoryMap[catKey] = { name: catName, total_rooms: 0 } + categoryMap[catKey].total_rooms++ + } + + // Build 7-day date window + const dates = [] + for (let i = 0; i < 7; i++) dates.push(offsetDate(weekStart, i)) + + // day_data[date][catKey] = {departs, stays, arrivals, rooms:{}} + const dayData = {} + for (const d of dates) dayData[d] = {} + + // delta_data[date][catKey] = {new:0, cancelled:0} + const deltaData = {} + for (const d of dates) deltaData[d] = {} + + // prior_arrivals[catKey][dow] = [{arrival_date, placed_date}] + const priorArrivals = {} + + // prior_occ[catKey][date] = {rooms:{siteId:true}} + const priorOcc = {} + + // ── Process main bookings ────────────────────────────────────────────── + + for (const booking of bookingsResp.data) { + const siteId = booking.site_id || '' + if (!siteId) continue + + let catName + if (booking.category_name) { + catName = booking.category_name.trim() + } else if (siteToKey[siteId]) { + catName = categoryMap[siteToKey[siteId]].name + } else { + catName = 'Unknown' + } + const catKey = catName.toLowerCase() + if (!categoryMap[catKey]) categoryMap[catKey] = { name: catName, total_rooms: 0 } + + const arrivalDate = (booking.booking_arrival || '').slice(0, 10) + const departureDate = (booking.booking_departure || '').slice(0, 10) + if (!arrivalDate || !departureDate) continue + + const arrivalMs = dateToMs(arrivalDate) + const departureMs = dateToMs(departureDate) + + // This-week occupancy + for (const date of dates) { + const isArriving = arrivalDate === date + const isDeparting = departureDate === date + const isStaying = arrivalDate < date && departureDate > date + + if (!isArriving && !isDeparting && !isStaying) continue + + if (!dayData[date][catKey]) { + dayData[date][catKey] = { departs: 0, stays: 0, arrivals: 0, rooms: {} } + } + if (isDeparting) dayData[date][catKey].departs++ + if (isStaying) dayData[date][catKey].stays++ + if (isArriving) dayData[date][catKey].arrivals++ + dayData[date][catKey].rooms[siteId] = true + } + + // Skip future arrivals for prior-week data + if (arrivalMs >= todayMs) continue + + // Prior-week occupancy (7 days before each display date) + for (let i = 0; i < 7; i++) { + const priorDate = offsetDate(today, -(7 - i)) + const priorDateMs = dateToMs(priorDate) + if (arrivalMs <= priorDateMs && departureMs > priorDateMs) { + if (!priorOcc[catKey]) priorOcc[catKey] = {} + if (!priorOcc[catKey][priorDate]) priorOcc[catKey][priorDate] = { rooms: {} } + priorOcc[catKey][priorDate].rooms[siteId] = true + } + } + + // Prior-week arrival pickup hints (keyed by weekday) + const placedDate = (booking.booking_placed || '').slice(0, 10) + if (!placedDate) continue + + const dow = new Date(arrivalDate + 'T00:00:00').getDay() // 0=Sun…6=Sat + if (!priorArrivals[catKey]) priorArrivals[catKey] = {} + if (!priorArrivals[catKey][dow]) priorArrivals[catKey][dow] = [] + priorArrivals[catKey][dow].push({ arrival_date: arrivalDate, placed_date: placedDate }) + } + + // ── Process delta bookings ───────────────────────────────────────────── + + const deltaBookings = deltaResp.data || [] + + for (const booking of deltaBookings) { + const siteId = booking.site_id || '' + if (!siteId) continue + + const catName = (booking.category_name || 'Unknown').trim() + const catKey = catName.toLowerCase() + + const arrivalDate = (booking.booking_arrival || '').slice(0, 10) + const departureDate = (booking.booking_departure || '').slice(0, 10) + if (!arrivalDate || !departureDate) continue + + const status = (booking.booking_status || '').trim() + const isActive = ACTIVE_STATUSES.includes(status) + const isCancelled = CANCELLED_STATUSES.includes(status) + + const placedDateStr = (booking.booking_placed || '').slice(0, 10) + const cancelledDateStr = (booking.booking_cancelled || '').slice(0, 10) + const placedMs = placedDateStr ? dateToMs(placedDateStr) : 0 + const cancelledMs = cancelledDateStr ? dateToMs(cancelledDateStr) : 0 + + const isNewSince = isActive && placedMs && placedMs >= lastViewedMs + const isCancelledSince = isCancelled && cancelledMs && cancelledMs >= lastViewedMs + + if (!isNewSince && !isCancelledSince) continue + + for (const date of dates) { + const isArriving = arrivalDate === date + const isStaying = arrivalDate < date && departureDate > date + if (!isArriving && !isStaying) continue + + if (!deltaData[date][catKey]) deltaData[date][catKey] = { new: 0, cancelled: 0 } + if (isNewSince) deltaData[date][catKey].new++ + else if (isCancelledSince) deltaData[date][catKey].cancelled++ + } + } + + // ── Apply saved category order ───────────────────────────────────────── + + const savedOrder = (await getConfig('category_order', [])) || [] + const excluded = (await getConfig('excluded_categories', [])) || [] + + const orderedKeys = [] + for (const key of savedOrder) { + if (categoryMap[key]) orderedKeys.push(key) + } + for (const key of Object.keys(categoryMap)) { + if (!orderedKeys.includes(key)) orderedKeys.push(key) + } + + // ── Build output ─────────────────────────────────────────────────────── + + const categoriesOut = [] + + for (const catKey of orderedKeys) { + if (!categoryMap[catKey]) continue + if (excluded.includes(catKey)) continue + + const catDays = {} + + for (let di = 0; di < dates.length; di++) { + const date = dates[di] + const dateMs = dateToMs(date) + const leadDays = Math.round((dateMs - todayMs) / 86400000) + const dow = new Date(date + 'T00:00:00').getDay() + + // Pickup hint — how many last-week same-weekday arrivals were booked within leadDays + let hintCount = 0 + if (priorArrivals[catKey]?.[dow]) { + for (const pa of priorArrivals[catKey][dow]) { + const arrMs = dateToMs(pa.arrival_date) + const plMs = dateToMs(pa.placed_date) + const daysBefore = Math.round((arrMs - plMs) / 86400000) + if (daysBefore <= leadDays) hintCount++ + } + } + + const priorDate = offsetDate(date, -7) + const priorOccCount = priorOcc[catKey]?.[priorDate] + ? Object.keys(priorOcc[catKey][priorDate].rooms).length + : 0 + const totalRooms = categoryMap[catKey].total_rooms + const priorVacCount = Math.max(0, totalRooms - priorOccCount) + + const delta = deltaData[date]?.[catKey] || {} + const dd = dayData[date]?.[catKey] + + catDays[date] = dd ? { + total_servicing: Object.keys(dd.rooms).length, + departs: dd.departs, + stays: dd.stays, + arrivals: dd.arrivals, + pickup_hint: hintCount, + pickup_lead: leadDays, + prior_occ: priorOccCount, + prior_vac: priorVacCount, + delta_new: delta.new || 0, + delta_cancelled: delta.cancelled || 0, + } : { + total_servicing: 0, departs: 0, stays: 0, arrivals: 0, + pickup_hint: hintCount, pickup_lead: leadDays, + prior_occ: priorOccCount, prior_vac: priorVacCount, + delta_new: delta.new || 0, delta_cancelled: delta.cancelled || 0, + } + } + + categoriesOut.push({ + id: catKey, + name: categoryMap[catKey].name, + total_rooms: categoryMap[catKey].total_rooms, + days: catDays, + }) + } + + const result = { dates, categories: categoriesOut, last_viewed: lastViewed } + bookingsCache.set(cacheKey, { data: result, expiry: Date.now() + 5 * 60 * 1000 }) + return result +} diff --git a/backend/src/routes/bookings.js b/backend/src/routes/bookings.js new file mode 100644 index 0000000..67f7e88 --- /dev/null +++ b/backend/src/routes/bookings.js @@ -0,0 +1,26 @@ +import { requireAuth } from '../auth.js' +import { getConfig } from '../db.js' +import { getBookingsData, todayStr, offsetDate } from '../lib/newbook.js' + +export async function bookingRoutes(app) { + app.addHook('preHandler', requireAuth) + + app.get('/api/bookings', async (req, reply) => { + const today = todayStr() + + let weekStart = req.query.week_start || today + if (!/^\d{4}-\d{2}-\d{2}$/.test(weekStart)) weekStart = today + + let lastViewed = req.query.last_viewed || offsetDate(today, -1) + if (!/^\d{4}-\d{2}-\d{2}$/.test(lastViewed)) lastViewed = offsetDate(today, -1) + + const force = req.query.force_refresh === '1' + + try { + return await getBookingsData(weekStart, lastViewed, force, getConfig) + } catch (err) { + app.log.error(err) + return reply.status(500).send({ error: err.message }) + } + }) +} diff --git a/backend/src/routes/config.js b/backend/src/routes/config.js new file mode 100644 index 0000000..7d4a310 --- /dev/null +++ b/backend/src/routes/config.js @@ -0,0 +1,188 @@ +import { requireAuth } from '../auth.js' +import { getConfig, setConfig } from '../db.js' +import { fetchSitesList, testConnection } from '../lib/newbook.js' + +export async function configRoutes(app) { + app.addHook('preHandler', requireAuth) + + // ── GET /api/config — all settings at once ────────────────────────────── + + app.get('/api/config', async (req) => { + const [timeReqs, staffData, pickupData, generalTasks, lastReviewed, toleranceMins] = + await Promise.all([ + getConfig('time_requirements', {}), + getConfig('staff_data', []), + getConfig('pickup_data', {}), + getConfig('general_tasks', []), + getConfig('last_reviewed', null), + getConfig('tolerance_minutes', 30), + ]) + + const today = new Date() + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + const yestStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, '0')}-${String(yesterday.getDate()).padStart(2, '0')}` + + return { + time_requirements: timeReqs || {}, + staff_data: staffData || [], + pickup_data: pickupData || {}, + general_tasks: generalTasks || [], + last_reviewed: lastReviewed || yestStr, + tolerance_minutes: toleranceMins != null ? toleranceMins : 30, + } + }) + + // ── PUT /api/config/time-requirements ──────────────────────────────────── + + app.put('/api/config/time-requirements', async (req, reply) => { + const { cat, action, value } = req.body || {} + if (!cat || !['depart', 'stay', 'arrive'].includes(action)) { + return reply.status(400).send({ error: 'Invalid data' }) + } + + const stored = (await getConfig('time_requirements', {})) || {} + if (!stored[cat]) stored[cat] = { depart: 0, stay: 0, arrive: 0 } + stored[cat][action] = Math.max(0, parseInt(value, 10) || 0) + await setConfig('time_requirements', stored) + return { ok: true } + }) + + // ── PUT /api/config/staff ───────────────────────────────────────────────── + + app.put('/api/config/staff', async (req, reply) => { + const { staff_data } = req.body || {} + if (!Array.isArray(staff_data)) return reply.status(400).send({ error: 'Invalid data' }) + + const clean = staff_data + .filter(m => m && typeof m.name === 'string' && m.name.trim()) + .map(m => ({ + name: m.name.trim(), + hours: Object.fromEntries( + Object.entries(m.hours || {}).map(([k, v]) => [k, Math.max(0, parseFloat(v) || 0)]) + ), + })) + + await setConfig('staff_data', clean) + return { ok: true } + }) + + // ── PUT /api/config/pickup ──────────────────────────────────────────────── + + app.put('/api/config/pickup', async (req, reply) => { + const { pickup_data } = req.body || {} + if (!pickup_data || typeof pickup_data !== 'object') { + return reply.status(400).send({ error: 'Invalid data' }) + } + + const clean = {} + for (const [catId, dates] of Object.entries(pickup_data)) { + if (typeof dates !== 'object') continue + clean[catId] = {} + for (const [date, vals] of Object.entries(dates)) { + clean[catId][date] = { + count: Math.max(0, parseInt(vals?.count, 10) || 0), + total: Math.max(0, parseInt(vals?.total, 10) || 0), + } + } + } + + await setConfig('pickup_data', clean) + return { ok: true } + }) + + // ── PUT /api/config/general-tasks ──────────────────────────────────────── + + app.put('/api/config/general-tasks', async (req, reply) => { + const { general_tasks } = req.body || {} + if (!Array.isArray(general_tasks)) return reply.status(400).send({ error: 'Invalid data' }) + + const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + const clean = general_tasks + .filter(t => t && typeof t.name === 'string' && t.name.trim()) + .map(t => ({ + name: t.name.trim(), + hours: Object.fromEntries(DAYS.map(day => [day, Math.max(0, parseInt(t.hours?.[day], 10) || 0)])), + })) + + await setConfig('general_tasks', clean) + return { ok: true } + }) + + // ── PUT /api/config/last-reviewed ──────────────────────────────────────── + + app.put('/api/config/last-reviewed', async (req, reply) => { + const { date } = req.body || {} + if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { + return reply.status(400).send({ error: 'Invalid date' }) + } + await setConfig('last_reviewed', date) + return { ok: true, date } + }) + + // ── GET /api/categories — admin: fetch from Newbook + saved order ───────── + + app.get('/api/categories', async (req, reply) => { + if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' }) + + let sites + try { + const resp = await fetchSitesList() + if (!resp.data) throw new Error(resp.error || 'No data') + sites = resp.data + } catch (err) { + return reply.status(502).send({ error: err.message }) + } + + const cats = {} + for (const site of sites) { + const catName = (site.category_name || 'Unknown').trim() + const catKey = catName.toLowerCase() + if (!cats[catKey]) cats[catKey] = { id: catKey, name: catName, room_count: 0 } + cats[catKey].room_count++ + } + + const savedOrder = (await getConfig('category_order', [])) || [] + const excluded = (await getConfig('excluded_categories', [])) || [] + + const ordered = [] + for (const key of savedOrder) { + if (cats[key]) { + ordered.push({ ...cats[key], excluded: excluded.includes(key) }) + delete cats[key] + } + } + for (const [key, cat] of Object.entries(cats)) { + ordered.push({ ...cat, excluded: excluded.includes(key) }) + } + + return { categories: ordered } + }) + + // ── PUT /api/categories — admin: save order + exclusions ───────────────── + + app.put('/api/categories', async (req, reply) => { + if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' }) + + const { order, excluded } = req.body || {} + if (!Array.isArray(order)) return reply.status(400).send({ error: 'Invalid data' }) + + const cleanOrder = order.filter(k => typeof k === 'string') + const cleanExcl = Array.isArray(excluded) ? excluded.filter(k => typeof k === 'string') : [] + + await Promise.all([ + setConfig('category_order', cleanOrder), + setConfig('excluded_categories', cleanExcl), + ]) + + return { ok: true } + }) + + // ── POST /api/newbook/test — admin ──────────────────────────────────────── + + app.post('/api/newbook/test', async (req, reply) => { + if (!req.user.is_admin) return reply.status(403).send({ error: 'Admin only' }) + return testConnection() + }) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b2837cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + hk-planner-backend: + build: ./backend + container_name: hk-planner-backend + restart: unless-stopped + environment: + DATABASE_URL: ${DATABASE_URL} + CENTRAL_AUTH_SECRET: ${CENTRAL_AUTH_SECRET} + SETTINGS_URL: ${SETTINGS_URL} + SETTINGS_SECRET: ${SETTINGS_SECRET} + APP_SLUG: hk-planner + networks: + - hnf_net + + hk-planner-frontend: + build: + context: ./frontend + args: + VITE_HOTEL_NAME: ${VITE_HOTEL_NAME} + container_name: hk-planner-frontend + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-3080}:80" + depends_on: + - hk-planner-backend + networks: + - hnf_net + +networks: + hnf_net: + external: true diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..2fdd848 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine AS build +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +ARG VITE_HOTEL_NAME="Number Four at Stow" +ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html/hk-planner +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..6edafe8 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + HK Planner + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..0731954 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,37 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + + location = /hk-planner/manifest.json { + add_header Cache-Control "no-cache"; + try_files $uri =404; + } + + location /hk-planner/api/ { + proxy_pass http://backend:3001/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + add_header Cache-Control "no-store"; + } + + location /hk-planner/health { + proxy_pass http://backend:3001/health; + } + + location ~* /hk-planner/.*\.(js|css|png|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location /hk-planner/ { + add_header Cache-Control "no-cache" always; + try_files $uri $uri/ /hk-planner/index.html; + } + + location = / { + return 301 /hk-planner/; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7b418a1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "hnf-hk-planner-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^1.23.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0" + }, + "devDependencies": { + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..1e7000c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,29 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { AuthGate } from './components/AuthGate' +import { Layout } from './components/Layout' +import { Planner } from './pages/Planner' +import { CategorySettings } from './pages/CategorySettings' +import type { User } from './types' + +function AppRoutes({ user }: { user: User }) { + return ( + + + } /> + } /> + : } /> + } /> + + + ) +} + +export default function App() { + return ( + + + {user => } + + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..c078b32 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,96 @@ +import type { BookingsData, StaffMember, GeneralTask, PickupData, TimeReqs } from './types' + +const BASE = '/hk-planner/api' + +export interface ConfigData { + time_requirements: TimeReqs + staff_data: StaffMember[] + pickup_data: PickupData + general_tasks: GeneralTask[] + last_reviewed: string + tolerance_minutes: number +} + +export interface CategoryConfig { + id: string + name: string + room_count: number + excluded: boolean +} + +async function request(path: string, opts?: RequestInit): Promise { + const res = await fetch(BASE + path, { credentials: 'include', ...opts }) + if (!res.ok) { + const body = await res.json().catch(() => ({})) as { error?: string } + throw new Error(body.error || `HTTP ${res.status}`) + } + return res.json() as Promise +} + +export function getBookings(weekStart?: string, lastViewed?: string, forceRefresh = false): Promise { + const p = new URLSearchParams() + if (weekStart) p.set('week_start', weekStart) + if (lastViewed) p.set('last_viewed', lastViewed) + if (forceRefresh) p.set('force_refresh', '1') + return request(`/bookings?${p}`) +} + +export function getConfig(): Promise { + return request('/config') +} + +export function putTimeReq(cat: string, action: string, value: number): Promise<{ ok: boolean }> { + return request('/config/time-requirements', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cat, action, value }), + }) +} + +export function putStaff(staff_data: StaffMember[]): Promise<{ ok: boolean }> { + return request('/config/staff', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ staff_data }), + }) +} + +export function putPickup(pickup_data: PickupData): Promise<{ ok: boolean }> { + return request('/config/pickup', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pickup_data }), + }) +} + +export function putGeneralTasks(general_tasks: GeneralTask[]): Promise<{ ok: boolean }> { + return request('/config/general-tasks', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ general_tasks }), + }) +} + +export function putLastReviewed(date: string): Promise<{ ok: boolean }> { + return request('/config/last-reviewed', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ date }), + }) +} + +export function getCategories(): Promise<{ categories: CategoryConfig[] }> { + return request('/categories') +} + +export function putCategories(order: string[], excluded: string[]): Promise<{ ok: boolean }> { + return request('/categories', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order, excluded }), + }) +} + +export function testNewbook(): Promise<{ ok: boolean; message?: string; error?: string }> { + return request('/newbook/test', { method: 'POST' }) +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..12d386a --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' +import type { User } from '../types' + +interface Props { + children: (user: User) => React.ReactNode +} + +const inputStyle: React.CSSProperties = { + background: 'var(--navy-dark)', border: '1px solid var(--surface-2)', + borderRadius: '6px', color: 'var(--text)', padding: '0.625rem 0.75rem', + fontSize: '1rem', width: '100%', outline: 'none', +} +const btnStyle: React.CSSProperties = { + background: 'var(--hk-green)', color: '#fff', border: 'none', + borderRadius: '6px', padding: '0.625rem', fontSize: '1rem', + fontWeight: 600, marginTop: '0.25rem', width: '100%', +} + +export function AuthGate({ children }: Props) { + const [state, setState] = useState<'checking' | 'authed' | 'login'>('checking') + const [user, setUser] = useState(null) + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + useEffect(() => { + fetch('/api/auth/verify?app=hk-planner', { credentials: 'include' }) + .then(async r => { + if (r.ok) { setUser(await r.json()); setState('authed') } + else setState('login') + }) + .catch(() => setState('login')) + }, []) + + async function login(e: React.FormEvent) { + e.preventDefault() + setLoading(true) + setError('') + try { + const res = await fetch('/api/auth/login', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (!res.ok) { setError('Invalid email or password'); return } + const verify = await fetch('/api/auth/verify?app=hk-planner', { credentials: 'include' }) + if (verify.ok) { setUser(await verify.json()); setState('authed') } + else setError("You don't have access to this app.") + } catch { + setError('Connection error — please try again') + } finally { + setLoading(false) + } + } + + if (state === 'checking') { + return ( +
+
Loading…
+
+ ) + } + + if (state === 'login') { + return ( +
+
+

+ HK Planner +

+

+ {import.meta.env.VITE_HOTEL_NAME} +

+
+ setEmail(e.target.value)} + placeholder="Email" required autoComplete="email" style={inputStyle} /> + setPassword(e.target.value)} + placeholder="Password" required autoComplete="current-password" style={inputStyle} /> + {error &&

{error}

} + +
+
+
+ ) + } + + return <>{children(user!)} +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..2becb73 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,69 @@ +import { NavLink } from 'react-router-dom' +import { CalendarClock, Settings, LogOut } from 'lucide-react' +import type { User } from '../types' + +interface Props { + user: User + children: React.ReactNode +} + +export function Layout({ user, children }: Props) { + async function logout() { + await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }) + window.location.href = '/' + } + + return ( +
+ + +
+ {children} +
+
+ ) +} + +function NavItem({ to, icon: Icon, label }: { to: string; icon: typeof CalendarClock; label: string }) { + return ( + ({ + display: 'flex', alignItems: 'center', gap: '0.625rem', + padding: '0.625rem 1rem', textDecoration: 'none', + color: isActive ? '#74c69d' : 'var(--text)', + background: isActive ? 'var(--surface)' : 'transparent', + borderLeft: isActive ? '2px solid #74c69d' : '2px solid transparent', + fontSize: '0.875rem', transition: 'background 0.15s', + })}> + + {label} + + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..ac856b2 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,155 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --navy: #1a1a2e; + --navy-dark: #0f0f20; + --gold: #c9a84c; + --gold-light: #e8c96d; + --surface: rgba(255,255,255,0.07); + --surface-2: rgba(255,255,255,0.08); + --text: rgba(255,255,255,0.88); + --text-muted: rgba(255,255,255,0.48); + + --body-bg: #f4f5f7; + --card-bg: #ffffff; + --card-border: #e4e8ee; + --text-dark: #1e293b; + --text-mid: #64748b; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04); + --shadow-md: 0 4px 12px rgba(0,0,0,0.08); + + --danger: #dc2626; + --success: #16a34a; + --warning: #d97706; + --hk-green: #2d6a4f; + --radius: 10px; + --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +body { + background: var(--body-bg); + color: var(--text-dark); + font-family: var(--font); + min-height: 100dvh; +} + +button { cursor: pointer; font-family: inherit; } +input, textarea, select { font-family: inherit; } + +/* Planning tables */ +.hk-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; + table-layout: fixed; +} +.hk-table th { + background: var(--navy); + color: var(--text); + padding: 0.45rem 0.5rem; + text-align: center; + font-weight: 600; + white-space: nowrap; +} +.hk-table th.col-label { + text-align: left; + width: 140px; +} +.hk-table td { + padding: 0.35rem 0.5rem; + border-bottom: 1px solid var(--card-border); + text-align: center; + vertical-align: middle; +} +.hk-table td.col-label { + text-align: left; + font-weight: 600; + color: var(--text-dark); + background: var(--card-bg); +} +.hk-table tfoot td { + font-weight: 600; + background: #f8fafc; +} + +/* Row accent colours for summary table */ +.row-depart { background: #fff8f0; } +.row-stay { background: #f0f7ff; } +.row-arrive { background: #f0fff5; } +.row-depart td, .row-stay td, .row-arrive td { border-bottom: none; } +.row-group-last td { border-bottom: 2px solid var(--card-border) !important; } + +/* Pickup tag */ +.pickup-tag { + color: var(--hk-green); + font-size: 0.75rem; + font-weight: 600; +} + +/* Delta badges */ +.delta-new { color: var(--success); font-size: 0.72rem; } +.delta-canc { color: var(--danger); font-size: 0.72rem; } + +/* Pickup +/- controls */ +.pickup-ctrl { + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + margin-top: 2px; +} +.pickup-btn { + background: none; + border: 1px solid var(--card-border); + border-radius: 3px; + padding: 0 5px; + line-height: 1.4; + font-size: 0.8rem; + color: var(--text-mid); +} +.pickup-btn:disabled { opacity: 0.35; cursor: not-allowed; } +.pickup-num { min-width: 16px; text-align: center; font-size: 0.8rem; font-weight: 600; } + +/* Diff cells */ +.diff-ok { color: var(--success); font-weight: 600; } +.diff-under { color: var(--danger); font-weight: 600; } +.diff-over { color: var(--warning); font-weight: 600; } + +/* Numeric inputs in tables */ +.hk-num-input { + width: 60px; + border: 1px solid var(--card-border); + border-radius: 4px; + padding: 0.3rem 0.4rem; + font-size: 0.82rem; + text-align: center; + background: var(--body-bg); + color: var(--text-dark); +} +.hk-num-input:focus { + outline: 2px solid var(--hk-green); + border-color: transparent; +} + +/* Text inputs in tables */ +.hk-text-input { + border: 1px solid var(--card-border); + border-radius: 4px; + padding: 0.3rem 0.5rem; + font-size: 0.82rem; + background: var(--body-bg); + color: var(--text-dark); + width: 100%; +} +.hk-text-input:focus { + outline: 2px solid var(--hk-green); + border-color: transparent; +} + +/* Section wrapper with horizontal scroll */ +.table-scroll { + overflow-x: auto; + border-radius: var(--radius); + border: 1px solid var(--card-border); + box-shadow: var(--shadow-sm); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..520b520 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/frontend/src/pages/CategorySettings.tsx b/frontend/src/pages/CategorySettings.tsx new file mode 100644 index 0000000..8a3efd1 --- /dev/null +++ b/frontend/src/pages/CategorySettings.tsx @@ -0,0 +1,162 @@ +import { useState, useEffect } from 'react' +import { GripVertical } from 'lucide-react' +import { getCategories, putCategories, testNewbook } from '../api' +import type { CategoryConfig } from '../api' + +export function CategorySettings() { + const [cats, setCats] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [error, setError] = useState('') + const [msg, setMsg] = useState('') + const [dragIdx, setDragIdx] = useState(null) + + useEffect(() => { + getCategories() + .then(r => { setCats(r.categories); setLoading(false) }) + .catch(e => { setError(e.message); setLoading(false) }) + }, []) + + function toggleExcluded(i: number) { + setCats(cats.map((c, idx) => idx === i ? { ...c, excluded: !c.excluded } : c)) + } + + function onDragStart(e: React.DragEvent, i: number) { + setDragIdx(i) + e.dataTransfer.effectAllowed = 'move' + } + + function onDragOver(e: React.DragEvent, i: number) { + e.preventDefault() + if (dragIdx === null || dragIdx === i) return + const next = [...cats] + const [moved] = next.splice(dragIdx, 1) + next.splice(i, 0, moved) + setCats(next) + setDragIdx(i) + } + + function onDragEnd() { setDragIdx(null) } + + async function save() { + setSaving(true) + setError('') + setMsg('') + try { + const order = cats.map(c => c.id) + const excluded = cats.filter(c => c.excluded).map(c => c.id) + await putCategories(order, excluded) + setMsg('Saved') + setTimeout(() => setMsg(''), 2500) + } catch (e) { + setError(e instanceof Error ? e.message : 'Save failed') + } finally { + setSaving(false) + } + } + + async function handleTest() { + setTesting(true) + setError('') + setMsg('') + try { + const res = await testNewbook() + setMsg(res.ok ? `Connection OK: ${res.message || ''}` : `Failed: ${res.error || 'unknown'}`) + } catch (e) { + setError(e instanceof Error ? e.message : 'Test failed') + } finally { + setTesting(false) + } + } + + if (loading) { + return
Loading categories…
+ } + + return ( +
+

+ Category Settings +

+

+ Drag to reorder. Toggle to exclude categories from the planner. +

+ + {error && ( +
+ {error} +
+ )} + {msg && ( +
+ {msg} +
+ )} + +
+ {cats.map((cat, i) => ( +
onDragStart(e, i)} + onDragOver={e => onDragOver(e, i)} + onDragEnd={onDragEnd} + style={{ + display: 'flex', alignItems: 'center', gap: '0.75rem', + padding: '0.625rem 0.875rem', + border: '1px solid var(--card-border)', borderRadius: '8px', + background: cat.excluded ? '#f8fafc' : 'var(--card-bg)', + opacity: dragIdx === i ? 0.5 : 1, + }} + > + + + {cat.name} + + + {cat.room_count} rooms + + +
+ ))} +
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/pages/Planner.tsx b/frontend/src/pages/Planner.tsx new file mode 100644 index 0000000..47b2168 --- /dev/null +++ b/frontend/src/pages/Planner.tsx @@ -0,0 +1,857 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { RefreshCw } from 'lucide-react' +import { + getBookings, getConfig, putTimeReq, putStaff, putPickup, + putGeneralTasks, putLastReviewed, +} from '../api' +import type { + BookingsData, TimeReqs, StaffMember, GeneralTask, + PickupData, RequiredDay, DayData, +} from '../types' + +// ── Date helpers ────────────────────────────────────────────────────────────── + +function todayStr() { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +function offsetDate(s: string, days: number) { + const [y, m, d] = s.split('-').map(Number) + const dt = new Date(y, m - 1, d) + dt.setDate(dt.getDate() + days) + return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}` +} + +function fmtDayHeader(date: string) { + const d = new Date(date + 'T00:00:00') + return ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][d.getDay()] + ' ' + d.getDate() + ' ' + + ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()] +} + +function getDayName(date: string) { + const d = new Date(date + 'T00:00:00') + return ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'][(d.getDay() + 6) % 7] +} + +function fmtDate(date: string) { + return new Date(date + 'T00:00:00').toLocaleDateString('en-GB', { + weekday: 'short', day: 'numeric', month: 'short', + }) +} + +function fmtH(n: number) { return n.toFixed(1) + 'h' } + +// ── Pickup calculation ──────────────────────────────────────────────────────── + +function getDisplayPickup( + pickup: PickupData, catId: string, date: string, occupiedNow: number, totalRooms: number, +): number { + const vacant = Math.max(0, totalRooms - occupiedNow) + const saved = pickup[catId]?.[date] + if (!saved || !saved.count) return 0 + const savedBooked = saved.total - saved.count + const count = occupiedNow > savedBooked + ? Math.max(0, saved.total - occupiedNow) + : saved.count + return Math.min(count, vacant) +} + +// ── Required hours calculation ──────────────────────────────────────────────── + +function calcRequired( + bookings: BookingsData, timeReqs: TimeReqs, pickup: PickupData, generalTasks: GeneralTask[], +): Record { + const result: Record = {} + + for (const date of bookings.dates) { + const dayName = getDayName(date) + const genHrs = generalTasks.reduce((s, t) => s + ((t.hours[dayName] || 0) / 60), 0) + result[date] = { booked: 0, pickup: 0, general: genHrs, total: genHrs, by_cat: {}, pickup_by_cat: {} } + } + + for (const cat of bookings.categories) { + const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 } + bookings.dates.forEach((date, di) => { + const d = cat.days[date] + const occupied = d.stays + d.arrivals + const pickupCount = getDisplayPickup(pickup, cat.id, date, occupied, cat.total_rooms) + + const bookedHrs = (d.departs * (req.depart || 0) + d.stays * (req.stay || 0) + d.arrivals * (req.arrive || 0)) / 60 + const pickupArrHrs = pickupCount * (req.arrive || 0) / 60 + + result[date].by_cat[cat.id] = bookedHrs + result[date].pickup_by_cat[cat.id] = (result[date].pickup_by_cat[cat.id] || 0) + pickupArrHrs + result[date].booked += bookedHrs + result[date].pickup += pickupArrHrs + result[date].total += bookedHrs + pickupArrHrs + + const nextDate = bookings.dates[di + 1] + if (nextDate && pickupCount > 0) { + const pickupDepHrs = pickupCount * (req.depart || 0) / 60 + result[nextDate].pickup_by_cat[cat.id] = (result[nextDate].pickup_by_cat[cat.id] || 0) + pickupDepHrs + result[nextDate].pickup += pickupDepHrs + result[nextDate].total += pickupDepHrs + } + }) + } + + return result +} + +// ── Main Planner component ──────────────────────────────────────────────────── + +export function Planner() { + const [bookings, setBookings] = useState(null) + const [timeReqs, setTimeReqs] = useState({}) + const [staff, setStaff] = useState([]) + const [pickup, setPickup] = useState({}) + const [generalTasks, setGeneralTasks] = useState([]) + const [tolerance, setTolerance] = useState(30) + const [weekStart, setWeekStart] = useState(null) + const [lastViewed, setLastViewed] = useState('') + const [savedLastReviewed, setSavedLastReviewed] = useState('') + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [saveMsg, setSaveMsg] = useState<{ text: string; err: boolean } | null>(null) + + const timers = useRef>>({}) + const saveMsgTimer = useRef | null>(null) + + function debounce(key: string, fn: () => void, delay = 400) { + clearTimeout(timers.current[key]) + timers.current[key] = setTimeout(fn, delay) + } + + function flash(text: string, err = false) { + if (saveMsgTimer.current) clearTimeout(saveMsgTimer.current) + setSaveMsg({ text, err }) + saveMsgTimer.current = setTimeout(() => setSaveMsg(null), err ? 5000 : 2500) + } + + function stampLastReviewed() { + const today = todayStr() + if (today === savedLastReviewed) return + setSavedLastReviewed(today) + putLastReviewed(today).catch(() => {}) + } + + const loadAll = useCallback(async (force = false) => { + setLoading(true) + setError('') + const today = todayStr() + const ws = weekStart || today + const lv = lastViewed || savedLastReviewed || offsetDate(today, -1) + try { + const [b, cfg] = await Promise.all([getBookings(ws, lv, force), getConfig()]) + setBookings(b) + setTimeReqs(cfg.time_requirements || {}) + setStaff(cfg.staff_data || []) + setPickup(cfg.pickup_data || {}) + setGeneralTasks(cfg.general_tasks || []) + setTolerance(cfg.tolerance_minutes ?? 30) + if (cfg.last_reviewed) { + setSavedLastReviewed(cfg.last_reviewed) + if (!lastViewed) setLastViewed(cfg.last_reviewed) + } + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load data') + } finally { + setLoading(false) + } + }, [weekStart, lastViewed, savedLastReviewed]) + + useEffect(() => { loadAll(false) }, []) // eslint-disable-line react-hooks/exhaustive-deps + + // Beacon save on unload + useEffect(() => { + function onUnload() { + if (staff.length) { + navigator.sendBeacon('/hk-planner/api/config/staff', JSON.stringify({ staff_data: staff })) + } + if (Object.keys(pickup).length) { + navigator.sendBeacon('/hk-planner/api/config/pickup', JSON.stringify({ pickup_data: pickup })) + } + if (generalTasks.length) { + navigator.sendBeacon('/hk-planner/api/config/general-tasks', JSON.stringify({ general_tasks: generalTasks })) + } + } + window.addEventListener('beforeunload', onUnload) + return () => window.removeEventListener('beforeunload', onUnload) + }, [staff, pickup, generalTasks]) + + function handleWeekOffset(days: number) { + const today = todayStr() + const newStart = offsetDate(weekStart || today, days) + setWeekStart(newStart) + stampLastReviewed() + setTimeout(() => loadAll(true), 0) + } + + function handleWeekToday() { + setWeekStart(null) + stampLastReviewed() + setTimeout(() => loadAll(true), 0) + } + + function handleRefresh() { + stampLastReviewed() + loadAll(true) + } + + // ── Pickup ────────────────────────────────────────────────────────────────── + + function handlePickupChange(catId: string, date: string, delta: number, cat: { total_rooms: number; days: Record }) { + const d = cat.days[date] + const occupied = d.stays + d.arrivals + const vacant = Math.max(0, cat.total_rooms - occupied) + const current = getDisplayPickup(pickup, catId, date, occupied, cat.total_rooms) + const newVal = Math.max(0, Math.min(current + delta, vacant)) + + const next: PickupData = { + ...pickup, + [catId]: { ...(pickup[catId] || {}), [date]: { count: newVal, total: occupied + newVal } }, + } + setPickup(next) + stampLastReviewed() + debounce('pickup', () => putPickup(next).then(() => flash('Pickup saved')).catch(e => flash(e.message, true))) + } + + // ── Time requirements ─────────────────────────────────────────────────────── + + function handleTimeReqChange(cat: string, action: string, value: number) { + const next: TimeReqs = { + ...timeReqs, + [cat]: { ...(timeReqs[cat] || { depart: 0, stay: 0, arrive: 0 }), [action]: value }, + } + setTimeReqs(next) + stampLastReviewed() + debounce(`req-${cat}-${action}`, () => + putTimeReq(cat, action, value).then(() => flash('Time requirements saved')).catch(e => flash(e.message, true)) + ) + } + + // ── Staff ──────────────────────────────────────────────────────────────────── + + function handleStaffChange(next: StaffMember[]) { + setStaff(next) + stampLastReviewed() + debounce('staff', () => putStaff(next).then(() => flash('Staff hours saved')).catch(e => flash(e.message, true))) + } + + function addStaffRow() { + handleStaffChange([...staff, { name: '', hours: {} }]) + } + + // ── General tasks ──────────────────────────────────────────────────────────── + + function handleTasksChange(next: GeneralTask[]) { + setGeneralTasks(next) + stampLastReviewed() + debounce('tasks', () => putGeneralTasks(next).then(() => flash('Tasks saved')).catch(e => flash(e.message, true))) + } + + function addTaskRow() { + handleTasksChange([...generalTasks, { name: '', hours: {} }]) + } + + // ── Required hours (memoised on state changes) ──────────────────────────── + + const required = bookings ? calcRequired(bookings, timeReqs, pickup, generalTasks) : null + + // ── Render ────────────────────────────────────────────────────────────────── + + const today = todayStr() + const displayWeekStart = weekStart || today + + return ( +
+ + {/* Controls */} +
+
+

+ Housekeeping Planner +

+ {bookings && ( +

+ {fmtDate(bookings.dates[0])} – {fmtDate(bookings.dates[bookings.dates.length - 1])} +

+ )} +
+ +
+ {/* Week navigation */} + handleWeekOffset(-7)}>‹ Prev + Today + handleWeekOffset(7)}>Next › + { if (e.target.value) { setWeekStart(e.target.value); stampLastReviewed(); setTimeout(() => loadAll(true), 0) } }} + style={{ border: '1px solid var(--card-border)', borderRadius: '6px', padding: '0.35rem 0.5rem', fontSize: '0.82rem', color: 'var(--text-dark)' }} + /> +
+ {/* Last-reviewed */} + + { stampLastReviewed(); loadAll(true) }}>Update to now +
+ +
+
+ + {/* Status */} + + {saveMsg && ( +
+ {saveMsg.text} +
+ )} + + {error && ( +
+ Error: {error} +
+ )} + + {loading && !bookings && ( +
+ Loading bookings… +
+ )} + + {bookings && required && ( + <> + {/* 1 — Occupancy summary */} +
+
+ +
+
+ + {/* 2 — Required hours */} +
+
+ +
+
+ + {/* 3 — Staff rota */} +
+ Add staff}> +
+ +
+
+ + {/* 4 — General tasks */} +
+ Add task}> +
+ +
+
+ + {/* 5 — Time requirements */} +
+
+ +
+
+ + )} +
+ ) +} + +// ── Section wrapper ─────────────────────────────────────────────────────────── + +function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { + return ( +
+
+

+ {title} +

+ {action} +
+ {children} +
+ ) +} + +function CtrlBtn({ children, onClick, primary }: { children: React.ReactNode; onClick?: () => void; primary?: boolean }) { + return ( + + ) +} + +// ── Summary Table ───────────────────────────────────────────────────────────── + +interface SummaryProps { + bookings: BookingsData + pickup: PickupData + onPickupChange: (catId: string, date: string, delta: number, cat: { total_rooms: number; days: Record }) => void +} + +function SummaryTable({ bookings, pickup, onPickupChange }: SummaryProps) { + const { dates, categories: cats } = bookings + + return ( + + + + + {dates.map(d => )} + + + {dates.map(d => ( + + ))} + + + + {cats.map((cat, ci) => { + const isLast = ci === cats.length - 1 + return ( + onPickupChange(cat.id, date, delta, cat)} + isLast={isLast} + /> + ) + })} + + + + +
Category{fmtDayHeader(d)}
+ Occ + D / S / A +
+ ) +} + +function CatRows({ cat, dates, pickup, onPickupChange, isLast }: { + cat: { id: string; name: string; total_rooms: number; days: Record } + dates: string[] + pickup: PickupData + onPickupChange: (date: string, delta: number) => void + isLast: boolean +}) { + return ( + <> + {/* Row 1: Occ + Departs */} + + + {cat.name} + + {dates.map((date, di) => { + const d = cat.days[date] + const occupied = d.stays + d.arrivals + const vacant = Math.max(0, cat.total_rooms - occupied) + const pu = getDisplayPickup(pickup, cat.id, date, occupied, cat.total_rooms) + const prevDate = dates[di - 1] + const prevOcc = prevDate ? (cat.days[prevDate].stays + cat.days[prevDate].arrivals) : 0 + const prevPu = prevDate ? getDisplayPickup(pickup, cat.id, prevDate, prevOcc, cat.total_rooms) : 0 + const dNew = d.delta_new || 0 + const dCanc = d.delta_cancelled || 0 + + return ( + + {(dNew > 0 || dCanc > 0) && ( +
+ {dNew > 0 && ▲{dNew}} + {dCanc > 0 && ▼{dCanc}} +
+ )} +
+ {occupied} + {pu > 0 && +{pu}} +
+
+ {vacant} vac{pu > 0 && ({vacant - pu})} +
+
+ + {pu} + +
+ {/* D/S/A breakdown: sits to the right via colSpan trick — shown as sub-rows via CSS */} +
+ {d.departs}d{prevPu > 0 && +{prevPu}} + {' / '} + {d.stays}s + {' / '} + {d.arrivals}a{pu > 0 && +{pu}} +
+ + ) + })} + + {/* Rows 2 & 3 are consumed by rowSpan — needed for tbody structure */} + + + + ) +} + +function TotalRows({ dates, cats, pickup }: { + dates: string[] + cats: BookingsData['categories'] + pickup: PickupData +}) { + return ( + + Total + {dates.map((date, di) => { + let occupied = 0, vacant = 0, pu = 0, prevPu = 0, departs = 0, stays = 0, arrivals = 0 + let dNew = 0, dCanc = 0 + for (const cat of cats) { + const d = cat.days[date] + const occ = d.stays + d.arrivals + const vac = Math.max(0, cat.total_rooms - occ) + const p = getDisplayPickup(pickup, cat.id, date, occ, cat.total_rooms) + const prev = dates[di - 1] + const pOcc = prev ? (cat.days[prev].stays + cat.days[prev].arrivals) : 0 + const pPu = prev ? getDisplayPickup(pickup, cat.id, prev, pOcc, cat.total_rooms) : 0 + occupied += occ; vacant += vac; pu += p; prevPu += pPu + departs += d.departs; stays += d.stays; arrivals += d.arrivals + dNew += d.delta_new || 0 + dCanc += d.delta_cancelled || 0 + } + return ( + + {(dNew > 0 || dCanc > 0) && ( +
+ {dNew > 0 && ▲{dNew}} + {dCanc > 0 && ▼{dCanc}} +
+ )} +
+ {occupied}{pu > 0 && +{pu}} +
+
+ {vacant} vac{pu > 0 && ({vacant - pu})} +
+
+ {departs}d{prevPu > 0 && +{prevPu}} + {' / '} + {stays}s + {' / '} + {arrivals}a{pu > 0 && +{pu}} +
+ + ) + })} + + ) +} + +// ── Required Hours Table ────────────────────────────────────────────────────── + +function RequiredTable({ bookings, required }: { bookings: BookingsData; required: Record }) { + const { dates, categories: cats } = bookings + return ( + + + + + {dates.map(d => )} + + + + {cats.map(cat => ( + + + {dates.map(date => { + const booked = required[date].by_cat[cat.id] || 0 + const pu = required[date].pickup_by_cat[cat.id] || 0 + return ( + + ) + })} + + ))} + + + + + {dates.map(d => )} + + + + {dates.map(d => )} + + + + {dates.map(d => )} + + + + {dates.map(d => ( + + ))} + + +
Category{fmtDayHeader(d)}
{cat.name} + {booked === 0 && pu === 0 ? '—' : fmtH(booked)} + {pu > 0.001 && (+{fmtH(pu)})} +
Booked hrs{fmtH(required[d].booked)}
Pickup hrs{required[d].pickup > 0.001 ? fmtH(required[d].pickup) : '—'}
General tasks{required[d].general > 0.001 ? fmtH(required[d].general) : '—'}
Total Required + {fmtH(required[d].total)} + {required[d].pickup > 0.001 && ( + + inc {fmtH(required[d].pickup)} pickup + + )} +
+ ) +} + +// ── Staff Table ─────────────────────────────────────────────────────────────── + +function StaffTable({ bookings, staff, required, tolerance, onChange }: { + bookings: BookingsData + staff: StaffMember[] + required: Record + tolerance: number + onChange: (next: StaffMember[]) => void +}) { + const { dates } = bookings + const tolHrs = tolerance / 60 + + function setMemberName(i: number, name: string) { + const next = staff.map((m, idx) => idx === i ? { ...m, name } : m) + onChange(next) + } + + function setMemberHours(i: number, date: string, val: string) { + const hrs = parseFloat(val) + const next = staff.map((m, idx) => { + if (idx !== i) return m + const hours = { ...m.hours } + if (!isNaN(hrs) && hrs >= 0) hours[date] = hrs + else delete hours[date] + return { ...m, hours } + }) + onChange(next) + } + + function removeRow(i: number) { + onChange(staff.filter((_, idx) => idx !== i)) + } + + return ( + + + + + {dates.map(d => )} + + + + + {staff.map((member, i) => ( + + + {dates.map(date => ( + + ))} + + + ))} + + + + + {dates.map(date => { + const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0) + return + })} + + + + {dates.map(date => { + const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0) + const reqHrs = required[date].booked + required[date].general + return + })} + + + + {dates.map(date => { + const avail = staff.reduce((s, m) => s + (m.hours[date] || 0), 0) + return + })} + + +
Staff Member{fmtDayHeader(d)}
+ setMemberName(i, e.target.value)} + /> + + setMemberHours(i, date, e.target.value)} + /> + + +
Total Available{fmtH(avail)} +
vs Booked +
vs with Pickup +
+ ) +} + +function DiffCell({ available, required, tolerance }: { available: number; required: number; tolerance: number }) { + const diff = available - required + if (required === 0 && available === 0) return + if (diff < -tolerance) return ✗ {fmtH(Math.abs(diff))} short + if (diff > tolerance) return ⚠ {fmtH(diff)} spare + return ✓ {diff >= 0 ? '+' : ''}{fmtH(diff)} +} + +// ── General Tasks Table ─────────────────────────────────────────────────────── + +const WEEKDAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + +function GeneralTasksTable({ tasks, onChange }: { tasks: GeneralTask[]; onChange: (next: GeneralTask[]) => void }) { + function setName(i: number, name: string) { + onChange(tasks.map((t, idx) => idx === i ? { ...t, name } : t)) + } + + function setHours(i: number, day: string, val: string) { + const mins = parseInt(val, 10) + onChange(tasks.map((t, idx) => { + if (idx !== i) return t + return { ...t, hours: { ...t.hours, [day]: isNaN(mins) ? 0 : Math.max(0, mins) } } + })) + } + + function removeRow(i: number) { onChange(tasks.filter((_, idx) => idx !== i)) } + + return ( + + + + + {WEEKDAYS.map(d => )} + + + + + {tasks.length === 0 && ( + + )} + {tasks.map((task, i) => ( + + + {WEEKDAYS.map(day => ( + + ))} + + + ))} + +
Task{d}
No tasks yet — add one above
+ setName(i, e.target.value)} /> + + setHours(i, day, e.target.value)} /> + + +
+ ) +} + +// ── Time Requirements Table ─────────────────────────────────────────────────── + +function TimeTable({ categories, timeReqs, onChange }: { + categories: BookingsData['categories'] + timeReqs: TimeReqs + onChange: (cat: string, action: string, value: number) => void +}) { + return ( + + + + + + + + + + + {categories.map(cat => { + const req = timeReqs[cat.id] || { depart: 0, stay: 0, arrive: 0 } + return ( + + + {(['depart', 'stay', 'arrive'] as const).map(action => ( + + ))} + + ) + })} + +
CategoryDepart (mins)Stay (mins)Arrive (mins)
{cat.name} + onChange(cat.id, action, parseInt(e.target.value, 10) || 0)} + onBlur={e => onChange(cat.id, action, parseInt(e.target.value, 10) || 0)} + /> +
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..ea02651 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,54 @@ +export interface User { + email: string + name: string + is_admin: boolean +} + +export interface DayData { + total_servicing: number + departs: number + stays: number + arrivals: number + pickup_hint: number + pickup_lead: number + prior_occ: number + prior_vac: number + delta_new: number + delta_cancelled: number +} + +export interface Category { + id: string + name: string + total_rooms: number + days: Record +} + +export interface BookingsData { + dates: string[] + categories: Category[] + last_viewed: string +} + +export type TimeReqs = Record + +export interface StaffMember { + name: string + hours: Record +} + +export interface GeneralTask { + name: string + hours: Record +} + +export type PickupData = Record> + +export interface RequiredDay { + booked: number + pickup: number + general: number + total: number + by_cat: Record + pickup_by_cat: Record +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..5ed2038 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_HOTEL_NAME: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1538675 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6cd4553 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + base: '/hk-planner/', + plugins: [react()], +}) diff --git a/seed-app.js b/seed-app.js new file mode 100644 index 0000000..5efff06 --- /dev/null +++ b/seed-app.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node +// Run from hk-planner/ dir: DATABASE_URL=... node seed-app.js +import pg from 'pg' + +const { Pool } = pg +const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +await pool.query(` + INSERT INTO apps (slug, name, description, base_path, icon, theme_color, category, internal_host, internal_port) + VALUES ('hk-planner', 'HK Planner', 'Housekeeping workload and hours planning', '/hk-planner', 'CalendarClock', '#2d6a4f', 'Housekeeping', '10.10.10.116', 3080) + ON CONFLICT (slug) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + base_path = EXCLUDED.base_path, + icon = EXCLUDED.icon, + theme_color = EXCLUDED.theme_color, + category = EXCLUDED.category, + internal_host = EXCLUDED.internal_host, + internal_port = EXCLUDED.internal_port +`) + +console.log('hk-planner app seeded.') +await pool.end()