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:
commit
1e658b6a48
39 changed files with 3765 additions and 0 deletions
7
backend/Dockerfile
Normal file
7
backend/Dockerfile
Normal 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
16
backend/package.json
Normal 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
57
backend/src/auth.js
Normal 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
70
backend/src/db.js
Normal 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
35
backend/src/index.js
Normal 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
80
backend/src/ip-check.js
Normal 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
|
||||||
|
}
|
||||||
124
backend/src/lib/booking-flow.js
Normal file
124
backend/src/lib/booking-flow.js
Normal 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
125
backend/src/lib/newbook.js
Normal 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 ?? []
|
||||||
|
}
|
||||||
49
backend/src/routes/activity.js
Normal file
49
backend/src/routes/activity.js
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
42
backend/src/routes/config.js
Normal file
42
backend/src/routes/config.js
Normal 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}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
54
backend/src/routes/events.js
Normal file
54
backend/src/routes/events.js
Normal 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
155
backend/src/routes/rooms.js
Normal 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),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
41
backend/src/routes/status.js
Normal file
41
backend/src/routes/status.js
Normal 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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
58
backend/src/routes/tasks.js
Normal file
58
backend/src/routes/tasks.js
Normal 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
38
docker-compose.yml
Normal 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
13
frontend/Dockerfile
Normal 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
16
frontend/index.html
Normal 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
23
frontend/nginx.conf
Normal 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
24
frontend/package.json
Normal 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
22
frontend/src/App.tsx
Normal 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
77
frontend/src/api.ts
Normal 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)}`)
|
||||||
|
}
|
||||||
59
frontend/src/components/ActivityPanel.tsx
Normal file
59
frontend/src/components/ActivityPanel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
51
frontend/src/components/AuthGate.tsx
Normal file
51
frontend/src/components/AuthGate.tsx
Normal 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>
|
||||||
|
}
|
||||||
61
frontend/src/components/CategoryGroup.tsx
Normal file
61
frontend/src/components/CategoryGroup.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
frontend/src/components/CheckoutNotification.tsx
Normal file
48
frontend/src/components/CheckoutNotification.tsx
Normal 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 }
|
||||||
|
}
|
||||||
133
frontend/src/components/FilterBar.tsx
Normal file
133
frontend/src/components/FilterBar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
55
frontend/src/components/Layout.tsx
Normal file
55
frontend/src/components/Layout.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
frontend/src/components/RoomCard.tsx
Normal file
127
frontend/src/components/RoomCard.tsx
Normal 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)
|
||||||
276
frontend/src/components/RoomModal.tsx
Normal file
276
frontend/src/components/RoomModal.tsx
Normal 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
903
frontend/src/index.css
Normal 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;
|
||||||
|
}
|
||||||
69
frontend/src/lib/booking-flow.ts
Normal file
69
frontend/src/lib/booking-flow.ts
Normal 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)
|
||||||
|
}
|
||||||
46
frontend/src/lib/twin-detect.ts
Normal file
46
frontend/src/lib/twin-detect.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>
|
||||||
|
)
|
||||||
331
frontend/src/pages/Planner.tsx
Normal file
331
frontend/src/pages/Planner.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
244
frontend/src/pages/Settings.tsx
Normal file
244
frontend/src/pages/Settings.tsx
Normal 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
143
frontend/src/types.ts
Normal 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
19
frontend/tsconfig.json
Normal 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
7
frontend/vite.config.ts
Normal 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
57
seed-app.js
Normal 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()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue