Faster voice announcements, Care/Frontdesk API, and console UI polish.
Deploy Ladill Queue / deploy (push) Successful in 28s
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:
@@ -23,6 +23,9 @@ SESSION_LIFETIME=1440
|
|||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
QUEUE_CONNECTION=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 (Sign in with Ladill — auth.ladill.com OIDC client) ---
|
||||||
LADILL_SSO_CLIENT_ID=
|
LADILL_SSO_CLIENT_ID=
|
||||||
LADILL_SSO_CLIENT_SECRET=
|
LADILL_SSO_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -11,13 +11,32 @@ use Illuminate\Http\Request;
|
|||||||
|
|
||||||
trait ScopesApiToAccount
|
trait ScopesApiToAccount
|
||||||
{
|
{
|
||||||
|
protected function isServiceRequest(Request $request): bool
|
||||||
|
{
|
||||||
|
return $request->attributes->get('service_caller') !== null;
|
||||||
|
}
|
||||||
|
|
||||||
protected function ownerRef(Request $request): string
|
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;
|
return (string) $request->user()->public_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function organization(Request $request): Organization
|
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());
|
$organization = app(OrganizationResolver::class)->resolveForUser($request->user());
|
||||||
abort_unless($organization, 404);
|
abort_unless($organization, 404);
|
||||||
|
|
||||||
@@ -26,11 +45,19 @@ trait ScopesApiToAccount
|
|||||||
|
|
||||||
protected function member(Request $request): ?Member
|
protected function member(Request $request): ?Member
|
||||||
{
|
{
|
||||||
|
if ($this->isServiceRequest($request)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
|
return app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function authorizeAbility(Request $request, string $ability): void
|
protected function authorizeAbility(Request $request, string $ability): void
|
||||||
{
|
{
|
||||||
|
if ($this->isServiceRequest($request)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
abort_unless(
|
abort_unless(
|
||||||
app(QmsPermissions::class)->can($this->member($request), $ability),
|
app(QmsPermissions::class)->can($this->member($request), $ability),
|
||||||
403,
|
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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,9 +36,28 @@ class CounterController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorizeAbility($request, 'counters.view');
|
$this->authorizeAbility($request, 'counters.view');
|
||||||
$this->authorizeOwner($request, $counter);
|
$this->authorizeOwner($request, $counter);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
$counter->load(['branch', 'serviceQueues']);
|
$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
|
public function create(Request $request): View
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Controllers\Qms;
|
namespace App\Http\Controllers\Qms;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\VoiceAnnouncement;
|
||||||
use App\Services\Qms\DisplayService;
|
use App\Services\Qms\DisplayService;
|
||||||
use App\Services\Qms\VoiceAnnouncementService;
|
use App\Services\Qms\VoiceAnnouncementService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -23,6 +24,8 @@ class DisplayPublicController extends Controller
|
|||||||
return view('qms.display.public', [
|
return view('qms.display.public', [
|
||||||
'screen' => $screen,
|
'screen' => $screen,
|
||||||
'dataUrl' => route('qms.display.data', $token),
|
'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);
|
$screen = $this->displays->findByToken($token) ?? abort(404);
|
||||||
$this->displays->touch($screen);
|
$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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json($payload);
|
public function played(string $token, VoiceAnnouncement $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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,22 @@ class SettingsController extends Controller
|
|||||||
'appointment_mode' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
|
'appointment_mode' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
|
||||||
'industry' => ['nullable', 'string'],
|
'industry' => ['nullable', 'string'],
|
||||||
'notifications_enabled' => ['boolean'],
|
'notifications_enabled' => ['boolean'],
|
||||||
|
'care_integration_enabled' => ['nullable', 'boolean'],
|
||||||
|
'frontdesk_integration_enabled' => ['nullable', 'boolean'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$settings = $organization->settings ?? [];
|
$settings = $organization->settings ?? [];
|
||||||
$settings['appointment_mode'] = $validated['appointment_mode'];
|
$settings['appointment_mode'] = $validated['appointment_mode'];
|
||||||
$settings['industry'] = $validated['industry'] ?? $settings['industry'] ?? null;
|
$settings['industry'] = $validated['industry'] ?? $settings['industry'] ?? null;
|
||||||
$settings['notifications_enabled'] = $request->boolean('notifications_enabled', true);
|
$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([
|
$organization->update([
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ class TicketController extends Controller
|
|||||||
$tickets = Ticket::owned($owner)
|
$tickets = Ticket::owned($owner)
|
||||||
->where('organization_id', $organization->id)
|
->where('organization_id', $organization->id)
|
||||||
->with(['serviceQueue', 'counter'])
|
->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))
|
->when($request->query('status'), fn ($q, $s) => $q->where('status', $s))
|
||||||
->latest('issued_at')
|
->latest('issued_at')
|
||||||
->paginate(30);
|
->paginate(30)
|
||||||
|
->withQueryString();
|
||||||
|
|
||||||
$queues = ServiceQueue::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get();
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'qms.setup' => \App\Http\Middleware\EnsureOrganizationSetup::class,
|
'qms.setup' => \App\Http\Middleware\EnsureOrganizationSetup::class,
|
||||||
'qms.ability' => \App\Http\Middleware\EnsureQmsAbility::class,
|
'qms.ability' => \App\Http\Middleware\EnsureQmsAbility::class,
|
||||||
'qms.device' => \App\Http\Middleware\AuthenticateQmsDevice::class,
|
'qms.device' => \App\Http\Middleware\AuthenticateQmsDevice::class,
|
||||||
|
'qms.integration' => \App\Http\Middleware\EnsureQueueIntegration::class,
|
||||||
]);
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
|
|||||||
@@ -213,4 +213,18 @@ return [
|
|||||||
'custom' => [],
|
'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
@@ -54,26 +54,90 @@ export function registerKioskFlow(Alpine) {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
Alpine.data('displayBoard', (dataUrl) => ({
|
Alpine.data('displayBoard', (config = {}) => ({
|
||||||
payload: null,
|
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() {
|
async poll() {
|
||||||
try {
|
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();
|
this.payload = await res.json();
|
||||||
if (this.payload?.announcements?.length && window.speechSynthesis) {
|
await this.handleAnnouncements(this.payload?.announcements ?? []);
|
||||||
const msg = this.payload.announcements[0]?.message;
|
|
||||||
if (msg) {
|
|
||||||
const utter = new SpeechSynthesisUtterance(msg);
|
|
||||||
window.speechSynthesis.speak(utter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Display poll failed', 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() {
|
init() {
|
||||||
this.poll();
|
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>
|
||||||
@@ -1,54 +1,89 @@
|
|||||||
<x-app-layout title="Staff console · {{ $counter->name }}">
|
<x-app-layout title="Staff console · {{ $counter->name }}">
|
||||||
<div class="mb-6 flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">{{ $counter->name }}</h1>
|
<a href="{{ route('qms.counters.show', $counter) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">← {{ $counter->name }}</a>
|
||||||
<p class="text-sm text-slate-600 dark:text-slate-400">{{ $counter->branch?->name }} · Status: {{ config('qms.counter_statuses.'.$counter->status, $counter->status) }}</p>
|
<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>
|
||||||
<div class="flex gap-2">
|
<div class="flex flex-wrap items-center 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>
|
<x-counter-status :status="$counter->status" />
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($currentTicket)
|
@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">
|
<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">
|
||||||
<p class="text-sm font-medium text-indigo-700 dark:text-indigo-300">Current customer</p>
|
<div class="border-b border-indigo-100 px-6 py-4">
|
||||||
<p class="mt-1 text-4xl font-bold text-indigo-900 dark:text-white">{{ $currentTicket->ticket_number }}</p>
|
<p class="text-xs font-semibold uppercase tracking-wider text-indigo-600">Current customer</p>
|
||||||
<p class="mt-1 text-slate-600 dark:text-slate-400">{{ $currentTicket->serviceQueue?->name }} · {{ $currentTicket->customer_name }}</p>
|
</div>
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
<div class="px-6 py-5">
|
||||||
@foreach (['recall' => 'Recall', 'start' => 'Start serving', 'hold' => 'Hold', 'skip' => 'Skip', 'complete' => 'Complete', 'no_show' => 'No show'] as $action => $label)
|
<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) }}">
|
<form method="POST" action="{{ route('qms.console.action', $counter) }}">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="action" value="{{ $action }}">
|
<input type="hidden" name="action" value="{{ $action }}">
|
||||||
<input type="hidden" name="ticket_id" value="{{ $currentTicket->id }}">
|
<input type="hidden" name="ticket_id" value="{{ $currentTicket->id }}">
|
||||||
<button type="submit" class="btn-secondary text-sm">{{ $label }}</button>
|
<button type="submit" class="{{ $btnClass }} w-full text-sm">{{ $label }}</button>
|
||||||
</form>
|
</form>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ($allQueues->count() > 1)
|
@if ($allQueues->count() > 1)
|
||||||
<form method="POST" action="{{ route('qms.console.action', $counter) }}" class="mt-4 flex flex-wrap items-end gap-2">
|
<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
|
@csrf
|
||||||
<input type="hidden" name="action" value="transfer">
|
<input type="hidden" name="action" value="transfer">
|
||||||
<input type="hidden" name="ticket_id" value="{{ $currentTicket->id }}">
|
<input type="hidden" name="ticket_id" value="{{ $currentTicket->id }}">
|
||||||
<select name="to_queue_id" class="rounded-lg border-slate-300 text-sm">
|
<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)
|
@foreach ($allQueues as $q)
|
||||||
@if ($q->id !== $currentTicket->service_queue_id)
|
@if ($q->id !== $currentTicket->service_queue_id)
|
||||||
<option value="{{ $q->id }}">{{ $q->name }}</option>
|
<option value="{{ $q->id }}">{{ $q->name }}</option>
|
||||||
@endif
|
@endif
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
<input type="text" name="reason" placeholder="Reason" class="rounded-lg border-slate-300 text-sm">
|
</div>
|
||||||
<button type="submit" class="btn-secondary text-sm">Transfer</button>
|
<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>
|
</form>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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
|
@endif
|
||||||
|
|
||||||
<div class="grid gap-6 lg:grid-cols-2">
|
<div class="mt-6 grid gap-4 lg:grid-cols-2">
|
||||||
@foreach ($queues as $queue)
|
@foreach ($queues as $queue)
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
<section class="rounded-2xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||||
<h2 class="font-semibold text-slate-900 dark:text-white">{{ $queue->name }}</h2>
|
<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) }}">
|
<form method="POST" action="{{ route('qms.console.action', $counter) }}">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="action" value="call_next">
|
<input type="hidden" name="action" value="call_next">
|
||||||
@@ -56,17 +91,17 @@
|
|||||||
<button type="submit" class="btn-primary text-sm">Call next</button>
|
<button type="submit" class="btn-primary text-sm">Call next</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-2">
|
<ul class="divide-y divide-slate-50 px-2 py-2">
|
||||||
@forelse ($waitingByQueue[$queue->id] ?? [] as $ticket)
|
@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">
|
<li class="flex items-center justify-between rounded-lg px-3 py-2.5 text-sm hover:bg-slate-50">
|
||||||
<span class="font-mono font-semibold">{{ $ticket->ticket_number }}</span>
|
<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>
|
<span class="text-slate-500">{{ $ticket->customer_name ?? 'Walk-in' }}</span>
|
||||||
</li>
|
</li>
|
||||||
@empty
|
@empty
|
||||||
<li class="text-sm text-slate-500">No one waiting</li>
|
<li class="px-3 py-6 text-center text-sm text-slate-500">Queue is empty</li>
|
||||||
@endforelse
|
@endforelse
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</section>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -1,20 +1,42 @@
|
|||||||
<x-app-layout title="Counters">
|
<x-app-layout title="Counters">
|
||||||
<div class="mb-6 flex justify-between">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<h1 class="text-2xl font-semibold">Counters</h1>
|
<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>
|
<a href="{{ route('qms.counters.create') }}" class="btn-primary">New counter</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
|
||||||
@foreach ($counters as $counter)
|
<div class="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div class="rounded-2xl border bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
@forelse ($counters as $counter)
|
||||||
<div class="flex justify-between">
|
<article class="flex flex-col rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
<h2 class="font-semibold">{{ $counter->name }}</h2>
|
<div class="flex items-start justify-between gap-3">
|
||||||
<span class="text-sm capitalize">{{ $counter->status }}</span>
|
<div>
|
||||||
</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>
|
<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>
|
</div>
|
||||||
@endforeach
|
<x-counter-status :status="$counter->status" />
|
||||||
</div>
|
</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>
|
<div class="mt-4">{{ $counters->links() }}</div>
|
||||||
|
@endif
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -1,14 +1,55 @@
|
|||||||
<x-app-layout :title="$counter->name">
|
<x-app-layout :title="$counter->name">
|
||||||
<div class="mb-6 flex justify-between">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<a href="{{ route('qms.counters.index') }}" class="text-sm text-indigo-600">← Counters</a>
|
<a href="{{ route('qms.counters.index') }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">← Counters</a>
|
||||||
<h1 class="mt-2 text-2xl font-semibold">{{ $counter->name }}</h1>
|
<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>
|
</div>
|
||||||
<div class="flex gap-2">
|
<p class="mt-1 text-sm text-slate-500">{{ $counter->branch?->name }} · {{ $counter->code ?: 'No desk code' }}</p>
|
||||||
<a href="{{ route('qms.counters.edit', $counter) }}" class="btn-secondary">Edit</a>
|
</div>
|
||||||
|
<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>
|
<a href="{{ route('qms.console.show', $counter) }}" class="btn-primary">Open console</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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="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>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<title>{{ $screen->name }} · Queue Display</title>
|
<title>{{ $screen->name }} · Queue Display</title>
|
||||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
</head>
|
</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="mx-auto max-w-6xl">
|
||||||
<div class="mb-8 flex items-center justify-between">
|
<div class="mb-8 flex items-center justify-between">
|
||||||
<h1 class="text-4xl font-bold" x-text="payload?.screen?.name ?? '{{ $screen->name }}'"></h1>
|
<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)>
|
<input type="checkbox" name="notifications_enabled" value="1" @checked($organization->settings['notifications_enabled'] ?? true) @disabled(! $canManage)>
|
||||||
Enable SMS/email queue notifications
|
Enable SMS/email queue notifications
|
||||||
</label>
|
</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)
|
@if ($canManage)
|
||||||
<button type="submit" class="btn-primary w-full">Save settings</button>
|
<button type="submit" class="btn-primary w-full">Save settings</button>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -1,29 +1,70 @@
|
|||||||
<x-app-layout title="Tickets">
|
<x-app-layout title="Tickets">
|
||||||
<div class="mb-6 flex items-center justify-between">
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
<h1 class="text-2xl font-semibold text-slate-900 dark:text-white">Tickets</h1>
|
<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>
|
||||||
@if(app(\App\Services\Qms\QmsPermissions::class)->can(app(\App\Services\Qms\OrganizationResolver::class)->memberFor(auth()->user()), 'tickets.issue'))
|
@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>
|
<a href="{{ route('qms.tickets.create') }}" class="btn-primary">Issue ticket</a>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</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">
|
<form method="GET" class="mt-5 flex flex-wrap items-end gap-3">
|
||||||
<thead class="bg-slate-50 dark:bg-slate-800"><tr>
|
<div>
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Ticket</th>
|
<label class="block text-xs font-medium uppercase tracking-wide text-slate-500">Queue</label>
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Queue</th>
|
<select name="queue" class="mt-1 rounded-lg border-slate-300 text-sm">
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Status</th>
|
<option value="">All queues</option>
|
||||||
<th class="px-4 py-3 text-left text-xs font-semibold uppercase text-slate-500">Issued</th>
|
@foreach ($queues as $queue)
|
||||||
</tr></thead>
|
<option value="{{ $queue->uuid }}" @selected(request('queue') === $queue->uuid)>{{ $queue->name }}</option>
|
||||||
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
@endforeach
|
||||||
@foreach ($tickets as $ticket)
|
</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>
|
<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>
|
<th class="px-4 py-3">Ticket</th>
|
||||||
<td class="px-4 py-3 text-sm">{{ $ticket->serviceQueue?->name }}</td>
|
<th class="px-4 py-3">Customer</th>
|
||||||
<td class="px-4 py-3 text-sm capitalize">{{ str_replace('_', ' ', $ticket->status) }}</td>
|
<th class="px-4 py-3">Queue</th>
|
||||||
<td class="px-4 py-3 text-sm text-slate-500">{{ $ticket->issued_at?->format('M j, H:i') }}</td>
|
<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>
|
</tr>
|
||||||
@endforeach
|
@endforeach
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="border-t px-4 py-3">{{ $tickets->links() }}</div>
|
<div class="border-t border-slate-200 px-4 py-3">{{ $tickets->links() }}</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</x-app-layout>
|
</x-app-layout>
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
use App\Http\Controllers\Api\AnalyticsController;
|
use App\Http\Controllers\Api\AnalyticsController;
|
||||||
use App\Http\Controllers\Api\AppointmentController;
|
use App\Http\Controllers\Api\AppointmentController;
|
||||||
use App\Http\Controllers\Api\BranchController;
|
use App\Http\Controllers\Api\BranchController;
|
||||||
|
use App\Http\Controllers\Api\ConsoleApiController;
|
||||||
use App\Http\Controllers\Api\CounterController;
|
use App\Http\Controllers\Api\CounterController;
|
||||||
use App\Http\Controllers\Api\DeviceHeartbeatController;
|
use App\Http\Controllers\Api\DeviceHeartbeatController;
|
||||||
|
use App\Http\Controllers\Api\IntegrationController;
|
||||||
use App\Http\Controllers\Api\PublicQueueController;
|
use App\Http\Controllers\Api\PublicQueueController;
|
||||||
use App\Http\Controllers\Api\QueueRuleController;
|
use App\Http\Controllers\Api\QueueRuleController;
|
||||||
use App\Http\Controllers\Api\ReportController;
|
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::get('/branches', [BranchController::class, 'index'])->name('api.branches.index');
|
||||||
Route::post('/branches', [BranchController::class, 'store'])->name('api.branches.store');
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('qms.dash
|
|||||||
// Public: digital display
|
// Public: digital display
|
||||||
Route::get('/display/{token}', [DisplayPublicController::class, 'show'])->name('qms.display.public');
|
Route::get('/display/{token}', [DisplayPublicController::class, 'show'])->name('qms.display.public');
|
||||||
Route::get('/display/{token}/data', [DisplayPublicController::class, 'data'])->name('qms.display.data');
|
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
|
// Public: mobile queue
|
||||||
Route::get('/join', [MobileQueueController::class, 'joinForm'])->name('qms.mobile.join');
|
Route::get('/join', [MobileQueueController::class, 'joinForm'])->name('qms.mobile.join');
|
||||||
|
|||||||
Reference in New Issue
Block a user