diff --git a/app/Services/Qms/DisplayService.php b/app/Services/Qms/DisplayService.php index 7c11119..fcc859d 100644 --- a/app/Services/Qms/DisplayService.php +++ b/app/Services/Qms/DisplayService.php @@ -35,7 +35,8 @@ class DisplayService ->get(); // One active ticket per destination (service point / counter). Older called/serving - // tickets at the same window must not crowd the public board. + // tickets at the same window must not crowd the public board. Order is stable by + // destination so every serving point is presented equally (no "newest" primacy). $nowServing = Ticket::query() ->whereIn('service_queue_id', $queueIds) ->whereIn('status', ['called', 'serving']) @@ -60,9 +61,20 @@ class DisplayService 'counter' => $point?->displayDestination() ?? $point?->name, 'destination' => $point?->displayDestination(), 'staff_display_name' => $point?->staff_display_name, + 'point_key' => $point + ? 'counter:'.$point->id + : 'queue:'.$t->service_queue_id, 'announcement_text' => TicketPresenter::announcementText($t, $point), + 'called_at' => optional($t->called_at)?->toIso8601String(), ]; }) + ->sortBy(fn (array $item) => sprintf( + '%s|%s|%s', + mb_strtolower((string) ($item['queue_name'] ?? '')), + mb_strtolower((string) ($item['destination'] ?? $item['counter'] ?? '')), + (string) ($item['ticket_number'] ?? ''), + )) + ->values() ->all(); $waiting = Ticket::query() diff --git a/resources/css/app.css b/resources/css/app.css index 3575b55..87081e0 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -491,20 +491,9 @@ html:has(.qms-display) body { box-shadow: 0 16px 32px -20px rgb(15 23 42 / 0.2); } -.qms-display__ticket--newest { - border-color: rgb(129 140 248); - background: linear-gradient(145deg, rgb(238 242 255) 0%, #fff 46%); - box-shadow: 0 24px 48px -24px rgb(79 70 229 / 0.45); -} - .qms-display__ticket-accent { height: 4px; flex-shrink: 0; - background: rgb(203 213 225); -} - -.qms-display__ticket--newest .qms-display__ticket-accent { - height: 6px; background: linear-gradient(90deg, rgb(67 56 202), rgb(124 58 237), rgb(99 102 241)); } @@ -539,11 +528,6 @@ html:has(.qms-display) body { color: #fff; } -.qms-display__ticket:not(.qms-display__ticket--newest) .qms-display__badge { - background: rgb(241 245 249); - color: rgb(71 85 105); -} - .qms-display__queue-name { min-width: 0; overflow: hidden; @@ -573,10 +557,6 @@ html:has(.qms-display) body { color: rgb(30 41 59); } -.qms-display__ticket--newest .qms-display__ticket-number { - color: rgb(67 56 202); -} - .qms-display__destination { flex-shrink: 0; min-width: 0; diff --git a/resources/js/kiosk-flow.js b/resources/js/kiosk-flow.js index 59e7cc0..e4069c9 100644 --- a/resources/js/kiosk-flow.js +++ b/resources/js/kiosk-flow.js @@ -202,6 +202,9 @@ export function registerKioskFlow(Alpine) { Alpine.data('displayBoard', (config = {}) => ({ payload: config.initial ?? null, announcedIds: {}, + servingSignatures: {}, + servingPrimed: false, + pendingLocalAnnouncements: [], isAnnouncing: false, speechReady: false, voices: [], @@ -229,6 +232,10 @@ export function registerKioskFlow(Alpine) { } this.payload = await res.json(); this.pollError = null; + this.trackServingChanges( + this.payload?.now_serving ?? [], + this.payload?.announcements ?? [], + ); this.maybeAnnounce(this.payload?.announcements ?? []); } catch (e) { this.pollError = e.message || 'Could not refresh display data'; @@ -236,6 +243,64 @@ export function registerKioskFlow(Alpine) { } }, + servingPointKey(item) { + return item?.point_key + || item?.destination + || item?.counter + || item?.queue_name + || item?.ticket_number + || 'unknown'; + }, + + trackServingChanges(nowServing, serverAnnouncements = []) { + const nextSignatures = {}; + const changed = []; + + for (const item of nowServing) { + if (! item?.ticket_number) { + continue; + } + const key = this.servingPointKey(item); + const signature = `${item.ticket_number}|${item.called_at || ''}`; + nextSignatures[key] = signature; + + if (this.servingPrimed && this.servingSignatures[key] !== signature) { + changed.push(item); + } + } + + this.servingSignatures = nextSignatures; + if (! this.servingPrimed) { + this.servingPrimed = true; + return; + } + + for (const item of changed) { + const ticket = String(item.ticket_number); + const coveredByServer = (serverAnnouncements || []).some( + (announcement) => announcement?.message && String(announcement.message).includes(ticket), + ); + if (coveredByServer) { + continue; + } + + const message = item.announcement_text + || `Ticket ${item.ticket_number}, please proceed to ${item.destination || item.counter || 'the service point'}.`; + const localId = `local:${this.servingPointKey(item)}:${item.ticket_number}:${item.called_at || Date.now()}`; + if (this.announcedIds[localId]) { + continue; + } + this.pendingLocalAnnouncements.push({ + id: localId, + message, + locale: 'en', + voice: 'female', + volume: 80, + repeat_count: 1, + local: true, + }); + } + }, primeSpeech() { if (! window.speechSynthesis) { return; @@ -277,17 +342,22 @@ export function registerKioskFlow(Alpine) { }, maybeAnnounce(announcements) { - if (! window.speechSynthesis || ! this.speechReady || this.isAnnouncing || ! announcements.length) { + if (! window.speechSynthesis || ! this.speechReady || this.isAnnouncing) { return; } - const next = announcements.find((item) => item?.id && item?.message && ! this.announcedIds[item.id]); + const queue = [ + ...(Array.isArray(announcements) ? announcements : []), + ...this.pendingLocalAnnouncements, + ]; + const next = queue.find((item) => item?.id && item?.message && ! this.announcedIds[item.id]); if (! next) { return; } this.isAnnouncing = true; this.announcedIds[next.id] = true; + this.pendingLocalAnnouncements = this.pendingLocalAnnouncements.filter((item) => item.id !== next.id); this.speakAnnouncement(next); }, @@ -307,8 +377,13 @@ export function registerKioskFlow(Alpine) { const finish = () => { release(); - this.ackPlayed(item.id); - this.maybeAnnounce(this.payload?.announcements ?? []); + if (! item.local) { + this.ackPlayed(item.id); + } + this.maybeAnnounce([ + ...(this.payload?.announcements ?? []), + ...this.pendingLocalAnnouncements, + ]); }; const fail = (reason) => { @@ -400,6 +475,8 @@ export function registerKioskFlow(Alpine) { // ignore storage failures } + // Seed signatures from SSR payload without announcing historical tickets. + this.trackServingChanges(this.payload?.now_serving ?? []); this.poll(); setInterval(() => this.poll(), this.pollMs); }, diff --git a/resources/views/qms/display/public.blade.php b/resources/views/qms/display/public.blade.php index fdabcdb..5d6cdf6 100644 --- a/resources/views/qms/display/public.blade.php +++ b/resources/views/qms/display/public.blade.php @@ -82,11 +82,8 @@ x-show="(payload?.now_serving ?? []).length" :data-ticket-count="Math.min((payload?.now_serving ?? []).length, 12)" > -