diff --git a/.env.example b/.env.example index dd507a1..10f7a34 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,10 @@ SESSION_LIFETIME=1440 CACHE_STORE=database QUEUE_CONNECTION=database +# Ladill Queue service API (in-app service queue console) +QUEUE_API_URL=https://queue.ladill.com/api/v1 +QUEUE_API_KEY_FRONTDESK= + # --- Ladill SSO (Sign in with Ladill — auth.ladill.com OIDC client) --- LADILL_SSO_CLIENT_ID= LADILL_SSO_CLIENT_SECRET= diff --git a/app/Http/Controllers/Frontdesk/ServiceQueueController.php b/app/Http/Controllers/Frontdesk/ServiceQueueController.php new file mode 100644 index 0000000..dc8b8bd --- /dev/null +++ b/app/Http/Controllers/Frontdesk/ServiceQueueController.php @@ -0,0 +1,87 @@ +authorizeAbility($request, 'service_queues.console'); + $organization = $this->organization($request); + + if (! $this->integrationEnabled($organization)) { + return redirect()->route('frontdesk.settings')->with('error', 'Enable Ladill Queue integration in settings first.'); + } + + $owner = $this->ownerRef($request); + + try { + $counters = $queue->counters($owner); + } catch (RequestException) { + return redirect()->route('frontdesk.settings')->with('error', 'Could not reach Ladill Queue. Enable Frontdesk integration in Queue settings and verify API keys.'); + } + + return view('frontdesk.service-queues.index', compact('counters', 'organization')); + } + + public function console(Request $request, string $counterUuid, QueueClient $queue): View|RedirectResponse + { + $this->authorizeAbility($request, 'service_queues.console'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + if (! $this->integrationEnabled($organization)) { + return redirect()->route('frontdesk.settings'); + } + + try { + $state = $queue->console($owner, $counterUuid); + } catch (RequestException) { + return redirect()->route('frontdesk.service-queues.index')->with('error', 'Counter console unavailable.'); + } + + return view('frontdesk.service-queues.console', [ + 'state' => $state, + 'counterUuid' => $counterUuid, + 'organization' => $organization, + ]); + } + + public function action(Request $request, string $counterUuid, QueueClient $queue): RedirectResponse + { + $this->authorizeAbility($request, 'service_queues.console'); + $owner = $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'], + ]); + + try { + $queue->consoleAction($owner, $counterUuid, $validated); + } catch (RequestException) { + return back()->with('error', 'Queue action failed. Try again.'); + } + + return back()->with('success', 'Queue updated.'); + } + + protected function integrationEnabled(\App\Models\Organization $organization): bool + { + return (bool) data_get($organization->settings, 'queue_integration_enabled', false); + } +} diff --git a/app/Http/Controllers/Frontdesk/SettingsController.php b/app/Http/Controllers/Frontdesk/SettingsController.php index 34434be..f477b39 100644 --- a/app/Http/Controllers/Frontdesk/SettingsController.php +++ b/app/Http/Controllers/Frontdesk/SettingsController.php @@ -9,6 +9,7 @@ use App\Services\Frontdesk\FrontdeskPermissions; use App\Services\Frontdesk\NotificationPricingService; use App\Services\Frontdesk\NotificationUsageService; use App\Services\Frontdesk\PlanService; +use App\Services\Queue\QueueClient; use App\Support\OrganizationBranding; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -52,10 +53,11 @@ class SettingsController extends Controller ]); } - public function update(Request $request): RedirectResponse + public function update(Request $request, QueueClient $queue): RedirectResponse { $this->authorizeAbility($request, 'settings.manage'); $organization = $this->organization($request); + $owner = $this->ownerRef($request); $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], @@ -71,10 +73,24 @@ class SettingsController extends Controller 'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'], 'remove_logo' => ['nullable', 'boolean'], 'employee_kiosk_enabled' => ['nullable', 'boolean'], + 'queue_integration_enabled' => ['nullable', 'boolean'], ]); $settings = $organization->settings ?? []; $settings['onboarded'] = true; + + $wantsQueue = $request->boolean('queue_integration_enabled'); + $hadQueue = (bool) data_get($settings, 'queue_integration_enabled', false); + $settings['queue_integration_enabled'] = $wantsQueue; + + if ($wantsQueue && ! $hadQueue && $queue->configured()) { + $branch = Branch::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->first(); + try { + $queue->enable($owner, $organization->name, $branch?->name); + } catch (\Throwable) { + return back()->withInput()->with('error', 'Could not provision Ladill Queue for this account. Ensure Queue API keys are configured and Queue allows Frontdesk integration.'); + } + } $settings['badge_expiry_hours'] = $validated['badge_expiry_hours']; $settings['kiosk_reset_seconds'] = $validated['kiosk_reset_seconds']; $settings['visitor_policy'] = $validated['visitor_policy'] ?? null; diff --git a/app/Services/Frontdesk/FrontdeskPermissions.php b/app/Services/Frontdesk/FrontdeskPermissions.php index 0d3612d..38596a5 100644 --- a/app/Services/Frontdesk/FrontdeskPermissions.php +++ b/app/Services/Frontdesk/FrontdeskPermissions.php @@ -17,10 +17,12 @@ class FrontdeskPermissions 'settings.view', 'admin.branches.view', 'admin.desks.view', 'admin.desks.manage', 'watchlist.view', 'watchlist.manage', 'audit.view', 'audit.export', 'devices.view', 'devices.manage', 'reports.view', 'reports.export', + 'service_queues.console', ], 'receptionist' => [ 'dashboard.view', 'visits.view', 'visits.manage', 'visitors.view', 'visitors.manage', 'hosts.view', 'employees.view', 'kiosk.use', 'watchlist.view', 'devices.view', + 'service_queues.console', ], 'security_officer' => [ 'dashboard.view', 'visits.view', 'visitors.view', 'employees.view', diff --git a/app/Services/Queue/QueueClient.php b/app/Services/Queue/QueueClient.php new file mode 100644 index 0000000..25d0546 --- /dev/null +++ b/app/Services/Queue/QueueClient.php @@ -0,0 +1,104 @@ +token()) + ->acceptJson() + ->timeout(12) + ->withQueryParameters(['owner' => $owner]); + } + + public function configured(): bool + { + return $this->token() !== '' && $this->base() !== ''; + } + + /** + * @return array + */ + public function status(string $owner): array + { + $response = $this->http($owner)->get($this->base().'/integrations/status'); + $response->throw(); + + return (array) ($response->json('data') ?? []); + } + + /** + * @return array + */ + public function enable(string $owner, string $organizationName, ?string $branchName = null): array + { + $response = $this->http($owner)->post($this->base().'/integrations/provision', array_filter([ + 'organization_name' => $organizationName, + 'branch_name' => $branchName, + 'industry' => 'visitor', + ])); + $response->throw(); + + return (array) ($response->json('data') ?? []); + } + + public function isLinked(string $owner): bool + { + if (! $this->configured()) { + return false; + } + + try { + $status = $this->status($owner); + + return (bool) ($status['provisioned'] ?? false) + && (bool) data_get($status, 'integrations.frontdesk', false); + } catch (RequestException) { + return false; + } + } + + /** + * @return list> + */ + public function counters(string $owner): array + { + $response = $this->http($owner)->get($this->base().'/counters'); + $response->throw(); + + return (array) ($response->json('data') ?? []); + } + + /** + * @return array + */ + public function console(string $owner, string $counterUuid): array + { + $response = $this->http($owner)->get($this->base().'/counters/'.$counterUuid.'/console'); + $response->throw(); + + return (array) ($response->json('data') ?? []); + } + + /** + * @param array $payload + */ + public function consoleAction(string $owner, string $counterUuid, array $payload): void + { + $this->http($owner)->post($this->base().'/counters/'.$counterUuid.'/console', $payload)->throw(); + } +} diff --git a/config/frontdesk.php b/config/frontdesk.php index 02d21c1..01511c3 100644 --- a/config/frontdesk.php +++ b/config/frontdesk.php @@ -236,6 +236,12 @@ return [ 'lab' => 'Ladill Lab', 'pos' => 'Ladill POS', 'crm' => 'Ladill CRM', + 'queue' => 'Ladill Queue', + ], + + 'queue' => [ + 'api_url' => env('QUEUE_API_URL', 'https://queue.ladill.com/api/v1'), + 'api_key' => env('QUEUE_API_KEY_FRONTDESK'), ], 'printers' => [ diff --git a/resources/views/frontdesk/service-queues/console.blade.php b/resources/views/frontdesk/service-queues/console.blade.php new file mode 100644 index 0000000..c2b35e1 --- /dev/null +++ b/resources/views/frontdesk/service-queues/console.blade.php @@ -0,0 +1,97 @@ +@php + $counter = $state['counter'] ?? []; + $current = $state['current_ticket'] ?? null; + $queues = $state['queues'] ?? []; + $waiting = $state['waiting_by_queue'] ?? []; + $transferQueues = $state['transfer_queues'] ?? []; +@endphp + + +
+
+ ← Service queues +

{{ $counter['name'] ?? 'Counter' }}

+

{{ $counter['branch'] ?? '' }} · Ladill Queue console

+
+
+
@csrf
+
@csrf
+
+
+ + @if ($current) +
+
+

Current customer

+
+
+

{{ $current['ticket_number'] }}

+

{{ $current['queue']['name'] ?? '' }}

+

{{ $current['customer_name'] ?? 'Walk-in customer' }}

+
+ @foreach ([ + 'recall' => 'Recall', + 'start' => 'Start serving', + 'hold' => 'Hold', + 'skip' => 'Skip', + 'complete' => 'Complete', + 'no_show' => 'No show', + ] as $action => $label) +
+ @csrf + + + +
+ @endforeach +
+ @if (count($transferQueues) > 1) +
+ @csrf + + + + + +
+ @endif +
+
+ @else +
+ No active customer — call the next ticket from a queue below. +
+ @endif + +
+ @foreach ($queues as $queue) +
+
+

{{ $queue['name'] }}

+
+ @csrf + + + +
+
+
    + @forelse ($waiting[$queue['uuid']] ?? [] as $ticket) +
  • + {{ $ticket['ticket_number'] }} + {{ $ticket['customer_name'] ?? 'Walk-in' }} +
  • + @empty +
  • Queue is empty
  • + @endforelse +
+
+ @endforeach +
+
diff --git a/resources/views/frontdesk/service-queues/index.blade.php b/resources/views/frontdesk/service-queues/index.blade.php new file mode 100644 index 0000000..af446ca --- /dev/null +++ b/resources/views/frontdesk/service-queues/index.blade.php @@ -0,0 +1,34 @@ + +
+
+

Service queues

+

Powered by Ladill Queue — call tickets and manage counters without leaving Frontdesk

+
+ Open Queue admin +
+ + @if (session('error')) +
{{ session('error') }}
+ @endif + +
+ @forelse ($counters as $counter) +
+
+
+

{{ $counter['name'] }}

+

{{ $counter['status'] ?? 'offline' }}

+
+
+

+ Queues: {{ collect($counter['queues'] ?? [])->join(', ') ?: 'None assigned' }} +

+ Open console +
+ @empty +
+ No counters found in Ladill Queue. Create counters in Queue admin, then return here. +
+ @endforelse +
+
diff --git a/resources/views/frontdesk/settings/edit.blade.php b/resources/views/frontdesk/settings/edit.blade.php index bcd8235..3fb9f85 100644 --- a/resources/views/frontdesk/settings/edit.blade.php +++ b/resources/views/frontdesk/settings/edit.blade.php @@ -114,6 +114,14 @@ @checked(old('employee_kiosk_enabled', $settings['employee_kiosk_enabled'] ?? true))> Enable employee sign-in on kiosk (code + PIN) +
+

Ladill Queue

+

Manage service queues and counters in Frontdesk. You must also enable Frontdesk integration in Ladill Queue settings.

+ +

Notification channels

Alerts go to each host's email and/or phone — no Ladill account required.

diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 14dddda..f250b4a 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -9,6 +9,9 @@ ? app(\App\Services\Frontdesk\OrganizationResolver::class)->memberFor(auth()->user()) : null; $permissions = app(\App\Services\Frontdesk\FrontdeskPermissions::class); + $organization = auth()->user() + ? app(\App\Services\Frontdesk\OrganizationResolver::class)->resolveForUser(auth()->user()) + : null; $linkedHost = auth()->user() ? app(\App\Services\Frontdesk\OrganizationResolver::class)->hostFor(auth()->user()) : null; @@ -74,6 +77,11 @@ 'icon' => '']; } + if ($organization && data_get($organization->settings, 'queue_integration_enabled') && $permissions->can($member, 'service_queues.console')) { + $nav[] = ['name' => 'Service queues', 'route' => route('frontdesk.service-queues.index'), 'active' => request()->routeIs('frontdesk.service-queues.*'), + 'icon' => '']; + } + if ($permissions->can($member, 'watchlist.view')) { $nav[] = ['name' => 'Watchlist', 'route' => route('frontdesk.watchlist.index'), 'active' => request()->routeIs('frontdesk.watchlist.*'), 'icon' => '']; diff --git a/routes/web.php b/routes/web.php index b088f97..89cf85e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -23,6 +23,7 @@ use App\Http\Controllers\Frontdesk\QrScanController; use App\Http\Controllers\Frontdesk\ReceptionDeskController; use App\Http\Controllers\Frontdesk\ReportController; use App\Http\Controllers\Frontdesk\SecurityController; +use App\Http\Controllers\Frontdesk\ServiceQueueController; use App\Http\Controllers\Frontdesk\SettingsController; use App\Http\Controllers\Frontdesk\VisitController; use App\Http\Controllers\Frontdesk\VisitorController; @@ -145,6 +146,10 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::delete('/devices/{device}', [DeviceController::class, 'destroy'])->name('frontdesk.devices.destroy'); Route::post('/devices/{device}/regenerate-token', [DeviceController::class, 'regenerateToken'])->name('frontdesk.devices.regenerate-token'); + Route::get('/service-queues', [ServiceQueueController::class, 'index'])->name('frontdesk.service-queues.index'); + Route::get('/service-queues/console/{counterUuid}', [ServiceQueueController::class, 'console'])->name('frontdesk.service-queues.console'); + Route::post('/service-queues/console/{counterUuid}/action', [ServiceQueueController::class, 'action'])->name('frontdesk.service-queues.action'); + Route::get('/security', [SecurityController::class, 'index'])->name('frontdesk.security.index'); Route::get('/security/evacuation', [SecurityController::class, 'evacuation'])->name('frontdesk.security.evacuation'); Route::get('/security/evacuation/export', [SecurityController::class, 'evacuationExport'])->name('frontdesk.security.evacuation.export'); diff --git a/storage/framework/testing/disks/public/frontdesk/organizations/1/C3rpHMGrjNAFEJ6Vk7lwPcDkl2jQxHS4oA5cslmM.png b/storage/framework/testing/disks/public/frontdesk/organizations/1/C3rpHMGrjNAFEJ6Vk7lwPcDkl2jQxHS4oA5cslmM.png new file mode 100644 index 0000000..78bf8a8 Binary files /dev/null and b/storage/framework/testing/disks/public/frontdesk/organizations/1/C3rpHMGrjNAFEJ6Vk7lwPcDkl2jQxHS4oA5cslmM.png differ