Faster voice announcements, Care/Frontdesk API, and console UI polish.
Deploy Ladill Queue / deploy (push) Successful in 28s

Poll displays every 1.2s with client-side TTS ack; expose service API for sibling apps; redesign tickets, counters, and staff console.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 21:51:13 +00:00
co-authored by Cursor
parent 051372672b
commit bc6bf0a07c
22 changed files with 813 additions and 140 deletions
+3
View File
@@ -23,6 +23,9 @@ SESSION_LIFETIME=1440
CACHE_STORE=database
QUEUE_CONNECTION=database
# Display board poll interval in milliseconds (voice announcements)
QUEUE_DISPLAY_POLL_MS=1200
# --- Ladill SSO (Sign in with Ladill — auth.ladill.com OIDC client) ---
LADILL_SSO_CLIENT_ID=
LADILL_SSO_CLIENT_SECRET=
@@ -11,13 +11,32 @@ use Illuminate\Http\Request;
trait ScopesApiToAccount
{
protected function isServiceRequest(Request $request): bool
{
return $request->attributes->get('service_caller') !== null;
}
protected function ownerRef(Request $request): string
{
if ($this->isServiceRequest($request)) {
$owner = trim((string) ($request->input('owner') ?? $request->query('owner', '')));
abort_if($owner === '', 422, 'The owner parameter is required.');
return $owner;
}
return (string) $request->user()->public_id;
}
protected function organization(Request $request): Organization
{
if ($this->isServiceRequest($request)) {
$organization = Organization::owned($this->ownerRef($request))->first();
abort_unless($organization, 404, 'Queue organization not found.');
return $organization;
}
$organization = app(OrganizationResolver::class)->resolveForUser($request->user());
abort_unless($organization, 404);
@@ -26,11 +45,19 @@ trait ScopesApiToAccount
protected function member(Request $request): ?Member
{
if ($this->isServiceRequest($request)) {
return null;
}
return app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
}
protected function authorizeAbility(Request $request, string $ability): void
{
if ($this->isServiceRequest($request)) {
return;
}
abort_unless(
app(QmsPermissions::class)->can($this->member($request), $ability),
403,
@@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\Counter;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Services\Qms\QueueEngine;
use App\Services\Qms\TicketPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ConsoleApiController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected QueueEngine $engine,
) {}
public function show(Request $request, Counter $counter): JsonResponse
{
$this->authorizeAbility($request, 'console.use');
$this->authorizeOwner($request, $counter);
$owner = $this->ownerRef($request);
$counter->load(['serviceQueues', 'branch']);
$currentTicket = Ticket::owned($owner)
->where('counter_id', $counter->id)
->whereIn('status', ['called', 'serving', 'on_hold'])
->with('serviceQueue')
->latest('called_at')
->first();
$waitingByQueue = collect($counter->serviceQueues)->mapWithKeys(function (ServiceQueue $queue) {
return [$queue->uuid => array_map(
fn ($t) => TicketPresenter::toArray($t),
$this->engine->waitingTickets($queue, 10),
)];
});
$allQueues = ServiceQueue::owned($owner)
->where('organization_id', $counter->organization_id)
->where('branch_id', $counter->branch_id)
->where('is_active', true)
->orderBy('name')
->get(['uuid', 'name', 'prefix']);
return response()->json([
'data' => [
'counter' => [
'uuid' => $counter->uuid,
'name' => $counter->name,
'status' => $counter->status,
'branch' => $counter->branch?->name,
],
'current_ticket' => $currentTicket ? TicketPresenter::toArray($currentTicket) : null,
'queues' => $counter->serviceQueues->map(fn (ServiceQueue $q) => [
'uuid' => $q->uuid,
'name' => $q->name,
'prefix' => $q->prefix,
]),
'waiting_by_queue' => $waitingByQueue,
'transfer_queues' => $allQueues,
],
]);
}
public function action(Request $request, Counter $counter): JsonResponse
{
$this->authorizeAbility($request, 'console.use');
$this->authorizeOwner($request, $counter);
$actor = $this->ownerRef($request);
$validated = $request->validate([
'action' => ['required', 'string'],
'queue_uuid' => ['nullable', 'uuid'],
'ticket_uuid' => ['nullable', 'uuid'],
'to_queue_uuid' => ['nullable', 'uuid'],
'reason' => ['nullable', 'string', 'max:500'],
]);
$result = match ($validated['action']) {
'call_next' => $this->handleCallNext($counter, $validated['queue_uuid'] ?? '', $actor),
'available' => tap($counter)->update(['status' => 'available']),
'offline' => tap($counter)->update(['status' => 'offline']),
default => $this->handleTicketAction($validated, $actor),
};
if ($result instanceof Ticket) {
return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter']))]);
}
return $this->show($request, $counter->fresh());
}
protected function handleCallNext(Counter $counter, string $queueUuid, string $actor): ?Ticket
{
abort_if($queueUuid === '', 422, 'queue_uuid is required.');
$queue = ServiceQueue::where('uuid', $queueUuid)->firstOrFail();
return $this->engine->callNext($queue, $counter, $actor);
}
protected function handleTicketAction(array $validated, string $actor): Ticket|Counter|null
{
$ticketUuid = $validated['ticket_uuid'] ?? '';
abort_if($ticketUuid === '', 422, 'ticket_uuid is required for this action.');
$ticket = Ticket::where('uuid', $ticketUuid)->firstOrFail();
return match ($validated['action']) {
'recall' => $this->engine->recall($ticket, $actor),
'start' => $this->engine->startServing($ticket, $actor),
'hold' => $this->engine->hold($ticket, $actor),
'skip' => $this->engine->skip($ticket, $actor),
'complete' => $this->engine->completeTicket($ticket, $actor),
'no_show' => $this->engine->markNoShow($ticket, $actor),
'transfer' => $this->engine->transfer(
$ticket,
ServiceQueue::where('uuid', $validated['to_queue_uuid'] ?? '')->firstOrFail(),
null,
$validated['reason'] ?? null,
$actor,
),
default => abort(422, 'Unknown action.'),
};
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Organization;
use App\Services\Qms\AuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class IntegrationController extends Controller
{
use ScopesApiToAccount;
public function status(Request $request): JsonResponse
{
$owner = $this->ownerRef($request);
$organization = Organization::owned($owner)->first();
if (! $organization) {
return response()->json([
'data' => [
'provisioned' => false,
'onboarded' => false,
'integrations' => ['care' => false, 'frontdesk' => false],
],
]);
}
return response()->json([
'data' => [
'provisioned' => true,
'onboarded' => (bool) data_get($organization->settings, 'onboarded', false),
'organization_name' => $organization->name,
'integrations' => [
'care' => (bool) data_get($organization->settings, 'integrations.care_enabled', false),
'frontdesk' => (bool) data_get($organization->settings, 'integrations.frontdesk_enabled', false),
],
],
]);
}
public function provision(Request $request): JsonResponse
{
$owner = $this->ownerRef($request);
$caller = (string) $request->attributes->get('service_caller', '');
$validated = $request->validate([
'organization_name' => ['nullable', 'string', 'max:255'],
'branch_name' => ['nullable', 'string', 'max:255'],
'timezone' => ['nullable', 'timezone'],
'industry' => ['nullable', 'string'],
]);
$organization = Organization::owned($owner)->first();
$created = false;
if (! $organization) {
$created = true;
$name = $validated['organization_name'] ?? 'Service queues';
$organization = Organization::create([
'owner_ref' => $owner,
'name' => $name,
'slug' => Str::slug($name).'-'.substr($owner, 0, 6),
'timezone' => $validated['timezone'] ?? config('app.timezone', 'UTC'),
'settings' => [
'onboarded' => true,
'industry' => $validated['industry'] ?? ($caller === 'care' ? 'healthcare' : 'visitor'),
'appointment_mode' => 'hybrid',
'integrations' => [
'care_enabled' => false,
'frontdesk_enabled' => false,
],
],
]);
$branch = Branch::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
'name' => $validated['branch_name'] ?? 'Main branch',
'is_active' => true,
]);
Department::create([
'owner_ref' => $owner,
'branch_id' => $branch->id,
'name' => 'Reception',
'type' => 'reception',
'is_active' => true,
]);
AuditLogger::record($owner, 'organization.created', $organization->id, $owner, Organization::class, $organization->id);
}
return response()->json([
'data' => [
'provisioned' => true,
'organization_name' => $organization->fresh()->name,
'integrations' => [
'care' => (bool) data_get($organization->fresh()->settings, 'integrations.care_enabled', false),
'frontdesk' => (bool) data_get($organization->fresh()->settings, 'integrations.frontdesk_enabled', false),
],
],
], $created ? 201 : 200);
}
public function update(Request $request): JsonResponse
{
$validated = $request->validate([
'care_enabled' => ['nullable', 'boolean'],
'frontdesk_enabled' => ['nullable', 'boolean'],
]);
$organization = $this->organization($request);
$caller = (string) $request->attributes->get('service_caller', '');
$settings = $organization->settings ?? [];
$integrations = $settings['integrations'] ?? [];
if (array_key_exists('care_enabled', $validated) && in_array($caller, ['care', 'crm'], true)) {
$integrations['care_enabled'] = (bool) $validated['care_enabled'];
}
if (array_key_exists('frontdesk_enabled', $validated) && in_array($caller, ['frontdesk', 'crm'], true)) {
$integrations['frontdesk_enabled'] = (bool) $validated['frontdesk_enabled'];
}
$settings['integrations'] = $integrations;
$organization->update(['settings' => $settings]);
return response()->json([
'data' => [
'integrations' => [
'care' => (bool) ($integrations['care_enabled'] ?? false),
'frontdesk' => (bool) ($integrations['frontdesk_enabled'] ?? false),
],
],
]);
}
protected function syncIntegrationFlag(Organization $organization, string $caller, bool $enabled): void
{
if (! in_array($caller, ['care', 'frontdesk'], true)) {
return;
}
$settings = $organization->settings ?? [];
$integrations = $settings['integrations'] ?? [];
$key = $caller.'_enabled';
$integrations[$key] = $enabled;
$settings['integrations'] = $integrations;
$organization->update(['settings' => $settings]);
}
}
+20 -1
View File
@@ -36,9 +36,28 @@ class CounterController extends Controller
{
$this->authorizeAbility($request, 'counters.view');
$this->authorizeOwner($request, $counter);
$owner = $this->ownerRef($request);
$counter->load(['branch', 'serviceQueues']);
return view('qms.counters.show', compact('counter'));
$currentTicket = \App\Models\Ticket::owned($owner)
->where('counter_id', $counter->id)
->whereIn('status', ['called', 'serving', 'on_hold'])
->with('serviceQueue')
->latest('called_at')
->first();
$waitingCounts = $counter->serviceQueues->mapWithKeys(function (ServiceQueue $queue) use ($owner) {
$count = \App\Models\Ticket::owned($owner)
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->count();
return [$queue->id => $count];
});
$canManage = app(\App\Services\Qms\QmsPermissions::class)->can($this->member($request), 'counters.manage');
return view('qms.counters.show', compact('counter', 'currentTicket', 'waitingCounts', 'canManage'));
}
public function create(Request $request): View
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Models\VoiceAnnouncement;
use App\Services\Qms\DisplayService;
use App\Services\Qms\VoiceAnnouncementService;
use Illuminate\Http\JsonResponse;
@@ -23,6 +24,8 @@ class DisplayPublicController extends Controller
return view('qms.display.public', [
'screen' => $screen,
'dataUrl' => route('qms.display.data', $token),
'playedUrl' => route('qms.display.announcement.played', ['token' => $token, 'announcement' => '__ID__']),
'pollMs' => config('qms.display_poll_ms', 1200),
]);
}
@@ -31,17 +34,16 @@ class DisplayPublicController extends Controller
$screen = $this->displays->findByToken($token) ?? abort(404);
$this->displays->touch($screen);
$payload = $this->displays->payload($screen);
return response()->json($this->displays->payload($screen));
}
foreach ($payload['announcements'] as $announcement) {
if (isset($announcement['id'])) {
$model = \App\Models\VoiceAnnouncement::find($announcement['id']);
if ($model) {
$this->voice->markPlayed($model);
}
}
}
public function played(string $token, VoiceAnnouncement $announcement): JsonResponse
{
$screen = $this->displays->findByToken($token) ?? abort(404);
abort_unless($announcement->branch_id === $screen->branch_id, 404);
return response()->json($payload);
$this->voice->markPlayed($announcement);
return response()->json(['ok' => true]);
}
}
@@ -46,12 +46,22 @@ class SettingsController extends Controller
'appointment_mode' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
'industry' => ['nullable', 'string'],
'notifications_enabled' => ['boolean'],
'care_integration_enabled' => ['nullable', 'boolean'],
'frontdesk_integration_enabled' => ['nullable', 'boolean'],
]);
$settings = $organization->settings ?? [];
$settings['appointment_mode'] = $validated['appointment_mode'];
$settings['industry'] = $validated['industry'] ?? $settings['industry'] ?? null;
$settings['notifications_enabled'] = $request->boolean('notifications_enabled', true);
$integrations = $settings['integrations'] ?? [];
if ($request->has('care_integration_enabled')) {
$integrations['care_enabled'] = $request->boolean('care_integration_enabled');
}
if ($request->has('frontdesk_integration_enabled')) {
$integrations['frontdesk_enabled'] = $request->boolean('frontdesk_integration_enabled');
}
$settings['integrations'] = $integrations;
$organization->update([
'name' => $validated['name'],
@@ -29,9 +29,11 @@ class TicketController extends Controller
$tickets = Ticket::owned($owner)
->where('organization_id', $organization->id)
->with(['serviceQueue', 'counter'])
->when($request->query('queue'), fn ($q, $uuid) => $q->whereHas('serviceQueue', fn ($sq) => $sq->where('uuid', $uuid)))
->when($request->query('status'), fn ($q, $s) => $q->where('status', $s))
->latest('issued_at')
->paginate(30);
->paginate(30)
->withQueryString();
$queues = ServiceQueue::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get();
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Middleware;
use App\Models\Organization;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureQueueIntegration
{
public function handle(Request $request, Closure $next): Response
{
$caller = (string) $request->attributes->get('service_caller', '');
if (! in_array($caller, ['care', 'frontdesk'], true)) {
return $next($request);
}
$owner = trim((string) ($request->input('owner') ?? $request->query('owner', '')));
if ($owner === '') {
return response()->json(['error' => 'The owner parameter is required.'], 422);
}
$organization = Organization::owned($owner)->first();
if (! $organization || ! data_get($organization->settings, 'integrations.'.$caller.'_enabled', false)) {
return response()->json(['error' => 'Queue integration is not enabled for this account.'], 403);
}
return $next($request);
}
}
+1
View File
@@ -51,6 +51,7 @@ return Application::configure(basePath: dirname(__DIR__))
'qms.setup' => \App\Http\Middleware\EnsureOrganizationSetup::class,
'qms.ability' => \App\Http\Middleware\EnsureQmsAbility::class,
'qms.device' => \App\Http\Middleware\AuthenticateQmsDevice::class,
'qms.integration' => \App\Http\Middleware\EnsureQueueIntegration::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
+14
View File
@@ -213,4 +213,18 @@ return [
'custom' => [],
],
/*
|--------------------------------------------------------------------------
| Inbound service API keys (sibling Ladill apps calling Queue)
|--------------------------------------------------------------------------
*/
'service_api_keys' => array_filter([
'pos' => env('QUEUE_API_KEY_POS'),
'crm' => env('QUEUE_API_KEY_CRM'),
'care' => env('QUEUE_API_KEY_CARE'),
'frontdesk' => env('QUEUE_API_KEY_FRONTDESK'),
]),
'display_poll_ms' => (int) env('QUEUE_DISPLAY_POLL_MS', 1200),
];
+74 -10
View File
@@ -54,26 +54,90 @@ export function registerKioskFlow(Alpine) {
},
}));
Alpine.data('displayBoard', (dataUrl) => ({
Alpine.data('displayBoard', (config = {}) => ({
payload: null,
spokenIds: new Set(),
speaking: false,
dataUrl: typeof config === 'string' ? config : config.dataUrl,
playedUrlTemplate: config.playedUrl ?? null,
pollMs: config.pollMs ?? 1200,
csrf: config.csrf ?? document.querySelector('meta[name="csrf-token"]')?.content ?? '',
async poll() {
try {
const res = await fetch(dataUrl, { headers: { Accept: 'application/json' } });
const res = await fetch(this.dataUrl, { headers: { Accept: 'application/json' } });
this.payload = await res.json();
if (this.payload?.announcements?.length && window.speechSynthesis) {
const msg = this.payload.announcements[0]?.message;
if (msg) {
const utter = new SpeechSynthesisUtterance(msg);
window.speechSynthesis.speak(utter);
}
}
await this.handleAnnouncements(this.payload?.announcements ?? []);
} catch (e) {
console.error('Display poll failed', e);
}
},
async handleAnnouncements(announcements) {
if (! window.speechSynthesis || this.speaking) return;
for (const item of announcements) {
if (! item?.id || ! item?.message || this.spokenIds.has(item.id)) {
continue;
}
this.speaking = true;
try {
await this.speakAnnouncement(item);
this.spokenIds.add(item.id);
await this.ackPlayed(item.id);
} finally {
this.speaking = false;
}
}
},
speakAnnouncement(item) {
const repeats = Math.max(1, Number(item.repeat_count) || 1);
const volume = Math.min(1, Math.max(0, (Number(item.volume) || 80) / 100));
return new Promise((resolve) => {
let remaining = repeats;
const speakOnce = () => {
const utter = new SpeechSynthesisUtterance(item.message);
utter.volume = volume;
utter.lang = item.locale === 'fr' ? 'fr-FR' : item.locale === 'tw' ? 'ak-GH' : 'en-US';
utter.onend = () => {
remaining -= 1;
if (remaining > 0) {
speakOnce();
} else {
resolve();
}
};
utter.onerror = () => resolve();
window.speechSynthesis.speak(utter);
};
speakOnce();
});
},
async ackPlayed(id) {
if (! this.playedUrlTemplate) return;
const url = this.playedUrlTemplate.replace('__ID__', String(id));
try {
await fetch(url, {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-TOKEN': this.csrf,
},
});
} catch (e) {
console.error('Announcement ack failed', e);
}
},
init() {
this.poll();
setInterval(() => this.poll(), 5000);
setInterval(() => this.poll(), this.pollMs);
},
}));
@@ -0,0 +1,16 @@
@props(['status'])
@php
$styles = [
'available' => 'bg-emerald-50 text-emerald-800 ring-emerald-200',
'busy' => 'bg-amber-50 text-amber-800 ring-amber-200',
'offline' => 'bg-slate-100 text-slate-600 ring-slate-200',
'paused' => 'bg-violet-50 text-violet-800 ring-violet-200',
];
$label = config('qms.counter_statuses.'.$status, ucfirst($status));
$class = $styles[$status] ?? 'bg-slate-100 text-slate-700 ring-slate-200';
@endphp
<span {{ $attributes->merge(['class' => "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset {$class}"]) }}>
{{ $label }}
</span>
@@ -0,0 +1,20 @@
@props(['status'])
@php
$styles = [
'waiting' => 'bg-slate-100 text-slate-700 ring-slate-200',
'called' => 'bg-amber-50 text-amber-800 ring-amber-200',
'serving' => 'bg-indigo-50 text-indigo-800 ring-indigo-200',
'on_hold' => 'bg-violet-50 text-violet-800 ring-violet-200',
'completed' => 'bg-emerald-50 text-emerald-800 ring-emerald-200',
'no_show' => 'bg-rose-50 text-rose-800 ring-rose-200',
'cancelled' => 'bg-slate-100 text-slate-500 ring-slate-200',
'transferred' => 'bg-sky-50 text-sky-800 ring-sky-200',
];
$label = config('qms.ticket_statuses.'.$status, ucfirst(str_replace('_', ' ', $status)));
$class = $styles[$status] ?? 'bg-slate-100 text-slate-700 ring-slate-200';
@endphp
<span {{ $attributes->merge(['class' => "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset {$class}"]) }}>
{{ $label }}
</span>
+100 -65
View File
@@ -1,72 +1,107 @@
<x-app-layout title="Staff console · {{ $counter->name }}">
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">{{ $counter->name }}</h1>
<p class="text-sm text-slate-600 dark:text-slate-400">{{ $counter->branch?->name }} · Status: {{ config('qms.counter_statuses.'.$counter->status, $counter->status) }}</p>
</div>
<div class="flex gap-2">
<form method="POST" action="{{ route('qms.console.action', $counter) }}">@csrf<input type="hidden" name="action" value="available"><button class="btn-secondary">Available</button></form>
<form method="POST" action="{{ route('qms.console.action', $counter) }}">@csrf<input type="hidden" name="action" value="offline"><button class="btn-secondary">Go offline</button></form>
</div>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<a href="{{ route('qms.counters.show', $counter) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> {{ $counter->name }}</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">Staff console</h1>
<p class="mt-1 text-sm text-slate-500">{{ $counter->branch?->name }}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<x-counter-status :status="$counter->status" />
<form method="POST" action="{{ route('qms.console.action', $counter) }}">@csrf<input type="hidden" name="action" value="available"><button class="btn-secondary text-sm">Mark available</button></form>
<form method="POST" action="{{ route('qms.console.action', $counter) }}">@csrf<input type="hidden" name="action" value="offline"><button class="btn-secondary text-sm">Go offline</button></form>
</div>
</div>
@if ($currentTicket)
<div class="mb-8 rounded-2xl border-2 border-indigo-200 bg-indigo-50 p-6 dark:border-indigo-800 dark:bg-indigo-950">
<p class="text-sm font-medium text-indigo-700 dark:text-indigo-300">Current customer</p>
<p class="mt-1 text-4xl font-bold text-indigo-900 dark:text-white">{{ $currentTicket->ticket_number }}</p>
<p class="mt-1 text-slate-600 dark:text-slate-400">{{ $currentTicket->serviceQueue?->name }} · {{ $currentTicket->customer_name }}</p>
<div class="mt-4 flex flex-wrap gap-2">
@foreach (['recall' => 'Recall', 'start' => 'Start serving', 'hold' => 'Hold', 'skip' => 'Skip', 'complete' => 'Complete', 'no_show' => 'No show'] as $action => $label)
<form method="POST" action="{{ route('qms.console.action', $counter) }}">
@csrf
<input type="hidden" name="action" value="{{ $action }}">
<input type="hidden" name="ticket_id" value="{{ $currentTicket->id }}">
<button type="submit" class="btn-secondary text-sm">{{ $label }}</button>
</form>
@if ($currentTicket)
<section class="mt-6 overflow-hidden rounded-2xl border border-indigo-200 bg-gradient-to-br from-indigo-50 via-white to-white shadow-sm">
<div class="border-b border-indigo-100 px-6 py-4">
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Current customer</p>
</div>
<div class="px-6 py-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<p class="text-5xl font-bold tracking-tight text-slate-900">{{ $currentTicket->ticket_number }}</p>
<p class="mt-2 text-sm text-slate-600">{{ $currentTicket->serviceQueue?->name }}</p>
<p class="mt-1 text-base font-medium text-slate-800">{{ $currentTicket->customer_name ?: 'Walk-in customer' }}</p>
</div>
<x-ticket-status :status="$currentTicket->status" class="text-sm" />
</div>
<div class="mt-6 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
@foreach ([
'recall' => ['Recall', 'btn-secondary'],
'start' => ['Start serving', 'btn-primary'],
'hold' => ['Hold', 'btn-secondary'],
'skip' => ['Skip', 'btn-secondary'],
'complete' => ['Complete', 'btn-primary'],
'no_show' => ['No show', 'btn-secondary'],
] as $action => [$label, $btnClass])
<form method="POST" action="{{ route('qms.console.action', $counter) }}">
@csrf
<input type="hidden" name="action" value="{{ $action }}">
<input type="hidden" name="ticket_id" value="{{ $currentTicket->id }}">
<button type="submit" class="{{ $btnClass }} w-full text-sm">{{ $label }}</button>
</form>
@endforeach
</div>
@if ($allQueues->count() > 1)
<form method="POST" action="{{ route('qms.console.action', $counter) }}" class="mt-4 flex flex-wrap items-end gap-2 border-t border-indigo-100 pt-4">
@csrf
<input type="hidden" name="action" value="transfer">
<input type="hidden" name="ticket_id" value="{{ $currentTicket->id }}">
<div class="min-w-[12rem] flex-1">
<label class="block text-xs font-medium text-slate-500">Transfer to queue</label>
<select name="to_queue_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($allQueues as $q)
@if ($q->id !== $currentTicket->service_queue_id)
<option value="{{ $q->id }}">{{ $q->name }}</option>
@endif
@endforeach
</select>
</div>
@if ($allQueues->count() > 1)
<form method="POST" action="{{ route('qms.console.action', $counter) }}" class="mt-4 flex flex-wrap items-end gap-2">
@csrf
<input type="hidden" name="action" value="transfer">
<input type="hidden" name="ticket_id" value="{{ $currentTicket->id }}">
<select name="to_queue_id" class="rounded-lg border-slate-300 text-sm">
@foreach ($allQueues as $q)
@if ($q->id !== $currentTicket->service_queue_id)
<option value="{{ $q->id }}">{{ $q->name }}</option>
@endif
@endforeach
</select>
<input type="text" name="reason" placeholder="Reason" class="rounded-lg border-slate-300 text-sm">
<button type="submit" class="btn-secondary text-sm">Transfer</button>
</form>
@endif
</div>
@endif
<div class="min-w-[12rem] flex-1">
<label class="block text-xs font-medium text-slate-500">Reason (optional)</label>
<input type="text" name="reason" placeholder="e.g. Specialist required" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<button type="submit" class="btn-secondary text-sm">Transfer ticket</button>
</form>
@endif
</div>
</section>
@else
<section class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-slate-50 px-6 py-10 text-center">
<p class="text-sm font-medium text-slate-700">No active customer</p>
<p class="mt-1 text-sm text-slate-500">Call the next waiting ticket from a queue below.</p>
</section>
@endif
<div class="grid gap-6 lg:grid-cols-2">
@foreach ($queues as $queue)
<div class="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
<div class="mb-4 flex items-center justify-between">
<h2 class="font-semibold text-slate-900 dark:text-white">{{ $queue->name }}</h2>
<form method="POST" action="{{ route('qms.console.action', $counter) }}">
@csrf
<input type="hidden" name="action" value="call_next">
<input type="hidden" name="queue_id" value="{{ $queue->id }}">
<button type="submit" class="btn-primary text-sm">Call next</button>
</form>
</div>
<ul class="space-y-2">
@forelse ($waitingByQueue[$queue->id] ?? [] as $ticket)
<li class="flex justify-between rounded-lg bg-slate-50 px-3 py-2 text-sm dark:bg-slate-800">
<span class="font-mono font-semibold">{{ $ticket->ticket_number }}</span>
<span class="text-slate-500">{{ $ticket->customer_name ?? 'Walk-in' }}</span>
</li>
@empty
<li class="text-sm text-slate-500">No one waiting</li>
@endforelse
</ul>
</div>
@endforeach
</div>
<div class="mt-6 grid gap-4 lg:grid-cols-2">
@foreach ($queues as $queue)
<section class="rounded-2xl border border-slate-200 bg-white shadow-sm">
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4">
<div>
<h2 class="font-semibold text-slate-900">{{ $queue->name }}</h2>
<p class="text-xs text-slate-500">{{ count($waitingByQueue[$queue->id] ?? []) }} waiting</p>
</div>
<form method="POST" action="{{ route('qms.console.action', $counter) }}">
@csrf
<input type="hidden" name="action" value="call_next">
<input type="hidden" name="queue_id" value="{{ $queue->id }}">
<button type="submit" class="btn-primary text-sm">Call next</button>
</form>
</div>
<ul class="divide-y divide-slate-50 px-2 py-2">
@forelse ($waitingByQueue[$queue->id] ?? [] as $ticket)
<li class="flex items-center justify-between rounded-lg px-3 py-2.5 text-sm hover:bg-slate-50">
<span class="font-mono text-base font-semibold text-slate-900">{{ $ticket->ticket_number }}</span>
<span class="text-slate-500">{{ $ticket->customer_name ?? 'Walk-in' }}</span>
</li>
@empty
<li class="px-3 py-6 text-center text-sm text-slate-500">Queue is empty</li>
@endforelse
</ul>
</section>
@endforeach
</div>
</x-app-layout>
+38 -16
View File
@@ -1,20 +1,42 @@
<x-app-layout title="Counters">
<div class="mb-6 flex justify-between">
<h1 class="text-2xl font-semibold">Counters</h1>
<a href="{{ route('qms.counters.create') }}" class="btn-primary">New counter</a>
</div>
<div class="grid gap-4 md:grid-cols-2">
@foreach ($counters as $counter)
<div class="rounded-2xl border bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
<div class="flex justify-between">
<h2 class="font-semibold">{{ $counter->name }}</h2>
<span class="text-sm capitalize">{{ $counter->status }}</span>
</div>
<p class="mt-1 text-sm text-slate-500">{{ $counter->branch?->name }}</p>
<p class="mt-2 text-sm">Queues: {{ $counter->serviceQueues->pluck('name')->join(', ') ?: '—' }}</p>
<a href="{{ route('qms.console.show', $counter) }}" class="btn-primary mt-4 inline-block text-sm">Open console</a>
</div>
@endforeach
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-slate-900">Counters</h1>
<p class="mt-1 text-sm text-slate-500">Service desks and windows where staff call and serve tickets</p>
</div>
<a href="{{ route('qms.counters.create') }}" class="btn-primary">New counter</a>
</div>
<div class="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
@forelse ($counters as $counter)
<article class="flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex items-start justify-between gap-3">
<div>
<a href="{{ route('qms.counters.show', $counter) }}" class="text-lg font-semibold text-slate-900 hover:text-indigo-700">{{ $counter->name }}</a>
<p class="mt-1 text-sm text-slate-500">{{ $counter->branch?->name }}</p>
</div>
<x-counter-status :status="$counter->status" />
</div>
<p class="mt-4 text-sm text-slate-600">
<span class="font-medium text-slate-700">Queues:</span>
{{ $counter->serviceQueues->pluck('name')->join(', ') ?: 'None assigned' }}
</p>
<div class="mt-5 flex gap-2">
<a href="{{ route('qms.console.show', $counter) }}" class="btn-primary flex-1 text-center text-sm">Open console</a>
<a href="{{ route('qms.counters.show', $counter) }}" class="btn-secondary text-sm">Details</a>
</div>
</article>
@empty
<div class="col-span-full">
<x-empty-state title="No counters yet" description="Create a counter for each service desk or teller window.">
<x-slot:action>
<a href="{{ route('qms.counters.create') }}" class="btn-primary">Create your first counter</a>
</x-slot:action>
</x-empty-state>
</div>
@endforelse
</div>
@if ($counters->hasPages())
<div class="mt-4">{{ $counters->links() }}</div>
@endif
</x-app-layout>
+52 -11
View File
@@ -1,14 +1,55 @@
<x-app-layout :title="$counter->name">
<div class="mb-6 flex justify-between">
<div>
<a href="{{ route('qms.counters.index') }}" class="text-sm text-indigo-600"> Counters</a>
<h1 class="mt-2 text-2xl font-semibold">{{ $counter->name }}</h1>
</div>
<div class="flex gap-2">
<a href="{{ route('qms.counters.edit', $counter) }}" class="btn-secondary">Edit</a>
<a href="{{ route('qms.console.show', $counter) }}" class="btn-primary">Open console</a>
</div>
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<a href="{{ route('qms.counters.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800"> Counters</a>
<div class="mt-2 flex flex-wrap items-center gap-3">
<h1 class="text-xl font-semibold text-slate-900">{{ $counter->name }}</h1>
<x-counter-status :status="$counter->status" />
</div>
<p class="mt-1 text-sm text-slate-500">{{ $counter->branch?->name }} · {{ $counter->code ?: 'No desk code' }}</p>
</div>
<p class="text-sm text-slate-600">Status: {{ $counter->status }} · {{ $counter->branch?->name }}</p>
<p class="mt-2 text-sm">Queues: {{ $counter->serviceQueues->pluck('name')->join(', ') ?: '—' }}</p>
<div class="flex flex-wrap gap-2">
@if ($canManage)
<a href="{{ route('qms.counters.edit', $counter) }}" class="btn-secondary">Edit counter</a>
@endif
<a href="{{ route('qms.console.show', $counter) }}" class="btn-primary">Open console</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>
@if ($counter->serviceQueues->isEmpty())
<p class="mt-3 text-sm text-slate-500">No queues linked yet. Edit this counter to assign service queues.</p>
@else
<ul class="mt-4 space-y-3">
@foreach ($counter->serviceQueues as $queue)
<li class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3">
<div>
<p class="font-medium text-slate-900">{{ $queue->name }}</p>
<p class="text-xs text-slate-500">Prefix {{ $queue->prefix }}</p>
</div>
<span class="rounded-full bg-white px-2.5 py-1 text-xs font-medium text-slate-600 ring-1 ring-slate-200">
{{ $waitingCounts[$queue->id] ?? 0 }} waiting
</span>
</li>
@endforeach
</ul>
@endif
</section>
<section class="rounded-2xl border border-slate-200 bg-white p-5">
<h2 class="text-sm font-semibold text-slate-900">Now at this counter</h2>
@if ($currentTicket)
<div class="mt-4 rounded-xl bg-indigo-50 p-4 ring-1 ring-indigo-100">
<p class="text-3xl font-bold tracking-tight text-indigo-900">{{ $currentTicket->ticket_number }}</p>
<p class="mt-1 text-sm text-indigo-800">{{ $currentTicket->serviceQueue?->name }}</p>
<p class="mt-1 text-sm text-slate-600">{{ $currentTicket->customer_name ?: 'Walk-in' }}</p>
<div class="mt-3"><x-ticket-status :status="$currentTicket->status" /></div>
</div>
@else
<p class="mt-4 text-sm text-slate-500">No customer is being served. Open the console to call the next ticket.</p>
@endif
</section>
</div>
</x-app-layout>
+1 -1
View File
@@ -7,7 +7,7 @@
<title>{{ $screen->name }} · Queue Display</title>
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="min-h-screen p-8" x-data="displayBoard('{{ $dataUrl }}')" x-init="init()">
<body class="min-h-screen p-8" x-data="displayBoard({ dataUrl: '{{ $dataUrl }}', playedUrl: '{{ $playedUrl }}', pollMs: {{ $pollMs }}, csrf: '{{ csrf_token() }}' })" x-init="init()">
<div class="mx-auto max-w-6xl">
<div class="mb-8 flex items-center justify-between">
<h1 class="text-4xl font-bold" x-text="payload?.screen?.name ?? '{{ $screen->name }}'"></h1>
@@ -27,6 +27,21 @@
<input type="checkbox" name="notifications_enabled" value="1" @checked($organization->settings['notifications_enabled'] ?? true) @disabled(! $canManage)>
Enable SMS/email queue notifications
</label>
<div class="rounded-xl border border-slate-200 p-4">
<h2 class="text-sm font-semibold text-slate-900">Sibling app integrations</h2>
<p class="mt-1 text-xs text-slate-500">Allow Ladill Care and Frontdesk to manage queues in-app via the Queue API. Both apps must also enable integration in their settings.</p>
<div class="mt-3 space-y-2">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="care_integration_enabled" value="1" @checked(data_get($organization->settings, 'integrations.care_enabled')) @disabled(! $canManage)>
Ladill Care can manage queues
</label>
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="frontdesk_integration_enabled" value="1" @checked(data_get($organization->settings, 'integrations.frontdesk_enabled')) @disabled(! $canManage)>
Ladill Frontdesk can manage queues
</label>
</div>
</div>
@if ($canManage)
<button type="submit" class="btn-primary w-full">Save settings</button>
@endif
+66 -25
View File
@@ -1,29 +1,70 @@
<x-app-layout title="Tickets">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">Tickets</h1>
@if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'tickets.issue'))
<a href="{{ route('qms.tickets.create') }}" class="btn-primary">Issue ticket</a>
@endif
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-xl font-semibold text-slate-900">Tickets</h1>
<p class="mt-1 text-sm text-slate-500">Live and historical queue tickets across all service lines</p>
</div>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
<table class="min-w-full divide-y divide-slate-200 dark:divide-slate-700">
<thead class="bg-slate-50 dark:bg-slate-800"><tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Ticket</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Queue</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Status</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Issued</th>
</tr></thead>
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
@foreach ($tickets as $ticket)
<tr>
<td class="px-4 py-3"><a href="{{ route('qms.tickets.show', $ticket) }}" class="font-mono font-semibold text-indigo-600">{{ $ticket->ticket_number }}</a></td>
<td class="px-4 py-3 text-sm">{{ $ticket->serviceQueue?->name }}</td>
<td class="px-4 py-3 text-sm capitalize">{{ str_replace('_', ' ', $ticket->status) }}</td>
<td class="px-4 py-3 text-sm text-slate-500">{{ $ticket->issued_at?->format('M j, H:i') }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="border-t px-4 py-3">{{ $tickets->links() }}</div>
@if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'tickets.issue'))
<a href="{{ route('qms.tickets.create') }}" class="btn-primary">Issue ticket</a>
@endif
</div>
<form method="GET" class="mt-5 flex flex-wrap items-end gap-3">
<div>
<label class="block text-xs font-medium uppercase tracking-wide text-slate-500">Queue</label>
<select name="queue" class="mt-1 rounded-lg border-slate-300 text-sm">
<option value="">All queues</option>
@foreach ($queues as $queue)
<option value="{{ $queue->uuid }}" @selected(request('queue') === $queue->uuid)>{{ $queue->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-xs font-medium uppercase tracking-wide text-slate-500">Status</label>
<select name="status" class="mt-1 rounded-lg border-slate-300 text-sm">
<option value="">All statuses</option>
@foreach (config('qms.ticket_statuses') as $value => $label)
<option value="{{ $value }}" @selected(request('status') === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<button type="submit" class="btn-secondary text-sm">Filter</button>
@if (request()->hasAny(['queue', 'status']))
<a href="{{ route('qms.tickets.index') }}" class="text-sm text-slate-500 hover:text-slate-700">Clear</a>
@endif
</form>
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
@if ($tickets->isEmpty())
<x-empty-state title="No tickets match your filters" description="Issue a walk-in ticket or adjust filters to see results." />
@else
<table class="min-w-full divide-y divide-slate-100 text-sm">
<thead class="bg-slate-50 text-left text-xs uppercase tracking-wide text-slate-500">
<tr>
<th class="px-4 py-3">Ticket</th>
<th class="px-4 py-3">Customer</th>
<th class="px-4 py-3">Queue</th>
<th class="px-4 py-3">Counter</th>
<th class="px-4 py-3">Status</th>
<th class="px-4 py-3">Issued</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
@foreach ($tickets as $ticket)
<tr class="hover:bg-slate-50/80">
<td class="px-4 py-3">
<a href="{{ route('qms.tickets.show', $ticket) }}" class="font-mono text-base font-semibold text-indigo-700 hover:underline">{{ $ticket->ticket_number }}</a>
</td>
<td class="px-4 py-3 text-slate-700">{{ $ticket->customer_name ?: 'Walk-in' }}</td>
<td class="px-4 py-3 text-slate-600">{{ $ticket->serviceQueue?->name }}</td>
<td class="px-4 py-3 text-slate-600">{{ $ticket->counter?->name ?? '—' }}</td>
<td class="px-4 py-3"><x-ticket-status :status="$ticket->status" /></td>
<td class="px-4 py-3 text-slate-500">{{ $ticket->issued_at?->format('M j, H:i') }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="border-t border-slate-200 px-4 py-3">{{ $tickets->links() }}</div>
@endif
</div>
</x-app-layout>
+21
View File
@@ -3,8 +3,10 @@
use App\Http\Controllers\Api\AnalyticsController;
use App\Http\Controllers\Api\AppointmentController;
use App\Http\Controllers\Api\BranchController;
use App\Http\Controllers\Api\ConsoleApiController;
use App\Http\Controllers\Api\CounterController;
use App\Http\Controllers\Api\DeviceHeartbeatController;
use App\Http\Controllers\Api\IntegrationController;
use App\Http\Controllers\Api\PublicQueueController;
use App\Http\Controllers\Api\QueueRuleController;
use App\Http\Controllers\Api\ReportController;
@@ -58,3 +60,22 @@ Route::middleware(['auth:sanctum', 'qms.setup'])->prefix('v1')->group(function (
Route::get('/branches', [BranchController::class, 'index'])->name('api.branches.index');
Route::post('/branches', [BranchController::class, 'store'])->name('api.branches.store');
});
Route::middleware(['auth.service:qms'])->prefix('v1')->group(function () {
Route::get('/integrations/status', [IntegrationController::class, 'status'])->name('api.integrations.status');
Route::post('/integrations/provision', [IntegrationController::class, 'provision'])->name('api.integrations.provision');
Route::patch('/integrations', [IntegrationController::class, 'update'])->name('api.integrations.update');
});
Route::middleware(['auth.service:qms', 'qms.integration'])->prefix('v1')->group(function () {
Route::get('/queues', [ServiceQueueController::class, 'index'])->name('api.service.queues.index');
Route::get('/queues/{serviceQueue}/waiting', [ServiceQueueController::class, 'waiting'])->name('api.service.queues.waiting');
Route::post('/queues/{serviceQueue}/call-next', [TicketController::class, 'callNext'])->name('api.service.queues.call-next');
Route::get('/tickets', [TicketController::class, 'index'])->name('api.service.tickets.index');
Route::post('/tickets/{ticket}/action', [TicketController::class, 'action'])->name('api.service.tickets.action');
Route::get('/counters', [CounterController::class, 'index'])->name('api.service.counters.index');
Route::get('/counters/{counter}/console', [ConsoleApiController::class, 'show'])->name('api.service.counters.console');
Route::post('/counters/{counter}/console', [ConsoleApiController::class, 'action'])->name('api.service.counters.console.action');
});
+1
View File
@@ -46,6 +46,7 @@ Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('qms.dash
// Public: digital display
Route::get('/display/{token}', [DisplayPublicController::class, 'show'])->name('qms.display.public');
Route::get('/display/{token}/data', [DisplayPublicController::class, 'data'])->name('qms.display.data');
Route::post('/display/{token}/announcements/{announcement}/played', [DisplayPublicController::class, 'played'])->name('qms.display.announcement.played');
// Public: mobile queue
Route::get('/join', [MobileQueueController::class, 'joinForm'])->name('qms.mobile.join');