commit 1e658b6a48a13c6ad53411c1f623493d496cdd0f Author: jtricerolph Date: Fri Jul 3 13:07:00 2026 +0000 feat: add room-planner app — 3-day HK room view with NewBook integration NewBook-connected daily housekeeping planner. Replaces the hotelhubmodule-housekeeping-dailylist WordPress plugin. LXC 120 · 10.10.10.120:3080 · slug: room-planner. - 3-day booking window (yesterday/today/tomorrow) fetched live from NewBook - Task completion ticks back to NewBook; room status patches NewBook directly - 23px border sliver CSS system for adjacent-day booking status - 3-state filter cycling (off→inclusive→exclusive) for categories and flow types - Stat filters for outstanding tasks and clean/dirty status - Rolling 48h activity log with checkout/checkin/status/tasks events - newbook_pings event bus for future NewBook poller integration - Room modal with permission-gated guest/rate/notes, task checkboxes, status buttons - Placeholder sections for future linen-count and routine-tasks modules - Settings page: task type colours, twin/extra-bed detection, category exclusions - Mobile-first layout (sidebar desktop, compact top bar mobile) Co-Authored-By: Claude Sonnet 4.6 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f455452 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-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..aa3577b --- /dev/null +++ b/backend/package.json @@ -0,0 +1,16 @@ +{ + "name": "hnf-room-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..f8f75f6 --- /dev/null +++ b/backend/src/auth.js @@ -0,0 +1,57 @@ +import { jwtVerify } from 'jose' +import { isOnsite } from './ip-check.js' + +const APP_SLUG = process.env.APP_SLUG || 'room-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' }) + } + } + + const prefix = `${APP_SLUG}:` + let caps + if (Array.isArray(payload.caps)) { + caps = payload.caps.filter(c => c.startsWith(prefix)).map(c => c.slice(prefix.length)) + } else { + // Legacy token — grant all non-settings caps until re-login + caps = ['view', 'guest_details', 'rate_details', 'view_all_notes', 'complete_tasks', 'update_status'] + } + + request.user = { + email: payload.sub, + name: payload.name, + is_admin: payload.is_admin ?? false, + caps, + } +} + +export function hasCap(request, cap) { + return request.user?.is_admin === true || request.user?.caps?.includes(cap) === true +} + +export function requireCap(cap) { + return async (request, reply) => { + if (!hasCap(request, cap)) { + return reply.status(403).send({ error: `Missing capability: ${cap}` }) + } + } +} diff --git a/backend/src/db.js b/backend/src/db.js new file mode 100644 index 0000000..e10ae32 --- /dev/null +++ b/backend/src/db.js @@ -0,0 +1,70 @@ +import pg from 'pg' + +const { Pool } = pg +export const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + +export async function initDb() { + await pool.query(` + -- Instance-level config: task display, twin detection, note visibility, etc. + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + + -- Rolling activity log. Pruned to 48h on each write. + CREATE TABLE IF NOT EXISTS activity_log ( + id SERIAL PRIMARY KEY, + room_id TEXT NOT NULL, + event_type TEXT NOT NULL, + event_data JSONB, + user_name TEXT, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + service_date DATE NOT NULL, + booking_ref TEXT + ); + + CREATE INDEX IF NOT EXISTS activity_log_date_idx ON activity_log (service_date, occurred_at DESC); + + -- Lightweight NewBook change signals from the poller. No booking data stored. + CREATE TABLE IF NOT EXISTS newbook_pings ( + id SERIAL PRIMARY KEY, + booking_ids TEXT[] NOT NULL, + event_types TEXT[] NOT NULL, + detected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '5 minutes' + ); + + CREATE INDEX IF NOT EXISTS newbook_pings_expires_idx ON newbook_pings (expires_at); + `) + + await seedDefaultConfig() +} + +async function seedDefaultConfig() { + const defaults = { + task_display: {}, + twin_detection: { + enabled: true, + custom_field_ids: [], + keywords: ['twin', 'two single', '2 single', 'single beds', 'sofabed', 'sofa bed'], + exclude_keywords: ['twin room', 'twin suite'], + }, + extra_bed_detection: { + enabled: true, + keywords: ['extra bed', 'extra cot', 'rollaway', 'roll away', 'fold out'], + }, + excluded_categories: [], + hide_excluded_categories: false, + visible_note_types: [], + checkout_notification_timeout: 30, + default_checkout_time: process.env.DEFAULT_CHECKOUT_TIME || '11:00', + } + + for (const [key, value] of Object.entries(defaults)) { + await pool.query( + `INSERT INTO config (key, value) VALUES ($1, $2) ON CONFLICT (key) DO NOTHING`, + [key, JSON.stringify(value)] + ) + } +} diff --git a/backend/src/index.js b/backend/src/index.js new file mode 100644 index 0000000..046877b --- /dev/null +++ b/backend/src/index.js @@ -0,0 +1,35 @@ +import Fastify from 'fastify' +import cookie from '@fastify/cookie' +import cors from '@fastify/cors' +import { initDb } from './db.js' +import { roomRoutes } from './routes/rooms.js' +import { taskRoutes } from './routes/tasks.js' +import { statusRoutes } from './routes/status.js' +import { configRoutes } from './routes/config.js' +import { activityRoutes } from './routes/activity.js' +import { eventsRoutes } from './routes/events.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(roomRoutes) +await app.register(taskRoutes) +await app.register(statusRoutes) +await app.register(configRoutes) +await app.register(activityRoutes) +await app.register(eventsRoutes) + +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/booking-flow.js b/backend/src/lib/booking-flow.js new file mode 100644 index 0000000..ea139cf --- /dev/null +++ b/backend/src/lib/booking-flow.js @@ -0,0 +1,124 @@ +// Shared booking-flow classification logic (server-side). +// The same logic is duplicated in frontend/src/lib/booking-flow.ts for client use. + +export const STATUS_COLORS = { + arrived: '#3b82f6', // blue + confirmed: '#10b981', // green + unconfirmed: '#f59e0b', // amber + departed: '#a855f7', // purple + cancelled: '#94a3b8', // slate + blocked: '#6b7280', // grey +} + +// Extract YYYY-MM-DD from a NewBook datetime string +export function toDateStr(dt) { + if (!dt) return null + return String(dt).slice(0, 10) +} + +// Extract HH:MM from a NewBook datetime string +export function toTimeStr(dt) { + if (!dt) return null + const t = String(dt).slice(11, 16) + return t || null +} + +// Find all bookings for a given site on a specific date. +// A booking occupies a site on viewDate if: arrival <= viewDate < departure +// (departure date is the check-out day, so the room is vacated on that morning) +export function bookingsForSiteOnDate(bookings, siteId, viewDate) { + return bookings.filter(b => { + if (String(b.site_id) !== String(siteId)) return false + const arrival = toDateStr(b.booking_arrival) + const departure = toDateStr(b.booking_departure) + if (!arrival || !departure) return false + return arrival <= viewDate && departure > viewDate + }) +} + +// Find the departing booking for a site on viewDate (departure_date === viewDate) +export function departingBookingForSite(bookings, siteId, viewDate) { + return bookings.find(b => + String(b.site_id) === String(siteId) && + toDateStr(b.booking_departure) === viewDate + ) ?? null +} + +// Classify a site's booking state for a given view date. +// Returns a rich descriptor used to build the room card. +export function classifyRoom(site, allBookings, viewDate, yesterday, tomorrow) { + const siteId = String(site.site_id) + + // Primary: booking that occupies the room on viewDate (arrival <= viewDate < departure) + const todayBookings = bookingsForSiteOnDate(allBookings, siteId, viewDate) + const departing = departingBookingForSite(allBookings, siteId, viewDate) + + // Adjacent day primary occupants (for border slivers) + const prevBookings = bookingsForSiteOnDate(allBookings, siteId, yesterday) + const nextBookings = bookingsForSiteOnDate(allBookings, siteId, tomorrow) + + const prevBooking = prevBookings[0] ?? null + const nextBooking = nextBookings[0] ?? null + + // Determine flow type + let flowType = 'vacant' + let primaryBooking = todayBookings[0] ?? null + + if (primaryBooking) { + const status = (primaryBooking.booking_status || '').toLowerCase() + if (status === 'blocked') { + flowType = 'blocked' + } else { + const arrivalDate = toDateStr(primaryBooking.booking_arrival) + const departureDate = toDateStr(primaryBooking.booking_departure) + const arrivingToday = arrivalDate === viewDate + const departingToday = departureDate === viewDate + + if (arrivingToday && departing && String(departing.booking_id) !== String(primaryBooking.booking_id)) { + // A different booking departs same day this one arrives + flowType = 'back-to-back' + } else if (arrivingToday) { + flowType = 'arrive' + } else if (departingToday) { + // Booking departs today but arrival <= viewDate so they were here — shouldn't normally hit + flowType = 'depart' + } else { + flowType = 'stopover' + } + } + } else if (departing) { + // Room had a departing guest but no incoming booking occupies it today + primaryBooking = departing + flowType = 'depart' + } + + const arrivalDate = primaryBooking ? toDateStr(primaryBooking.booking_arrival) : null + const departureDate = primaryBooking ? toDateStr(primaryBooking.booking_departure) : null + + return { + site_id: siteId, + site_name: site.site_name, + site_status: site.site_status || 'unknown', + category_id: String(site.site_category_id || ''), + category_name: site.site_category_name || '', + category_order: site.site_category_order ?? 0, + site_order: site.site_order ?? 0, + + flow_type: flowType, + booking: primaryBooking, + + // Span flags — does this booking continue across the day boundary? + spans_previous: !!primaryBooking && !!arrivalDate && arrivalDate < viewDate, + spans_next: !!primaryBooking && !!departureDate && departureDate > viewDate, + + // Adjacent day context for border slivers + previous_booking: prevBooking, + next_booking: nextBooking, + previous_status: prevBooking ? (prevBooking.booking_status || '').toLowerCase() : null, + next_status: nextBooking ? (nextBooking.booking_status || '').toLowerCase() : null, + + // Departure time info for the wider-border badge + departing_booking: departing, + departing_time: departing ? toTimeStr(departing.booking_departure) : null, + } +} diff --git a/backend/src/lib/newbook.js b/backend/src/lib/newbook.js new file mode 100644 index 0000000..0469d7e --- /dev/null +++ b/backend/src/lib/newbook.js @@ -0,0 +1,125 @@ +const API_BASE = 'https://api.newbook.cloud/rest/' + +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} fetching NewBook credentials`) + 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 locationId = process.env.NEWBOOK_LOCATION_ID + const body = { ...data, region: creds.region, api_key: creds.apiKey } + if (locationId) body.location_id = locationId + + 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)}`) + } + const json = await res.json() + return json + } catch (err) { + clearTimeout(timer) + throw err + } +} + +// Fetch all sites (rooms) with current status. +// Each site object includes site_id, site_name, site_status, and typically +// site_category_id, site_category_name, site_order from the NewBook response. +export async function fetchSites() { + const res = await callApi('sites_list', {}) + return res?.data ?? [] +} + +// Fetch bookings spanning a date range. +// list_type 'all' includes arrived, confirmed, unconfirmed, departed, blocked. +export async function fetchBookings(fromDate, toDate) { + const res = await callApi('bookings_list', { + period_from: `${fromDate} 00:00:00`, + period_to: `${toDate} 23:59:59`, + list_type: 'all', + }) + return res?.data ?? [] +} + +// Fetch tasks for a date range. show_uncomplete='true' (string) pulls rollover tasks from before fromDate. +export async function fetchTasks(fromDate, toDate) { + const res = await callApi('tasks_list', { + period_from: `${fromDate} 00:00:00`, + period_to: `${toDate} 23:59:59`, + show_uncomplete: 'true', + }) + return res?.data ?? [] +} + +// Fetch available task types (for settings configuration). +// Note: endpoint is tasks_types_list (not task_types_list). +export async function fetchTaskTypes() { + const res = await callApi('tasks_types_list', {}) + return res?.data ?? [] +} + +// Mark a task as complete in NewBook. +// completed_on must be 'YYYY-MM-DD HH:MM:SS'. Response may include updated site_status. +export async function completeTask(taskId) { + const now = new Date().toISOString().replace('T', ' ').slice(0, 19) + const res = await callApi('tasks_update', { + task_id: taskId, + completed_on: now, + }) + return res +} + +// Remove task completion in NewBook. +export async function uncompleteTask(taskId) { + const res = await callApi('tasks_update', { + task_id: taskId, + completed_on: null, + }) + return res +} + +// Update room status in NewBook. NewBook expects 'status' parameter (not 'site_status'). +export async function updateSiteStatus(siteId, status) { + const res = await callApi('sites_update', { + site_id: siteId, + status, + }) + return res +} + +// Fetch bookings changed since a timestamp — used by the NewBook poller. +export async function fetchChangedSince(sinceTimestamp) { + const res = await callApi('bookings_list', { + changed_since: sinceTimestamp, + list_type: 'all', + }) + return res?.data ?? [] +} diff --git a/backend/src/routes/activity.js b/backend/src/routes/activity.js new file mode 100644 index 0000000..2c8a71f --- /dev/null +++ b/backend/src/routes/activity.js @@ -0,0 +1,49 @@ +import { requireAuth } from '../auth.js' +import { pool } from '../db.js' + +export async function activityRoutes(app) { + app.addHook('preHandler', requireAuth) + + // GET /api/activity?date=YYYY-MM-DD&limit=50 + app.get('/api/activity', async (req, reply) => { + const date = req.query.date || new Date().toISOString().slice(0, 10) + const limit = Math.min(parseInt(req.query.limit || '50'), 200) + + const { rows } = await pool.query( + `SELECT id, room_id, event_type, event_data, user_name, occurred_at, booking_ref + FROM activity_log + WHERE service_date = $1 + ORDER BY occurred_at DESC + LIMIT $2`, + [date, limit] + ) + + return rows + }) + + // POST /api/activity — log an event from the frontend + // Body: { room_id, event_type, event_data?, service_date?, booking_ref? } + app.post('/api/activity', async (req, reply) => { + const { room_id, event_type, event_data, service_date, booking_ref } = req.body || {} + + const ALLOWED_TYPES = ['checkout', 'checkin', 'status_clean', 'status_dirty', 'tasks_complete'] + if (!room_id) return reply.status(400).send({ error: 'room_id required' }) + if (!ALLOWED_TYPES.includes(event_type)) { + return reply.status(400).send({ error: `event_type must be one of: ${ALLOWED_TYPES.join(', ')}` }) + } + + await pool.query( + `INSERT INTO activity_log (room_id, event_type, event_data, user_name, service_date, booking_ref) + VALUES ($1, $2, $3, $4, $5, $6)`, + [room_id, event_type, event_data ? JSON.stringify(event_data) : null, + req.user.name, service_date || new Date().toISOString().slice(0, 10), booking_ref || null] + ) + + // Prune on each write + await pool.query( + `DELETE FROM activity_log WHERE occurred_at < NOW() - INTERVAL '48 hours'` + ).catch(() => {}) + + return { ok: true } + }) +} diff --git a/backend/src/routes/config.js b/backend/src/routes/config.js new file mode 100644 index 0000000..0defb54 --- /dev/null +++ b/backend/src/routes/config.js @@ -0,0 +1,42 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool } from '../db.js' +import { fetchTaskTypes } from '../lib/newbook.js' + +export async function configRoutes(app) { + app.addHook('preHandler', requireAuth) + + // GET /api/config — returns all config keys as a flat object + app.get('/api/config', async (req, reply) => { + const { rows } = await pool.query('SELECT key, value FROM config ORDER BY key') + const config = Object.fromEntries(rows.map(r => [r.key, r.value])) + return config + }) + + // PUT /api/config/:key — update a single config key + app.put('/api/config/:key', { preHandler: requireCap('settings') }, async (req, reply) => { + const { key } = req.params + const { value } = req.body || {} + if (value === undefined) return reply.status(400).send({ error: 'value required' }) + + await pool.query( + `INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + [key, JSON.stringify(value)] + ) + + return { ok: true } + }) + + // GET /api/config/task-types — fetch available task types from NewBook for settings UI + app.get('/api/config/task-types', { preHandler: requireCap('settings') }, async (req, reply) => { + try { + const types = await fetchTaskTypes() + return types.map(t => ({ + id: String(t.task_type_id), + name: t.task_type_name || t.task_description || String(t.task_type_id), + })) + } catch (err) { + return reply.status(502).send({ error: `NewBook error: ${err.message}` }) + } + }) +} diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js new file mode 100644 index 0000000..6821657 --- /dev/null +++ b/backend/src/routes/events.js @@ -0,0 +1,54 @@ +import { requireAuth } from '../auth.js' +import { pool } from '../db.js' + +// Polling interval for checking new pings (ms) +const POLL_INTERVAL = 30_000 + +export async function eventsRoutes(app) { + // GET /api/events?since= + // Returns pings detected after `since`, then the client re-fetches rooms for affected IDs. + // Non-SSE: simple JSON poll endpoint so clients can choose their own cadence. + app.get('/api/events', { preHandler: requireAuth }, async (req, reply) => { + const since = req.query.since || new Date(Date.now() - POLL_INTERVAL).toISOString() + + // Prune expired pings + await pool.query(`DELETE FROM newbook_pings WHERE expires_at < NOW()`).catch(() => {}) + + const { rows } = await pool.query( + `SELECT id, booking_ids, event_types, detected_at + FROM newbook_pings + WHERE detected_at > $1 + ORDER BY detected_at ASC`, + [since] + ) + + return { + pings: rows, + server_time: new Date().toISOString(), + } + }) + + // POST /api/events/ping — used by the newbook-poller service to register a change signal + // The poller is a separate service; this endpoint receives its signals. + app.post('/api/events/ping', async (req, reply) => { + // Simple shared-secret auth for the poller (not user-facing) + const secret = req.headers['x-poller-secret'] + if (!secret || secret !== process.env.POLLER_SECRET) { + // If no POLLER_SECRET configured, allow from internal network only + const ip = req.headers['x-real-ip'] || req.ip + const isInternal = ip?.startsWith('10.10.10.') || ip?.startsWith('127.') + if (!isInternal) return reply.status(401).send({ error: 'Unauthorized' }) + } + + const { booking_ids, event_types } = req.body || {} + if (!booking_ids?.length) return reply.status(400).send({ error: 'booking_ids required' }) + + await pool.query( + `INSERT INTO newbook_pings (booking_ids, event_types) + VALUES ($1, $2)`, + [booking_ids, event_types || ['changed']] + ) + + return { ok: true } + }) +} diff --git a/backend/src/routes/rooms.js b/backend/src/routes/rooms.js new file mode 100644 index 0000000..1443a0e --- /dev/null +++ b/backend/src/routes/rooms.js @@ -0,0 +1,155 @@ +import { requireAuth, hasCap } from '../auth.js' +import { pool } from '../db.js' +import { fetchSites, fetchBookings, fetchTasks } from '../lib/newbook.js' +import { classifyRoom, toDateStr } from '../lib/booking-flow.js' + +function dateOffset(dateStr, days) { + const d = new Date(dateStr + 'T00:00:00Z') + d.setUTCDate(d.getUTCDate() + days) + return d.toISOString().slice(0, 10) +} + +function filterBookingData(booking, canSeeGuest, canSeeRate, canSeeAllNotes, visibleNoteTypes) { + if (!booking) return null + const out = { + booking_id: booking.booking_id, + booking_reference_id: booking.booking_reference_id, + booking_status: booking.booking_status, + booking_arrival: booking.booking_arrival, + booking_departure: booking.booking_departure, + booking_eta: booking.booking_eta, + booking_locked: booking.booking_locked, + pax: booking.pax, + site_id: booking.site_id, + custom_fields: booking.custom_fields || [], + } + + if (canSeeGuest) { + const guests = booking.guests || [] + out.guest_name = guests[0]?.guest_name || booking.account_for_name || null + } + + if (canSeeRate) { + out.rate_plan_name = booking.rate_plan_name || null + } + + if (canSeeAllNotes) { + out.notes = booking.notes || [] + } else if (visibleNoteTypes?.length) { + out.notes = (booking.notes || []).filter(n => visibleNoteTypes.includes(String(n.note_type_id))) + } else { + out.notes = [] + } + + return out +} + +export async function roomRoutes(app) { + app.addHook('preHandler', requireAuth) + + // GET /api/rooms?date=YYYY-MM-DD + app.get('/api/rooms', async (req, reply) => { + if (!hasCap(req, 'view')) return reply.status(403).send({ error: 'Missing capability: view' }) + + const viewDate = req.query.date || new Date().toISOString().slice(0, 10) + const yesterday = dateOffset(viewDate, -1) + const tomorrow = dateOffset(viewDate, +1) + + const canSeeGuest = hasCap(req, 'guest_details') + const canSeeRate = hasCap(req, 'rate_details') + const canSeeAllNotes = hasCap(req, 'view_all_notes') + + // Load config for note type visibility + const cfgRow = await pool.query(`SELECT value FROM config WHERE key = 'visible_note_types'`) + const visibleNoteTypes = cfgRow.rows[0]?.value || [] + + // Load exclusion config + const excRow = await pool.query( + `SELECT key, value FROM config WHERE key IN ('excluded_categories','hide_excluded_categories')` + ) + const cfgMap = Object.fromEntries(excRow.rows.map(r => [r.key, r.value])) + const excludedCategories = cfgMap.excluded_categories || [] + const hideExcluded = cfgMap.hide_excluded_categories ?? false + + // Fetch from NewBook in parallel + let sites, bookings, tasks + try { + ;[sites, bookings, tasks] = await Promise.all([ + fetchSites(), + fetchBookings(yesterday, tomorrow), + fetchTasks(yesterday, tomorrow), + ]) + } catch (err) { + return reply.status(502).send({ error: `NewBook fetch failed: ${err.message}` }) + } + + // Derive category map from sites — NewBook includes site_category_id/name/order per site + const categoryMap = {} + for (const site of sites) { + const catId = String(site.site_category_id || '') + if (catId && !categoryMap[catId]) { + categoryMap[catId] = { + id: catId, + name: site.site_category_name || catId, + order: site.site_category_order ?? 999, + } + } + } + + // Classify each site + const rooms = [] + for (const site of sites) { + const catId = String(site.site_category_id || '') + const isExcluded = excludedCategories.includes(catId) + if (isExcluded && hideExcluded) continue + + const classified = classifyRoom(site, bookings, viewDate, yesterday, tomorrow) + + // Filter booking data per capabilities + if (classified.booking) { + classified.booking = filterBookingData( + classified.booking, canSeeGuest, canSeeRate, canSeeAllNotes, visibleNoteTypes + ) + } + if (classified.departing_booking && classified.departing_booking !== classified.booking) { + classified.departing_booking = filterBookingData( + classified.departing_booking, canSeeGuest, canSeeRate, canSeeAllNotes, visibleNoteTypes + ) + } + classified.previous_booking = classified.previous_booking + ? filterBookingData(classified.previous_booking, false, false, false, []) + : null + classified.next_booking = classified.next_booking + ? filterBookingData(classified.next_booking, false, false, false, []) + : null + + classified.filter_excluded = isExcluded + + // Attach tasks for this site across the 3-day window, keyed by date + const siteId = String(site.site_id) + classified.tasks = tasks + .filter(t => String(t.booking_site_id || t.site_id) === siteId) + .map(t => ({ + task_id: t.task_id, + task_description: t.task_description, + task_type_id: String(t.task_type_id), + task_when_date: t.task_when_date ? toDateStr(t.task_when_date) : null, + task_period_from: t.task_period_from ? toDateStr(t.task_period_from) : null, + task_period_to: t.task_period_to ? toDateStr(t.task_period_to) : null, + site_id: siteId, + booking_id: t.booking_id, + completed_on: t.task_completed_on || null, + })) + + rooms.push(classified) + } + + return { + view_date: viewDate, + yesterday, + tomorrow, + rooms, + categories: Object.values(categoryMap).sort((a, b) => a.order - b.order), + } + }) +} diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js new file mode 100644 index 0000000..8130fa7 --- /dev/null +++ b/backend/src/routes/status.js @@ -0,0 +1,41 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool } from '../db.js' +import { updateSiteStatus } from '../lib/newbook.js' + +const VALID_STATUSES = ['Clean', 'Dirty', 'Inspected'] + +export async function statusRoutes(app) { + app.addHook('preHandler', requireAuth) + + // POST /api/status + // Body: { room_id, status: 'Clean'|'Dirty'|'Inspected', service_date, booking_ref } + app.post('/api/status', { preHandler: requireCap('update_status') }, async (req, reply) => { + const { room_id, status, service_date, booking_ref } = req.body || {} + + if (!room_id) return reply.status(400).send({ error: 'room_id required' }) + if (!VALID_STATUSES.includes(status)) { + return reply.status(400).send({ error: `status must be one of: ${VALID_STATUSES.join(', ')}` }) + } + + try { + await updateSiteStatus(room_id, status) + } catch (err) { + return reply.status(502).send({ error: `NewBook error: ${err.message}` }) + } + + // Log to activity + const eventType = status === 'Clean' ? 'status_clean' : 'status_dirty' + await pool.query( + `INSERT INTO activity_log (room_id, event_type, event_data, user_name, service_date, booking_ref) + VALUES ($1, $2, $3, $4, $5, $6)`, + [room_id, eventType, JSON.stringify({ status }), req.user.name, + service_date || new Date().toISOString().slice(0, 10), booking_ref || null] + ).catch(() => {}) + + await pool.query( + `DELETE FROM activity_log WHERE occurred_at < NOW() - INTERVAL '48 hours'` + ).catch(() => {}) + + return { ok: true } + }) +} diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js new file mode 100644 index 0000000..0ae445e --- /dev/null +++ b/backend/src/routes/tasks.js @@ -0,0 +1,58 @@ +import { requireAuth, requireCap } from '../auth.js' +import { pool } from '../db.js' +import { completeTask, uncompleteTask } from '../lib/newbook.js' + +export async function taskRoutes(app) { + app.addHook('preHandler', requireAuth) + + // POST /api/tasks/complete + // Body: { task_id, room_id, service_date, booking_ref } + app.post('/api/tasks/complete', { preHandler: requireCap('complete_tasks') }, async (req, reply) => { + const { task_id, room_id, service_date, booking_ref } = req.body || {} + if (!task_id) return reply.status(400).send({ error: 'task_id required' }) + + let nbResult + try { + nbResult = await completeTask(task_id) + } catch (err) { + return reply.status(502).send({ error: `NewBook error: ${err.message}` }) + } + + // Log tasks_complete event in activity log if site_status came back clean + // (NewBook marks clean when last task is ticked — site_status in response indicates this) + const siteStatus = nbResult?.site_status || nbResult?.data?.site_status + if (room_id && siteStatus === 'Clean') { + await pool.query( + `INSERT INTO activity_log (room_id, event_type, event_data, user_name, service_date, booking_ref) + VALUES ($1, 'tasks_complete', $2, $3, $4, $5)`, + [room_id, JSON.stringify({ site_status: 'Clean' }), req.user.name, + service_date || new Date().toISOString().slice(0, 10), booking_ref || null] + ).catch(() => {}) // non-critical + + await pruneActivityLog() + } + + return { ok: true, site_status: siteStatus || null } + }) + + // POST /api/tasks/uncomplete + // Body: { task_id } + app.post('/api/tasks/uncomplete', { preHandler: requireCap('complete_tasks') }, async (req, reply) => { + const { task_id } = req.body || {} + if (!task_id) return reply.status(400).send({ error: 'task_id required' }) + + try { + await uncompleteTask(task_id) + } catch (err) { + return reply.status(502).send({ error: `NewBook error: ${err.message}` }) + } + + return { ok: true } + }) +} + +async function pruneActivityLog() { + await pool.query( + `DELETE FROM activity_log WHERE occurred_at < NOW() - INTERVAL '48 hours'` + ).catch(() => {}) +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1a82b9a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +services: + backend: + build: ./backend + security_opt: + - apparmor=unconfined + environment: + - DATABASE_URL=${DATABASE_URL} + - CENTRAL_AUTH_SECRET=${CENTRAL_AUTH_SECRET} + - SETTINGS_URL=${SETTINGS_URL} + - SETTINGS_SECRET=${SETTINGS_SECRET} + - APP_SLUG=room-planner + - OFFICE_IP_CHECK=${OFFICE_IP_CHECK:-disabled} + - NEWBOOK_LOCATION_ID=${NEWBOOK_LOCATION_ID} + - DEFAULT_CHECKOUT_TIME=${DEFAULT_CHECKOUT_TIME:-11:00} + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health || exit 1"] + interval: 10s + retries: 5 + start_period: 20s + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + VITE_HOTEL_NAME: ${VITE_HOTEL_NAME} + security_opt: + - apparmor=unconfined + ports: + - "${FRONTEND_PORT:-3080}:80" + depends_on: + backend: + condition: service_healthy + restart: unless-stopped + +networks: + default: + driver: bridge diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..0a0c7ce --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine AS build +WORKDIR /app +COPY package.json . +RUN npm install +COPY . . +ARG VITE_HOTEL_NAME +ENV VITE_HOTEL_NAME=$VITE_HOTEL_NAME +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +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..88fe588 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Room Planner + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..dff3788 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 80; + + location /room-planner/ { + alias /usr/share/nginx/html/; + try_files $uri $uri/ /room-planner/index.html; + } + + location /room-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; + # SSE support + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 3600s; + } + + location /room-planner/health { + proxy_pass http://backend:3001/health; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..571e504 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "hnf-room-planner-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.468.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..74d4452 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,22 @@ +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 Settings from './pages/Settings' + +export default function App() { + return ( + + + + + } /> + } /> + } /> + } /> + + + + + ) +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..43c4b57 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,77 @@ +import type { RoomsResponse, ActivityEntry, AppConfig, TaskData } from './types' + +const BASE = '/room-planner/api' + +async function request(path: string, opts: RequestInit = {}): Promise { + const res = await fetch(`${BASE}${path}`, { + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...opts.headers }, + ...opts, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })) + throw new Error(err.error || `Request failed: ${res.status}`) + } + return res.json() +} + +export function fetchRooms(date: string): Promise { + return request(`/rooms?date=${date}`) +} + +export function completeTask(taskId: string, roomId: string, serviceDate: string, bookingRef?: string) { + return request<{ ok: boolean; site_status: string | null }>('/tasks/complete', { + method: 'POST', + body: JSON.stringify({ task_id: taskId, room_id: roomId, service_date: serviceDate, booking_ref: bookingRef }), + }) +} + +export function uncompleteTask(taskId: string) { + return request<{ ok: boolean }>('/tasks/uncomplete', { + method: 'POST', + body: JSON.stringify({ task_id: taskId }), + }) +} + +export function updateStatus(roomId: string, status: string, serviceDate: string, bookingRef?: string) { + return request<{ ok: boolean }>('/status', { + method: 'POST', + body: JSON.stringify({ room_id: roomId, status, service_date: serviceDate, booking_ref: bookingRef }), + }) +} + +export function fetchConfig(): Promise { + return request('/config') +} + +export function updateConfig(key: string, value: unknown): Promise<{ ok: boolean }> { + return request(`/config/${key}`, { + method: 'PUT', + body: JSON.stringify({ value }), + }) +} + +export function fetchTaskTypes(): Promise> { + return request('/config/task-types') +} + +export function fetchActivity(date: string): Promise { + return request(`/activity?date=${date}`) +} + +export function logActivity(entry: { + room_id: string + event_type: ActivityEntry['event_type'] + event_data?: Record + service_date?: string + booking_ref?: string +}): Promise<{ ok: boolean }> { + return request('/activity', { method: 'POST', body: JSON.stringify(entry) }) +} + +export function fetchEvents(since: string): Promise<{ + pings: Array<{ id: number; booking_ids: string[]; event_types: string[]; detected_at: string }> + server_time: string +}> { + return request(`/events?since=${encodeURIComponent(since)}`) +} diff --git a/frontend/src/components/ActivityPanel.tsx b/frontend/src/components/ActivityPanel.tsx new file mode 100644 index 0000000..8c91655 --- /dev/null +++ b/frontend/src/components/ActivityPanel.tsx @@ -0,0 +1,59 @@ +import type { ActivityEntry } from '../types' + +interface Props { + entries: ActivityEntry[] +} + +const EVENT_LABELS: Record = { + checkout: 'Checked out', + checkin: 'Checked in', + status_clean: 'Marked clean', + status_dirty: 'Marked dirty', + tasks_complete: 'All tasks done', +} + +const EVENT_CLASS: Record = { + checkout: 'event-checkout', + checkin: 'event-checkin', + status_clean: 'event-clean', + status_dirty: 'event-dirty', + tasks_complete: 'event-tasks', +} + +function relativeTime(iso: string) { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 1) return 'Just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + return new Date(iso).toLocaleDateString() +} + +export default function ActivityPanel({ entries }: Props) { + return ( + + ) +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..c6afac4 --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState, createContext, useContext } from 'react' +import type { User } from '../types' + +interface AuthCtx { user: User } +const Ctx = createContext(null) + +export function useAuth() { + const ctx = useContext(Ctx) + if (!ctx) throw new Error('useAuth must be used inside AuthGate') + return ctx +} + +export default function AuthGate({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + fetch('/api/auth/verify?app=room-planner', { credentials: 'include' }) + .then(r => { + if (r.status === 401 || r.status === 403) { + window.location.href = `/portal?redirect=${encodeURIComponent(window.location.href)}` + return null + } + if (!r.ok) throw new Error(`Auth check failed: ${r.status}`) + return r.json() + }) + .then(data => { if (data) setUser(data.user) }) + .catch(err => setError(err.message)) + }, []) + + if (error) { + return ( +
+ Authentication error: {error} +
+ ) + } + + if (!user) { + return ( +
+ Loading… +
+ ) + } + + return {children} +} diff --git a/frontend/src/components/CategoryGroup.tsx b/frontend/src/components/CategoryGroup.tsx new file mode 100644 index 0000000..06091ea --- /dev/null +++ b/frontend/src/components/CategoryGroup.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react' +import { ChevronDown } from 'lucide-react' +import type { RoomData, AppConfig } from '../types' +import RoomCard from './RoomCard' + +interface Props { + categoryId: string + categoryName: string + rooms: RoomData[] + viewDate: string + config: AppConfig | null + onRoomClick: (room: RoomData) => void +} + +export default function CategoryGroup({ categoryId, categoryName, rooms, viewDate, config, onRoomClick }: Props) { + const [open, setOpen] = useState(true) + + const outstandingTasks = rooms.reduce((n, r) => { + return n + r.tasks.filter(t => { + const d = t.task_when_date || t.task_period_from + const inWindow = !d || (d <= viewDate && (!t.task_period_to || t.task_period_to >= viewDate)) + return inWindow && !t.completed_on + }).length + }, 0) + + const dirtyCount = rooms.filter(r => (r.site_status || '').toLowerCase() === 'dirty').length + + return ( +
+
setOpen(o => !o)}> + {categoryName} + {rooms.length} + {outstandingTasks > 0 && ( + {outstandingTasks} tasks + )} + {dirtyCount > 0 && ( + {dirtyCount} dirty + )} + +
+ + {open && ( +
+ {rooms.map(room => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/CheckoutNotification.tsx b/frontend/src/components/CheckoutNotification.tsx new file mode 100644 index 0000000..abad0fa --- /dev/null +++ b/frontend/src/components/CheckoutNotification.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react' +import { X } from 'lucide-react' + +interface Toast { + id: string + roomName: string + message: string +} + +interface Props { + toasts: Toast[] + onDismiss: (id: string) => void +} + +export type { Toast } + +export default function CheckoutNotification({ toasts, onDismiss }: Props) { + if (toasts.length === 0) return null + + return ( +
+ {toasts.map(toast => ( +
+ +
{toast.roomName}
+
{toast.message}
+
+ ))} +
+ ) +} + +// Hook to auto-expire toasts +export function useToasts(timeoutMs = 30000) { + const [toasts, setToasts] = useState([]) + + const addToast = (roomName: string, message: string) => { + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}` + setToasts(t => [...t, { id, roomName, message }]) + setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), timeoutMs) + } + + const dismiss = (id: string) => setToasts(t => t.filter(x => x.id !== id)) + + return { toasts, addToast, dismiss } +} diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx new file mode 100644 index 0000000..799358e --- /dev/null +++ b/frontend/src/components/FilterBar.tsx @@ -0,0 +1,133 @@ +import { useRef } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import type { FlowType, FilterMode, FilterState, StatFilters, StatFilterMode } from '../types' +import type { RoomData } from '../types' + +interface FilterBarProps { + filters: FilterState + categories: Array<{ id: string; name: string }> + rooms: RoomData[] + viewDate: string + onToggleCategory: (id: string) => void + onToggleFlow: (flow: FlowType) => void +} + +const FLOW_TYPES: FlowType[] = ['arrive', 'depart', 'stopover', 'back-to-back', 'vacant', 'blocked'] +const FLOW_LABELS: Record = { + arrive: 'Arriving', depart: 'Departing', stopover: 'Staying', + 'back-to-back': 'B2B', vacant: 'Vacant', blocked: 'Blocked', +} + +function nextMode(mode: FilterMode): FilterMode { + if (mode === 'off') return 'inclusive' + if (mode === 'inclusive') return 'exclusive' + return 'off' +} + +function countByCategory(rooms: RoomData[], catId: string) { + return rooms.filter(r => r.category_id === catId).length +} + +function countByFlow(rooms: RoomData[], flow: FlowType) { + return rooms.filter(r => r.flow_type === flow).length +} + +export function FilterBar({ filters, categories, rooms, viewDate, onToggleCategory, onToggleFlow }: FilterBarProps) { + const scrollRef = useRef(null) + + const scroll = (dir: 'left' | 'right') => { + scrollRef.current?.scrollBy({ left: dir === 'left' ? -120 : 120, behavior: 'smooth' }) + } + + return ( +
+ {/* Category filter row */} +
+ +
+ {categories.map(cat => { + const mode = filters.categories[cat.id] ?? 'off' + const count = countByCategory(rooms, cat.id) + return ( + + ) + })} +
+ +
+ + {/* Flow type filter row */} +
+
+ {FLOW_TYPES.map(flow => { + const mode = filters.flowTypes[flow] ?? 'off' + const count = countByFlow(rooms, flow) + if (count === 0 && mode === 'off') return null + return ( + + ) + })} +
+
+
+ ) +} + +interface StatFilterBarProps { + statFilters: StatFilters + onChange: (key: keyof StatFilters) => void +} + +function nextStatMode(mode: StatFilterMode): StatFilterMode { + if (mode === 'off') return 'show-only' + if (mode === 'show-only') return 'hide' + return 'off' +} + +const STAT_LABELS: Record = { + newbookTasks: ['Tasks', 'Has Tasks', 'No Tasks'], + cleanDirty: ['Clean/Dirty', 'Dirty Only', 'Clean Only'], +} + +export function StatFilterBar({ statFilters, onChange }: StatFilterBarProps) { + return ( +
+
+
+ {(Object.keys(statFilters) as Array).map(key => { + const mode = statFilters[key] + const [off, showOnly, hide] = STAT_LABELS[key] + const label = mode === 'off' ? off : mode === 'show-only' ? showOnly : hide + return ( + + ) + })} +
+
+
+ ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..87e428b --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,55 @@ +import { NavLink } from 'react-router-dom' +import { BedDouble, Settings, LogOut } from 'lucide-react' +import { useAuth } from './AuthGate' + +const ICON_PROPS = { size: 16, strokeWidth: 1.75 } + +export default function Layout({ children }: { children: React.ReactNode }) { + const { user } = useAuth() + const hasCap = (cap: string) => user.caps.includes(`room-planner:${cap}`) + + return ( +
+ {/* Desktop sidebar */} + + + {/* Mobile top bar */} +
+ + Room Planner + +
+ +
+ {children} +
+
+ ) +} diff --git a/frontend/src/components/RoomCard.tsx b/frontend/src/components/RoomCard.tsx new file mode 100644 index 0000000..7d522d3 --- /dev/null +++ b/frontend/src/components/RoomCard.tsx @@ -0,0 +1,127 @@ +import { memo } from 'react' +import type { RoomData, AppConfig } from '../types' +import { + roomCardColor, bookingStatusColor, FLOW_TYPE_LABEL, sliverColor, spanDataAttrs, + hasOutstandingTasks, isDirty, +} from '../lib/booking-flow' +import { detectTwin } from '../lib/twin-detect' + +interface Props { + room: RoomData + viewDate: string + config: AppConfig | null + onClick: (room: RoomData) => void +} + +function RoomCard({ room, viewDate, config, onClick }: Props) { + const booking = room.booking + const twinType = detectTwin(booking, config) + const taskDisplay = config?.task_display ?? {} + const isBlocked = room.flow_type === 'blocked' + + const stripColor = booking ? bookingStatusColor(booking.booking_status) : 'transparent' + const prevColor = sliverColor(room.previous_status) + const nextColor = sliverColor(room.next_status) + + // Tasks relevant for today + const todayTasks = room.tasks.filter(t => { + const d = t.task_when_date || t.task_period_from + return !d || d <= viewDate && (t.task_period_to ? t.task_period_to >= viewDate : d === viewDate) + }) + + const outstanding = todayTasks.filter(t => !t.completed_on) + const dirty = isDirty(room) + + const spanAttrs = spanDataAttrs(room) + const cssVars: Record = {} + if (room.spans_previous) cssVars['--prev-color'] = prevColor + if (room.spans_next) cssVars['--next-color'] = nextColor + + return ( +
)} + onClick={() => onClick(room)} + > + {!isBlocked && ( +
+ )} + +
+
+ {room.site_name} +
+ {twinType === 'twin' && Twin} + {twinType === 'extra-bed' && +Bed} + {booking?.pax != null && booking.pax > 0 && ( + {booking.pax}p + )} + {room.departing_time && ( + {room.departing_time.slice(0,5)} + )} +
+
+ + {!isBlocked && ( +
+ {booking?.guest_name ? ( + {booking.guest_name} + ) : ( + + {room.flow_type === 'vacant' ? 'Vacant' : '—'} + + )} + {booking?.rate_plan_name && ( + {booking.rate_plan_name} + )} +
+ )} + +
+ {!isBlocked && ( + + {FLOW_TYPE_LABEL[room.flow_type]} + + )} + + + {dirty ? 'Dirty' : room.site_status} + + + {outstanding.length > 0 && ( + + {outstanding.length} task{outstanding.length > 1 ? 's' : ''} + + )} +
+ + {/* Task dots for show_on_card tasks */} + {todayTasks.length > 0 && ( +
+ {todayTasks.map(t => { + const display = taskDisplay[t.task_type_id] + if (!display?.show_on_card) return null + return ( +
+ ) + })} +
+ )} +
+
+ ) +} + +export default memo(RoomCard) diff --git a/frontend/src/components/RoomModal.tsx b/frontend/src/components/RoomModal.tsx new file mode 100644 index 0000000..0340511 --- /dev/null +++ b/frontend/src/components/RoomModal.tsx @@ -0,0 +1,276 @@ +import { useState, useCallback } from 'react' +import { X, CheckSquare, Square, Package, ClipboardList } from 'lucide-react' +import type { RoomData, TaskData, AppConfig, User } from '../types' +import { bookingStatusColor, isDirty } from '../lib/booking-flow' +import { detectTwin } from '../lib/twin-detect' +import * as api from '../api' + +interface Props { + room: RoomData + viewDate: string + config: AppConfig | null + user: User + onClose: () => void + onTaskToggle: (taskId: string, completed: boolean, siteStatus: string | null) => void + onStatusUpdate: (roomId: string, status: string) => void +} + +const ICON = { size: 14, strokeWidth: 1.75 } + +function formatTime(dt: string | null | undefined) { + if (!dt) return '—' + const t = dt.slice(11, 16) + return t || dt.slice(0, 10) +} + +function formatDate(dt: string | null | undefined) { + if (!dt) return '—' + return dt.slice(0, 10) +} + +export default function RoomModal({ room, viewDate, config, user, onClose, onTaskToggle, onStatusUpdate }: Props) { + const booking = room.booking + const departing = room.departing_booking + + const [loadingTasks, setLoadingTasks] = useState>(new Set()) + const [statusLoading, setStatusLoading] = useState(false) + const [notesTab, setNotesTab] = useState<'booking' | 'departing'>('booking') + + const hasCap = (cap: string) => user.caps.includes(`room-planner:${cap}`) + + const todayTasks = room.tasks.filter(t => { + const d = t.task_when_date || t.task_period_from + return !d || (d <= viewDate && (!t.task_period_to || t.task_period_to >= viewDate)) + }) + + const isRollover = (t: TaskData) => { + const d = t.task_when_date || t.task_period_from + return !!d && d < viewDate + } + + const handleTaskToggle = useCallback(async (task: TaskData) => { + if (!hasCap('complete_tasks')) return + if (loadingTasks.has(task.task_id)) return + + setLoadingTasks(prev => new Set([...prev, task.task_id])) + try { + if (task.completed_on) { + await api.uncompleteTask(task.task_id) + onTaskToggle(task.task_id, false, null) + } else { + const result = await api.completeTask(task.task_id, room.site_id, viewDate, booking?.booking_reference_id) + onTaskToggle(task.task_id, true, result.site_status) + } + } catch (err) { + console.error('Task toggle failed:', err) + } finally { + setLoadingTasks(prev => { const s = new Set(prev); s.delete(task.task_id); return s }) + } + }, [loadingTasks, booking, room.site_id, viewDate, hasCap]) + + const handleStatus = useCallback(async (status: string) => { + if (!hasCap('update_status') || statusLoading) return + setStatusLoading(true) + try { + await api.updateStatus(room.site_id, status, viewDate, booking?.booking_reference_id) + onStatusUpdate(room.site_id, status) + } catch (err) { + console.error('Status update failed:', err) + } finally { + setStatusLoading(false) + } + }, [statusLoading, room.site_id, viewDate, booking, hasCap]) + + const twinType = detectTwin(booking, config) + const stripColor = booking ? bookingStatusColor(booking.booking_status) : 'var(--app-primary)' + + const allNotesForTab = notesTab === 'booking' + ? (booking?.notes ?? []) + : (departing?.notes ?? []) + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+ {/* Header */} +
+
+
{room.site_name}
+
+ + {(booking?.booking_status || room.flow_type).toUpperCase()} + + {twinType && ( + + {twinType === 'twin' ? 'TWIN' : twinType === 'extra-bed' ? '+BED' : 'DOUBLE'} + + )} +
+
+ +
+ +
+ {/* Booking info bar */} + {booking && ( +
+ {hasCap('guest_details') && booking.guest_name && ( +
+
Guest
+
{booking.guest_name}
+
+ )} +
+
Arrival
+
{formatDate(booking.booking_arrival)}
+
+
+
Departure
+
{formatDate(booking.booking_departure)}
+
+ {booking.pax != null && ( +
+
Pax
+
{booking.pax}
+
+ )} + {hasCap('rate_details') && booking.rate_plan_name && ( +
+
Rate
+
{booking.rate_plan_name}
+
+ )} + {booking.booking_eta && ( +
+
ETA
+
{formatTime(booking.booking_eta)}
+
+ )} + {booking.booking_reference_id && ( +
+
Ref
+
{booking.booking_reference_id}
+
+ )} +
+ )} + + {/* Departing booking bar */} + {departing && departing.booking_id !== booking?.booking_id && ( +
+
+
Departing today
+ {hasCap('guest_details') && departing.guest_name && ( +
{departing.guest_name}
+ )} +
+ {departing.booking_reference_id && ( +
+
Ref
+
{departing.booking_reference_id}
+
+ )} +
+ )} + + {/* Room status */} +
+
Room Status
+
+ {(['Clean', 'Dirty', 'Inspected'] as const).map(s => ( + + ))} +
+
+ + {/* NewBook Tasks */} + {todayTasks.length > 0 && ( +
+
+ Tasks ({todayTasks.filter(t => !t.completed_on).length} outstanding) +
+
+ {todayTasks.map(task => { + const isLoading = loadingTasks.has(task.task_id) + const done = !!task.completed_on + const rollover = isRollover(task) + return ( +
handleTaskToggle(task)} + style={{ cursor: hasCap('complete_tasks') ? 'pointer' : 'default' }} + > +
+ {done ? : null} +
+ + {task.task_description} + + {rollover && Rollover} +
+ ) + })} +
+
+ )} + + {/* Notes */} + {((booking?.notes?.length ?? 0) > 0 || (departing?.notes?.length ?? 0) > 0) && ( +
+
Notes
+ {departing && departing.booking_id !== booking?.booking_id && ( +
+ + +
+ )} + {allNotesForTab.length === 0 ? ( +
No notes
+ ) : ( + allNotesForTab.map(note => ( +
+ {note.note_text} +
+ )) + )} +
+ )} + + {/* Placeholder: Linen Count */} +
+ +
Linen Count — coming soon
+
+ + {/* Placeholder: Routine Tasks */} +
+ +
Routine Tasks — coming soon
+
+
+
+
+ ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..48d356b --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,903 @@ +/* Stack design system tokens */ +:root { + --navy: #1a1a2e; + --gold: #c9a84c; + --body-bg: #f4f5f7; + --card-bg: #ffffff; + --text-primary: #1a1a2e; + --text-muted: #6b7280; + --border: #e5e7eb; + --radius: 8px; + --shadow-sm: 0 1px 3px rgba(0,0,0,0.08); + --shadow-md: 0 4px 12px rgba(0,0,0,0.12); + + /* App theme */ + --app-primary: #2d6a4f; + --app-primary-light: #40916c; + --app-primary-dark: #1b4332; + + /* Booking status colours */ + --col-arrived: #3b82f6; + --col-confirmed: #10b981; + --col-unconfirmed: #f59e0b; + --col-departed: #a855f7; + --col-blocked: #6b7280; + --col-cancelled: #94a3b8; + + /* Planner layout */ + --sidebar-w: 240px; + --topbar-h: 56px; + --card-h: 72px; + --sliver-w: 23px; +} + +*, *::before, *::after { box-sizing: border-box; } + +html, body, #root { + height: 100%; + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--body-bg); + color: var(--text-primary); + font-size: 14px; +} + +/* ── Scrollbars ─────────────────────────────────────────────── */ +::-webkit-scrollbar { width: 4px; height: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +/* ── Layout ─────────────────────────────────────────────────── */ +.app-shell { + display: flex; + height: 100vh; + overflow: hidden; +} + +/* Sidebar (desktop) */ +.sidebar { + width: var(--sidebar-w); + background: var(--navy); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow-y: auto; +} +.sidebar-logo { + padding: 20px 16px 12px; + color: var(--gold); + font-size: 13px; + font-weight: 600; + letter-spacing: .05em; + text-transform: uppercase; + display: flex; + align-items: center; + gap: 8px; +} +.sidebar-logo svg { opacity: .8; } +.sidebar-nav { + flex: 1; + padding: 8px 0; +} +.sidebar-nav a { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + color: rgba(255,255,255,.65); + text-decoration: none; + font-size: 13.5px; + transition: background .15s, color .15s; +} +.sidebar-nav a:hover { background: rgba(255,255,255,.05); color: #fff; } +.sidebar-nav a.active { background: rgba(201,168,76,.1); color: var(--gold); } +.sidebar-user { + padding: 12px 16px; + border-top: 1px solid rgba(255,255,255,.08); + color: rgba(255,255,255,.45); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Mobile top bar */ +.top-bar { + display: none; + height: var(--topbar-h); + background: var(--navy); + color: #fff; + align-items: center; + padding: 0 12px; + gap: 10px; + flex-shrink: 0; +} +.top-bar-title { + flex: 1; + font-size: 15px; + font-weight: 600; + color: var(--gold); +} +.top-bar-nav { + display: flex; + gap: 4px; +} +.top-bar-nav a { + color: rgba(255,255,255,.6); + padding: 6px 10px; + border-radius: 6px; + text-decoration: none; + font-size: 12px; +} +.top-bar-nav a.active { color: var(--gold); } + +.page-content { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +@media (max-width: 768px) { + .sidebar { display: none; } + .top-bar { display: flex; } +} + +/* ── Date bar ────────────────────────────────────────────────── */ +.date-bar { + background: var(--card-bg); + border-bottom: 1px solid var(--border); + padding: 8px 12px; + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.date-bar input[type="date"] { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 5px 8px; + font-size: 14px; + color: var(--text-primary); + background: var(--body-bg); +} +.date-nav-btn { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 10px; + cursor: pointer; + color: var(--text-muted); + font-size: 13px; + transition: background .12s; +} +.date-nav-btn:hover { background: var(--body-bg); } +.date-today-btn { + background: var(--app-primary); + color: #fff; + border: none; + border-radius: 6px; + padding: 5px 12px; + cursor: pointer; + font-size: 13px; +} +.date-label { + flex: 1; + font-weight: 600; + font-size: 14px; +} +.refresh-btn { + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + padding: 4px; + border-radius: 6px; + display: flex; + transition: color .12s; +} +.refresh-btn:hover { color: var(--app-primary); } +.refresh-btn.spinning svg { animation: spin .7s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Filter bars ─────────────────────────────────────────────── */ +.filter-section { + background: var(--card-bg); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.filter-bar { + position: relative; + display: flex; + align-items: center; + overflow: hidden; +} +.filter-bar-scroll { + display: flex; + gap: 6px; + padding: 6px 10px; + overflow-x: auto; + scroll-behavior: smooth; + scrollbar-width: none; + flex: 1; +} +.filter-bar-scroll::-webkit-scrollbar { display: none; } +.filter-arrow { + background: linear-gradient(to right, transparent, var(--card-bg) 40%); + border: none; + cursor: pointer; + color: var(--text-muted); + padding: 0 6px; + height: 100%; + flex-shrink: 0; + display: flex; + align-items: center; +} +.filter-arrow.left { background: linear-gradient(to left, transparent, var(--card-bg) 40%); } + +/* Filter chips */ +.filter-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 20px; + border: 1px solid var(--border); + background: var(--body-bg); + cursor: pointer; + white-space: nowrap; + font-size: 12px; + color: var(--text-muted); + user-select: none; + transition: all .12s; +} +.filter-chip:hover { border-color: var(--app-primary); color: var(--app-primary); } +.filter-chip.inclusive { + background: #d1fae5; + border-color: var(--col-confirmed); + color: #065f46; +} +.filter-chip.exclusive { + background: #fee2e2; + border-color: #ef4444; + color: #991b1b; +} +.chip-count { + background: rgba(0,0,0,.12); + border-radius: 10px; + padding: 1px 5px; + font-size: 10px; + min-width: 16px; + text-align: center; +} + +/* Stat filter chips */ +.stat-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 20px; + border: 1px solid var(--border); + background: var(--body-bg); + cursor: pointer; + font-size: 12px; + color: var(--text-muted); + user-select: none; + transition: all .12s; +} +.stat-chip.show-only { background: #fffbeb; border-color: #f59e0b; color: #92400e; } +.stat-chip.hide { background: #f3f4f6; border-color: #9ca3af; color: #374151; text-decoration: line-through; } + +/* ── Planner body ────────────────────────────────────────────── */ +.planner-body { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* ── Category group ──────────────────────────────────────────── */ +.category-group { + margin-bottom: 2px; +} +.category-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px 5px; + background: var(--body-bg); + border-bottom: 1px solid var(--border); + cursor: pointer; + user-select: none; + position: sticky; + top: 0; + z-index: 10; +} +.category-name { + flex: 1; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--text-muted); +} +.category-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + font-weight: 600; +} +.badge-tasks { background: #fef3c7; color: #92400e; } +.badge-dirty { background: #fee2e2; color: #991b1b; } +.category-chevron { color: var(--text-muted); transition: transform .15s; } +.category-chevron.open { transform: rotate(0deg); } +.category-chevron.closed { transform: rotate(-90deg); } +.category-rooms { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1px; + background: var(--border); +} + +/* ── Room card ───────────────────────────────────────────────── */ +.room-card { + position: relative; + background: var(--card-bg); + min-height: var(--card-h); + cursor: pointer; + overflow: hidden; + transition: box-shadow .12s; + padding-left: var(--sliver-w); + padding-right: var(--sliver-w); +} +.room-card:hover { box-shadow: var(--shadow-md); z-index: 1; } + +/* Left sliver (previous day status) */ +.room-card[data-spans-previous="true"]::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: var(--sliver-w); + background: var(--prev-color, transparent); + opacity: .7; +} +/* Right sliver (next day status) */ +.room-card[data-spans-next="true"]::after { + content: ''; + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: var(--sliver-w); + background: var(--next-color, transparent); + opacity: .7; +} + +/* Blocked rooms — fill entire card */ +.room-card.blocked-room { + background: var(--col-blocked); +} +.room-card.blocked-room .card-room-name, +.room-card.blocked-room .card-status-dot { color: rgba(255,255,255,.85); } + +/* Status indicator strip at top of card */ +.card-status-strip { + position: absolute; + top: 0; + left: var(--sliver-w); + right: var(--sliver-w); + height: 3px; +} + +/* Card inner */ +.card-inner { + padding: 7px 10px; + display: flex; + flex-direction: column; + gap: 3px; + height: 100%; +} +.card-top { + display: flex; + align-items: center; + gap: 6px; +} +.card-room-name { + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + flex: 1; +} +.card-badges { + display: flex; + gap: 3px; + flex-wrap: wrap; +} +.card-badge { + font-size: 9px; + font-weight: 600; + padding: 2px 5px; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: .04em; +} +.badge-twin { background: #e0e7ff; color: #3730a3; } +.badge-extra { background: #fce7f3; color: #9d174d; } +.badge-pax { background: #f3f4f6; color: #374151; } +.badge-depart-time { background: #f5f3ff; color: #6d28d9; } + +.card-mid { + display: flex; + align-items: center; + gap: 6px; +} +.card-guest-name { + font-size: 12px; + color: var(--text-muted); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.card-rate { + font-size: 10px; + color: var(--text-muted); + background: #f9fafb; + border-radius: 4px; + padding: 1px 5px; +} + +.card-bottom { + display: flex; + align-items: center; + gap: 6px; + margin-top: auto; +} +.card-flow-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .05em; + padding: 2px 6px; + border-radius: 4px; + flex-shrink: 0; +} +.card-clean-status { + font-size: 10px; + font-weight: 600; + padding: 2px 6px; + border-radius: 4px; +} +.clean-dot { background: #d1fae5; color: #065f46; } +.dirty-dot { background: #fee2e2; color: #991b1b; } + +/* Task dots row */ +.card-task-dots { + display: flex; + gap: 3px; + flex-wrap: wrap; + margin-top: 2px; +} +.task-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.task-dot.complete { opacity: .3; } + +/* ── Room modal ──────────────────────────────────────────────── */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,.4); + z-index: 100; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 0; + animation: fade-in .15s ease; +} +@keyframes fade-in { from { opacity: 0; } } + +@media (min-width: 640px) { + .modal-overlay { + align-items: center; + padding: 20px; + } +} + +.modal-sheet { + background: var(--card-bg); + border-radius: 16px 16px 0 0; + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + overflow: hidden; + animation: slide-up .2s ease; +} +@keyframes slide-up { from { transform: translateY(30px); opacity: 0; } } + +@media (min-width: 640px) { + .modal-sheet { + border-radius: 16px; + max-width: 600px; + max-height: 85vh; + } +} + +.modal-header { + display: flex; + align-items: center; + padding: 16px 20px 12px; + border-bottom: 1px solid var(--border); + gap: 12px; + flex-shrink: 0; +} +.modal-header-main { flex: 1; min-width: 0; } +.modal-room-name { + font-size: 20px; + font-weight: 700; +} +.modal-status-badge { + display: inline-block; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + padding: 2px 7px; + border-radius: 4px; + margin-top: 2px; +} +.modal-close { + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + padding: 6px; + border-radius: 8px; + display: flex; +} +.modal-close:hover { background: var(--body-bg); } + +.modal-body { + flex: 1; + overflow-y: auto; + padding: 0; +} + +/* Modal booking info bar */ +.modal-booking-bar { + display: flex; + gap: 12px; + padding: 12px 20px; + background: var(--body-bg); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.modal-booking-field { + display: flex; + flex-direction: column; + gap: 1px; +} +.field-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: .04em; } +.field-value { font-size: 13px; font-weight: 600; } + +/* Modal section */ +.modal-section { + padding: 14px 20px; + border-bottom: 1px solid var(--border); +} +.modal-section:last-child { border-bottom: none; } +.modal-section-title { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--text-muted); + margin-bottom: 10px; +} + +/* Tasks in modal */ +.task-list { display: flex; flex-direction: column; gap: 6px; } +.task-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border-radius: var(--radius); + background: var(--body-bg); + transition: background .12s; +} +.task-item:hover { background: #f0fdf4; } +.task-checkbox { + width: 18px; + height: 18px; + border-radius: 4px; + border: 2px solid var(--border); + background: var(--card-bg); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: all .12s; +} +.task-checkbox.checked { + background: var(--app-primary); + border-color: var(--app-primary); + color: #fff; +} +.task-checkbox.loading { opacity: .5; } +.task-label { font-size: 13px; flex: 1; } +.task-label.done { text-decoration: line-through; color: var(--text-muted); } +.task-rollover-tag { + font-size: 9px; + background: #fef3c7; + color: #92400e; + border-radius: 3px; + padding: 1px 4px; +} + +/* Status update buttons in modal */ +.status-btn-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.status-btn { + padding: 7px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--body-bg); + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all .12s; +} +.status-btn.active-clean { background: #d1fae5; border-color: #10b981; color: #065f46; } +.status-btn.active-dirty { background: #fee2e2; border-color: #ef4444; color: #991b1b; } +.status-btn.active-inspected { background: #ede9fe; border-color: #8b5cf6; color: #5b21b6; } + +/* Notes tabs */ +.notes-tabs { display: flex; gap: 4px; margin-bottom: 10px; } +.notes-tab { + padding: 4px 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--body-bg); + cursor: pointer; + font-size: 12px; + color: var(--text-muted); +} +.notes-tab.active { background: var(--app-primary); color: #fff; border-color: var(--app-primary); } +.note-item { + padding: 6px 10px; + border-radius: 6px; + background: var(--body-bg); + font-size: 13px; + margin-bottom: 4px; + line-height: 1.4; +} +.note-type-label { font-size: 10px; color: var(--text-muted); margin-bottom: 2px; } + +/* Placeholder sections (linen, routine tasks) */ +.placeholder-section { + padding: 14px 20px; + border-top: 1px solid var(--border); + opacity: .5; + text-align: center; + font-size: 12px; + color: var(--text-muted); +} + +/* ── Activity panel ──────────────────────────────────────────── */ +.activity-panel { + width: 260px; + flex-shrink: 0; + border-left: 1px solid var(--border); + background: var(--card-bg); + display: flex; + flex-direction: column; + overflow: hidden; +} +.activity-panel-header { + padding: 10px 12px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--text-muted); + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.activity-list { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} +.activity-item { + padding: 7px 12px; + border-bottom: 1px solid var(--border); + font-size: 12px; +} +.activity-item:last-child { border-bottom: none; } +.activity-room { font-weight: 700; font-size: 13px; } +.activity-event { color: var(--text-muted); margin-top: 1px; } +.activity-time { font-size: 10px; color: var(--text-muted); margin-top: 2px; } +.event-checkout { color: #6d28d9; } +.event-checkin { color: #065f46; } +.event-clean { color: #0369a1; } +.event-dirty { color: #991b1b; } +.event-tasks { color: #2d6a4f; } + +@media (max-width: 1024px) { + .activity-panel { display: none; } +} + +/* ── Planner layout (date-bar + filter + scroll body) ─────────── */ +.planner-main { + display: flex; + flex: 1; + overflow: hidden; +} +.planner-rooms-area { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* ── Checkout notification toasts ────────────────────────────── */ +.checkout-toasts { + position: fixed; + bottom: 20px; + right: 20px; + z-index: 200; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} +.checkout-toast { + background: var(--card-bg); + border: 1px solid var(--col-departed); + border-left: 4px solid var(--col-departed); + border-radius: var(--radius); + padding: 10px 14px; + box-shadow: var(--shadow-md); + font-size: 13px; + max-width: 280px; + pointer-events: auto; + animation: toast-in .2s ease; +} +@keyframes toast-in { from { transform: translateX(20px); opacity: 0; } } +.toast-room { font-weight: 700; } +.toast-msg { color: var(--text-muted); font-size: 12px; margin-top: 2px; } +.toast-dismiss { + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + float: right; + padding: 0; + font-size: 14px; + line-height: 1; +} + +/* ── Settings page ───────────────────────────────────────────── */ +.settings-page { + padding: 20px; + max-width: 720px; +} +.settings-section { + background: var(--card-bg); + border-radius: var(--radius); + border: 1px solid var(--border); + margin-bottom: 20px; + overflow: hidden; +} +.settings-section-header { + padding: 14px 20px; + border-bottom: 1px solid var(--border); + font-weight: 600; + font-size: 15px; +} +.settings-section-body { + padding: 16px 20px; +} +.settings-row { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 0; + border-bottom: 1px solid var(--border); +} +.settings-row:last-child { border-bottom: none; } +.settings-label { flex: 1; font-size: 13px; } +.settings-hint { font-size: 11px; color: var(--text-muted); margin-top: 2px; } +.settings-input { + border: 1px solid var(--border); + border-radius: 6px; + padding: 5px 8px; + font-size: 13px; + color: var(--text-primary); + background: var(--body-bg); +} +.settings-toggle { + width: 40px; + height: 22px; + border-radius: 11px; + border: none; + cursor: pointer; + position: relative; + transition: background .15s; + flex-shrink: 0; +} +.settings-toggle.on { background: var(--app-primary); } +.settings-toggle.off { background: #d1d5db; } +.settings-toggle::after { + content: ''; + position: absolute; + top: 3px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + transition: left .15s; + box-shadow: 0 1px 3px rgba(0,0,0,.2); +} +.settings-toggle.on::after { left: 21px; } +.settings-toggle.off::after { left: 3px; } +.save-btn { + background: var(--app-primary); + color: #fff; + border: none; + border-radius: 8px; + padding: 8px 20px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: background .12s; +} +.save-btn:hover { background: var(--app-primary-dark); } +.save-btn:disabled { opacity: .5; cursor: not-allowed; } + +/* ── Empty / loading states ──────────────────────────────────── */ +.loading-spinner { + display: flex; + flex: 1; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 14px; + gap: 10px; +} +.loading-spinner svg { animation: spin .7s linear infinite; } + +.error-banner { + margin: 16px; + padding: 12px 16px; + background: #fee2e2; + border: 1px solid #fca5a5; + border-radius: var(--radius); + color: #991b1b; + font-size: 13px; +} + +.empty-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + font-size: 14px; + padding: 40px; + text-align: center; +} diff --git a/frontend/src/lib/booking-flow.ts b/frontend/src/lib/booking-flow.ts new file mode 100644 index 0000000..7289bd9 --- /dev/null +++ b/frontend/src/lib/booking-flow.ts @@ -0,0 +1,69 @@ +import type { RoomData, BookingData, FlowType } from '../types' + +export const STATUS_COLORS: Record = { + arrived: '#3b82f6', + confirmed: '#10b981', + unconfirmed: '#f59e0b', + departed: '#a855f7', + cancelled: '#94a3b8', + blocked: '#6b7280', +} + +export const FLOW_TYPE_LABEL: Record = { + arrive: 'Arriving', + depart: 'Departing', + stopover: 'Staying', + 'back-to-back': 'B2B', + vacant: 'Vacant', + blocked: 'Blocked', +} + +export function bookingStatusColor(status: string): string { + return STATUS_COLORS[(status || '').toLowerCase()] ?? '#94a3b8' +} + +// The primary colour for a room card is based on the booking status of the primary booking. +// Blocked rooms fill the entire card background. +export function roomCardColor(room: RoomData): string { + if (room.flow_type === 'blocked') return '#6b7280' + if (!room.booking) return 'transparent' + return bookingStatusColor(room.booking.booking_status) +} + +// True when all tasks for today's date are complete — used for stat filters +export function allTasksComplete(room: RoomData, viewDate: string): boolean { + const todayTasks = room.tasks.filter(t => { + const d = t.task_when_date || t.task_period_from + return d === viewDate || (!d && t.task_period_to && t.task_period_to >= viewDate) + }) + if (todayTasks.length === 0) return true + return todayTasks.every(t => !!t.completed_on) +} + +export function hasOutstandingTasks(room: RoomData, viewDate: string): boolean { + return !allTasksComplete(room, viewDate) +} + +export function isDirty(room: RoomData): boolean { + return (room.site_status || '').toLowerCase() === 'dirty' +} + +// Data attributes for border sliver CSS system (23px margin cards) +export function spanDataAttrs(room: RoomData): Record { + const attrs: Record = {} + if (room.spans_previous) { + attrs['data-spans-previous'] = 'true' + if (room.previous_status) attrs['data-prev-status'] = room.previous_status + } + if (room.spans_next) { + attrs['data-spans-next'] = 'true' + if (room.next_status) attrs['data-next-status'] = room.next_status + } + return attrs +} + +// Sliver color for ::before / ::after pseudo-elements (passed via CSS custom property) +export function sliverColor(status: string | null): string { + if (!status) return 'transparent' + return bookingStatusColor(status) +} diff --git a/frontend/src/lib/twin-detect.ts b/frontend/src/lib/twin-detect.ts new file mode 100644 index 0000000..b521435 --- /dev/null +++ b/frontend/src/lib/twin-detect.ts @@ -0,0 +1,46 @@ +import type { BookingData, AppConfig, TwinType } from '../types' + +const DEFAULT_TWIN_KEYWORDS = ['twin', 'two single', '2 single', 'single beds'] +const DEFAULT_EXTRA_KEYWORDS = ['extra bed', 'extra cot', 'rollaway', 'fold out'] +const DEFAULT_EXCLUDES = ['twin room', 'twin suite'] + +export function detectTwin(booking: BookingData | null, config: AppConfig | null): TwinType { + if (!booking) return null + + const twinCfg = config?.twin_detection + const extraCfg = config?.extra_bed_detection + + // 1. Check custom fields first (highest priority) + if (twinCfg?.custom_field_ids?.length) { + for (const field of booking.custom_fields || []) { + if (twinCfg.custom_field_ids.includes(String(field.field_name ?? ''))) { + const val = (field.field_value ?? '').toLowerCase() + if (val === 'twin' || val === 'yes' || val === 'true' || val === '1') return 'twin' + if (val === 'double') return 'double' + } + } + } + + // 2. Scan notes text + const allText = [ + ...(booking.notes || []).map(n => n.note_text || ''), + ].join(' ').toLowerCase() + + const excludeTerms = twinCfg?.exclude_keywords ?? DEFAULT_EXCLUDES + const twinTerms = twinCfg?.keywords ?? DEFAULT_TWIN_KEYWORDS + const extraTerms = extraCfg?.keywords ?? DEFAULT_EXTRA_KEYWORDS + + if (excludeTerms.some(term => allText.includes(term.toLowerCase()))) { + return null + } + + if (extraCfg?.enabled !== false && extraTerms.some(term => allText.includes(term.toLowerCase()))) { + return 'extra-bed' + } + + if (twinCfg?.enabled !== false && twinTerms.some(term => allText.includes(term.toLowerCase()))) { + return 'twin' + } + + return null +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..4a1b150 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + +) diff --git a/frontend/src/pages/Planner.tsx b/frontend/src/pages/Planner.tsx new file mode 100644 index 0000000..965f316 --- /dev/null +++ b/frontend/src/pages/Planner.tsx @@ -0,0 +1,331 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import { RefreshCw } from 'lucide-react' +import type { RoomData, AppConfig, Category, FilterState, StatFilters, FlowType, ActivityEntry } from '../types' +import { useAuth } from '../components/AuthGate' +import CategoryGroup from '../components/CategoryGroup' +import { FilterBar, StatFilterBar } from '../components/FilterBar' +import ActivityPanel from '../components/ActivityPanel' +import RoomModal from '../components/RoomModal' +import CheckoutNotification, { useToasts } from '../components/CheckoutNotification' +import { hasOutstandingTasks, isDirty } from '../lib/booking-flow' +import * as api from '../api' + +const POLL_INTERVAL = 60000 // rooms refresh every 60s +const EVENT_INTERVAL = 30000 // event ping every 30s +const ACTIVITY_INTERVAL = 60000 + +function todayStr() { + return new Date().toISOString().slice(0, 10) +} + +function formatDateLabel(dateStr: string) { + const d = new Date(dateStr + 'T00:00:00') + const today = todayStr() + if (dateStr === today) return `Today — ${d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short' })}` + return d.toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'short', year: 'numeric' }) +} + +export default function Planner() { + const { user } = useAuth() + const [viewDate, setViewDate] = useState(todayStr) + const [rooms, setRooms] = useState([]) + const [categories, setCategories] = useState([]) + const [config, setConfig] = useState(null) + const [activity, setActivity] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [refreshing, setRefreshing] = useState(false) + const [selectedRoom, setSelectedRoom] = useState(null) + + const [filters, setFilters] = useState({ categories: {}, flowTypes: {} }) + const [statFilters, setStatFilters] = useState({ newbookTasks: 'off', cleanDirty: 'off' }) + + const lastEventTime = useRef(new Date().toISOString()) + const { toasts, addToast, dismiss: dismissToast } = useToasts( + (config?.checkout_notification_timeout ?? 30) * 1000 + ) + + const loadRooms = useCallback(async (date: string, silent = false) => { + if (!silent) setLoading(true) + else setRefreshing(true) + setError(null) + try { + const data = await api.fetchRooms(date) + setRooms(data.rooms) + setCategories(data.categories) + + // Detect fresh checkouts vs prior load (depart rooms that just became dirty) + if (silent && config) { + const deptRooms = data.rooms.filter(r => r.flow_type === 'depart') + deptRooms.forEach(r => { + const prev = rooms.find(p => p.site_id === r.site_id) + if (!prev) { + addToast(r.site_name, 'Guest has checked out') + } + }) + } + } catch (err: any) { + setError(err.message) + } finally { + setLoading(false) + setRefreshing(false) + } + }, [config, rooms]) + + const loadActivity = useCallback(async (date: string) => { + try { + const data = await api.fetchActivity(date) + setActivity(data) + } catch { /* non-critical */ } + }, []) + + // Initial load + useEffect(() => { + Promise.all([ + api.fetchConfig().then(setConfig).catch(() => {}), + ]).then(() => { + loadRooms(viewDate) + loadActivity(viewDate) + }) + }, []) + + // Reload on date change + useEffect(() => { + setRooms([]) + loadRooms(viewDate) + loadActivity(viewDate) + }, [viewDate]) + + // Periodic room poll + useEffect(() => { + const timer = setInterval(() => loadRooms(viewDate, true), POLL_INTERVAL) + return () => clearInterval(timer) + }, [viewDate, loadRooms]) + + // Activity poll + useEffect(() => { + const timer = setInterval(() => loadActivity(viewDate), ACTIVITY_INTERVAL) + return () => clearInterval(timer) + }, [viewDate, loadActivity]) + + // Event pings poll + useEffect(() => { + const timer = setInterval(async () => { + try { + const { pings, server_time } = await api.fetchEvents(lastEventTime.current) + lastEventTime.current = server_time + if (pings.length > 0) { + loadRooms(viewDate, true) + } + } catch { /* ignore */ } + }, EVENT_INTERVAL) + return () => clearInterval(timer) + }, [viewDate, loadRooms]) + + // Date navigation + const navigate = (delta: number) => { + const d = new Date(viewDate + 'T00:00:00Z') + d.setUTCDate(d.getUTCDate() + delta) + setViewDate(d.toISOString().slice(0, 10)) + } + + // Filter logic + const toggleCategory = useCallback((id: string) => { + setFilters(f => { + const cur = f.categories[id] ?? 'off' + const next = cur === 'off' ? 'inclusive' : cur === 'inclusive' ? 'exclusive' : 'off' + return { ...f, categories: { ...f.categories, [id]: next } } + }) + }, []) + + const toggleFlow = useCallback((flow: FlowType) => { + setFilters(f => { + const cur = f.flowTypes[flow] ?? 'off' + const next = cur === 'off' ? 'inclusive' : cur === 'inclusive' ? 'exclusive' : 'off' + return { ...f, flowTypes: { ...f.flowTypes, [flow]: next } } + }) + }, []) + + const toggleStatFilter = useCallback((key: keyof StatFilters) => { + setStatFilters(f => { + const cur = f[key] + const next = cur === 'off' ? 'show-only' : cur === 'show-only' ? 'hide' : 'off' + return { ...f, [key]: next } + }) + }, []) + + // Apply filters — inclusive/exclusive logic for categories and flows + const visibleRooms = useMemo(() => { + const inclCats = Object.entries(filters.categories).filter(([, m]) => m === 'inclusive').map(([k]) => k) + const exclCats = Object.entries(filters.categories).filter(([, m]) => m === 'exclusive').map(([k]) => k) + const inclFlows = Object.entries(filters.flowTypes).filter(([, m]) => m === 'inclusive').map(([k]) => k) as FlowType[] + const exclFlows = Object.entries(filters.flowTypes).filter(([, m]) => m === 'exclusive').map(([k]) => k) as FlowType[] + + return rooms.filter(room => { + // Category filters: if any inclusive → must be in inclusive set; exclusive → must not be in exclusive set + if (inclCats.length && !inclCats.includes(room.category_id)) return false + if (exclCats.includes(room.category_id)) return false + + // Flow type filters + if (inclFlows.length && !inclFlows.includes(room.flow_type)) return false + if (exclFlows.includes(room.flow_type)) return false + + // Stat filters + const tasks = statFilters.newbookTasks + if (tasks === 'show-only' && !hasOutstandingTasks(room, viewDate)) return false + if (tasks === 'hide' && hasOutstandingTasks(room, viewDate)) return false + + const cd = statFilters.cleanDirty + if (cd === 'show-only' && !isDirty(room)) return false + if (cd === 'hide' && isDirty(room)) return false + + return true + }) + }, [rooms, filters, statFilters, viewDate]) + + // Group by category + const groupedRooms = useMemo(() => { + const map = new Map() + for (const room of visibleRooms) { + const key = room.category_id || '__none__' + if (!map.has(key)) map.set(key, []) + map.get(key)!.push(room) + } + return map + }, [visibleRooms]) + + // Modal handlers + const handleTaskToggle = useCallback((taskId: string, completed: boolean, siteStatus: string | null) => { + setRooms(prev => prev.map(r => ({ + ...r, + site_status: siteStatus && r.site_id === selectedRoom?.site_id ? siteStatus : r.site_status, + tasks: r.tasks.map(t => + t.task_id === taskId + ? { ...t, completed_on: completed ? new Date().toISOString() : null } + : t + ), + }))) + if (selectedRoom) { + setSelectedRoom(prev => prev ? { + ...prev, + site_status: siteStatus ?? prev.site_status, + tasks: prev.tasks.map(t => + t.task_id === taskId + ? { ...t, completed_on: completed ? new Date().toISOString() : null } + : t + ), + } : null) + } + }, [selectedRoom]) + + const handleStatusUpdate = useCallback((roomId: string, status: string) => { + setRooms(prev => prev.map(r => r.site_id === roomId ? { ...r, site_status: status } : r)) + setSelectedRoom(prev => prev?.site_id === roomId ? { ...prev, site_status: status } : prev) + loadActivity(viewDate) + }, [viewDate, loadActivity]) + + // Render + const orderedCategories = useMemo(() => + [...categories].sort((a, b) => a.order - b.order), + [categories] + ) + + return ( +
+ {/* Date bar */} +
+ + setViewDate(e.target.value)} + /> + {formatDateLabel(viewDate)} + {viewDate !== todayStr() && ( + + )} + +
+ + {/* Filters */} + + + + {/* Main area */} +
+
+ {loading ? ( +
+ + Loading rooms… +
+ ) : error ? ( +
{error}
+ ) : visibleRooms.length === 0 ? ( +
No rooms match the current filters
+ ) : ( +
+ {orderedCategories.map(cat => { + const catRooms = groupedRooms.get(cat.id) + if (!catRooms?.length) return null + return ( + a.site_order - b.site_order)} + viewDate={viewDate} + config={config} + onRoomClick={setSelectedRoom} + /> + ) + })} + {/* Rooms without a category */} + {groupedRooms.has('__none__') && ( + a.site_order - b.site_order)} + viewDate={viewDate} + config={config} + onRoomClick={setSelectedRoom} + /> + )} +
+ )} +
+ + +
+ + {/* Room modal */} + {selectedRoom && ( + setSelectedRoom(null)} + onTaskToggle={handleTaskToggle} + onStatusUpdate={handleStatusUpdate} + /> + )} + + {/* Checkout toasts */} + +
+ ) +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..52edd8d --- /dev/null +++ b/frontend/src/pages/Settings.tsx @@ -0,0 +1,244 @@ +import { useState, useEffect } from 'react' +import { useAuth } from '../components/AuthGate' +import type { AppConfig, TaskDisplayConfig } from '../types' +import * as api from '../api' + +export default function Settings() { + const { user } = useAuth() + const hasCap = (cap: string) => user.caps.includes(`room-planner:${cap}`) + + const [config, setConfig] = useState(null) + const [taskTypes, setTaskTypes] = useState>([]) + const [saving, setSaving] = useState>({}) + const [saved, setSaved] = useState>({}) + const [error, setError] = useState(null) + + useEffect(() => { + Promise.all([ + api.fetchConfig(), + hasCap('settings') ? api.fetchTaskTypes() : Promise.resolve([]), + ]).then(([cfg, types]) => { + setConfig(cfg) + setTaskTypes(types) + }).catch(err => setError(err.message)) + }, []) + + if (!hasCap('settings')) { + return
You don't have permission to access settings.
+ } + + if (!config) { + return
Loading…
+ } + + async function save(key: string, value: unknown) { + setSaving(s => ({ ...s, [key]: true })) + setError(null) + try { + await api.updateConfig(key, value) + setConfig(c => c ? { ...c, [key]: value } : c) + setSaved(s => ({ ...s, [key]: true })) + setTimeout(() => setSaved(s => ({ ...s, [key]: false })), 2000) + } catch (err: any) { + setError(err.message) + } finally { + setSaving(s => ({ ...s, [key]: false })) + } + } + + function TaskTypeRow({ typeId, typeName }: { typeId: string; typeName: string }) { + const display = (config?.task_display ?? {})[typeId] ?? { color: '#94a3b8', show_on_card: true } + const update = (patch: Partial) => { + const updated: TaskDisplayConfig = { + ...(config?.task_display ?? {}), + [typeId]: { ...display, ...patch }, + } + save('task_display', updated) + } + return ( +
+
+
{typeName}
+
ID: {typeId}
+
+ update({ color: e.target.value })} + /> +
+ ) + } + + return ( +
+

Settings

+ + {error &&
{error}
} + + {/* General */} +
+
General
+
+
+
+
Default checkout time
+
Used for checkout notification display
+
+ save('default_checkout_time', e.target.value)} + /> +
+
+
+
Checkout notification timeout
+
Seconds before toast auto-dismisses
+
+ save('checkout_notification_timeout', parseInt(e.target.value))} + /> +
+
+
+ + {/* Task display */} + {taskTypes.length > 0 && ( +
+
Task Display
+
+ {taskTypes.map(t => ( +
+ +
+ ))} +
+
+ )} + + {/* Twin / Extra bed detection */} +
+
Twin Detection
+
+
+
Enable twin detection
+
+
+
+
Keywords (comma-separated)
+
Notes text that indicates a twin configuration
+
+ save('twin_detection', { + ...config.twin_detection, + keywords: e.target.value.split(',').map(s => s.trim()).filter(Boolean), + })} + /> +
+
+
+
Exclude keywords (comma-separated)
+
Override: if present, twin detection is suppressed
+
+ save('twin_detection', { + ...config.twin_detection, + exclude_keywords: e.target.value.split(',').map(s => s.trim()).filter(Boolean), + })} + /> +
+
+
+ +
+
Extra Bed Detection
+
+
+
Enable extra bed detection
+
+
+
+
Keywords (comma-separated)
+
+ save('extra_bed_detection', { + ...config.extra_bed_detection, + keywords: e.target.value.split(',').map(s => s.trim()).filter(Boolean), + })} + /> +
+
+
+ + {/* Category exclusions */} +
+
Category Exclusions
+
+
+
+
Excluded category IDs (comma-separated)
+
Rooms in these categories show with excluded flag
+
+ save('excluded_categories', + e.target.value.split(',').map(s => s.trim()).filter(Boolean) + )} + /> +
+
+
Hide excluded categories entirely
+
+
+
+
+ ) +} diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..4f2ba03 --- /dev/null +++ b/frontend/src/types.ts @@ -0,0 +1,143 @@ +export type BookingStatus = 'arrived' | 'confirmed' | 'unconfirmed' | 'departed' | 'cancelled' | 'blocked' + +export type FlowType = 'arrive' | 'depart' | 'stopover' | 'back-to-back' | 'vacant' | 'blocked' + +export type TwinType = 'twin' | 'double' | 'extra-bed' | null + +export type SiteStatus = 'Clean' | 'Dirty' | 'Inspected' + +export interface BookingData { + booking_id: string + booking_reference_id?: string + booking_status: string + booking_arrival: string + booking_departure: string + booking_eta?: string + booking_locked?: boolean + pax?: number + site_id: string + custom_fields?: Array<{ field_name: string; field_value: string }> + guest_name?: string // only if user has guest_details cap + rate_plan_name?: string // only if user has rate_details cap + notes?: Array<{ note_id: string; note_type_id: string; note_text: string }> +} + +export interface TaskData { + task_id: string + task_description: string + task_type_id: string + task_when_date: string | null + task_period_from: string | null + task_period_to: string | null + site_id: string + booking_id?: string + completed_on: string | null +} + +export interface RoomData { + site_id: string + site_name: string + site_status: string + category_id: string + category_name: string + category_order: number + site_order: number + + flow_type: FlowType + booking: BookingData | null + spans_previous: boolean + spans_next: boolean + + previous_booking: BookingData | null + next_booking: BookingData | null + previous_status: string | null + next_status: string | null + + departing_booking: BookingData | null + departing_time: string | null + + filter_excluded: boolean + tasks: TaskData[] +} + +export interface Category { + id: string + name: string + order: number +} + +export interface RoomsResponse { + view_date: string + yesterday: string + tomorrow: string + rooms: RoomData[] + categories: Category[] +} + +export interface ActivityEntry { + id: number + room_id: string + event_type: 'checkout' | 'checkin' | 'status_clean' | 'status_dirty' | 'tasks_complete' + event_data: Record | null + user_name: string + occurred_at: string + booking_ref: string | null +} + +export type FilterMode = 'off' | 'inclusive' | 'exclusive' + +export interface FilterState { + categories: Record + flowTypes: Partial> +} + +export type StatFilterMode = 'off' | 'show-only' | 'hide' + +export interface StatFilters { + newbookTasks: StatFilterMode // off → show-only (has outstanding tasks) → hide + cleanDirty: StatFilterMode // off → show-only (dirty) → hide +} + +export interface TaskDisplayConfig { + [taskTypeId: string]: { + color: string + label?: string + show_on_card: boolean + } +} + +export interface AppConfig { + task_display: TaskDisplayConfig + twin_detection: { + enabled: boolean + custom_field_ids: string[] + keywords: string[] + exclude_keywords: string[] + } + extra_bed_detection: { + enabled: boolean + keywords: string[] + } + excluded_categories: string[] + hide_excluded_categories: boolean + visible_note_types: string[] + checkout_notification_timeout: number + default_checkout_time: string +} + +export interface User { + id: number + name: string + email: string + apps: string[] + caps: string[] +} + +export type PlannerCap = + | 'view' + | 'guest_details' + | 'rate_details' + | 'view_all_notes' + | 'complete_tasks' + | 'update_status' + | 'settings' diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..79a2287 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..40e28dd --- /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: '/room-planner/', + plugins: [react()], +}) diff --git a/seed-app.js b/seed-app.js new file mode 100644 index 0000000..7eb46da --- /dev/null +++ b/seed-app.js @@ -0,0 +1,57 @@ +#!/usr/bin/env node +// Run from room-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 ('room-planner', 'Room Planner', 'Daily housekeeping room view with NewBook task management', '/room-planner', 'BedDouble', '#2d6a4f', 'Housekeeping', '10.10.10.120', 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 +`) + +// Seed capabilities +await pool.query(` + INSERT INTO app_capabilities (app_id, slug, name, description, sort_order) + SELECT a.id, c.slug, c.name, c.description, c.sort_order + FROM apps a, (VALUES + ('view', 'View Planner', 'Access the room planner view', 1), + ('guest_details', 'Guest Details', 'View guest names and personal information', 2), + ('rate_details', 'Rate Details', 'View pricing and rate plan information', 3), + ('view_all_notes','View All Notes', 'View all booking note types', 4), + ('complete_tasks','Complete Tasks', 'Mark NewBook tasks as complete', 5), + ('update_status', 'Update Room Status', 'Mark rooms clean, dirty or inspected', 6), + ('settings', 'Settings', 'Configure room planner settings', 7) + ) AS c(slug, name, description, sort_order) + WHERE a.slug = 'room-planner' + ON CONFLICT (app_id, slug) DO NOTHING +`) + +// Grant all caps to Staff role (except settings) if they have none yet +await pool.query(` + INSERT INTO role_capabilities (role_id, cap_id) + SELECT r.id, ac.id + FROM roles r + JOIN app_capabilities ac ON ac.slug != 'settings' + JOIN apps a ON a.id = ac.app_id AND a.slug = 'room-planner' + WHERE r.name = 'Staff' + AND NOT EXISTS ( + SELECT 1 FROM role_capabilities rc2 + JOIN app_capabilities ac2 ON rc2.cap_id = ac2.id + JOIN apps a2 ON ac2.app_id = a2.id AND a2.slug = 'room-planner' + WHERE rc2.role_id = r.id + ) + ON CONFLICT DO NOTHING +`) + +console.log('room-planner app seeded.') +await pool.end()