Add Care waiting-area TV displays with browser TTS voice.
Deploy Ladill Care / deploy (push) Failing after 57s
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:
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]));
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {}) => ({
|
||||
|
||||
@@ -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>
|
||||
@@ -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">→</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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user