From bc6bf0a07cf90c87b2ec2651efe2c81e827e47cd Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 29 Jun 2026 21:51:13 +0000 Subject: [PATCH] Faster voice announcements, Care/Frontdesk API, and console UI polish. Poll displays every 1.2s with client-side TTS ack; expose service API for sibling apps; redesign tickets, counters, and staff console. Co-authored-by: Cursor --- .env.example | 3 + .../Api/Concerns/ScopesApiToAccount.php | 27 +++ .../Controllers/Api/ConsoleApiController.php | 132 ++++++++++++++ .../Controllers/Api/IntegrationController.php | 156 +++++++++++++++++ .../Controllers/Qms/CounterController.php | 21 ++- .../Qms/DisplayPublicController.php | 22 +-- .../Controllers/Qms/SettingsController.php | 10 ++ app/Http/Controllers/Qms/TicketController.php | 4 +- .../Middleware/EnsureQueueIntegration.php | 31 ++++ bootstrap/app.php | 1 + config/qms.php | 14 ++ resources/js/kiosk-flow.js | 84 +++++++-- .../views/components/counter-status.blade.php | 16 ++ .../views/components/ticket-status.blade.php | 20 +++ resources/views/qms/console/show.blade.php | 165 +++++++++++------- resources/views/qms/counters/index.blade.php | 54 ++++-- resources/views/qms/counters/show.blade.php | 63 +++++-- resources/views/qms/display/public.blade.php | 2 +- resources/views/qms/settings/edit.blade.php | 15 ++ resources/views/qms/tickets/index.blade.php | 91 +++++++--- routes/api.php | 21 +++ routes/web.php | 1 + 22 files changed, 813 insertions(+), 140 deletions(-) create mode 100644 app/Http/Controllers/Api/ConsoleApiController.php create mode 100644 app/Http/Controllers/Api/IntegrationController.php create mode 100644 app/Http/Middleware/EnsureQueueIntegration.php create mode 100644 resources/views/components/counter-status.blade.php create mode 100644 resources/views/components/ticket-status.blade.php diff --git a/.env.example b/.env.example index aa2a2e8..6de4b96 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,9 @@ SESSION_LIFETIME=1440 CACHE_STORE=database QUEUE_CONNECTION=database +# Display board poll interval in milliseconds (voice announcements) +QUEUE_DISPLAY_POLL_MS=1200 + # --- Ladill SSO (Sign in with Ladill — auth.ladill.com OIDC client) --- LADILL_SSO_CLIENT_ID= LADILL_SSO_CLIENT_SECRET= diff --git a/app/Http/Controllers/Api/Concerns/ScopesApiToAccount.php b/app/Http/Controllers/Api/Concerns/ScopesApiToAccount.php index c7a8ac0..f9956c7 100644 --- a/app/Http/Controllers/Api/Concerns/ScopesApiToAccount.php +++ b/app/Http/Controllers/Api/Concerns/ScopesApiToAccount.php @@ -11,13 +11,32 @@ use Illuminate\Http\Request; trait ScopesApiToAccount { + protected function isServiceRequest(Request $request): bool + { + return $request->attributes->get('service_caller') !== null; + } + protected function ownerRef(Request $request): string { + if ($this->isServiceRequest($request)) { + $owner = trim((string) ($request->input('owner') ?? $request->query('owner', ''))); + abort_if($owner === '', 422, 'The owner parameter is required.'); + + return $owner; + } + return (string) $request->user()->public_id; } protected function organization(Request $request): Organization { + if ($this->isServiceRequest($request)) { + $organization = Organization::owned($this->ownerRef($request))->first(); + abort_unless($organization, 404, 'Queue organization not found.'); + + return $organization; + } + $organization = app(OrganizationResolver::class)->resolveForUser($request->user()); abort_unless($organization, 404); @@ -26,11 +45,19 @@ trait ScopesApiToAccount protected function member(Request $request): ?Member { + if ($this->isServiceRequest($request)) { + return null; + } + return app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request)); } protected function authorizeAbility(Request $request, string $ability): void { + if ($this->isServiceRequest($request)) { + return; + } + abort_unless( app(QmsPermissions::class)->can($this->member($request), $ability), 403, diff --git a/app/Http/Controllers/Api/ConsoleApiController.php b/app/Http/Controllers/Api/ConsoleApiController.php new file mode 100644 index 0000000..67abbdf --- /dev/null +++ b/app/Http/Controllers/Api/ConsoleApiController.php @@ -0,0 +1,132 @@ +authorizeAbility($request, 'console.use'); + $this->authorizeOwner($request, $counter); + $owner = $this->ownerRef($request); + + $counter->load(['serviceQueues', 'branch']); + + $currentTicket = Ticket::owned($owner) + ->where('counter_id', $counter->id) + ->whereIn('status', ['called', 'serving', 'on_hold']) + ->with('serviceQueue') + ->latest('called_at') + ->first(); + + $waitingByQueue = collect($counter->serviceQueues)->mapWithKeys(function (ServiceQueue $queue) { + return [$queue->uuid => array_map( + fn ($t) => TicketPresenter::toArray($t), + $this->engine->waitingTickets($queue, 10), + )]; + }); + + $allQueues = ServiceQueue::owned($owner) + ->where('organization_id', $counter->organization_id) + ->where('branch_id', $counter->branch_id) + ->where('is_active', true) + ->orderBy('name') + ->get(['uuid', 'name', 'prefix']); + + return response()->json([ + 'data' => [ + 'counter' => [ + 'uuid' => $counter->uuid, + 'name' => $counter->name, + 'status' => $counter->status, + 'branch' => $counter->branch?->name, + ], + 'current_ticket' => $currentTicket ? TicketPresenter::toArray($currentTicket) : null, + 'queues' => $counter->serviceQueues->map(fn (ServiceQueue $q) => [ + 'uuid' => $q->uuid, + 'name' => $q->name, + 'prefix' => $q->prefix, + ]), + 'waiting_by_queue' => $waitingByQueue, + 'transfer_queues' => $allQueues, + ], + ]); + } + + public function action(Request $request, Counter $counter): JsonResponse + { + $this->authorizeAbility($request, 'console.use'); + $this->authorizeOwner($request, $counter); + $actor = $this->ownerRef($request); + + $validated = $request->validate([ + 'action' => ['required', 'string'], + 'queue_uuid' => ['nullable', 'uuid'], + 'ticket_uuid' => ['nullable', 'uuid'], + 'to_queue_uuid' => ['nullable', 'uuid'], + 'reason' => ['nullable', 'string', 'max:500'], + ]); + + $result = match ($validated['action']) { + 'call_next' => $this->handleCallNext($counter, $validated['queue_uuid'] ?? '', $actor), + 'available' => tap($counter)->update(['status' => 'available']), + 'offline' => tap($counter)->update(['status' => 'offline']), + default => $this->handleTicketAction($validated, $actor), + }; + + if ($result instanceof Ticket) { + return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter']))]); + } + + return $this->show($request, $counter->fresh()); + } + + protected function handleCallNext(Counter $counter, string $queueUuid, string $actor): ?Ticket + { + abort_if($queueUuid === '', 422, 'queue_uuid is required.'); + $queue = ServiceQueue::where('uuid', $queueUuid)->firstOrFail(); + + return $this->engine->callNext($queue, $counter, $actor); + } + + protected function handleTicketAction(array $validated, string $actor): Ticket|Counter|null + { + $ticketUuid = $validated['ticket_uuid'] ?? ''; + abort_if($ticketUuid === '', 422, 'ticket_uuid is required for this action.'); + + $ticket = Ticket::where('uuid', $ticketUuid)->firstOrFail(); + + return match ($validated['action']) { + 'recall' => $this->engine->recall($ticket, $actor), + 'start' => $this->engine->startServing($ticket, $actor), + 'hold' => $this->engine->hold($ticket, $actor), + 'skip' => $this->engine->skip($ticket, $actor), + 'complete' => $this->engine->completeTicket($ticket, $actor), + 'no_show' => $this->engine->markNoShow($ticket, $actor), + 'transfer' => $this->engine->transfer( + $ticket, + ServiceQueue::where('uuid', $validated['to_queue_uuid'] ?? '')->firstOrFail(), + null, + $validated['reason'] ?? null, + $actor, + ), + default => abort(422, 'Unknown action.'), + }; + } +} diff --git a/app/Http/Controllers/Api/IntegrationController.php b/app/Http/Controllers/Api/IntegrationController.php new file mode 100644 index 0000000..a7e800e --- /dev/null +++ b/app/Http/Controllers/Api/IntegrationController.php @@ -0,0 +1,156 @@ +ownerRef($request); + $organization = Organization::owned($owner)->first(); + + if (! $organization) { + return response()->json([ + 'data' => [ + 'provisioned' => false, + 'onboarded' => false, + 'integrations' => ['care' => false, 'frontdesk' => false], + ], + ]); + } + + return response()->json([ + 'data' => [ + 'provisioned' => true, + 'onboarded' => (bool) data_get($organization->settings, 'onboarded', false), + 'organization_name' => $organization->name, + 'integrations' => [ + 'care' => (bool) data_get($organization->settings, 'integrations.care_enabled', false), + 'frontdesk' => (bool) data_get($organization->settings, 'integrations.frontdesk_enabled', false), + ], + ], + ]); + } + + public function provision(Request $request): JsonResponse + { + $owner = $this->ownerRef($request); + $caller = (string) $request->attributes->get('service_caller', ''); + + $validated = $request->validate([ + 'organization_name' => ['nullable', 'string', 'max:255'], + 'branch_name' => ['nullable', 'string', 'max:255'], + 'timezone' => ['nullable', 'timezone'], + 'industry' => ['nullable', 'string'], + ]); + + $organization = Organization::owned($owner)->first(); + $created = false; + + if (! $organization) { + $created = true; + $name = $validated['organization_name'] ?? 'Service queues'; + $organization = Organization::create([ + 'owner_ref' => $owner, + 'name' => $name, + 'slug' => Str::slug($name).'-'.substr($owner, 0, 6), + 'timezone' => $validated['timezone'] ?? config('app.timezone', 'UTC'), + 'settings' => [ + 'onboarded' => true, + 'industry' => $validated['industry'] ?? ($caller === 'care' ? 'healthcare' : 'visitor'), + 'appointment_mode' => 'hybrid', + 'integrations' => [ + 'care_enabled' => false, + 'frontdesk_enabled' => false, + ], + ], + ]); + + $branch = Branch::create([ + 'owner_ref' => $owner, + 'organization_id' => $organization->id, + 'name' => $validated['branch_name'] ?? 'Main branch', + 'is_active' => true, + ]); + + Department::create([ + 'owner_ref' => $owner, + 'branch_id' => $branch->id, + 'name' => 'Reception', + 'type' => 'reception', + 'is_active' => true, + ]); + + AuditLogger::record($owner, 'organization.created', $organization->id, $owner, Organization::class, $organization->id); + } + + return response()->json([ + 'data' => [ + 'provisioned' => true, + 'organization_name' => $organization->fresh()->name, + 'integrations' => [ + 'care' => (bool) data_get($organization->fresh()->settings, 'integrations.care_enabled', false), + 'frontdesk' => (bool) data_get($organization->fresh()->settings, 'integrations.frontdesk_enabled', false), + ], + ], + ], $created ? 201 : 200); + } + + public function update(Request $request): JsonResponse + { + $validated = $request->validate([ + 'care_enabled' => ['nullable', 'boolean'], + 'frontdesk_enabled' => ['nullable', 'boolean'], + ]); + + $organization = $this->organization($request); + $caller = (string) $request->attributes->get('service_caller', ''); + $settings = $organization->settings ?? []; + $integrations = $settings['integrations'] ?? []; + + if (array_key_exists('care_enabled', $validated) && in_array($caller, ['care', 'crm'], true)) { + $integrations['care_enabled'] = (bool) $validated['care_enabled']; + } + if (array_key_exists('frontdesk_enabled', $validated) && in_array($caller, ['frontdesk', 'crm'], true)) { + $integrations['frontdesk_enabled'] = (bool) $validated['frontdesk_enabled']; + } + + $settings['integrations'] = $integrations; + $organization->update(['settings' => $settings]); + + return response()->json([ + 'data' => [ + 'integrations' => [ + 'care' => (bool) ($integrations['care_enabled'] ?? false), + 'frontdesk' => (bool) ($integrations['frontdesk_enabled'] ?? false), + ], + ], + ]); + } + + protected function syncIntegrationFlag(Organization $organization, string $caller, bool $enabled): void + { + if (! in_array($caller, ['care', 'frontdesk'], true)) { + return; + } + + $settings = $organization->settings ?? []; + $integrations = $settings['integrations'] ?? []; + $key = $caller.'_enabled'; + $integrations[$key] = $enabled; + $settings['integrations'] = $integrations; + $organization->update(['settings' => $settings]); + } +} diff --git a/app/Http/Controllers/Qms/CounterController.php b/app/Http/Controllers/Qms/CounterController.php index 2491215..6849f51 100644 --- a/app/Http/Controllers/Qms/CounterController.php +++ b/app/Http/Controllers/Qms/CounterController.php @@ -36,9 +36,28 @@ class CounterController extends Controller { $this->authorizeAbility($request, 'counters.view'); $this->authorizeOwner($request, $counter); + $owner = $this->ownerRef($request); $counter->load(['branch', 'serviceQueues']); - return view('qms.counters.show', compact('counter')); + $currentTicket = \App\Models\Ticket::owned($owner) + ->where('counter_id', $counter->id) + ->whereIn('status', ['called', 'serving', 'on_hold']) + ->with('serviceQueue') + ->latest('called_at') + ->first(); + + $waitingCounts = $counter->serviceQueues->mapWithKeys(function (ServiceQueue $queue) use ($owner) { + $count = \App\Models\Ticket::owned($owner) + ->where('service_queue_id', $queue->id) + ->where('status', 'waiting') + ->count(); + + return [$queue->id => $count]; + }); + + $canManage = app(\App\Services\Qms\QmsPermissions::class)->can($this->member($request), 'counters.manage'); + + return view('qms.counters.show', compact('counter', 'currentTicket', 'waitingCounts', 'canManage')); } public function create(Request $request): View diff --git a/app/Http/Controllers/Qms/DisplayPublicController.php b/app/Http/Controllers/Qms/DisplayPublicController.php index 064afe1..3f9a229 100644 --- a/app/Http/Controllers/Qms/DisplayPublicController.php +++ b/app/Http/Controllers/Qms/DisplayPublicController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Qms; use App\Http\Controllers\Controller; +use App\Models\VoiceAnnouncement; use App\Services\Qms\DisplayService; use App\Services\Qms\VoiceAnnouncementService; use Illuminate\Http\JsonResponse; @@ -23,6 +24,8 @@ class DisplayPublicController extends Controller return view('qms.display.public', [ 'screen' => $screen, 'dataUrl' => route('qms.display.data', $token), + 'playedUrl' => route('qms.display.announcement.played', ['token' => $token, 'announcement' => '__ID__']), + 'pollMs' => config('qms.display_poll_ms', 1200), ]); } @@ -31,17 +34,16 @@ class DisplayPublicController extends Controller $screen = $this->displays->findByToken($token) ?? abort(404); $this->displays->touch($screen); - $payload = $this->displays->payload($screen); + return response()->json($this->displays->payload($screen)); + } - foreach ($payload['announcements'] as $announcement) { - if (isset($announcement['id'])) { - $model = \App\Models\VoiceAnnouncement::find($announcement['id']); - if ($model) { - $this->voice->markPlayed($model); - } - } - } + public function played(string $token, VoiceAnnouncement $announcement): JsonResponse + { + $screen = $this->displays->findByToken($token) ?? abort(404); + abort_unless($announcement->branch_id === $screen->branch_id, 404); - return response()->json($payload); + $this->voice->markPlayed($announcement); + + return response()->json(['ok' => true]); } } diff --git a/app/Http/Controllers/Qms/SettingsController.php b/app/Http/Controllers/Qms/SettingsController.php index 57de73b..95d0da1 100644 --- a/app/Http/Controllers/Qms/SettingsController.php +++ b/app/Http/Controllers/Qms/SettingsController.php @@ -46,12 +46,22 @@ class SettingsController extends Controller 'appointment_mode' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))], 'industry' => ['nullable', 'string'], 'notifications_enabled' => ['boolean'], + 'care_integration_enabled' => ['nullable', 'boolean'], + 'frontdesk_integration_enabled' => ['nullable', 'boolean'], ]); $settings = $organization->settings ?? []; $settings['appointment_mode'] = $validated['appointment_mode']; $settings['industry'] = $validated['industry'] ?? $settings['industry'] ?? null; $settings['notifications_enabled'] = $request->boolean('notifications_enabled', true); + $integrations = $settings['integrations'] ?? []; + if ($request->has('care_integration_enabled')) { + $integrations['care_enabled'] = $request->boolean('care_integration_enabled'); + } + if ($request->has('frontdesk_integration_enabled')) { + $integrations['frontdesk_enabled'] = $request->boolean('frontdesk_integration_enabled'); + } + $settings['integrations'] = $integrations; $organization->update([ 'name' => $validated['name'], diff --git a/app/Http/Controllers/Qms/TicketController.php b/app/Http/Controllers/Qms/TicketController.php index efa18f3..b069468 100644 --- a/app/Http/Controllers/Qms/TicketController.php +++ b/app/Http/Controllers/Qms/TicketController.php @@ -29,9 +29,11 @@ class TicketController extends Controller $tickets = Ticket::owned($owner) ->where('organization_id', $organization->id) ->with(['serviceQueue', 'counter']) + ->when($request->query('queue'), fn ($q, $uuid) => $q->whereHas('serviceQueue', fn ($sq) => $sq->where('uuid', $uuid))) ->when($request->query('status'), fn ($q, $s) => $q->where('status', $s)) ->latest('issued_at') - ->paginate(30); + ->paginate(30) + ->withQueryString(); $queues = ServiceQueue::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get(); diff --git a/app/Http/Middleware/EnsureQueueIntegration.php b/app/Http/Middleware/EnsureQueueIntegration.php new file mode 100644 index 0000000..831faa9 --- /dev/null +++ b/app/Http/Middleware/EnsureQueueIntegration.php @@ -0,0 +1,31 @@ +attributes->get('service_caller', ''); + if (! in_array($caller, ['care', 'frontdesk'], true)) { + return $next($request); + } + + $owner = trim((string) ($request->input('owner') ?? $request->query('owner', ''))); + if ($owner === '') { + return response()->json(['error' => 'The owner parameter is required.'], 422); + } + + $organization = Organization::owned($owner)->first(); + if (! $organization || ! data_get($organization->settings, 'integrations.'.$caller.'_enabled', false)) { + return response()->json(['error' => 'Queue integration is not enabled for this account.'], 403); + } + + return $next($request); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 01c03df..db83f09 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -51,6 +51,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'qms.setup' => \App\Http\Middleware\EnsureOrganizationSetup::class, 'qms.ability' => \App\Http\Middleware\EnsureQmsAbility::class, 'qms.device' => \App\Http\Middleware\AuthenticateQmsDevice::class, + 'qms.integration' => \App\Http\Middleware\EnsureQueueIntegration::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/config/qms.php b/config/qms.php index 9d7fefe..aecfaa0 100644 --- a/config/qms.php +++ b/config/qms.php @@ -213,4 +213,18 @@ return [ 'custom' => [], ], + /* + |-------------------------------------------------------------------------- + | Inbound service API keys (sibling Ladill apps calling Queue) + |-------------------------------------------------------------------------- + */ + 'service_api_keys' => array_filter([ + 'pos' => env('QUEUE_API_KEY_POS'), + 'crm' => env('QUEUE_API_KEY_CRM'), + 'care' => env('QUEUE_API_KEY_CARE'), + 'frontdesk' => env('QUEUE_API_KEY_FRONTDESK'), + ]), + + 'display_poll_ms' => (int) env('QUEUE_DISPLAY_POLL_MS', 1200), + ]; diff --git a/resources/js/kiosk-flow.js b/resources/js/kiosk-flow.js index f3ee874..fed33e0 100644 --- a/resources/js/kiosk-flow.js +++ b/resources/js/kiosk-flow.js @@ -54,26 +54,90 @@ export function registerKioskFlow(Alpine) { }, })); - Alpine.data('displayBoard', (dataUrl) => ({ + Alpine.data('displayBoard', (config = {}) => ({ payload: null, + spokenIds: new Set(), + speaking: false, + dataUrl: typeof config === 'string' ? config : config.dataUrl, + playedUrlTemplate: config.playedUrl ?? null, + pollMs: config.pollMs ?? 1200, + csrf: config.csrf ?? document.querySelector('meta[name="csrf-token"]')?.content ?? '', + async poll() { try { - const res = await fetch(dataUrl, { headers: { Accept: 'application/json' } }); + const res = await fetch(this.dataUrl, { headers: { Accept: 'application/json' } }); this.payload = await res.json(); - if (this.payload?.announcements?.length && window.speechSynthesis) { - const msg = this.payload.announcements[0]?.message; - if (msg) { - const utter = new SpeechSynthesisUtterance(msg); - window.speechSynthesis.speak(utter); - } - } + await this.handleAnnouncements(this.payload?.announcements ?? []); } catch (e) { console.error('Display poll failed', e); } }, + + async handleAnnouncements(announcements) { + if (! window.speechSynthesis || this.speaking) return; + + for (const item of announcements) { + if (! item?.id || ! item?.message || this.spokenIds.has(item.id)) { + continue; + } + + this.speaking = true; + try { + await this.speakAnnouncement(item); + this.spokenIds.add(item.id); + await this.ackPlayed(item.id); + } finally { + this.speaking = false; + } + } + }, + + speakAnnouncement(item) { + const repeats = Math.max(1, Number(item.repeat_count) || 1); + const volume = Math.min(1, Math.max(0, (Number(item.volume) || 80) / 100)); + + return new Promise((resolve) => { + let remaining = repeats; + + const speakOnce = () => { + const utter = new SpeechSynthesisUtterance(item.message); + utter.volume = volume; + utter.lang = item.locale === 'fr' ? 'fr-FR' : item.locale === 'tw' ? 'ak-GH' : 'en-US'; + utter.onend = () => { + remaining -= 1; + if (remaining > 0) { + speakOnce(); + } else { + resolve(); + } + }; + utter.onerror = () => resolve(); + window.speechSynthesis.speak(utter); + }; + + speakOnce(); + }); + }, + + async ackPlayed(id) { + if (! this.playedUrlTemplate) return; + const url = this.playedUrlTemplate.replace('__ID__', String(id)); + try { + await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-TOKEN': this.csrf, + }, + }); + } catch (e) { + console.error('Announcement ack failed', e); + } + }, + init() { this.poll(); - setInterval(() => this.poll(), 5000); + setInterval(() => this.poll(), this.pollMs); }, })); diff --git a/resources/views/components/counter-status.blade.php b/resources/views/components/counter-status.blade.php new file mode 100644 index 0000000..6a58f3f --- /dev/null +++ b/resources/views/components/counter-status.blade.php @@ -0,0 +1,16 @@ +@props(['status']) + +@php + $styles = [ + 'available' => 'bg-emerald-50 text-emerald-800 ring-emerald-200', + 'busy' => 'bg-amber-50 text-amber-800 ring-amber-200', + 'offline' => 'bg-slate-100 text-slate-600 ring-slate-200', + 'paused' => 'bg-violet-50 text-violet-800 ring-violet-200', + ]; + $label = config('qms.counter_statuses.'.$status, ucfirst($status)); + $class = $styles[$status] ?? 'bg-slate-100 text-slate-700 ring-slate-200'; +@endphp + +merge(['class' => "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset {$class}"]) }}> + {{ $label }} + diff --git a/resources/views/components/ticket-status.blade.php b/resources/views/components/ticket-status.blade.php new file mode 100644 index 0000000..b7670c8 --- /dev/null +++ b/resources/views/components/ticket-status.blade.php @@ -0,0 +1,20 @@ +@props(['status']) + +@php + $styles = [ + 'waiting' => 'bg-slate-100 text-slate-700 ring-slate-200', + 'called' => 'bg-amber-50 text-amber-800 ring-amber-200', + 'serving' => 'bg-indigo-50 text-indigo-800 ring-indigo-200', + 'on_hold' => 'bg-violet-50 text-violet-800 ring-violet-200', + 'completed' => 'bg-emerald-50 text-emerald-800 ring-emerald-200', + 'no_show' => 'bg-rose-50 text-rose-800 ring-rose-200', + 'cancelled' => 'bg-slate-100 text-slate-500 ring-slate-200', + 'transferred' => 'bg-sky-50 text-sky-800 ring-sky-200', + ]; + $label = config('qms.ticket_statuses.'.$status, ucfirst(str_replace('_', ' ', $status))); + $class = $styles[$status] ?? 'bg-slate-100 text-slate-700 ring-slate-200'; +@endphp + +merge(['class' => "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset {$class}"]) }}> + {{ $label }} + diff --git a/resources/views/qms/console/show.blade.php b/resources/views/qms/console/show.blade.php index cb13033..34720c6 100644 --- a/resources/views/qms/console/show.blade.php +++ b/resources/views/qms/console/show.blade.php @@ -1,72 +1,107 @@ -
-
-

{{ $counter->name }}

-

{{ $counter->branch?->name }} · Status: {{ config('qms.counter_statuses.'.$counter->status, $counter->status) }}

-
-
-
@csrf
-
@csrf
-
+
+
+ ← {{ $counter->name }} +

Staff console

+

{{ $counter->branch?->name }}

+
+ +
@csrf
+
@csrf
+
+
- @if ($currentTicket) -
-

Current customer

-

{{ $currentTicket->ticket_number }}

-

{{ $currentTicket->serviceQueue?->name }} · {{ $currentTicket->customer_name }}

-
- @foreach (['recall' => 'Recall', 'start' => 'Start serving', 'hold' => 'Hold', 'skip' => 'Skip', 'complete' => 'Complete', 'no_show' => 'No show'] as $action => $label) -
- @csrf - - - -
+ @if ($currentTicket) +
+
+

Current customer

+
+
+
+
+

{{ $currentTicket->ticket_number }}

+

{{ $currentTicket->serviceQueue?->name }}

+

{{ $currentTicket->customer_name ?: 'Walk-in customer' }}

+
+ +
+ +
+ @foreach ([ + 'recall' => ['Recall', 'btn-secondary'], + 'start' => ['Start serving', 'btn-primary'], + 'hold' => ['Hold', 'btn-secondary'], + 'skip' => ['Skip', 'btn-secondary'], + 'complete' => ['Complete', 'btn-primary'], + 'no_show' => ['No show', 'btn-secondary'], + ] as $action => [$label, $btnClass]) +
+ @csrf + + + +
+ @endforeach +
+ + @if ($allQueues->count() > 1) +
+ @csrf + + +
+ +
- @if ($allQueues->count() > 1) - - @csrf - - - - - -
- @endif -
- @endif +
+ + +
+ + + @endif +
+ + @else +
+

No active customer

+

Call the next waiting ticket from a queue below.

+
+ @endif -
- @foreach ($queues as $queue) -
-
-

{{ $queue->name }}

-
- @csrf - - - -
-
-
    - @forelse ($waitingByQueue[$queue->id] ?? [] as $ticket) -
  • - {{ $ticket->ticket_number }} - {{ $ticket->customer_name ?? 'Walk-in' }} -
  • - @empty -
  • No one waiting
  • - @endforelse -
-
- @endforeach -
+
+ @foreach ($queues as $queue) +
+
+
+

{{ $queue->name }}

+

{{ count($waitingByQueue[$queue->id] ?? []) }} waiting

+
+
+ @csrf + + + +
+
+
    + @forelse ($waitingByQueue[$queue->id] ?? [] as $ticket) +
  • + {{ $ticket->ticket_number }} + {{ $ticket->customer_name ?? 'Walk-in' }} +
  • + @empty +
  • Queue is empty
  • + @endforelse +
+
+ @endforeach +
diff --git a/resources/views/qms/counters/index.blade.php b/resources/views/qms/counters/index.blade.php index 3764ebe..71a0f66 100644 --- a/resources/views/qms/counters/index.blade.php +++ b/resources/views/qms/counters/index.blade.php @@ -1,20 +1,42 @@ -
-

Counters

- New counter -
-
- @foreach ($counters as $counter) -
-
-

{{ $counter->name }}

- {{ $counter->status }} -
-

{{ $counter->branch?->name }}

-

Queues: {{ $counter->serviceQueues->pluck('name')->join(', ') ?: '—' }}

- Open console -
- @endforeach +
+
+

Counters

+

Service desks and windows where staff call and serve tickets

+ New counter +
+ +
+ @forelse ($counters as $counter) + + @empty + + @endforelse +
+ @if ($counters->hasPages())
{{ $counters->links() }}
+ @endif diff --git a/resources/views/qms/counters/show.blade.php b/resources/views/qms/counters/show.blade.php index fa22bfd..4b01f11 100644 --- a/resources/views/qms/counters/show.blade.php +++ b/resources/views/qms/counters/show.blade.php @@ -1,14 +1,55 @@ -
-
- ← Counters -

{{ $counter->name }}

-
- +
+
+ ← Counters +
+

{{ $counter->name }}

+ +
+

{{ $counter->branch?->name }} · {{ $counter->code ?: 'No desk code' }}

-

Status: {{ $counter->status }} · {{ $counter->branch?->name }}

-

Queues: {{ $counter->serviceQueues->pluck('name')->join(', ') ?: '—' }}

+
+ @if ($canManage) + Edit counter + @endif + Open console +
+
+ +
+
+

Assigned queues

+ @if ($counter->serviceQueues->isEmpty()) +

No queues linked yet. Edit this counter to assign service queues.

+ @else +
    + @foreach ($counter->serviceQueues as $queue) +
  • +
    +

    {{ $queue->name }}

    +

    Prefix {{ $queue->prefix }}

    +
    + + {{ $waitingCounts[$queue->id] ?? 0 }} waiting + +
  • + @endforeach +
+ @endif +
+ +
+

Now at this counter

+ @if ($currentTicket) +
+

{{ $currentTicket->ticket_number }}

+

{{ $currentTicket->serviceQueue?->name }}

+

{{ $currentTicket->customer_name ?: 'Walk-in' }}

+
+
+ @else +

No customer is being served. Open the console to call the next ticket.

+ @endif +
+
diff --git a/resources/views/qms/display/public.blade.php b/resources/views/qms/display/public.blade.php index 34dbe51..9057b46 100644 --- a/resources/views/qms/display/public.blade.php +++ b/resources/views/qms/display/public.blade.php @@ -7,7 +7,7 @@ {{ $screen->name }} · Queue Display @vite(['resources/css/app.css', 'resources/js/app.js']) - +

diff --git a/resources/views/qms/settings/edit.blade.php b/resources/views/qms/settings/edit.blade.php index 7dbfe54..738a18d 100644 --- a/resources/views/qms/settings/edit.blade.php +++ b/resources/views/qms/settings/edit.blade.php @@ -27,6 +27,21 @@ settings['notifications_enabled'] ?? true) @disabled(! $canManage)> Enable SMS/email queue notifications + +
+

Sibling app integrations

+

Allow Ladill Care and Frontdesk to manage queues in-app via the Queue API. Both apps must also enable integration in their settings.

+
+ + +
+
@if ($canManage) @endif diff --git a/resources/views/qms/tickets/index.blade.php b/resources/views/qms/tickets/index.blade.php index 4c92705..69e56c2 100644 --- a/resources/views/qms/tickets/index.blade.php +++ b/resources/views/qms/tickets/index.blade.php @@ -1,29 +1,70 @@ -
-

Tickets

- @if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'tickets.issue')) - Issue ticket - @endif +
+
+

Tickets

+

Live and historical queue tickets across all service lines

-
- - - - - - - - - @foreach ($tickets as $ticket) - - - - - - - @endforeach - -
TicketQueueStatusIssued
{{ $ticket->ticket_number }}{{ $ticket->serviceQueue?->name }}{{ str_replace('_', ' ', $ticket->status) }}{{ $ticket->issued_at?->format('M j, H:i') }}
-
{{ $tickets->links() }}
+ @if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'tickets.issue')) + Issue ticket + @endif +
+ +
+
+ +
+
+ + +
+ + @if (request()->hasAny(['queue', 'status'])) + Clear + @endif +
+ +
+ @if ($tickets->isEmpty()) + + @else + + + + + + + + + + + + + @foreach ($tickets as $ticket) + + + + + + + + + @endforeach + +
TicketCustomerQueueCounterStatusIssued
+ {{ $ticket->ticket_number }} + {{ $ticket->customer_name ?: 'Walk-in' }}{{ $ticket->serviceQueue?->name }}{{ $ticket->counter?->name ?? '—' }}{{ $ticket->issued_at?->format('M j, H:i') }}
+
{{ $tickets->links() }}
+ @endif +
diff --git a/routes/api.php b/routes/api.php index d7f1131..2c85337 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,8 +3,10 @@ use App\Http\Controllers\Api\AnalyticsController; use App\Http\Controllers\Api\AppointmentController; use App\Http\Controllers\Api\BranchController; +use App\Http\Controllers\Api\ConsoleApiController; use App\Http\Controllers\Api\CounterController; use App\Http\Controllers\Api\DeviceHeartbeatController; +use App\Http\Controllers\Api\IntegrationController; use App\Http\Controllers\Api\PublicQueueController; use App\Http\Controllers\Api\QueueRuleController; use App\Http\Controllers\Api\ReportController; @@ -58,3 +60,22 @@ Route::middleware(['auth:sanctum', 'qms.setup'])->prefix('v1')->group(function ( Route::get('/branches', [BranchController::class, 'index'])->name('api.branches.index'); Route::post('/branches', [BranchController::class, 'store'])->name('api.branches.store'); }); + +Route::middleware(['auth.service:qms'])->prefix('v1')->group(function () { + Route::get('/integrations/status', [IntegrationController::class, 'status'])->name('api.integrations.status'); + Route::post('/integrations/provision', [IntegrationController::class, 'provision'])->name('api.integrations.provision'); + Route::patch('/integrations', [IntegrationController::class, 'update'])->name('api.integrations.update'); +}); + +Route::middleware(['auth.service:qms', 'qms.integration'])->prefix('v1')->group(function () { + Route::get('/queues', [ServiceQueueController::class, 'index'])->name('api.service.queues.index'); + Route::get('/queues/{serviceQueue}/waiting', [ServiceQueueController::class, 'waiting'])->name('api.service.queues.waiting'); + Route::post('/queues/{serviceQueue}/call-next', [TicketController::class, 'callNext'])->name('api.service.queues.call-next'); + + Route::get('/tickets', [TicketController::class, 'index'])->name('api.service.tickets.index'); + Route::post('/tickets/{ticket}/action', [TicketController::class, 'action'])->name('api.service.tickets.action'); + + Route::get('/counters', [CounterController::class, 'index'])->name('api.service.counters.index'); + Route::get('/counters/{counter}/console', [ConsoleApiController::class, 'show'])->name('api.service.counters.console'); + Route::post('/counters/{counter}/console', [ConsoleApiController::class, 'action'])->name('api.service.counters.console.action'); +}); diff --git a/routes/web.php b/routes/web.php index 99ddf2a..cacbede 100644 --- a/routes/web.php +++ b/routes/web.php @@ -46,6 +46,7 @@ Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('qms.dash // Public: digital display Route::get('/display/{token}', [DisplayPublicController::class, 'show'])->name('qms.display.public'); Route::get('/display/{token}/data', [DisplayPublicController::class, 'data'])->name('qms.display.data'); +Route::post('/display/{token}/announcements/{announcement}/played', [DisplayPublicController::class, 'played'])->name('qms.display.announcement.played'); // Public: mobile queue Route::get('/join', [MobileQueueController::class, 'joinForm'])->name('qms.mobile.join');