From ebf9875ca86056cc44dc08a3eebee7ff644912ad Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Fri, 9 Oct 2026 12:09:01 +0000 Subject: [PATCH] Extension: add Today's Arrivals list to popup, pushable independent of a NewBook booking page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /api/staff/arrivals (NewBook bookings_list, same list_type: 'staying' pattern room-planner/hvac/maintenance already use) and a popup section that lists each arrival with its own Push button, falling back to a per-row device picker on busy/offline/no-default — a second route to the same push flow, separate from the content-script's injected NewBook page buttons. --- backend/extension-src/popup/popup.css | 34 +++++++ backend/extension-src/popup/popup.js | 128 +++++++++++++++++++++++++- backend/src/lib/newbook.js | 23 +++++ backend/src/routes/staff.js | 57 ++++++++++++ 4 files changed, 239 insertions(+), 3 deletions(-) diff --git a/backend/extension-src/popup/popup.css b/backend/extension-src/popup/popup.css index f2826c3..860f358 100644 --- a/backend/extension-src/popup/popup.css +++ b/backend/extension-src/popup/popup.css @@ -63,3 +63,37 @@ a.settings-link { display: block; margin-top: 12px; font-size: 12px; color: #7b4 padding: 2px 8px; border-radius: 999px; cursor: pointer; } .reset-btn:disabled { opacity: 0.5; cursor: default; } + +.arrival-list { + list-style: none; margin: 0 0 12px; padding: 0; + max-height: 220px; overflow-y: auto; +} +.arrival-row { + display: flex; align-items: center; gap: 6px; flex-wrap: wrap; + padding: 6px 0; font-size: 12px; border-bottom: 1px solid #e4e8ee; +} +.arrival-row:last-child { border-bottom: none; } +.arrival-info { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.arrival-name { color: #1a1a2e; font-weight: 600; } +.arrival-site { color: #64748b; font-size: 11px; } +.arrival-done { + font-size: 10px; padding: 1px 6px; border-radius: 999px; + background: #dcfce7; color: #166534; +} +.push-btn { + border: none; background: #c9a84c; color: #1a1a2e; font-size: 11px; font-weight: 700; + padding: 3px 10px; border-radius: 999px; cursor: pointer; +} +.push-btn:disabled { opacity: 0.5; cursor: default; } +.arrival-msg { flex-basis: 100%; font-size: 11px; } +.arrival-picker { + flex-basis: 100%; display: flex; gap: 6px; margin-top: 4px; +} +.arrival-picker select { + flex: 1; margin-bottom: 0; padding: 4px; font-size: 12px; +} +.send-small { + border: none; background: #c9a84c; color: #1a1a2e; font-size: 11px; font-weight: 700; + padding: 4px 10px; border-radius: 6px; cursor: pointer; +} +.send-small:disabled { opacity: 0.5; cursor: default; } diff --git a/backend/extension-src/popup/popup.js b/backend/extension-src/popup/popup.js index 519cf8e..3e9ecda 100644 --- a/backend/extension-src/popup/popup.js +++ b/backend/extension-src/popup/popup.js @@ -33,11 +33,15 @@ async function main() { return; } - // Two independent sections: tablet status + reset is always shown (not + // Three independent sections: tablet status + reset is always shown (not // gated on a booking being open) so reception can clear a stuck-busy - // tablet from the terminal without needing to be on a booking page at all. - contentEl.innerHTML = `
`; + // tablet from the terminal without needing to be on a booking page at all; + // arrivals is likewise always shown — it's the alternative route to a push + // for when reception isn't on a booking page either, picking the guest + // from today's list instead of from NewBook itself. + contentEl.innerHTML = `
`; const tabletsEl = document.getElementById('tabletsSection'); + const arrivalsEl = document.getElementById('arrivalsSection'); const bookingEl = document.getElementById('bookingSection'); let devices = []; @@ -53,6 +57,7 @@ async function main() { } renderTablets(tabletsEl, devices, baseUrl, apiKey); + loadArrivals(arrivalsEl, devices, defaultDeviceId, baseUrl, apiKey); if (!bookingId) { bookingEl.innerHTML = `

Open a booking in NewBook to send it to a check-in tablet.

`; @@ -62,6 +67,123 @@ async function main() { renderBooking(bookingEl, bookingId, devices, defaultDeviceId, baseUrl, apiKey); } +async function loadArrivals(el, devices, defaultDeviceId, baseUrl, apiKey) { + el.innerHTML = `

Today's Arrivals

Loading…

`; + let arrivals; + try { + const res = await fetch(`${baseUrl}/api/staff/arrivals`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || `HTTP ${res.status}`); + arrivals = await res.json(); + } catch (err) { + el.innerHTML = `

Today's Arrivals

Could not load: ${escapeHtml(err.message)}

`; + return; + } + renderArrivals(el, arrivals, devices, defaultDeviceId, baseUrl, apiKey); +} + +function renderArrivals(el, arrivals, devices, defaultDeviceId, baseUrl, apiKey) { + if (!arrivals.length) { + el.innerHTML = `

Today's Arrivals

No arrivals today.

`; + return; + } + + el.innerHTML = ` +

Today's Arrivals

+ + `; + + el.querySelectorAll('.push-btn').forEach(btn => { + btn.addEventListener('click', () => handleArrivalPush(btn, devices, defaultDeviceId, baseUrl, apiKey)); + }); +} + +// Mirrors renderBooking's own fast-path/picker split (straight to the +// default tablet when it's free, a manual picker when it isn't or there's +// none), just scoped to this one row instead of the whole popup — several +// of these can be mid-send at once across different arrivals. +async function handleArrivalPush(btn, devices, defaultDeviceId, baseUrl, apiKey) { + const row = btn.closest('.arrival-row'); + const bookingId = btn.dataset.booking; + const msgEl = row.querySelector('.arrival-msg'); + const defaultDevice = devices.find(d => d.device_id === defaultDeviceId); + + if (defaultDevice && defaultDevice.status !== 'busy') { + btn.disabled = true; + btn.textContent = '…'; + const result = await sendPush(bookingId, defaultDevice.device_id, baseUrl, apiKey); + if (result.ok && result.delivered !== false) { + btn.remove(); + setMsg(msgEl, 'Sent', 'ok'); + return; + } + if (result.ok) { + setMsg(msgEl, 'Not connected — choose another:', 'warn'); + } else if (result.status === 409) { + setMsg(msgEl, 'Busy — choose another:', 'warn'); + } else { + btn.disabled = false; + btn.textContent = 'Push'; + setMsg(msgEl, `Failed: ${result.error}`, 'error'); + return; + } + } + + showArrivalPicker(row, bookingId, devices, defaultDeviceId, baseUrl, apiKey); +} + +function showArrivalPicker(row, bookingId, devices, defaultDeviceId, baseUrl, apiKey) { + row.querySelector('.push-btn')?.remove(); + const msgEl = row.querySelector('.arrival-msg'); + + if (!devices.length) { + setMsg(msgEl, 'No tablets registered yet', 'error'); + return; + } + + const picker = document.createElement('div'); + picker.className = 'arrival-picker'; + picker.innerHTML = ` + + + `; + row.appendChild(picker); + const select = picker.querySelector('select'); + if (defaultDeviceId) select.value = defaultDeviceId; + + picker.querySelector('.send-small').addEventListener('click', async () => { + const sendBtn = picker.querySelector('.send-small'); + sendBtn.disabled = true; + const result = await sendPush(bookingId, select.value, baseUrl, apiKey); + if (result.ok && result.delivered !== false) { + picker.remove(); + setMsg(msgEl, 'Sent', 'ok'); + } else if (result.ok) { + setMsg(msgEl, 'Sent, but that tablet isn’t connected right now', 'warn'); + sendBtn.disabled = false; + } else { + setMsg(msgEl, result.error || 'Failed', 'error'); + sendBtn.disabled = false; + } + }); +} + function renderTablets(el, devices, baseUrl, apiKey) { if (!devices.length) { el.innerHTML = `

No tablets registered yet

`; diff --git a/backend/src/lib/newbook.js b/backend/src/lib/newbook.js index b9538a5..6ce5332 100644 --- a/backend/src/lib/newbook.js +++ b/backend/src/lib/newbook.js @@ -194,6 +194,29 @@ export function buildContactDetails(existing, { email, carRegos, optedIn }) { return details } +// Fetch bookings whose stay overlaps the date window — the same +// `bookings_list` call with `list_type: 'staying'` room-planner/hvac/ +// maintenance/hk-planner already use for exactly this (confirmed by reading +// those apps 2026-10-08 — there's no separate "arrivals" endpoint on the +// NewBook API, every app derives "arriving today" client-side by comparing +// booking_arrival to the target date). Used by routes/staff.js's arrivals +// list for the extension popup. +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: 'staying', + }) + return res?.data ?? [] +} + +// Site id -> name lookup, for when a bookings_list row doesn't already carry +// site_name directly (room-planner has seen both shapes from this same API). +export async function fetchSites() { + const res = await callApi('sites_list', {}) + return res?.data ?? [] +} + export async function testConnection() { try { const res = await callApi('sites_list', {}) diff --git a/backend/src/routes/staff.js b/backend/src/routes/staff.js index 1610a98..c8a92fd 100644 --- a/backend/src/routes/staff.js +++ b/backend/src/routes/staff.js @@ -1,6 +1,9 @@ import { requireTerminalAuth } from '../auth.js' import { pool } from '../db.js' import { pushBookingToDevice, resetDevice } from '../lib/push.js' +import { fetchBookings, fetchSites, pickPrimaryGuest } from '../lib/newbook.js' + +const CANCELLED_STATUSES = ['cancelled', 'no_show'] // The standalone Chrome extension's calls — terminal-key authenticated (NOT // a staff JWT/cookie), see auth.js's requireTerminalAuth and project_checkin's @@ -27,6 +30,60 @@ export async function staffRoutes(app) { return { ok: true, delivered: result.delivered } }) + // GET /api/staff/arrivals — today's arrivals for the extension popup's own + // "Today's Arrivals" list. A second, independent route onto the same push + // flow as the content-script's injected NewBook page buttons — this one + // lets reception push a guest to a tablet without a booking page open at + // all, picking the guest from this list instead. Cancelled/no-show + // bookings excluded the same way hvac's scheduler.js already does for this + // same NewBook data; already-arrived bookings are flagged rather than + // dropped, so reception can still see the full day's list at a glance. + app.get('/api/staff/arrivals', { preHandler: requireTerminalAuth }, async (req, reply) => { + const today = new Date().toISOString().slice(0, 10) + + let bookings, sites + try { + ;[bookings, sites] = await Promise.all([fetchBookings(today, today), fetchSites()]) + } catch (err) { + return reply.status(502).send({ error: `Could not reach NewBook: ${err.message}` }) + } + + const siteNameById = new Map(sites.map(s => [String(s.site_id), s.site_name])) + + const arrivals = bookings.filter(b => + String(b.booking_arrival || '').slice(0, 10) === today && + !CANCELLED_STATUSES.includes(String(b.booking_status || '').toLowerCase()) + ) + + const bookingIds = arrivals.map(b => String(b.booking_id)) + const { rows: checkedInRows } = bookingIds.length + ? await pool.query( + `SELECT DISTINCT booking_id FROM checkins WHERE booking_id = ANY($1) AND status = 'complete'`, + [bookingIds] + ) + : { rows: [] } + const checkedInHere = new Set(checkedInRows.map(r => r.booking_id)) + + return arrivals + .map(b => { + const guest = pickPrimaryGuest(b) + const guestName = (guest && (guest.guest_name || [guest.firstname, guest.lastname].filter(Boolean).join(' '))) + || b.guest_name || b.account_for_name || '' + const status = String(b.booking_status || '').toLowerCase() + return { + booking_id: String(b.booking_id), + guest_name: guestName || `Booking #${b.booking_id}`, + site_name: b.site_name || siteNameById.get(String(b.site_id)) || '', + // "arrived" covers a check-in NewBook itself already recorded (e.g. + // front desk did it manually) — not just one this app's own + // checkins table logged — so the list doesn't offer a tablet push + // for a guest who's already checked in by any route. + checked_in: checkedInHere.has(String(b.booking_id)) || status === 'arrived', + } + }) + .sort((a, b) => a.guest_name.localeCompare(b.guest_name)) + }) + // POST /api/staff/devices/:id/reset — same safety-net reset the tablet's // own cancel button and the Devices admin page already have, surfaced to // the extension so reception can clear a stuck-busy tablet from the