feat: add room-planner app — 3-day HK room view with NewBook integration

NewBook-connected daily housekeeping planner. Replaces the hotelhubmodule-housekeeping-dailylist
WordPress plugin. LXC 120 · 10.10.10.120:3080 · slug: room-planner.

- 3-day booking window (yesterday/today/tomorrow) fetched live from NewBook
- Task completion ticks back to NewBook; room status patches NewBook directly
- 23px border sliver CSS system for adjacent-day booking status
- 3-state filter cycling (off→inclusive→exclusive) for categories and flow types
- Stat filters for outstanding tasks and clean/dirty status
- Rolling 48h activity log with checkout/checkin/status/tasks events
- newbook_pings event bus for future NewBook poller integration
- Room modal with permission-gated guest/rate/notes, task checkboxes, status buttons
- Placeholder sections for future linen-count and routine-tasks modules
- Settings page: task type colours, twin/extra-bed detection, category exclusions
- Mobile-first layout (sidebar desktop, compact top bar mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-03 13:07:00 +00:00
commit 1e658b6a48
39 changed files with 3765 additions and 0 deletions

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

@ -0,0 +1,155 @@
import { requireAuth, hasCap } from '../auth.js'
import { pool } from '../db.js'
import { fetchSites, fetchBookings, fetchTasks } from '../lib/newbook.js'
import { classifyRoom, toDateStr } from '../lib/booking-flow.js'
function dateOffset(dateStr, days) {
const d = new Date(dateStr + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
function filterBookingData(booking, canSeeGuest, canSeeRate, canSeeAllNotes, visibleNoteTypes) {
if (!booking) return null
const out = {
booking_id: booking.booking_id,
booking_reference_id: booking.booking_reference_id,
booking_status: booking.booking_status,
booking_arrival: booking.booking_arrival,
booking_departure: booking.booking_departure,
booking_eta: booking.booking_eta,
booking_locked: booking.booking_locked,
pax: booking.pax,
site_id: booking.site_id,
custom_fields: booking.custom_fields || [],
}
if (canSeeGuest) {
const guests = booking.guests || []
out.guest_name = guests[0]?.guest_name || booking.account_for_name || null
}
if (canSeeRate) {
out.rate_plan_name = booking.rate_plan_name || null
}
if (canSeeAllNotes) {
out.notes = booking.notes || []
} else if (visibleNoteTypes?.length) {
out.notes = (booking.notes || []).filter(n => visibleNoteTypes.includes(String(n.note_type_id)))
} else {
out.notes = []
}
return out
}
export async function roomRoutes(app) {
app.addHook('preHandler', requireAuth)
// GET /api/rooms?date=YYYY-MM-DD
app.get('/api/rooms', async (req, reply) => {
if (!hasCap(req, 'view')) return reply.status(403).send({ error: 'Missing capability: view' })
const viewDate = req.query.date || new Date().toISOString().slice(0, 10)
const yesterday = dateOffset(viewDate, -1)
const tomorrow = dateOffset(viewDate, +1)
const canSeeGuest = hasCap(req, 'guest_details')
const canSeeRate = hasCap(req, 'rate_details')
const canSeeAllNotes = hasCap(req, 'view_all_notes')
// Load config for note type visibility
const cfgRow = await pool.query(`SELECT value FROM config WHERE key = 'visible_note_types'`)
const visibleNoteTypes = cfgRow.rows[0]?.value || []
// Load exclusion config
const excRow = await pool.query(
`SELECT key, value FROM config WHERE key IN ('excluded_categories','hide_excluded_categories')`
)
const cfgMap = Object.fromEntries(excRow.rows.map(r => [r.key, r.value]))
const excludedCategories = cfgMap.excluded_categories || []
const hideExcluded = cfgMap.hide_excluded_categories ?? false
// Fetch from NewBook in parallel
let sites, bookings, tasks
try {
;[sites, bookings, tasks] = await Promise.all([
fetchSites(),
fetchBookings(yesterday, tomorrow),
fetchTasks(yesterday, tomorrow),
])
} catch (err) {
return reply.status(502).send({ error: `NewBook fetch failed: ${err.message}` })
}
// Derive category map from sites — NewBook includes site_category_id/name/order per site
const categoryMap = {}
for (const site of sites) {
const catId = String(site.site_category_id || '')
if (catId && !categoryMap[catId]) {
categoryMap[catId] = {
id: catId,
name: site.site_category_name || catId,
order: site.site_category_order ?? 999,
}
}
}
// Classify each site
const rooms = []
for (const site of sites) {
const catId = String(site.site_category_id || '')
const isExcluded = excludedCategories.includes(catId)
if (isExcluded && hideExcluded) continue
const classified = classifyRoom(site, bookings, viewDate, yesterday, tomorrow)
// Filter booking data per capabilities
if (classified.booking) {
classified.booking = filterBookingData(
classified.booking, canSeeGuest, canSeeRate, canSeeAllNotes, visibleNoteTypes
)
}
if (classified.departing_booking && classified.departing_booking !== classified.booking) {
classified.departing_booking = filterBookingData(
classified.departing_booking, canSeeGuest, canSeeRate, canSeeAllNotes, visibleNoteTypes
)
}
classified.previous_booking = classified.previous_booking
? filterBookingData(classified.previous_booking, false, false, false, [])
: null
classified.next_booking = classified.next_booking
? filterBookingData(classified.next_booking, false, false, false, [])
: null
classified.filter_excluded = isExcluded
// Attach tasks for this site across the 3-day window, keyed by date
const siteId = String(site.site_id)
classified.tasks = tasks
.filter(t => String(t.booking_site_id || t.site_id) === siteId)
.map(t => ({
task_id: t.task_id,
task_description: t.task_description,
task_type_id: String(t.task_type_id),
task_when_date: t.task_when_date ? toDateStr(t.task_when_date) : null,
task_period_from: t.task_period_from ? toDateStr(t.task_period_from) : null,
task_period_to: t.task_period_to ? toDateStr(t.task_period_to) : null,
site_id: siteId,
booking_id: t.booking_id,
completed_on: t.task_completed_on || null,
}))
rooms.push(classified)
}
return {
view_date: viewDate,
yesterday,
tomorrow,
rooms,
categories: Object.values(categoryMap).sort((a, b) => a.order - b.order),
}
})
}