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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-03 13:07:00 +00:00
commit 1e658b6a48
39 changed files with 3765 additions and 0 deletions

7
backend/Dockerfile Normal file
View file

@ -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"]

16
backend/package.json Normal file
View file

@ -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"
}
}

57
backend/src/auth.js Normal file
View file

@ -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}` })
}
}
}

70
backend/src/db.js Normal file
View file

@ -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)]
)
}
}

35
backend/src/index.js Normal file
View file

@ -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)
}

80
backend/src/ip-check.js Normal file
View file

@ -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
}

View file

@ -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,
}
}

125
backend/src/lib/newbook.js Normal file
View file

@ -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 ?? []
}

View file

@ -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 }
})
}

View file

@ -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}` })
}
})
}

View file

@ -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=<ISO timestamp>
// 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 }
})
}

155
backend/src/routes/rooms.js Normal file
View file

@ -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),
}
})
}

View file

@ -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 }
})
}

View file

@ -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(() => {})
}

38
docker-compose.yml Normal file
View file

@ -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

13
frontend/Dockerfile Normal file
View file

@ -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

16
frontend/index.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="theme-color" content="#2d6a4f" />
<title>Room Planner</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/room-planner/src/main.tsx"></script>
</body>
</html>

23
frontend/nginx.conf Normal file
View file

@ -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;
}
}

24
frontend/package.json Normal file
View file

@ -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"
}
}

22
frontend/src/App.tsx Normal file
View file

@ -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 (
<BrowserRouter basename="/room-planner">
<AuthGate>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/planner" replace />} />
<Route path="/planner" element={<Planner />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/planner" replace />} />
</Routes>
</Layout>
</AuthGate>
</BrowserRouter>
)
}

77
frontend/src/api.ts Normal file
View file

@ -0,0 +1,77 @@
import type { RoomsResponse, ActivityEntry, AppConfig, TaskData } from './types'
const BASE = '/room-planner/api'
async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
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<RoomsResponse> {
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<AppConfig> {
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<Array<{ id: string; name: string }>> {
return request('/config/task-types')
}
export function fetchActivity(date: string): Promise<ActivityEntry[]> {
return request(`/activity?date=${date}`)
}
export function logActivity(entry: {
room_id: string
event_type: ActivityEntry['event_type']
event_data?: Record<string, unknown>
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)}`)
}

View file

@ -0,0 +1,59 @@
import type { ActivityEntry } from '../types'
interface Props {
entries: ActivityEntry[]
}
const EVENT_LABELS: Record<ActivityEntry['event_type'], string> = {
checkout: 'Checked out',
checkin: 'Checked in',
status_clean: 'Marked clean',
status_dirty: 'Marked dirty',
tasks_complete: 'All tasks done',
}
const EVENT_CLASS: Record<ActivityEntry['event_type'], string> = {
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 (
<aside className="activity-panel">
<div className="activity-panel-header">Recent Changes</div>
<div className="activity-list">
{entries.length === 0 ? (
<div style={{ padding: '16px 12px', color: 'var(--text-muted)', fontSize: 12 }}>
No activity yet today
</div>
) : (
entries.map(entry => (
<div key={entry.id} className="activity-item">
<div className="activity-room">{entry.room_id}</div>
<div className={`activity-event ${EVENT_CLASS[entry.event_type]}`}>
{EVENT_LABELS[entry.event_type]}
</div>
{entry.user_name && (
<div className="activity-time">{entry.user_name}</div>
)}
<div className="activity-time">{relativeTime(entry.occurred_at)}</div>
</div>
))
)}
</div>
</aside>
)
}

View file

@ -0,0 +1,51 @@
import { useEffect, useState, createContext, useContext } from 'react'
import type { User } from '../types'
interface AuthCtx { user: User }
const Ctx = createContext<AuthCtx | null>(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<User | null>(null)
const [error, setError] = useState<string | null>(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 (
<div style={{ padding: 32, color: '#991b1b', fontFamily: 'sans-serif' }}>
Authentication error: {error}
</div>
)
}
if (!user) {
return (
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
height: '100vh', fontFamily: 'sans-serif', color: '#6b7280'
}}>
Loading
</div>
)
}
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
}

View file

@ -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 (
<div className="category-group">
<div className="category-header" onClick={() => setOpen(o => !o)}>
<span className="category-name">{categoryName}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{rooms.length}</span>
{outstandingTasks > 0 && (
<span className="category-badge badge-tasks">{outstandingTasks} tasks</span>
)}
{dirtyCount > 0 && (
<span className="category-badge badge-dirty">{dirtyCount} dirty</span>
)}
<ChevronDown
size={14}
strokeWidth={1.75}
className={`category-chevron ${open ? 'open' : 'closed'}`}
/>
</div>
{open && (
<div className="category-rooms">
{rooms.map(room => (
<RoomCard
key={room.site_id}
room={room}
viewDate={viewDate}
config={config}
onClick={onRoomClick}
/>
))}
</div>
)}
</div>
)
}

View file

@ -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 (
<div className="checkout-toasts">
{toasts.map(toast => (
<div key={toast.id} className="checkout-toast">
<button className="toast-dismiss" onClick={() => onDismiss(toast.id)}>
<X size={12} strokeWidth={1.75} />
</button>
<div className="toast-room">{toast.roomName}</div>
<div className="toast-msg">{toast.message}</div>
</div>
))}
</div>
)
}
// Hook to auto-expire toasts
export function useToasts(timeoutMs = 30000) {
const [toasts, setToasts] = useState<Toast[]>([])
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 }
}

View file

@ -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<FlowType, string> = {
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<HTMLDivElement>(null)
const scroll = (dir: 'left' | 'right') => {
scrollRef.current?.scrollBy({ left: dir === 'left' ? -120 : 120, behavior: 'smooth' })
}
return (
<div className="filter-section">
{/* Category filter row */}
<div className="filter-bar">
<button className="filter-arrow left" onClick={() => scroll('left')}>
<ChevronLeft size={14} strokeWidth={1.75} />
</button>
<div className="filter-bar-scroll" ref={scrollRef}>
{categories.map(cat => {
const mode = filters.categories[cat.id] ?? 'off'
const count = countByCategory(rooms, cat.id)
return (
<button
key={cat.id}
className={`filter-chip ${mode}`}
onClick={() => onToggleCategory(cat.id)}
>
{cat.name}
<span className="chip-count">{count}</span>
</button>
)
})}
</div>
<button className="filter-arrow" onClick={() => scroll('right')}>
<ChevronRight size={14} strokeWidth={1.75} />
</button>
</div>
{/* Flow type filter row */}
<div className="filter-bar" style={{ borderTop: '1px solid var(--border)' }}>
<div className="filter-bar-scroll">
{FLOW_TYPES.map(flow => {
const mode = filters.flowTypes[flow] ?? 'off'
const count = countByFlow(rooms, flow)
if (count === 0 && mode === 'off') return null
return (
<button
key={flow}
className={`filter-chip ${mode}`}
onClick={() => onToggleFlow(flow)}
>
{FLOW_LABELS[flow]}
<span className="chip-count">{count}</span>
</button>
)
})}
</div>
</div>
</div>
)
}
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<keyof StatFilters, [string, string, string]> = {
newbookTasks: ['Tasks', 'Has Tasks', 'No Tasks'],
cleanDirty: ['Clean/Dirty', 'Dirty Only', 'Clean Only'],
}
export function StatFilterBar({ statFilters, onChange }: StatFilterBarProps) {
return (
<div className="filter-section" style={{ borderBottom: 'none', borderTop: '1px solid var(--border)' }}>
<div className="filter-bar">
<div className="filter-bar-scroll">
{(Object.keys(statFilters) as Array<keyof StatFilters>).map(key => {
const mode = statFilters[key]
const [off, showOnly, hide] = STAT_LABELS[key]
const label = mode === 'off' ? off : mode === 'show-only' ? showOnly : hide
return (
<button
key={key}
className={`stat-chip ${mode}`}
onClick={() => onChange(key)}
>
{label}
</button>
)
})}
</div>
</div>
</div>
)
}

View file

@ -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 (
<div className="app-shell">
{/* Desktop sidebar */}
<aside className="sidebar">
<div className="sidebar-logo">
<BedDouble size={18} strokeWidth={1.75} />
Room Planner
</div>
<nav className="sidebar-nav">
<NavLink to="/planner" className={({ isActive }) => isActive ? 'active' : ''}>
<BedDouble {...ICON_PROPS} />
Planner
</NavLink>
{hasCap('settings') && (
<NavLink to="/settings" className={({ isActive }) => isActive ? 'active' : ''}>
<Settings {...ICON_PROPS} />
Settings
</NavLink>
)}
</nav>
<div className="sidebar-user">{user.name}</div>
</aside>
{/* Mobile top bar */}
<header className="top-bar">
<BedDouble size={18} strokeWidth={1.75} color="var(--gold)" />
<span className="top-bar-title">Room Planner</span>
<nav className="top-bar-nav">
<NavLink to="/planner" className={({ isActive }) => isActive ? 'active' : ''}>
Planner
</NavLink>
{hasCap('settings') && (
<NavLink to="/settings" className={({ isActive }) => isActive ? 'active' : ''}>
Settings
</NavLink>
)}
</nav>
</header>
<main className="page-content">
{children}
</main>
</div>
)
}

View file

@ -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<string, string> = {}
if (room.spans_previous) cssVars['--prev-color'] = prevColor
if (room.spans_next) cssVars['--next-color'] = nextColor
return (
<div
className={`room-card${isBlocked ? ' blocked-room' : ''}`}
style={cssVars as React.CSSProperties}
{...(spanAttrs as React.HTMLAttributes<HTMLDivElement>)}
onClick={() => onClick(room)}
>
{!isBlocked && (
<div className="card-status-strip" style={{ background: stripColor }} />
)}
<div className="card-inner">
<div className="card-top">
<span className="card-room-name">{room.site_name}</span>
<div className="card-badges">
{twinType === 'twin' && <span className="card-badge badge-twin">Twin</span>}
{twinType === 'extra-bed' && <span className="card-badge badge-extra">+Bed</span>}
{booking?.pax != null && booking.pax > 0 && (
<span className="card-badge badge-pax">{booking.pax}p</span>
)}
{room.departing_time && (
<span className="card-badge badge-depart-time">{room.departing_time.slice(0,5)}</span>
)}
</div>
</div>
{!isBlocked && (
<div className="card-mid">
{booking?.guest_name ? (
<span className="card-guest-name">{booking.guest_name}</span>
) : (
<span className="card-guest-name" style={{ opacity: .4 }}>
{room.flow_type === 'vacant' ? 'Vacant' : '—'}
</span>
)}
{booking?.rate_plan_name && (
<span className="card-rate">{booking.rate_plan_name}</span>
)}
</div>
)}
<div className="card-bottom">
{!isBlocked && (
<span
className="card-flow-label"
style={{
background: stripColor + '22',
color: stripColor === 'transparent' ? 'var(--text-muted)' : stripColor,
}}
>
{FLOW_TYPE_LABEL[room.flow_type]}
</span>
)}
<span className={`card-clean-status ${dirty ? 'dirty-dot' : 'clean-dot'}`}>
{dirty ? 'Dirty' : room.site_status}
</span>
{outstanding.length > 0 && (
<span className="card-badge badge-tasks" style={{ background: '#fef3c7', color: '#92400e' }}>
{outstanding.length} task{outstanding.length > 1 ? 's' : ''}
</span>
)}
</div>
{/* Task dots for show_on_card tasks */}
{todayTasks.length > 0 && (
<div className="card-task-dots">
{todayTasks.map(t => {
const display = taskDisplay[t.task_type_id]
if (!display?.show_on_card) return null
return (
<div
key={t.task_id}
className={`task-dot${t.completed_on ? ' complete' : ''}`}
style={{ background: display.color || '#94a3b8' }}
title={display.label || t.task_description}
/>
)
})}
</div>
)}
</div>
</div>
)
}
export default memo(RoomCard)

View file

@ -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<Set<string>>(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 (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal-sheet">
{/* Header */}
<div className="modal-header">
<div className="modal-header-main">
<div className="modal-room-name">{room.site_name}</div>
<div>
<span
className="modal-status-badge"
style={{ background: stripColor + '22', color: stripColor }}
>
{(booking?.booking_status || room.flow_type).toUpperCase()}
</span>
{twinType && (
<span className="modal-status-badge" style={{ background: '#e0e7ff', color: '#3730a3', marginLeft: 4 }}>
{twinType === 'twin' ? 'TWIN' : twinType === 'extra-bed' ? '+BED' : 'DOUBLE'}
</span>
)}
</div>
</div>
<button className="modal-close" onClick={onClose}><X size={20} strokeWidth={1.75} /></button>
</div>
<div className="modal-body">
{/* Booking info bar */}
{booking && (
<div className="modal-booking-bar">
{hasCap('guest_details') && booking.guest_name && (
<div className="modal-booking-field">
<div className="field-label">Guest</div>
<div className="field-value">{booking.guest_name}</div>
</div>
)}
<div className="modal-booking-field">
<div className="field-label">Arrival</div>
<div className="field-value">{formatDate(booking.booking_arrival)}</div>
</div>
<div className="modal-booking-field">
<div className="field-label">Departure</div>
<div className="field-value">{formatDate(booking.booking_departure)}</div>
</div>
{booking.pax != null && (
<div className="modal-booking-field">
<div className="field-label">Pax</div>
<div className="field-value">{booking.pax}</div>
</div>
)}
{hasCap('rate_details') && booking.rate_plan_name && (
<div className="modal-booking-field">
<div className="field-label">Rate</div>
<div className="field-value">{booking.rate_plan_name}</div>
</div>
)}
{booking.booking_eta && (
<div className="modal-booking-field">
<div className="field-label">ETA</div>
<div className="field-value">{formatTime(booking.booking_eta)}</div>
</div>
)}
{booking.booking_reference_id && (
<div className="modal-booking-field">
<div className="field-label">Ref</div>
<div className="field-value" style={{ fontSize: 11 }}>{booking.booking_reference_id}</div>
</div>
)}
</div>
)}
{/* Departing booking bar */}
{departing && departing.booking_id !== booking?.booking_id && (
<div className="modal-booking-bar" style={{ background: '#faf5ff', borderTop: '1px solid #ede9fe' }}>
<div className="modal-booking-field">
<div className="field-label" style={{ color: '#7c3aed' }}>Departing today</div>
{hasCap('guest_details') && departing.guest_name && (
<div className="field-value">{departing.guest_name}</div>
)}
</div>
{departing.booking_reference_id && (
<div className="modal-booking-field">
<div className="field-label">Ref</div>
<div className="field-value" style={{ fontSize: 11 }}>{departing.booking_reference_id}</div>
</div>
)}
</div>
)}
{/* Room status */}
<div className="modal-section">
<div className="modal-section-title">Room Status</div>
<div className="status-btn-row">
{(['Clean', 'Dirty', 'Inspected'] as const).map(s => (
<button
key={s}
className={`status-btn${
room.site_status === s ? ` active-${s.toLowerCase()}` : ''
}`}
disabled={statusLoading || !hasCap('update_status')}
onClick={() => handleStatus(s)}
>
{s}
</button>
))}
</div>
</div>
{/* NewBook Tasks */}
{todayTasks.length > 0 && (
<div className="modal-section">
<div className="modal-section-title">
Tasks ({todayTasks.filter(t => !t.completed_on).length} outstanding)
</div>
<div className="task-list">
{todayTasks.map(task => {
const isLoading = loadingTasks.has(task.task_id)
const done = !!task.completed_on
const rollover = isRollover(task)
return (
<div
key={task.task_id}
className="task-item"
onClick={() => handleTaskToggle(task)}
style={{ cursor: hasCap('complete_tasks') ? 'pointer' : 'default' }}
>
<div className={`task-checkbox${done ? ' checked' : ''}${isLoading ? ' loading' : ''}`}>
{done ? <CheckSquare {...ICON} /> : null}
</div>
<span className={`task-label${done ? ' done' : ''}`}>
{task.task_description}
</span>
{rollover && <span className="task-rollover-tag">Rollover</span>}
</div>
)
})}
</div>
</div>
)}
{/* Notes */}
{((booking?.notes?.length ?? 0) > 0 || (departing?.notes?.length ?? 0) > 0) && (
<div className="modal-section">
<div className="modal-section-title">Notes</div>
{departing && departing.booking_id !== booking?.booking_id && (
<div className="notes-tabs">
<button
className={`notes-tab${notesTab === 'booking' ? ' active' : ''}`}
onClick={() => setNotesTab('booking')}
>
Arriving
</button>
<button
className={`notes-tab${notesTab === 'departing' ? ' active' : ''}`}
onClick={() => setNotesTab('departing')}
>
Departing
</button>
</div>
)}
{allNotesForTab.length === 0 ? (
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No notes</div>
) : (
allNotesForTab.map(note => (
<div key={note.note_id} className="note-item">
{note.note_text}
</div>
))
)}
</div>
)}
{/* Placeholder: Linen Count */}
<div className="placeholder-section">
<Package size={16} strokeWidth={1.75} style={{ marginBottom: 4 }} />
<div>Linen Count coming soon</div>
</div>
{/* Placeholder: Routine Tasks */}
<div className="placeholder-section">
<ClipboardList size={16} strokeWidth={1.75} style={{ marginBottom: 4 }} />
<div>Routine Tasks coming soon</div>
</div>
</div>
</div>
</div>
)
}

903
frontend/src/index.css Normal file
View file

@ -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;
}

View file

@ -0,0 +1,69 @@
import type { RoomData, BookingData, FlowType } from '../types'
export const STATUS_COLORS: Record<string, string> = {
arrived: '#3b82f6',
confirmed: '#10b981',
unconfirmed: '#f59e0b',
departed: '#a855f7',
cancelled: '#94a3b8',
blocked: '#6b7280',
}
export const FLOW_TYPE_LABEL: Record<FlowType, string> = {
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<string, string> {
const attrs: Record<string, string> = {}
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)
}

View file

@ -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
}

10
frontend/src/main.tsx Normal file
View file

@ -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(
<React.StrictMode>
<App />
</React.StrictMode>
)

View file

@ -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<RoomData[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [config, setConfig] = useState<AppConfig | null>(null)
const [activity, setActivity] = useState<ActivityEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [selectedRoom, setSelectedRoom] = useState<RoomData | null>(null)
const [filters, setFilters] = useState<FilterState>({ categories: {}, flowTypes: {} })
const [statFilters, setStatFilters] = useState<StatFilters>({ 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<string, RoomData[]>()
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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* Date bar */}
<div className="date-bar">
<button className="date-nav-btn" onClick={() => navigate(-1)}></button>
<input
type="date"
value={viewDate}
onChange={e => setViewDate(e.target.value)}
/>
<span className="date-label">{formatDateLabel(viewDate)}</span>
{viewDate !== todayStr() && (
<button className="date-today-btn" onClick={() => setViewDate(todayStr())}>Today</button>
)}
<button
className={`refresh-btn${refreshing ? ' spinning' : ''}`}
onClick={() => loadRooms(viewDate, true)}
title="Refresh"
>
<RefreshCw size={16} strokeWidth={1.75} />
</button>
</div>
{/* Filters */}
<FilterBar
filters={filters}
categories={orderedCategories}
rooms={rooms}
viewDate={viewDate}
onToggleCategory={toggleCategory}
onToggleFlow={toggleFlow}
/>
<StatFilterBar statFilters={statFilters} onChange={toggleStatFilter} />
{/* Main area */}
<div className="planner-main">
<div className="planner-rooms-area">
{loading ? (
<div className="loading-spinner">
<RefreshCw size={18} strokeWidth={1.75} />
Loading rooms
</div>
) : error ? (
<div className="error-banner">{error}</div>
) : visibleRooms.length === 0 ? (
<div className="empty-state">No rooms match the current filters</div>
) : (
<div className="planner-body">
{orderedCategories.map(cat => {
const catRooms = groupedRooms.get(cat.id)
if (!catRooms?.length) return null
return (
<CategoryGroup
key={cat.id}
categoryId={cat.id}
categoryName={cat.name}
rooms={catRooms.sort((a, b) => a.site_order - b.site_order)}
viewDate={viewDate}
config={config}
onRoomClick={setSelectedRoom}
/>
)
})}
{/* Rooms without a category */}
{groupedRooms.has('__none__') && (
<CategoryGroup
categoryId="__none__"
categoryName="Uncategorised"
rooms={groupedRooms.get('__none__')!.sort((a, b) => a.site_order - b.site_order)}
viewDate={viewDate}
config={config}
onRoomClick={setSelectedRoom}
/>
)}
</div>
)}
</div>
<ActivityPanel entries={activity} />
</div>
{/* Room modal */}
{selectedRoom && (
<RoomModal
room={selectedRoom}
viewDate={viewDate}
config={config}
user={user}
onClose={() => setSelectedRoom(null)}
onTaskToggle={handleTaskToggle}
onStatusUpdate={handleStatusUpdate}
/>
)}
{/* Checkout toasts */}
<CheckoutNotification toasts={toasts} onDismiss={dismissToast} />
</div>
)
}

View file

@ -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<AppConfig | null>(null)
const [taskTypes, setTaskTypes] = useState<Array<{ id: string; name: string }>>([])
const [saving, setSaving] = useState<Record<string, boolean>>({})
const [saved, setSaved] = useState<Record<string, boolean>>({})
const [error, setError] = useState<string | null>(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 <div className="error-banner">You don't have permission to access settings.</div>
}
if (!config) {
return <div className="loading-spinner">Loading</div>
}
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<typeof display>) => {
const updated: TaskDisplayConfig = {
...(config?.task_display ?? {}),
[typeId]: { ...display, ...patch },
}
save('task_display', updated)
}
return (
<div className="settings-row">
<div className="settings-label">
<div>{typeName}</div>
<div className="settings-hint">ID: {typeId}</div>
</div>
<input
type="color"
className="settings-input"
value={display.color || '#94a3b8'}
style={{ width: 40, padding: 2, height: 32 }}
onChange={e => update({ color: e.target.value })}
/>
<button
className={`settings-toggle ${display.show_on_card ? 'on' : 'off'}`}
onClick={() => update({ show_on_card: !display.show_on_card })}
title="Show dot on card"
/>
</div>
)
}
return (
<div className="settings-page">
<h2 style={{ marginTop: 0, marginBottom: 20, fontSize: 20, fontWeight: 700 }}>Settings</h2>
{error && <div className="error-banner" style={{ marginBottom: 16 }}>{error}</div>}
{/* General */}
<div className="settings-section">
<div className="settings-section-header">General</div>
<div className="settings-section-body">
<div className="settings-row">
<div className="settings-label">
<div>Default checkout time</div>
<div className="settings-hint">Used for checkout notification display</div>
</div>
<input
type="time"
className="settings-input"
defaultValue={config.default_checkout_time || '11:00'}
onBlur={e => save('default_checkout_time', e.target.value)}
/>
</div>
<div className="settings-row">
<div className="settings-label">
<div>Checkout notification timeout</div>
<div className="settings-hint">Seconds before toast auto-dismisses</div>
</div>
<input
type="number"
className="settings-input"
min={5}
max={300}
style={{ width: 70 }}
defaultValue={config.checkout_notification_timeout ?? 30}
onBlur={e => save('checkout_notification_timeout', parseInt(e.target.value))}
/>
</div>
</div>
</div>
{/* Task display */}
{taskTypes.length > 0 && (
<div className="settings-section">
<div className="settings-section-header">Task Display</div>
<div className="settings-section-body" style={{ padding: 0 }}>
{taskTypes.map(t => (
<div key={t.id} style={{ padding: '0 20px' }}>
<TaskTypeRow typeId={t.id} typeName={t.name} />
</div>
))}
</div>
</div>
)}
{/* Twin / Extra bed detection */}
<div className="settings-section">
<div className="settings-section-header">Twin Detection</div>
<div className="settings-section-body">
<div className="settings-row">
<div className="settings-label">Enable twin detection</div>
<button
className={`settings-toggle ${config.twin_detection?.enabled !== false ? 'on' : 'off'}`}
onClick={() => save('twin_detection', {
...config.twin_detection,
enabled: config.twin_detection?.enabled === false,
})}
/>
</div>
<div className="settings-row">
<div className="settings-label">
<div>Keywords (comma-separated)</div>
<div className="settings-hint">Notes text that indicates a twin configuration</div>
</div>
<input
type="text"
className="settings-input"
style={{ width: 240 }}
defaultValue={(config.twin_detection?.keywords ?? []).join(', ')}
onBlur={e => save('twin_detection', {
...config.twin_detection,
keywords: e.target.value.split(',').map(s => s.trim()).filter(Boolean),
})}
/>
</div>
<div className="settings-row">
<div className="settings-label">
<div>Exclude keywords (comma-separated)</div>
<div className="settings-hint">Override: if present, twin detection is suppressed</div>
</div>
<input
type="text"
className="settings-input"
style={{ width: 240 }}
defaultValue={(config.twin_detection?.exclude_keywords ?? []).join(', ')}
onBlur={e => save('twin_detection', {
...config.twin_detection,
exclude_keywords: e.target.value.split(',').map(s => s.trim()).filter(Boolean),
})}
/>
</div>
</div>
</div>
<div className="settings-section">
<div className="settings-section-header">Extra Bed Detection</div>
<div className="settings-section-body">
<div className="settings-row">
<div className="settings-label">Enable extra bed detection</div>
<button
className={`settings-toggle ${config.extra_bed_detection?.enabled !== false ? 'on' : 'off'}`}
onClick={() => save('extra_bed_detection', {
...config.extra_bed_detection,
enabled: config.extra_bed_detection?.enabled === false,
})}
/>
</div>
<div className="settings-row">
<div className="settings-label">
<div>Keywords (comma-separated)</div>
</div>
<input
type="text"
className="settings-input"
style={{ width: 240 }}
defaultValue={(config.extra_bed_detection?.keywords ?? []).join(', ')}
onBlur={e => save('extra_bed_detection', {
...config.extra_bed_detection,
keywords: e.target.value.split(',').map(s => s.trim()).filter(Boolean),
})}
/>
</div>
</div>
</div>
{/* Category exclusions */}
<div className="settings-section">
<div className="settings-section-header">Category Exclusions</div>
<div className="settings-section-body">
<div className="settings-row">
<div className="settings-label">
<div>Excluded category IDs (comma-separated)</div>
<div className="settings-hint">Rooms in these categories show with excluded flag</div>
</div>
<input
type="text"
className="settings-input"
style={{ width: 200 }}
defaultValue={(config.excluded_categories ?? []).join(', ')}
onBlur={e => save('excluded_categories',
e.target.value.split(',').map(s => s.trim()).filter(Boolean)
)}
/>
</div>
<div className="settings-row">
<div className="settings-label">Hide excluded categories entirely</div>
<button
className={`settings-toggle ${config.hide_excluded_categories ? 'on' : 'off'}`}
onClick={() => save('hide_excluded_categories', !config.hide_excluded_categories)}
/>
</div>
</div>
</div>
</div>
)
}

143
frontend/src/types.ts Normal file
View file

@ -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<string, unknown> | null
user_name: string
occurred_at: string
booking_ref: string | null
}
export type FilterMode = 'off' | 'inclusive' | 'exclusive'
export interface FilterState {
categories: Record<string, FilterMode>
flowTypes: Partial<Record<FlowType, FilterMode>>
}
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'

19
frontend/tsconfig.json Normal file
View file

@ -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"]
}

7
frontend/vite.config.ts Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
base: '/room-planner/',
plugins: [react()],
})

57
seed-app.js Normal file
View file

@ -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()