From 03befd1e7fec6bda7305916401235e88c3fc1dd7 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 11:23:15 +0000 Subject: [PATCH] Add Care waiting-area TV displays with browser TTS voice. Call-next and recall queue announcements for lobby screens without Ladill Queue. Co-authored-by: Cursor --- .../Care/DisplayPublicController.php | 62 ++++ .../Care/DisplayScreenController.php | 197 +++++++++++ .../Controllers/Care/SettingsController.php | 2 + app/Models/CareDisplayScreen.php | 54 +++ app/Models/CareServicePoint.php | 8 + app/Models/CareVoiceAnnouncement.php | 31 ++ app/Services/Care/CareDisplayService.php | 156 ++++++++ app/Services/Care/CarePermissions.php | 1 + app/Services/Care/CareQueueEngine.php | 28 ++ .../Care/CareVoiceAnnouncementService.php | 92 +++++ bootstrap/app.php | 3 + config/care.php | 14 + ...0_create_care_display_and_voice_tables.php | 52 +++ docs/care-queue-engine-and-specialty-plan.md | 3 +- resources/css/app.css | 329 +++++++++++++++++ resources/js/app.js | 2 + resources/js/care-display.js | 334 ++++++++++++++++++ resources/views/care/display/public.blade.php | 138 ++++++++ .../views/care/displays/create.blade.php | 43 +++ resources/views/care/displays/edit.blade.php | 49 +++ resources/views/care/displays/index.blade.php | 58 +++ resources/views/care/displays/show.blade.php | 147 ++++++++ resources/views/care/settings/edit.blade.php | 17 +- resources/views/partials/sidebar.blade.php | 3 +- routes/web.php | 16 + tests/Feature/CareDisplayVoiceTest.php | 232 ++++++++++++ 26 files changed, 2068 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/Care/DisplayPublicController.php create mode 100644 app/Http/Controllers/Care/DisplayScreenController.php create mode 100644 app/Models/CareDisplayScreen.php create mode 100644 app/Models/CareVoiceAnnouncement.php create mode 100644 app/Services/Care/CareDisplayService.php create mode 100644 app/Services/Care/CareVoiceAnnouncementService.php create mode 100644 database/migrations/2026_07_18_140000_create_care_display_and_voice_tables.php create mode 100644 resources/js/care-display.js create mode 100644 resources/views/care/display/public.blade.php create mode 100644 resources/views/care/displays/create.blade.php create mode 100644 resources/views/care/displays/edit.blade.php create mode 100644 resources/views/care/displays/index.blade.php create mode 100644 resources/views/care/displays/show.blade.php create mode 100644 tests/Feature/CareDisplayVoiceTest.php diff --git a/app/Http/Controllers/Care/DisplayPublicController.php b/app/Http/Controllers/Care/DisplayPublicController.php new file mode 100644 index 0000000..226324c --- /dev/null +++ b/app/Http/Controllers/Care/DisplayPublicController.php @@ -0,0 +1,62 @@ +displays->findByToken($token) ?? abort(404); + $screen->loadMissing(['organization', 'branch']); + $this->displays->touch($screen); + + $payload = $this->displays->payload($screen); + $organization = $screen->organization; + + return view('care.display.public', [ + 'screen' => $screen, + 'initialPayload' => $payload, + 'dataUrl' => '/display/'.$token.'/data', + 'playedUrl' => '/display/'.$token.'/announcements/__ID__/played', + 'pollMs' => config('care.display_poll_ms', 1200), + 'logoUrl' => $organization + ? OrganizationBranding::logoUrl($organization) + : asset(OrganizationBranding::DEFAULT_LOGO), + 'logoAlt' => $organization + ? OrganizationBranding::logoAlt($organization) + : 'Ladill Care', + 'poweredByLogoUrl' => asset(OrganizationBranding::DEFAULT_LOGO), + ]); + } + + public function data(string $token): JsonResponse + { + $screen = $this->displays->findByToken($token) ?? abort(404); + $this->displays->touch($screen); + + return response()->json($this->displays->payload($screen)); + } + + public function played(string $token, CareVoiceAnnouncement $announcement): JsonResponse + { + $screen = $this->displays->findByToken($token) ?? abort(404); + abort_unless($announcement->branch_id === $screen->branch_id, 404); + + $this->voice->markPlayed($announcement); + + return response()->json(['ok' => true]); + } +} diff --git a/app/Http/Controllers/Care/DisplayScreenController.php b/app/Http/Controllers/Care/DisplayScreenController.php new file mode 100644 index 0000000..28b592c --- /dev/null +++ b/app/Http/Controllers/Care/DisplayScreenController.php @@ -0,0 +1,197 @@ +authorizeAbility($request, 'displays.view'); + $owner = $this->ownerRef($request); + $organization = $this->organization($request); + + $member = $this->member($request); + $permissions = app(CarePermissions::class); + $branchScope = app(OrganizationResolver::class)->branchScope($member); + + $screens = CareDisplayScreen::owned($owner) + ->where('organization_id', $organization->id) + ->with('branch') + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->orderBy('name') + ->paginate(20); + + $screenStats = CareDisplayScreen::owned($owner) + ->where('organization_id', $organization->id) + ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->get(); + + $heroStats = [ + 'total' => $screenStats->count(), + 'active' => $screenStats->where('is_active', true)->count(), + 'branches' => $screenStats->pluck('branch_id')->filter()->unique()->count(), + ]; + + return view('care.displays.index', [ + 'screens' => $screens, + 'organization' => $organization, + 'canManage' => $permissions->can($member, 'displays.manage'), + 'heroStats' => $heroStats, + ]); + } + + public function show(Request $request, CareDisplayScreen $display): View + { + $this->authorizeAbility($request, 'displays.view'); + $this->authorizeOwner($request, $display); + abort_unless($display->organization_id === $this->organization($request)->id, 404); + $display->load(['branch', 'organization']); + + $member = $this->member($request); + $permissions = app(CarePermissions::class); + + $queueIds = array_map('intval', $display->service_queue_ids ?? []); + $queues = CareServiceQueue::query() + ->whereIn('id', $queueIds) + ->orderBy('name') + ->get(); + + return view('care.displays.show', [ + 'display' => $display, + 'queues' => $queues, + 'publicUrl' => route('care.display.public', $display->access_token), + 'canManage' => $permissions->can($member, 'displays.manage'), + ]); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'displays.manage'); + $owner = $this->ownerRef($request); + $organization = $this->organization($request); + + return view('care.displays.create', [ + 'organization' => $organization, + 'branches' => $this->branches($request, $organization->id), + 'queues' => CareServiceQueue::owned($owner) + ->where('organization_id', $organization->id) + ->orderBy('name') + ->get(), + 'layouts' => config('care.display_layouts'), + ]); + } + + public function store(Request $request): RedirectResponse + { + $this->authorizeAbility($request, 'displays.manage'); + $owner = $this->ownerRef($request); + $organization = $this->organization($request); + + $validated = $this->validatedDisplay($request, $organization); + + $screen = CareDisplayScreen::create([ + 'owner_ref' => $owner, + 'organization_id' => $organization->id, + 'branch_id' => $validated['branch_id'], + 'name' => $validated['name'], + 'layout' => $validated['layout'], + 'service_queue_ids' => $validated['queue_ids'] ?? [], + 'is_active' => true, + ]); + + return redirect()->route('care.displays.show', $screen) + ->with('success', 'Display created. URL: '.route('care.display.public', $screen->access_token)); + } + + public function edit(Request $request, CareDisplayScreen $display): View + { + $this->authorizeAbility($request, 'displays.manage'); + $this->authorizeOwner($request, $display); + abort_unless($display->organization_id === $this->organization($request)->id, 404); + $owner = $this->ownerRef($request); + + return view('care.displays.edit', [ + 'display' => $display, + 'branches' => $this->branches($request, $display->organization_id), + 'queues' => CareServiceQueue::owned($owner) + ->where('organization_id', $display->organization_id) + ->orderBy('name') + ->get(), + 'layouts' => config('care.display_layouts'), + ]); + } + + public function update(Request $request, CareDisplayScreen $display): RedirectResponse + { + $this->authorizeAbility($request, 'displays.manage'); + $this->authorizeOwner($request, $display); + abort_unless($display->organization_id === $this->organization($request)->id, 404); + + $validated = $this->validatedDisplay($request, $this->organization($request)); + $display->update([ + 'name' => $validated['name'], + 'branch_id' => $validated['branch_id'], + 'layout' => $validated['layout'], + 'service_queue_ids' => $validated['queue_ids'] ?? [], + 'is_active' => $request->boolean('is_active', $display->is_active), + ]); + + return redirect()->route('care.displays.show', $display)->with('success', 'Display updated.'); + } + + /** + * @return array + */ + protected function validatedDisplay(Request $request, $organization): array + { + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'branch_id' => ['required', 'integer', 'exists:care_branches,id'], + 'layout' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.display_layouts')))], + 'queue_ids' => ['nullable', 'array'], + 'queue_ids.*' => ['integer', 'exists:care_service_queues,id'], + ]); + + $ok = Branch::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->where('id', $validated['branch_id']) + ->exists(); + abort_unless($ok, 422); + + if (! empty($validated['queue_ids'])) { + $count = CareServiceQueue::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->whereIn('id', $validated['queue_ids']) + ->count(); + abort_unless($count === count($validated['queue_ids']), 422); + } + + return $validated; + } + + /** @return \Illuminate\Database\Eloquent\Collection */ + protected function branches(Request $request, int $organizationId) + { + $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); + + return Branch::owned($this->ownerRef($request)) + ->where('organization_id', $organizationId) + ->where('is_active', true) + ->when($branchScope, fn ($q) => $q->where('id', $branchScope)) + ->orderBy('name') + ->get(); + } +} diff --git a/app/Http/Controllers/Care/SettingsController.php b/app/Http/Controllers/Care/SettingsController.php index 1810d5c..8f79b75 100644 --- a/app/Http/Controllers/Care/SettingsController.php +++ b/app/Http/Controllers/Care/SettingsController.php @@ -60,9 +60,11 @@ class SettingsController extends Controller 'canViewBranches' => $permissions->can($member, 'admin.branches.view'), 'canViewTeam' => $permissions->can($member, 'admin.members.view'), 'canViewDevices' => $permissions->can($member, 'devices.view'), + 'canViewDisplays' => $permissions->can($member, 'displays.view'), 'hasBranchesFeature' => $plans->hasFeature($organization, 'branches'), 'hasClinicalDevicesFeature' => $plans->hasFeature($organization, 'clinical_devices'), 'canUseQueueIntegration' => $plans->canUseQueueIntegration($organization), + 'queueIntegrationEnabled' => (bool) data_get($organization->settings, 'queue_integration_enabled'), 'canUseSpecialtyModules' => $plans->canUseSpecialtyModules($organization), 'assessmentsEngineEnabled' => $careFeatures->enabled($organization, CareFeatures::ASSESSMENTS_ENGINE), 'workflowEngineEnabled' => $careFeatures->enabled($organization, CareFeatures::WORKFLOW_ENGINE), diff --git a/app/Models/CareDisplayScreen.php b/app/Models/CareDisplayScreen.php new file mode 100644 index 0000000..c9dc46f --- /dev/null +++ b/app/Models/CareDisplayScreen.php @@ -0,0 +1,54 @@ + 'array', + 'settings' => 'array', + 'is_active' => 'boolean', + 'last_seen_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (self $screen) { + $screen->uuid ??= (string) Str::uuid(); + $screen->access_token ??= Str::random(48); + }); + } + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class, 'branch_id'); + } +} diff --git a/app/Models/CareServicePoint.php b/app/Models/CareServicePoint.php index 84f3fdc..d0efb22 100644 --- a/app/Models/CareServicePoint.php +++ b/app/Models/CareServicePoint.php @@ -51,4 +51,12 @@ class CareServicePoint extends Model { return $this->hasMany(CareQueueTicket::class, 'service_point_id'); } + + /** Public display label (Room 4, Counter 2, etc.). Falls back to name. */ + public function displayDestination(): string + { + $destination = trim((string) ($this->destination ?? '')); + + return $destination !== '' ? $destination : (string) $this->name; + } } diff --git a/app/Models/CareVoiceAnnouncement.php b/app/Models/CareVoiceAnnouncement.php new file mode 100644 index 0000000..669a1ca --- /dev/null +++ b/app/Models/CareVoiceAnnouncement.php @@ -0,0 +1,31 @@ + 'datetime', + ]; + } + + public function ticket(): BelongsTo + { + return $this->belongsTo(CareQueueTicket::class, 'ticket_id'); + } +} diff --git a/app/Services/Care/CareDisplayService.php b/app/Services/Care/CareDisplayService.php new file mode 100644 index 0000000..fbd9471 --- /dev/null +++ b/app/Services/Care/CareDisplayService.php @@ -0,0 +1,156 @@ +where('access_token', $token) + ->where('is_active', true) + ->first(); + } + + public function touch(CareDisplayScreen $screen): void + { + $screen->update(['last_seen_at' => now()]); + } + + /** + * @return array + */ + public function payload(CareDisplayScreen $screen): array + { + $screen->loadMissing(['branch', 'organization']); + $queueIds = $this->resolveQueueIds($screen); + $queues = CareServiceQueue::query() + ->whereIn('id', $queueIds) + ->where('is_active', true) + ->orderBy('name') + ->get(); + + // One active ticket per service point. Older called/serving tickets at the + // same window must not crowd the public board. + $nowServing = CareQueueTicket::query() + ->whereIn('service_queue_id', $queueIds) + ->whereIn('status', [ + CareQueueTicket::STATUS_CALLED, + CareQueueTicket::STATUS_SERVING, + ]) + ->with(['servicePoint', 'serviceQueue']) + ->orderByDesc('called_at') + ->limit(48) + ->get() + ->unique(fn (CareQueueTicket $t) => $t->service_point_id + ? 'point:'.$t->service_point_id + : 'queue:'.$t->service_queue_id) + ->take(12) + ->values() + ->map(function (CareQueueTicket $t) { + $point = $t->servicePoint; + + return [ + 'ticket_number' => $t->ticket_number, + 'queue_name' => $t->serviceQueue?->name, + 'department' => $t->serviceQueue?->name, + 'counter' => $point?->displayDestination() ?? $point?->name, + 'destination' => $point?->displayDestination(), + 'staff_display_name' => $point?->staff_display_name, + 'point_key' => $point + ? 'point:'.$point->id + : 'queue:'.$t->service_queue_id, + 'announcement_text' => $this->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 = CareQueueTicket::query() + ->whereIn('service_queue_id', $queueIds) + ->where('status', CareQueueTicket::STATUS_WAITING) + ->count(); + + $avgWait = (int) CareQueueTicket::query() + ->whereIn('service_queue_id', $queueIds) + ->where('status', CareQueueTicket::STATUS_COMPLETED) + ->whereDate('created_at', today()) + ->whereNotNull('called_at') + ->get(['created_at', 'called_at']) + ->avg(fn (CareQueueTicket $t) => $t->called_at->diffInSeconds($t->created_at)); + + return [ + 'screen' => [ + 'name' => $screen->name, + 'layout' => $screen->layout, + 'branch_name' => $screen->branch?->name, + 'organization_name' => $screen->organization?->name, + ], + 'now_serving' => $nowServing, + 'waiting_count' => $waiting, + 'estimated_wait_minutes' => $avgWait > 0 ? (int) ceil($avgWait / 60) : null, + 'queues' => $queues->map(fn (CareServiceQueue $q) => [ + 'name' => $q->name, + 'prefix' => $q->prefix, + 'is_paused' => false, + ])->values()->all(), + 'announcements' => app(CareVoiceAnnouncementService::class)->pendingForBranch( + (int) $screen->branch_id, + $queueIds, + ), + 'updated_at' => now()->toIso8601String(), + ]; + } + + protected function announcementText(CareQueueTicket $ticket, ?CareServicePoint $point): ?string + { + if (! $point) { + return $ticket->ticket_number; + } + + $parts = array_values(array_filter([ + $ticket->ticket_number, + $point->staff_display_name ?: null, + $ticket->serviceQueue?->name, + $point->displayDestination(), + ])); + + return implode(', ', $parts); + } + + /** + * @return list + */ + protected function resolveQueueIds(CareDisplayScreen $screen): array + { + $ids = array_values(array_unique(array_filter(array_map( + fn ($id) => (int) $id, + $screen->service_queue_ids ?? [], + )))); + + if ($ids !== []) { + return $ids; + } + + return CareServiceQueue::query() + ->where('organization_id', $screen->organization_id) + ->when($screen->branch_id, fn ($query) => $query->where('branch_id', $screen->branch_id)) + ->where('is_active', true) + ->orderBy('name') + ->pluck('id') + ->map(fn ($id) => (int) $id) + ->all(); + } +} diff --git a/app/Services/Care/CarePermissions.php b/app/Services/Care/CarePermissions.php index bfd98b1..e735fde 100644 --- a/app/Services/Care/CarePermissions.php +++ b/app/Services/Care/CarePermissions.php @@ -56,6 +56,7 @@ class CarePermissions 'admin.members.view', 'admin.members.manage', 'settings.view', 'settings.manage', 'devices.view', 'devices.manage', + 'displays.view', 'displays.manage', 'audit.view', 'audit.export', ]; diff --git a/app/Services/Care/CareQueueEngine.php b/app/Services/Care/CareQueueEngine.php index 15d1054..857f180 100644 --- a/app/Services/Care/CareQueueEngine.php +++ b/app/Services/Care/CareQueueEngine.php @@ -7,7 +7,9 @@ use App\Models\CareServicePoint; use App\Models\CareServiceQueue; use App\Models\Organization; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use InvalidArgumentException; +use Throwable; /** * In-app Care Queue Engine — issue / call-next / serve / complete / recall. @@ -149,6 +151,15 @@ class CareQueueEngine $waiting->setRelation('servicePoint', $point); $waiting->setRelation('serviceQueue', $queue); + try { + app(CareVoiceAnnouncementService::class)->announceCalled($waiting, $point); + } catch (Throwable $e) { + Log::warning('Care voice announcement failed on call-next', [ + 'ticket_id' => $waiting->id, + 'error' => $e->getMessage(), + ]); + } + return $this->toApiArray($waiting); }); } @@ -246,6 +257,23 @@ class CareQueueEngine 'status' => CareQueueTicket::STATUS_CALLED, 'called_at' => now(), ])->save(); + + $point = $ticket->servicePoint; + if (! $point && $ticket->service_point_id) { + $ticket->loadMissing('servicePoint'); + $point = $ticket->servicePoint; + } + + if ($point) { + try { + app(CareVoiceAnnouncementService::class)->announceCalled($ticket, $point); + } catch (Throwable $e) { + Log::warning('Care voice announcement failed on recall', [ + 'ticket_id' => $ticket->id, + 'error' => $e->getMessage(), + ]); + } + } } protected function markSkipped(CareQueueTicket $ticket): void diff --git a/app/Services/Care/CareVoiceAnnouncementService.php b/app/Services/Care/CareVoiceAnnouncementService.php new file mode 100644 index 0000000..500fb91 --- /dev/null +++ b/app/Services/Care/CareVoiceAnnouncementService.php @@ -0,0 +1,92 @@ +loadMissing(['organization', 'serviceQueue']); + $locale ??= 'en'; + $message = $this->buildMessage($ticket, $point, $locale); + + return CareVoiceAnnouncement::create([ + 'owner_ref' => $ticket->owner_ref, + 'organization_id' => $ticket->organization_id, + 'branch_id' => $ticket->serviceQueue->branch_id, + 'ticket_id' => $ticket->id, + 'locale' => $locale, + 'message' => $message, + 'voice' => 'female', + 'volume' => 80, + 'repeat_count' => 1, + 'status' => 'pending', + ]); + } + + protected function buildMessage(CareQueueTicket $ticket, CareServicePoint $point, string $locale): string + { + $destination = $point->displayDestination(); + $staff = trim((string) ($point->staff_display_name ?? '')); + $queueName = trim((string) ($ticket->serviceQueue?->name ?? '')); + $templates = config('care.announcement_templates', []); + + if ($staff !== '') { + $richDestination = $queueName !== '' && ! str_contains(strtolower($destination), strtolower($queueName)) + ? trim($queueName.' '.$destination) + : $destination; + $template = $templates['with_staff'] ?? ':ticket, :staff, :destination.'; + + return rtrim(str_replace( + [':ticket', ':staff', ':destination', ':counter'], + [$ticket->ticket_number, $staff, $richDestination, $richDestination], + $template, + ), '.').'.'; + } + + $template = $templates[$locale] + ?? $templates['en'] + ?? 'Ticket :ticket, please proceed to :destination.'; + + return str_replace( + [':ticket', ':destination', ':counter'], + [$ticket->ticket_number, $destination, $destination], + $template, + ); + } + + /** + * @param list|null $queueIds + * @return list> + */ + public function pendingForBranch(int $branchId, ?array $queueIds = null, int $limit = 10): array + { + return CareVoiceAnnouncement::query() + ->where('branch_id', $branchId) + ->where('status', 'pending') + ->when($queueIds, function ($query, array $queueIds) { + $query->whereHas('ticket', fn ($ticket) => $ticket->whereIn('service_queue_id', $queueIds)); + }) + ->orderBy('id') + ->limit($limit) + ->get() + ->map(fn (CareVoiceAnnouncement $a) => [ + 'id' => $a->id, + 'message' => $a->message, + 'locale' => $a->locale, + 'voice' => $a->voice, + 'volume' => $a->volume, + 'repeat_count' => $a->repeat_count, + ]) + ->all(); + } + + public function markPlayed(CareVoiceAnnouncement $announcement): void + { + $announcement->update(['status' => 'played', 'played_at' => now()]); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 91dfd81..5b5436f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -21,6 +21,9 @@ return Application::configure(basePath: dirname(__DIR__)) }, ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->validateCsrfTokens(except: [ + 'display/*/announcements/*/played', + ]); $middleware->redirectGuestsTo(fn (Request $request) => route('sso.connect', [ 'redirect' => $request->fullUrl(), ])); diff --git a/config/care.php b/config/care.php index fb257dc..8f0e6ea 100644 --- a/config/care.php +++ b/config/care.php @@ -396,6 +396,20 @@ return [ 'period_days' => (int) env('CARE_PRO_PERIOD_DAYS', 30), ], + 'display_layouts' => [ + 'standard' => 'Standard', + 'compact' => 'Compact', + ], + + 'announcement_templates' => [ + 'en' => 'Ticket :ticket, please proceed to :destination.', + 'fr' => 'Ticket :ticket, veuillez vous rendre au :destination.', + 'tw' => 'Ticket :ticket, mesrɛ kɔ :destination.', + 'with_staff' => ':ticket, :staff, :destination.', + ], + + 'display_poll_ms' => (int) env('CARE_DISPLAY_POLL_MS', 1200), + 'upgrade_banner' => [ 'free' => [ 'title' => 'Unlock Care Pro or Enterprise', diff --git a/database/migrations/2026_07_18_140000_create_care_display_and_voice_tables.php b/database/migrations/2026_07_18_140000_create_care_display_and_voice_tables.php new file mode 100644 index 0000000..df3b5b8 --- /dev/null +++ b/database/migrations/2026_07_18_140000_create_care_display_and_voice_tables.php @@ -0,0 +1,52 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->constrained('care_branches')->cascadeOnDelete(); + $table->string('name'); + $table->string('layout')->default('standard'); + $table->string('access_token')->unique(); + $table->json('service_queue_ids')->nullable(); + $table->json('settings')->nullable(); + $table->boolean('is_active')->default(true); + $table->timestamp('last_seen_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + + Schema::create('care_voice_announcements', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('care_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->constrained('care_branches')->cascadeOnDelete(); + $table->foreignId('ticket_id')->nullable()->constrained('care_queue_tickets')->nullOnDelete(); + $table->string('locale', 10)->default('en'); + $table->text('message'); + $table->string('voice')->default('female'); + $table->unsignedTinyInteger('volume')->default(80); + $table->unsignedTinyInteger('repeat_count')->default(1); + $table->string('status')->default('pending'); + $table->timestamp('played_at')->nullable(); + $table->timestamps(); + + $table->index(['branch_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('care_voice_announcements'); + Schema::dropIfExists('care_display_screens'); + } +}; diff --git a/docs/care-queue-engine-and-specialty-plan.md b/docs/care-queue-engine-and-specialty-plan.md index 39b8e50..a6079b7 100644 --- a/docs/care-queue-engine-and-specialty-plan.md +++ b/docs/care-queue-engine-and-specialty-plan.md @@ -1,6 +1,6 @@ # Care Queue Engine + Specialty Modules — Unified Plan -**Status:** Phase 5 complete (Care remote Queue adapter deleted) +**Status:** Phase 5 complete — remote Queue adapter removed; Care TV display + voice shipped **Date:** 2026-07-18 **Repos:** ladill-care (primary), ladill-queue (healthcare removal) **Related:** `docs/specialty-module-design-standard.md` @@ -42,6 +42,7 @@ - Bridge / provisioner / specialty activate always use Care Queue Engine tables - `care:queue-sync-tickets` remains (native backfill only) - Tests assert local `CareQueueTicket` / `CareServiceQueue` (no Queue HTTP fakes) +- Care TV display + browser TTS voice on call-next/recall --- diff --git a/resources/css/app.css b/resources/css/app.css index 64167e9..56c8311 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -373,3 +373,332 @@ html.qr-mobile-page body { box-shadow: 0 1px 2px rgb(15 23 42 / 0.08); pointer-events: none; } +html:has(.qms-display), +html:has(.qms-display) body, +html:has(.qms-display) .qms-display, +html:has(.qms-display) .qms-display__unlock { + font-family: 'Figtree', ui-sans-serif, system-ui, sans-serif; +} + +html:has(.qms-display), +html:has(.qms-display) body { + height: 100%; + overflow: hidden; +} + +.qms-display { + display: flex; + flex-direction: column; + height: 100dvh; + max-height: 100dvh; + overflow: hidden; + font-family: inherit; + background: + radial-gradient(ellipse 90% 60% at 50% -20%, rgb(238 242 255 / 0.9), transparent), + linear-gradient(180deg, rgb(248 250 252) 0%, rgb(255 255 255) 100%); + color: rgb(15 23 42); +} + +.qms-display__shell { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; +} + +.qms-display__header { + flex-shrink: 0; +} + +.qms-display__main { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + max-width: 120rem; +} + +.qms-display__footer { + flex-shrink: 0; +} + +.qms-display__tickets { + display: grid; + min-height: 0; + flex: 1; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-auto-rows: minmax(0, 1fr); + gap: clamp(0.75rem, 1.5vw, 1.5rem); + overflow: hidden; +} + +.qms-display__tickets[data-ticket-count="1"] { + grid-template-columns: minmax(0, 1fr); +} + +.qms-display__tickets[data-ticket-count="2"], +.qms-display__tickets[data-ticket-count="4"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.qms-display__serving-heading { + display: flex; + flex-shrink: 0; + align-items: end; + justify-content: space-between; + gap: 1rem; + margin-bottom: clamp(0.65rem, 1.6vh, 1.25rem); +} + +.qms-display__serving-heading h1 { + font-size: clamp(1.5rem, 3vw, 2.5rem); + font-weight: 700; + line-height: 1; + letter-spacing: -0.03em; + color: rgb(15 23 42); +} + +.qms-display__serving-heading > p { + font-size: clamp(0.75rem, 1.2vw, 1rem); + font-weight: 600; + color: rgb(100 116 139); +} + +.qms-display__grid-bg { + background-image: + linear-gradient(rgb(148 163 184 / 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgb(148 163 184 / 0.08) 1px, transparent 1px); + background-size: 48px 48px; +} + +.qms-display__ticket { + container-type: size; + display: flex; + min-height: 0; + flex-direction: column; + overflow: hidden; + background: #fff; + border: 1px solid rgb(203 213 225); + box-shadow: 0 16px 32px -20px rgb(15 23 42 / 0.2); +} + +.qms-display__ticket-accent { + height: 4px; + flex-shrink: 0; + background: linear-gradient(90deg, rgb(67 56 202), rgb(124 58 237), rgb(99 102 241)); +} + +.qms-display__ticket-body { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + gap: clamp(0.35rem, 1vh, 0.75rem); + padding: clamp(0.85rem, 2vh, 1.5rem) clamp(1rem, 2vw, 1.75rem); +} + +.qms-display__ticket-meta { + display: flex; + flex-shrink: 0; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.qms-display__badge { + flex-shrink: 0; + border-radius: 9999px; + padding: 0.35rem 0.7rem; + font-size: clamp(0.62rem, 0.85vw, 0.78rem); + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1; + text-transform: uppercase; + background: rgb(67 56 202); + color: #fff; +} + +.qms-display__queue-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: right; + font-size: clamp(0.75rem, 1.1vw, 1rem); + font-weight: 700; + line-height: 1.15; + color: rgb(51 65 85); +} + +.qms-display__ticket-number { + display: flex; + min-height: 0; + flex: 1 1 auto; + align-items: center; + justify-content: center; + margin: 0; + overflow: hidden; + text-align: center; + font-size: clamp(2.5rem, min(12cqh, 9cqw), 5.5rem); + font-weight: 800; + font-variant-numeric: tabular-nums; + letter-spacing: -0.045em; + line-height: 1; + color: rgb(30 41 59); +} + +.qms-display__destination { + flex-shrink: 0; + min-width: 0; + border-top: 1px solid rgb(226 232 240); + padding-top: clamp(0.55rem, 1.2vh, 0.9rem); + text-align: center; +} + +.qms-display__destination > span { + display: block; + margin-bottom: 0.25rem; + font-size: clamp(0.65rem, 0.85vw, 0.8rem); + font-weight: 700; + letter-spacing: 0.1em; + line-height: 1.2; + text-transform: uppercase; + color: rgb(100 116 139); +} + +.qms-display__counter-name { + margin: 0; + max-width: 100%; + overflow-wrap: anywhere; + font-size: clamp(1.1rem, min(4.5cqh, 3.2cqw), 2rem); + font-weight: 800; + line-height: 1.15; + letter-spacing: -0.02em; + color: rgb(15 23 42); +} + +.qms-display__tickets[data-ticket-count="1"] .qms-display__ticket-body { + padding-inline: clamp(1.5rem, 6vw, 6rem); +} + +.qms-display__tickets[data-ticket-count="1"] .qms-display__ticket-number { + font-size: clamp(4rem, min(28cqh, 18cqw), 12rem); +} + +.qms-display__tickets[data-ticket-count="1"] .qms-display__counter-name { + font-size: clamp(1.5rem, min(7cqh, 5cqw), 3.5rem); +} + +.qms-display__tickets[data-ticket-count="2"] .qms-display__ticket-number { + font-size: clamp(3.25rem, min(18cqh, 12cqw), 8rem); +} + +.qms-display__tickets[data-ticket-count="2"] .qms-display__counter-name { + font-size: clamp(1.25rem, min(5.5cqh, 3.8cqw), 2.5rem); +} + +@media (max-width: 767px) { + html:has(.qms-display), + html:has(.qms-display) body { + height: auto; + min-height: 100%; + overflow: auto; + } + + .qms-display { + height: auto; + min-height: 100dvh; + max-height: none; + overflow: visible; + } + + .qms-display__tickets, + .qms-display__tickets[data-ticket-count] { + grid-template-columns: minmax(0, 1fr); + grid-auto-rows: minmax(13rem, auto); + overflow: visible; + } + + .qms-display__ticket { + container-type: inline-size; + min-height: 13rem; + } + + .qms-display__ticket-number, + .qms-display__tickets[data-ticket-count="1"] .qms-display__ticket-number, + .qms-display__tickets[data-ticket-count="2"] .qms-display__ticket-number { + flex: 0 0 auto; + padding-block: 0.75rem; + font-size: clamp(3.25rem, 18vw, 5.5rem); + } + + .qms-display__counter-name, + .qms-display__tickets[data-ticket-count="1"] .qms-display__counter-name, + .qms-display__tickets[data-ticket-count="2"] .qms-display__counter-name { + font-size: clamp(1.15rem, 5vw, 1.75rem); + } +} + +@media (min-width: 768px) and (max-aspect-ratio: 4 / 3) { + .qms-display__tickets[data-ticket-count="3"], + .qms-display__tickets[data-ticket-count="5"], + .qms-display__tickets[data-ticket-count="6"], + .qms-display__tickets[data-ticket-count="7"], + .qms-display__tickets[data-ticket-count="8"], + .qms-display__tickets[data-ticket-count="9"], + .qms-display__tickets[data-ticket-count="10"], + .qms-display__tickets[data-ticket-count="11"], + .qms-display__tickets[data-ticket-count="12"] { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-height: 620px) and (min-width: 768px) { + .qms-display__header > div { + padding-block: 0.55rem; + } + + .qms-display__main { + padding-block: 0.6rem; + } + + .qms-display__footer { + padding-block: 0.4rem; + } + + .qms-display__ticket-body { + padding-block: 0.55rem; + gap: 0.35rem; + } + + .qms-display__ticket-number { + font-size: clamp(2.25rem, min(14cqh, 8cqw), 4rem); + } + + .qms-display__counter-name { + font-size: clamp(1rem, min(5cqh, 3cqw), 1.5rem); + } +} + +.qms-display__ticket--empty { + justify-content: center; + align-items: center; + text-align: center; + background: linear-gradient(180deg, #fff 0%, rgb(248 250 252) 100%); +} + +.qms-display__live-dot { + animation: qms-display-pulse 2s ease-in-out infinite; +} + +@keyframes qms-display-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +.qms-display__unlock { + background: + radial-gradient(ellipse 60% 40% at 50% 0%, rgb(199 210 254 / 0.5), transparent), + rgb(255 255 255 / 0.97); +} diff --git a/resources/js/app.js b/resources/js/app.js index 7f0694c..f27aecf 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,9 +1,11 @@ import Alpine from 'alpinejs'; import { registerLadillClipboard } from './ladill-clipboard'; +import { registerCareDisplay } from './care-display'; import collapse from '@alpinejs/collapse'; Alpine.plugin(collapse); registerLadillClipboard(Alpine); +registerCareDisplay(Alpine); // In-app notification bell + dropdown. Alpine.data('notificationDropdown', (config = {}) => ({ diff --git a/resources/js/care-display.js b/resources/js/care-display.js new file mode 100644 index 0000000..c854716 --- /dev/null +++ b/resources/js/care-display.js @@ -0,0 +1,334 @@ +const DISPLAY_AUDIO_KEY = 'care_display_audio'; + +function localeToSpeechLang(locale) { + if (locale === 'fr') return 'fr-FR'; + if (locale === 'tw') return 'ak-GH'; + + return 'en-US'; +} + +function pickVoice(voices, locale, preference) { + const list = voices.length ? voices : window.speechSynthesis?.getVoices() ?? []; + if (! list.length) { + return null; + } + + const lang = localeToSpeechLang(locale).toLowerCase(); + const langMatches = list.filter((voice) => voice.lang?.toLowerCase().startsWith(lang.slice(0, 2))); + const pool = langMatches.length ? langMatches : list; + const wantFemale = preference !== 'male'; + + const scored = pool.map((voice) => { + const name = voice.name.toLowerCase(); + const female = /female|woman|zira|samantha|karen|victoria|fiona|moira|tessa|veena|lekha/.test(name); + const male = /male|man|david|daniel|james|fred|thomas|lee|ralph/.test(name); + + let score = 0; + if (wantFemale && female) score += 2; + if (! wantFemale && male) score += 2; + if (voice.default) score += 1; + if (voice.localService) score += 1; + + return { voice, score }; + }); + + scored.sort((a, b) => b.score - a.score); + + return scored[0]?.voice ?? pool[0] ?? null; +} + + +export function registerCareDisplay(Alpine) { + Alpine.data('displayBoard', (config = {}) => ({ + payload: config.initial ?? null, + announcedIds: {}, + servingSignatures: {}, + servingPrimed: false, + pendingLocalAnnouncements: [], + isAnnouncing: false, + speechReady: false, + voices: [], + clock: '', + dateLabel: '', + pollError: null, + 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 ?? '', + + formatCount(value) { + return value === null || value === undefined ? '—' : String(value); + }, + + formatWait(minutes) { + return minutes === null || minutes === undefined ? '—' : String(minutes); + }, + + async poll() { + try { + const res = await fetch(this.dataUrl, { headers: { Accept: 'application/json' } }); + if (! res.ok) { + throw new Error(`Display poll failed (${res.status})`); + } + 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'; + console.error('Display poll failed', e); + } + }, + + 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; + } + + const loadVoices = () => { + this.voices = window.speechSynthesis.getVoices() || []; + }; + + loadVoices(); + window.speechSynthesis.addEventListener('voiceschanged', loadVoices); + }, + + unlockSpeech() { + if (! window.speechSynthesis) { + return; + } + + window.speechSynthesis.resume?.(); + window.speechSynthesis.cancel(); + + const utter = new SpeechSynthesisUtterance(' '); + utter.volume = 0; + utter.onend = () => this.markSpeechReady(); + utter.onerror = () => this.markSpeechReady(); + window.speechSynthesis.speak(utter); + }, + + markSpeechReady() { + this.speechReady = true; + + try { + sessionStorage.setItem(DISPLAY_AUDIO_KEY, '1'); + } catch (e) { + // ignore storage failures + } + + this.maybeAnnounce(this.payload?.announcements ?? []); + }, + + maybeAnnounce(announcements) { + if (! window.speechSynthesis || ! this.speechReady || this.isAnnouncing) { + return; + } + + 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); + }, + + 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)); + let remaining = repeats; + let watchdog = null; + + const release = () => { + if (watchdog) { + clearTimeout(watchdog); + watchdog = null; + } + this.isAnnouncing = false; + }; + + const finish = () => { + release(); + if (! item.local) { + this.ackPlayed(item.id); + } + this.maybeAnnounce([ + ...(this.payload?.announcements ?? []), + ...this.pendingLocalAnnouncements, + ]); + }; + + const fail = (reason) => { + console.error('Announcement speech failed', reason); + release(); + delete this.announcedIds[item.id]; + }; + + const speakOnce = () => { + window.speechSynthesis.resume?.(); + window.speechSynthesis.cancel(); + + const utter = new SpeechSynthesisUtterance(item.message); + utter.volume = volume; + utter.lang = localeToSpeechLang(item.locale); + const voice = pickVoice(this.voices, item.locale, item.voice); + if (voice) { + utter.voice = voice; + } + + const estimatedMs = Math.max(4000, (item.message?.length || 24) * 140); + watchdog = setTimeout(() => { + if (! this.isAnnouncing) { + return; + } + window.speechSynthesis.cancel(); + remaining -= 1; + if (remaining > 0) { + speakOnce(); + } else { + finish(); + } + }, estimatedMs); + + utter.onend = () => { + if (watchdog) { + clearTimeout(watchdog); + watchdog = null; + } + remaining -= 1; + if (remaining > 0) { + speakOnce(); + } else { + finish(); + } + }; + utter.onerror = (event) => fail(event); + + window.speechSynthesis.speak(utter); + }; + + speakOnce(); + }, + + async ackPlayed(id) { + if (! this.playedUrlTemplate) { + return; + } + + const url = this.playedUrlTemplate.replace('__ID__', String(id)); + + try { + const res = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-TOKEN': this.csrf, + }, + }); + if (! res.ok) { + throw new Error(`Ack failed (${res.status})`); + } + } catch (e) { + console.error('Announcement ack failed', e); + delete this.announcedIds[id]; + } + }, + + init() { + this.primeSpeech(); + this.updateClock(); + setInterval(() => this.updateClock(), 1000); + + try { + if (sessionStorage.getItem(DISPLAY_AUDIO_KEY) === '1') { + this.unlockSpeech(); + } + } catch (e) { + // 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); + }, + + updateClock() { + const now = new Date(); + this.clock = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + this.dateLabel = now.toLocaleDateString([], { + weekday: 'long', + month: 'long', + day: 'numeric', + }); + }, + })); +} diff --git a/resources/views/care/display/public.blade.php b/resources/views/care/display/public.blade.php new file mode 100644 index 0000000..47846c5 --- /dev/null +++ b/resources/views/care/display/public.blade.php @@ -0,0 +1,138 @@ + + + + + + + + {{ $screen->name }} · Care Display + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + {{-- Voice unlock — required by browsers before TTS --}} +
+
+
+ +
+

Tap to enable announcements

+

Your browser needs one interaction before this screen can call tickets aloud.

+
+
+ +
+ +
+
+
+
+ {{ $logoAlt }} +
+
+

--:--

+

+
+
+
+ +
+
+
+
+ +

Now serving

+
+

+
+ +
+ +
+ +
+
+ +
+

Please wait

+

Your ticket number will appear here when it is called.

+
+
+
+ +
+
+
+ Powered by + Ladill Care +
+

+
+
+
+ + diff --git a/resources/views/care/displays/create.blade.php b/resources/views/care/displays/create.blade.php new file mode 100644 index 0000000..80ff201 --- /dev/null +++ b/resources/views/care/displays/create.blade.php @@ -0,0 +1,43 @@ + +
+
+ ← Displays +

New display

+
+
+ @csrf +
+ + +
+
+ + +
+
+ + +
+
+ + @forelse ($queues as $q) + + @empty +

No service queues yet. Leave empty to show all branch queues.

+ @endforelse +
+ +
+
+
diff --git a/resources/views/care/displays/edit.blade.php b/resources/views/care/displays/edit.blade.php new file mode 100644 index 0000000..3a66041 --- /dev/null +++ b/resources/views/care/displays/edit.blade.php @@ -0,0 +1,49 @@ + +
+
+ ← {{ $display->name }} +

Edit display

+
+
+ @csrf + @method('PUT') +
+ + +
+
+ + +
+
+ + +
+
+ + +

Leave empty to show all active queues for the branch.

+
+ +
+ + Cancel +
+
+
+
diff --git a/resources/views/care/displays/index.blade.php b/resources/views/care/displays/index.blade.php new file mode 100644 index 0000000..e7b9f73 --- /dev/null +++ b/resources/views/care/displays/index.blade.php @@ -0,0 +1,58 @@ + +
+ + @if ($canManage) + + New display + + @endif + + +
+ + + + + + + + + + + @forelse ($screens as $screen) + + + + + + + @empty + + + + @endforelse + +
NameLayoutBranch
+ {{ $screen->name }} + {{ config('care.display_layouts.'.$screen->layout, $screen->layout) }}{{ $screen->branch?->name ?? '—' }} + Open display +
+ No display screens yet. + @if ($canManage) + Create your first display + @endif +
+ @if ($screens->hasPages()) +
{{ $screens->links() }}
+ @endif +
+
+
diff --git a/resources/views/care/displays/show.blade.php b/resources/views/care/displays/show.blade.php new file mode 100644 index 0000000..8939c8f --- /dev/null +++ b/resources/views/care/displays/show.blade.php @@ -0,0 +1,147 @@ +@php + $layoutLabel = config('care.display_layouts.'.$display->layout, $display->layout); + $isOnline = $display->last_seen_at && $display->last_seen_at->gt(now()->subMinutes(2)); +@endphp + + +
+
+ ← Displays +
+

{{ $display->name }}

+ @if ($display->is_active) + + + Active + + @else + Inactive + @endif + @if ($isOnline) + + + Display online + + @endif +
+

+ {{ $display->branch?->name ?? 'No branch' }} + · {{ $layoutLabel }} layout + @if ($display->last_seen_at) + · Last seen {{ $display->last_seen_at->diffForHumans() }} + @endif +

+
+
+ @if ($canManage) + Edit display + @endif + Open public display +
+
+ +
+
+

Assigned queues

+

Tickets from these queues appear on this screen and trigger voice calls.

+ + @if ($queues->isEmpty()) +
+

No queues assigned

+

All active queues for this branch will be shown. Edit to pick specific queues.

+ @if ($canManage) + Assign queues + @endif +
+ @else +
    + @foreach ($queues as $queue) +
  • + + {{ $queue->prefix }} + +
    +

    {{ $queue->name }}

    +

    + {{ $queue->is_active ? 'Accepting tickets' : 'Inactive' }} +

    +
    +
  • + @endforeach +
+ @endif +
+ +
+
+

Display details

+
+
+
Organization
+
{{ $display->organization?->name ?? '—' }}
+
+
+
Branch
+
{{ $display->branch?->name ?? '—' }}
+
+
+
Layout
+
{{ $layoutLabel }}
+
+
+
Queues
+
{{ $queues->count() }}
+
+
+
+ +
+

Public display URL

+

Open this link on a TV, tablet, or kiosk in your waiting area.

+
+

+
+
+ + Preview +
+
+
+
+ +
+
+

Live preview

+ + + Patient view + +
+
+ +
+

+ Voice announcements require a tap on the display screen once per browser session. +

+
+
diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index feae2f1..180bd92 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -48,7 +48,7 @@ - @if ($canViewBranches || $canViewTeam || $canViewDevices) + @if ($canViewBranches || $canViewTeam || $canViewDevices || $canViewDisplays)