Add Care waiting-area TV displays with browser TTS voice.
Deploy Ladill Care / deploy (push) Failing after 57s

Call-next and recall queue announcements for lobby screens without Ladill Queue.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 11:23:15 +00:00
co-authored by Cursor
parent dd4bc493b4
commit 03befd1e7f
26 changed files with 2068 additions and 3 deletions
@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Controller;
use App\Models\CareVoiceAnnouncement;
use App\Services\Care\CareDisplayService;
use App\Services\Care\CareVoiceAnnouncementService;
use App\Support\OrganizationBranding;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
class DisplayPublicController extends Controller
{
public function __construct(
protected CareDisplayService $displays,
protected CareVoiceAnnouncementService $voice,
) {}
public function show(string $token): View
{
$screen = $this->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]);
}
}
@@ -0,0 +1,197 @@
<?php
namespace App\Http\Controllers\Care;
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
use App\Http\Controllers\Controller;
use App\Models\Branch;
use App\Models\CareDisplayScreen;
use App\Models\CareServiceQueue;
use App\Services\Care\CarePermissions;
use App\Services\Care\OrganizationResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DisplayScreenController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->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<string, mixed>
*/
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<int, Branch> */
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();
}
}
@@ -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),
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
class CareDisplayScreen extends Model
{
use BelongsToOwner, SoftDeletes;
protected $table = 'care_display_screens';
protected $fillable = [
'uuid', 'owner_ref', 'organization_id', 'branch_id',
'name', 'layout', 'access_token', 'service_queue_ids', 'settings', 'is_active', 'last_seen_at',
];
protected function casts(): array
{
return [
'service_queue_ids' => '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');
}
}
+8
View File
@@ -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;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToOwner;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class CareVoiceAnnouncement extends Model
{
use BelongsToOwner;
protected $table = 'care_voice_announcements';
protected $fillable = [
'owner_ref', 'organization_id', 'branch_id', 'ticket_id',
'locale', 'message', 'voice', 'volume', 'repeat_count', 'status', 'played_at',
];
protected function casts(): array
{
return [
'played_at' => 'datetime',
];
}
public function ticket(): BelongsTo
{
return $this->belongsTo(CareQueueTicket::class, 'ticket_id');
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace App\Services\Care;
use App\Models\CareDisplayScreen;
use App\Models\CareQueueTicket;
use App\Models\CareServicePoint;
use App\Models\CareServiceQueue;
class CareDisplayService
{
public function findByToken(string $token): ?CareDisplayScreen
{
return CareDisplayScreen::query()
->where('access_token', $token)
->where('is_active', true)
->first();
}
public function touch(CareDisplayScreen $screen): void
{
$screen->update(['last_seen_at' => now()]);
}
/**
* @return array<string, mixed>
*/
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<int>
*/
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();
}
}
+1
View File
@@ -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',
];
+28
View File
@@ -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
@@ -0,0 +1,92 @@
<?php
namespace App\Services\Care;
use App\Models\CareQueueTicket;
use App\Models\CareServicePoint;
use App\Models\CareVoiceAnnouncement;
class CareVoiceAnnouncementService
{
public function announceCalled(CareQueueTicket $ticket, CareServicePoint $point, ?string $locale = null): CareVoiceAnnouncement
{
$ticket->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<int>|null $queueIds
* @return list<array<string, mixed>>
*/
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()]);
}
}
+3
View File
@@ -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(),
]));
+14
View File
@@ -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',
@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('care_display_screens', function (Blueprint $table) {
$table->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');
}
};
+2 -1
View File
@@ -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
---
+329
View File
@@ -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);
}
+2
View File
@@ -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 = {}) => ({
+334
View File
@@ -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',
});
},
}));
}
@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="en" class="h-full overflow-hidden bg-slate-50 font-sans">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<meta name="theme-color" content="#f8fafc">
<title>{{ $screen->name }} · Care Display</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet">
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body
class="qms-display qms-display__grid-bg overflow-hidden bg-slate-50 font-sans text-slate-900"
x-data="displayBoard(@js([
'dataUrl' => $dataUrl,
'playedUrl' => $playedUrl,
'pollMs' => $pollMs,
'csrf' => csrf_token(),
'initial' => $initialPayload,
]))"
x-init="init()"
>
{{-- Voice unlock required by browsers before TTS --}}
<div
x-cloak
x-show="!speechReady"
@click="unlockSpeech()"
@keydown.enter.prevent="unlockSpeech()"
tabindex="0"
class="qms-display__unlock fixed inset-0 z-50 flex cursor-pointer items-center justify-center p-8 text-center outline-none"
role="button"
aria-label="Enable voice announcements"
>
<div class="max-w-lg rounded-3xl border border-slate-200 bg-white px-10 py-12 shadow-xl">
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-indigo-50 ring-1 ring-indigo-100">
<svg class="h-8 w-8 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.114 5.636a9 9 0 0 1 0 12.728M16.463 8.288a5.25 5.25 0 0 1 0 7.424M6.75 8.25l4.72-4.72a.75.75 0 0 1 1.28.53v15.88a.75.75 0 0 1-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 0 1 2.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75Z" />
</svg>
</div>
<p class="mt-6 text-2xl font-semibold tracking-tight text-slate-900">Tap to enable announcements</p>
<p class="mt-3 text-base text-slate-500">Your browser needs one interaction before this screen can call tickets aloud.</p>
</div>
</div>
<div
x-show="pollError"
x-cloak
class="fixed left-1/2 top-4 z-40 max-w-lg -translate-x-1/2 rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-800 shadow-lg"
x-text="pollError"
></div>
<div class="qms-display__shell">
<header class="qms-display__header border-b border-slate-200/80 bg-white/90 backdrop-blur-md">
<div class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-5 py-4 lg:px-8">
<div class="flex min-w-0 items-center">
<img
src="{{ $logoUrl }}"
alt="{{ $logoAlt }}"
class="h-8 w-auto max-w-[min(100%,240px)] object-contain object-left lg:h-10"
>
</div>
<div class="shrink-0 text-right">
<p class="text-2xl font-semibold tabular-nums tracking-tight text-slate-900 lg:text-3xl" x-text="clock">--:--</p>
<p class="mt-0.5 text-xs text-slate-500" x-text="dateLabel"></p>
</div>
</div>
</header>
<main class="qms-display__main mx-auto w-full px-5 py-4 lg:px-8 lg:py-5">
<section class="flex min-h-0 flex-1 flex-col">
<div class="qms-display__serving-heading">
<div class="flex items-center gap-3">
<span class="qms-display__live-dot h-3 w-3 rounded-full bg-indigo-500" aria-hidden="true"></span>
<h1>Now serving</h1>
</div>
<p x-show="payload?.screen?.branch_name" x-text="payload?.screen?.branch_name"></p>
</div>
<div
class="qms-display__tickets"
x-show="(payload?.now_serving ?? []).length"
:data-ticket-count="Math.min((payload?.now_serving ?? []).length, 12)"
>
<template x-for="item in (payload?.now_serving ?? [])" :key="(item.destination || item.counter || item.queue_name || '') + ':' + item.ticket_number">
<article class="qms-display__ticket rounded-2xl lg:rounded-3xl">
<div class="qms-display__ticket-accent" aria-hidden="true"></div>
<div class="qms-display__ticket-body">
<div class="qms-display__ticket-meta">
<span class="qms-display__badge">Serving</span>
<span class="qms-display__queue-name" x-text="item.queue_name ?? 'Service queue'"></span>
</div>
<p class="qms-display__ticket-number" x-text="item.ticket_number"></p>
<div class="qms-display__destination">
<span>Please proceed to</span>
<p class="qms-display__counter-name" x-text="item.destination || item.counter || 'Await staff direction'"></p>
<p
class="mt-1 text-sm font-medium text-slate-600 lg:text-base"
x-show="item.staff_display_name"
x-text="item.staff_display_name"
></p>
</div>
</div>
</article>
</template>
</div>
<div
class="qms-display__ticket qms-display__ticket--empty flex min-h-0 flex-1 flex-col rounded-2xl px-6 py-8 lg:rounded-3xl"
x-show="!(payload?.now_serving ?? []).length"
>
<div class="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 ring-1 ring-slate-200 lg:h-14 lg:w-14">
<svg class="h-6 w-6 text-slate-400 lg:h-7 lg:w-7" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
</div>
<p class="mt-4 text-2xl font-semibold text-slate-900 lg:text-3xl">Please wait</p>
<p class="mt-2 max-w-md text-sm text-slate-500 lg:text-base">Your ticket number will appear here when it is called.</p>
</div>
</section>
</main>
<footer class="qms-display__footer border-t border-slate-200 bg-white/80 px-5 py-3 lg:px-8">
<div class="mx-auto flex max-w-6xl items-center justify-between gap-3">
<div class="flex items-center gap-2">
<span class="text-[10px] font-medium uppercase tracking-wider text-slate-400">Powered by</span>
<img
src="{{ $poweredByLogoUrl }}"
alt="Ladill Care"
class="h-3.5 w-auto opacity-80"
>
</div>
<p class="text-[10px] text-slate-400" x-show="payload?.updated_at" x-text="payload?.updated_at ? 'Updated ' + new Date(payload.updated_at).toLocaleTimeString() : ''"></p>
</div>
</footer>
</div>
</body>
</html>
@@ -0,0 +1,43 @@
<x-app-layout title="New display">
<div class="mx-auto max-w-lg space-y-4">
<div>
<a href="{{ route('care.displays.index') }}" class="text-sm text-slate-500 hover:text-slate-800"> Displays</a>
<h1 class="mt-2 text-2xl font-semibold text-slate-900">New display</h1>
</div>
<form method="POST" action="{{ route('care.displays.store') }}" class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
<div>
<label class="block text-sm font-medium text-slate-700">Name</label>
<input name="name" value="{{ old('name') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch</label>
<select name="branch_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($branches as $b)
<option value="{{ $b->id }}" @selected(old('branch_id') == $b->id)>{{ $b->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Layout</label>
<select name="layout" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($layouts as $k => $v)
<option value="{{ $k }}" @selected(old('layout', 'standard') === $k)>{{ $v }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Queues on screen</label>
@forelse ($queues as $q)
<label class="mt-2 flex gap-2 text-sm text-slate-700">
<input type="checkbox" name="queue_ids[]" value="{{ $q->id }}" @checked(in_array($q->id, old('queue_ids', [])))>
{{ $q->name }}
</label>
@empty
<p class="mt-2 text-sm text-slate-500">No service queues yet. Leave empty to show all branch queues.</p>
@endforelse
</div>
<button type="submit" class="btn-primary w-full">Create display</button>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,49 @@
<x-app-layout title="Edit display">
<div class="mx-auto max-w-lg space-y-4">
<div>
<a href="{{ route('care.displays.show', $display) }}" class="text-sm text-slate-500 hover:text-slate-800"> {{ $display->name }}</a>
<h1 class="mt-2 text-2xl font-semibold text-slate-900">Edit display</h1>
</div>
<form method="POST" action="{{ route('care.displays.update', $display) }}" class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
@method('PUT')
<div>
<label class="block text-sm font-medium text-slate-700">Name</label>
<input name="name" value="{{ old('name', $display->name) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Branch</label>
<select name="branch_id" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($branches as $b)
<option value="{{ $b->id }}" @selected(old('branch_id', $display->branch_id) == $b->id)>{{ $b->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Layout</label>
<select name="layout" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($layouts as $k => $l)
<option value="{{ $k }}" @selected(old('layout', $display->layout) === $k)>{{ $l }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Queues</label>
<select name="queue_ids[]" multiple class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($queues as $q)
<option value="{{ $q->id }}" @selected(in_array($q->id, old('queue_ids', $display->service_queue_ids ?? [])))>{{ $q->name }}</option>
@endforeach
</select>
<p class="mt-1 text-xs text-slate-500">Leave empty to show all active queues for the branch.</p>
</div>
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $display->is_active))>
Active
</label>
<div class="flex gap-2">
<button type="submit" class="btn-primary flex-1">Save</button>
<a href="{{ route('care.displays.show', $display) }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600">Cancel</a>
</div>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,58 @@
<x-app-layout title="Displays">
<div class="space-y-6">
<x-care.page-hero
badge="Waiting areas · Voice · Ticket calls"
title="Display screens"
description="Waiting-area screens and voice announcements so patients see called tickets and hear when their number is up."
:stats="[
['value' => number_format($heroStats['total']), 'label' => 'Displays'],
['value' => number_format($heroStats['active']), 'label' => 'Active'],
['value' => number_format($heroStats['branches']), 'label' => 'Branches'],
]">
@if ($canManage)
<x-slot name="actions">
<a href="{{ route('care.displays.create') }}" class="btn-primary">New display</a>
</x-slot>
@endif
</x-care.page-hero>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Layout</th>
<th class="px-4 py-3">Branch</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
@forelse ($screens as $screen)
<tr>
<td class="px-4 py-3 font-medium text-slate-900">
<a href="{{ route('care.displays.show', $screen) }}" class="hover:text-indigo-600">{{ $screen->name }}</a>
</td>
<td class="px-4 py-3 text-slate-600">{{ config('care.display_layouts.'.$screen->layout, $screen->layout) }}</td>
<td class="px-4 py-3 text-slate-600">{{ $screen->branch?->name ?? '—' }}</td>
<td class="px-4 py-3 text-right">
<a href="{{ route('care.display.public', $screen->access_token) }}" target="_blank" rel="noopener" class="text-sky-600 hover:text-sky-700">Open display</a>
</td>
</tr>
@empty
<tr>
<td colspan="4" class="px-4 py-8 text-center text-slate-500">
No display screens yet.
@if ($canManage)
<a href="{{ route('care.displays.create') }}" class="ml-1 text-indigo-600 hover:text-indigo-800">Create your first display</a>
@endif
</td>
</tr>
@endforelse
</tbody>
</table>
@if ($screens->hasPages())
<div class="border-t border-slate-100 px-5 py-3">{{ $screens->links() }}</div>
@endif
</div>
</div>
</x-app-layout>
@@ -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
<x-app-layout :title="$display->name">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<a href="{{ route('care.displays.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Displays</a>
<div class="mt-2 flex flex-wrap items-center gap-3">
<h1 class="text-xl font-semibold text-slate-900">{{ $display->name }}</h1>
@if ($display->is_active)
<span class="inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-2.5 py-1 text-xs font-semibold text-emerald-700 ring-1 ring-emerald-200">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
Active
</span>
@else
<span class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-1 text-xs font-semibold text-slate-600 ring-1 ring-slate-200">Inactive</span>
@endif
@if ($isOnline)
<span class="inline-flex items-center gap-1.5 rounded-full bg-sky-50 px-2.5 py-1 text-xs font-semibold text-sky-700 ring-1 ring-sky-200">
<span class="h-1.5 w-1.5 rounded-full bg-sky-500"></span>
Display online
</span>
@endif
</div>
<p class="mt-1 text-sm text-slate-500">
{{ $display->branch?->name ?? 'No branch' }}
· {{ $layoutLabel }} layout
@if ($display->last_seen_at)
· Last seen {{ $display->last_seen_at->diffForHumans() }}
@endif
</p>
</div>
<div class="flex flex-wrap gap-2">
@if ($canManage)
<a href="{{ route('care.displays.edit', $display) }}" class="btn-secondary">Edit display</a>
@endif
<a href="{{ $publicUrl }}" target="_blank" rel="noopener" class="btn-primary">Open public display</a>
</div>
</div>
<div class="mt-6 grid gap-4 lg:grid-cols-3">
<section class="rounded-2xl border border-slate-200 bg-white p-5 lg:col-span-2">
<h2 class="text-sm font-semibold text-slate-900">Assigned queues</h2>
<p class="mt-1 text-sm text-slate-500">Tickets from these queues appear on this screen and trigger voice calls.</p>
@if ($queues->isEmpty())
<div class="mt-4 rounded-xl border border-dashed border-slate-200 bg-slate-50 px-4 py-8 text-center">
<p class="text-sm font-medium text-slate-700">No queues assigned</p>
<p class="mt-1 text-sm text-slate-500">All active queues for this branch will be shown. Edit to pick specific queues.</p>
@if ($canManage)
<a href="{{ route('care.displays.edit', $display) }}" class="btn-secondary mt-4 inline-flex">Assign queues</a>
@endif
</div>
@else
<ul class="mt-4 grid gap-3 sm:grid-cols-2">
@foreach ($queues as $queue)
<li class="flex items-start gap-3 rounded-xl bg-slate-50 px-4 py-3">
<span class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-white text-xs font-bold text-indigo-700 ring-1 ring-indigo-100">
{{ $queue->prefix }}
</span>
<div class="min-w-0">
<p class="truncate font-medium text-slate-900">{{ $queue->name }}</p>
<p class="text-xs text-slate-500">
{{ $queue->is_active ? 'Accepting tickets' : 'Inactive' }}
</p>
</div>
</li>
@endforeach
</ul>
@endif
</section>
<section class="space-y-4">
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Display details</h2>
<dl class="mt-4 space-y-3 text-sm">
<div class="flex justify-between gap-4">
<dt class="text-slate-500">Organization</dt>
<dd class="text-right font-medium text-slate-900">{{ $display->organization?->name ?? '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-slate-500">Branch</dt>
<dd class="text-right font-medium text-slate-900">{{ $display->branch?->name ?? '—' }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-slate-500">Layout</dt>
<dd class="text-right font-medium text-slate-900">{{ $layoutLabel }}</dd>
</div>
<div class="flex justify-between gap-4">
<dt class="text-slate-500">Queues</dt>
<dd class="text-right font-medium text-slate-900">{{ $queues->count() }}</dd>
</div>
</dl>
</div>
<div
class="rounded-2xl border border-slate-200 bg-white p-5"
x-data="{
url: @js($publicUrl),
copied: false,
async copy() {
try {
await navigator.clipboard.writeText(this.url);
this.copied = true;
setTimeout(() => this.copied = false, 2000);
} catch (e) {
window.prompt('Copy this display URL:', this.url);
}
}
}"
>
<h2 class="text-sm font-semibold text-slate-900">Public display URL</h2>
<p class="mt-1 text-sm text-slate-500">Open this link on a TV, tablet, or kiosk in your waiting area.</p>
<div class="mt-4 rounded-xl bg-slate-50 p-3">
<p class="break-all font-mono text-xs text-slate-700" x-text="url"></p>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<button type="button" class="btn-secondary" @click="copy()" x-text="copied ? 'Copied!' : 'Copy URL'"></button>
<a href="{{ $publicUrl }}" target="_blank" rel="noopener" class="btn-secondary">Preview</a>
</div>
</div>
</section>
</div>
<section class="mt-4 overflow-hidden rounded-2xl bg-slate-900 shadow-sm">
<div class="flex items-center justify-between border-b border-white/10 px-5 py-3">
<p class="text-xs font-semibold uppercase tracking-widest text-slate-400">Live preview</p>
<span class="inline-flex items-center gap-1.5 text-xs text-slate-400">
<span class="qms-display__live-dot h-1.5 w-1.5 rounded-full bg-emerald-400"></span>
Patient view
</span>
</div>
<div class="relative aspect-video w-full bg-slate-950">
<iframe
src="{{ $publicUrl }}"
title="Display preview"
class="absolute inset-0 h-full w-full border-0"
loading="lazy"
></iframe>
</div>
<p class="border-t border-white/10 px-5 py-3 text-xs text-slate-500">
Voice announcements require a tap on the display screen once per browser session.
</p>
</section>
</x-app-layout>
+16 -1
View File
@@ -48,7 +48,7 @@
</div>
</x-settings.card>
@if ($canViewBranches || $canViewTeam || $canViewDevices)
@if ($canViewBranches || $canViewTeam || $canViewDevices || $canViewDisplays)
<x-settings.card title="Branches & team" description="Locations, staff access, and clinical hardware for this facility. Multi-branch management requires Care Pro.">
<ul class="space-y-3">
@if ($canViewBranches)
@@ -101,6 +101,21 @@
</a>
</li>
@endif
@if ($canViewDisplays && ($queueIntegrationEnabled || $canUseQueueIntegration))
<li>
<a href="{{ route('care.displays.index') }}"
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
<span>
Waiting displays
<span class="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Pro</span>
<span class="mt-0.5 block text-xs text-slate-500">
TV boards and browser voice announcements for called tickets
</span>
</span>
<span class="text-slate-400">&rarr;</span>
</a>
</li>
@endif
<li>
<a href="{{ route('care.settings.modules') }}"
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
+2 -1
View File
@@ -130,7 +130,8 @@
|| request()->routeIs('care.branches.*')
|| request()->routeIs('care.members.*')
|| request()->routeIs('care.integrations*')
|| request()->routeIs('care.devices.*');
|| request()->routeIs('care.devices.*')
|| request()->routeIs('care.displays.*');
@endphp
<nav class="flex-1 space-y-0.5 overflow-y-auto px-3 py-4">
+16
View File
@@ -12,6 +12,8 @@ use App\Http\Controllers\Care\ConsultationController;
use App\Http\Controllers\Care\DashboardController;
use App\Http\Controllers\Care\DepartmentController;
use App\Http\Controllers\Care\DeviceController;
use App\Http\Controllers\Care\DisplayPublicController;
use App\Http\Controllers\Care\DisplayScreenController;
use App\Http\Controllers\Care\DrugController;
use App\Http\Controllers\Care\FinancialObligationController;
use App\Http\Controllers\Care\IntegrationsController;
@@ -51,6 +53,11 @@ Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannel
Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSignedOut'])->name('sso.platform-signed-out');
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('care.dashboard') : view('auth.signed-out'))->name('care.signed-out');
Route::get('/display/{token}', [DisplayPublicController::class, 'show'])->name('care.display.public');
Route::get('/display/{token}/data', [DisplayPublicController::class, 'data'])->name('care.display.data');
Route::post('/display/{token}/announcements/{announcement}/played', [DisplayPublicController::class, 'played'])
->name('care.display.announcement.played');
Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread');
@@ -233,6 +240,15 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::post('/settings/devices/{device}/token', [DeviceController::class, 'regenerateToken'])->name('care.devices.token.regenerate');
Route::delete('/settings/devices/{device}/token', [DeviceController::class, 'revokeToken'])->name('care.devices.token.revoke');
Route::middleware('care.paid')->group(function () {
Route::get('/settings/displays', [DisplayScreenController::class, 'index'])->name('care.displays.index');
Route::get('/settings/displays/create', [DisplayScreenController::class, 'create'])->name('care.displays.create');
Route::post('/settings/displays', [DisplayScreenController::class, 'store'])->name('care.displays.store');
Route::get('/settings/displays/{display}', [DisplayScreenController::class, 'show'])->name('care.displays.show');
Route::get('/settings/displays/{display}/edit', [DisplayScreenController::class, 'edit'])->name('care.displays.edit');
Route::put('/settings/displays/{display}', [DisplayScreenController::class, 'update'])->name('care.displays.update');
});
Route::redirect('/branches', '/settings/branches');
Route::redirect('/branches/create', '/settings/branches/create');
+232
View File
@@ -0,0 +1,232 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Branch;
use App\Models\CareDisplayScreen;
use App\Models\CareQueueTicket;
use App\Models\CareServicePoint;
use App\Models\CareServiceQueue;
use App\Models\CareVoiceAnnouncement;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Services\Care\CareQueueEngine;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CareDisplayVoiceTest extends TestCase
{
use RefreshDatabase;
protected User $owner;
protected Organization $organization;
protected Branch $branch;
protected CareServiceQueue $queue;
protected CareServicePoint $point;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(EnsurePlatformSession::class);
$this->owner = User::create([
'public_id' => 'display-voice-owner',
'name' => 'Owner',
'email' => 'display-voice@example.com',
]);
$this->organization = Organization::create([
'owner_ref' => $this->owner->public_id,
'name' => 'Display Voice Clinic',
'slug' => 'display-voice-clinic',
'settings' => [
'onboarded' => true,
'facility_type' => 'clinic',
'plan' => 'pro',
'plan_expires_at' => now()->addMonth()->toIso8601String(),
'queue_integration_enabled' => true,
],
]);
Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->owner->public_id,
'role' => 'hospital_admin',
]);
$this->branch = Branch::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'name' => 'Main',
'is_active' => true,
]);
$this->queue = CareServiceQueue::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'context' => 'consultation',
'name' => 'Reception',
'prefix' => 'A',
'routing_mode' => 'shared_pool',
'is_active' => true,
]);
$this->point = CareServicePoint::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'service_queue_id' => $this->queue->id,
'name' => 'Counter 1',
'is_active' => true,
]);
}
public function test_display_data_includes_pending_announcement_after_call(): void
{
$screen = CareDisplayScreen::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'name' => 'Lobby',
'layout' => 'standard',
'service_queue_ids' => [$this->queue->id],
'is_active' => true,
]);
$engine = app(CareQueueEngine::class);
$engine->issueTicket($this->organization, [
'service_queue_id' => $this->queue->uuid,
]);
$engine->callNext($this->organization, $this->queue->uuid, $this->point->uuid);
$response = $this->getJson(route('care.display.data', $screen->access_token));
$response->assertOk()
->assertJsonPath('announcements.0.message', 'Ticket A001, please proceed to Counter 1.')
->assertJsonPath('waiting_count', 0)
->assertJsonPath('now_serving.0.ticket_number', 'A001')
->assertJsonPath('now_serving.0.queue_name', 'Reception')
->assertJsonPath('now_serving.0.counter', 'Counter 1');
$this->get(route('care.display.public', $screen->access_token))
->assertOk()
->assertSee('Please proceed to')
->assertSee('A001')
->assertSee('Reception')
->assertSee('Counter 1');
}
public function test_display_shows_only_latest_ticket_per_service_point(): void
{
$room12 = CareServicePoint::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'service_queue_id' => $this->queue->id,
'name' => 'Room 12',
'is_active' => true,
]);
$room14 = CareServicePoint::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'service_queue_id' => $this->queue->id,
'name' => 'Room 14',
'is_active' => true,
]);
$screen = CareDisplayScreen::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'name' => 'Busy Lobby',
'layout' => 'standard',
'service_queue_ids' => [$this->queue->id],
'is_active' => true,
]);
foreach ([
['A001', $room12->id, 6],
['A002', $room12->id, 5],
['A003', $room12->id, 2],
['A004', $room14->id, 4],
['A005', $room14->id, 1],
] as [$number, $pointId, $minutesAgo]) {
CareQueueTicket::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'service_queue_id' => $this->queue->id,
'service_point_id' => $pointId,
'ticket_number' => $number,
'status' => CareQueueTicket::STATUS_CALLED,
'called_at' => now()->subMinutes($minutesAgo),
]);
}
$payload = $this->getJson(route('care.display.data', $screen->access_token))
->assertOk()
->assertJsonCount(2, 'now_serving')
->json('now_serving');
$byCounter = collect($payload)->keyBy('counter');
$this->assertSame('A003', $byCounter['Room 12']['ticket_number']);
$this->assertSame('A005', $byCounter['Room 14']['ticket_number']);
$this->assertSame(['Room 12', 'Room 14'], collect($payload)->pluck('counter')->all());
}
public function test_display_falls_back_to_branch_queues_when_none_assigned(): void
{
$screen = CareDisplayScreen::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'name' => 'Lobby',
'layout' => 'standard',
'service_queue_ids' => [],
'is_active' => true,
]);
app(CareQueueEngine::class)->issueTicket($this->organization, [
'service_queue_id' => $this->queue->uuid,
]);
$this->getJson(route('care.display.data', $screen->access_token))
->assertOk()
->assertJsonPath('waiting_count', 1)
->assertJsonPath('queues.0.name', 'Reception');
}
public function test_display_marks_announcement_played(): void
{
$screen = CareDisplayScreen::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'name' => 'Lobby',
'layout' => 'standard',
'service_queue_ids' => [$this->queue->id],
'is_active' => true,
]);
$announcement = CareVoiceAnnouncement::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'locale' => 'en',
'message' => 'Ticket A001, please proceed to Counter 1.',
'status' => 'pending',
]);
$this->postJson(route('care.display.announcement.played', [
'token' => $screen->access_token,
'announcement' => $announcement->id,
]))->assertOk();
$this->assertSame('played', $announcement->fresh()->status);
}
}