Extension: add Today's Arrivals list to popup, pushable independent of a NewBook booking page

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.
This commit is contained in:
jtricerolph 2026-10-09 12:09:01 +00:00
parent d7fbdce04d
commit ebf9875ca8
4 changed files with 239 additions and 3 deletions

View file

@ -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; padding: 2px 8px; border-radius: 999px; cursor: pointer;
} }
.reset-btn:disabled { opacity: 0.5; cursor: default; } .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; }

View file

@ -33,11 +33,15 @@ async function main() {
return; 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 // 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. // tablet from the terminal without needing to be on a booking page at all;
contentEl.innerHTML = `<div id="tabletsSection"></div><div id="bookingSection"></div>`; // 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 = `<div id="tabletsSection"></div><div id="arrivalsSection"></div><div id="bookingSection"></div>`;
const tabletsEl = document.getElementById('tabletsSection'); const tabletsEl = document.getElementById('tabletsSection');
const arrivalsEl = document.getElementById('arrivalsSection');
const bookingEl = document.getElementById('bookingSection'); const bookingEl = document.getElementById('bookingSection');
let devices = []; let devices = [];
@ -53,6 +57,7 @@ async function main() {
} }
renderTablets(tabletsEl, devices, baseUrl, apiKey); renderTablets(tabletsEl, devices, baseUrl, apiKey);
loadArrivals(arrivalsEl, devices, defaultDeviceId, baseUrl, apiKey);
if (!bookingId) { if (!bookingId) {
bookingEl.innerHTML = `<p class="muted">Open a booking in NewBook to send it to a check-in tablet.</p>`; bookingEl.innerHTML = `<p class="muted">Open a booking in NewBook to send it to a check-in tablet.</p>`;
@ -62,6 +67,123 @@ async function main() {
renderBooking(bookingEl, bookingId, devices, defaultDeviceId, baseUrl, apiKey); renderBooking(bookingEl, bookingId, devices, defaultDeviceId, baseUrl, apiKey);
} }
async function loadArrivals(el, devices, defaultDeviceId, baseUrl, apiKey) {
el.innerHTML = `<p class="section-label">Today's Arrivals</p><p class="muted">Loading…</p>`;
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 = `<p class="section-label">Today's Arrivals</p><p class="muted">Could not load: ${escapeHtml(err.message)}</p>`;
return;
}
renderArrivals(el, arrivals, devices, defaultDeviceId, baseUrl, apiKey);
}
function renderArrivals(el, arrivals, devices, defaultDeviceId, baseUrl, apiKey) {
if (!arrivals.length) {
el.innerHTML = `<p class="section-label">Today's Arrivals</p><p class="muted">No arrivals today.</p>`;
return;
}
el.innerHTML = `
<p class="section-label">Today's Arrivals</p>
<ul class="arrival-list">
${arrivals.map(a => `
<li class="arrival-row">
<div class="arrival-info">
<span class="arrival-name">${escapeHtml(a.guest_name)}</span>
${a.site_name ? `<span class="arrival-site">${escapeHtml(a.site_name)}</span>` : ''}
</div>
${a.checked_in
? `<span class="arrival-done">Checked in</span>`
: `<button type="button" class="push-btn" data-booking="${a.booking_id}">Push</button>`}
<div class="arrival-msg"></div>
</li>
`).join('')}
</ul>
`;
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 = `
<select>
${devices.map(d => `<option value="${d.device_id}" ${d.status === 'busy' ? 'disabled' : ''}>${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''}</option>`).join('')}
</select>
<button type="button" class="send-small">Send</button>
`;
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) { function renderTablets(el, devices, baseUrl, apiKey) {
if (!devices.length) { if (!devices.length) {
el.innerHTML = `<p class="muted">No tablets registered yet</p>`; el.innerHTML = `<p class="muted">No tablets registered yet</p>`;

View file

@ -194,6 +194,29 @@ export function buildContactDetails(existing, { email, carRegos, optedIn }) {
return details 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() { export async function testConnection() {
try { try {
const res = await callApi('sites_list', {}) const res = await callApi('sites_list', {})

View file

@ -1,6 +1,9 @@
import { requireTerminalAuth } from '../auth.js' import { requireTerminalAuth } from '../auth.js'
import { pool } from '../db.js' import { pool } from '../db.js'
import { pushBookingToDevice, resetDevice } from '../lib/push.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 // The standalone Chrome extension's calls — terminal-key authenticated (NOT
// a staff JWT/cookie), see auth.js's requireTerminalAuth and project_checkin's // 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 } 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 // 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 // own cancel button and the Devices admin page already have, surfaced to
// the extension so reception can clear a stuck-busy tablet from the // the extension so reception can clear a stuck-busy tablet from the