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
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),
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue