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:
parent
d7fbdce04d
commit
ebf9875ca8
4 changed files with 239 additions and 3 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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 = `<div id="tabletsSection"></div><div id="bookingSection"></div>`;
|
||||
// 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 = `<div id="tabletsSection"></div><div id="arrivalsSection"></div><div id="bookingSection"></div>`;
|
||||
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 = `<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);
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!devices.length) {
|
||||
el.innerHTML = `<p class="muted">No tablets registered yet</p>`;
|
||||
|
|
|
|||
|
|
@ -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', {})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue