From a3839da8691ebdbac2ed8a231d4f79674e3fd5b2 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 20 Jul 2026 10:59:02 +0000 Subject: [PATCH] Add queue kiosk and display devices for Care Queue management. Register walk-up kiosks and waiting-room display devices under Devices, with public kiosk ticket issue and linked TV boards surfaced in sidebar and Queue shortcuts. Co-authored-by: Cursor --- .../Controllers/Care/DeviceController.php | 178 ++++++++++++++++-- .../Care/KioskDeviceController.php | 86 +++++++++ .../Middleware/AuthenticateCareDevice.php | 11 +- app/Services/Care/DeviceService.php | 43 ++++- app/Services/Care/KioskService.php | 90 +++++++++ config/care.php | 8 + docs/devices.md | 15 +- resources/js/app.js | 2 + resources/js/care-kiosk.js | 162 ++++++++++++++++ resources/views/care/devices/create.blade.php | 86 +++++++-- resources/views/care/devices/edit.blade.php | 104 ++++++++-- resources/views/care/devices/index.blade.php | 19 +- resources/views/care/kiosk/device.blade.php | 142 ++++++++++++++ resources/views/care/queue/index.blade.php | 13 ++ resources/views/care/settings/edit.blade.php | 19 +- resources/views/partials/sidebar.blade.php | 9 + routes/web.php | 5 + tests/Feature/CareQueueDevicesTest.php | 171 +++++++++++++++++ 18 files changed, 1103 insertions(+), 60 deletions(-) create mode 100644 app/Http/Controllers/Care/KioskDeviceController.php create mode 100644 app/Services/Care/KioskService.php create mode 100644 resources/js/care-kiosk.js create mode 100644 resources/views/care/kiosk/device.blade.php create mode 100644 tests/Feature/CareQueueDevicesTest.php diff --git a/app/Http/Controllers/Care/DeviceController.php b/app/Http/Controllers/Care/DeviceController.php index 7fda634..c4a0799 100644 --- a/app/Http/Controllers/Care/DeviceController.php +++ b/app/Http/Controllers/Care/DeviceController.php @@ -2,11 +2,15 @@ namespace App\Http\Controllers\Care; -use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; +use App\Http\Controllers\Controller; use App\Models\Branch; +use App\Models\CareDisplayScreen; +use App\Models\CareServiceQueue; use App\Models\Device; +use App\Services\Care\CarePermissions; use App\Services\Care\DeviceService; +use App\Services\Care\KioskService; use App\Services\Care\OrganizationResolver; use App\Services\Care\PlanService; use Illuminate\Http\RedirectResponse; @@ -20,6 +24,7 @@ class DeviceController extends Controller public function __construct( protected DeviceService $devices, protected PlanService $plans, + protected KioskService $kiosk, ) {} public function index(Request $request): View @@ -35,14 +40,14 @@ class DeviceController extends Controller ->orderBy('name'); $devices = (clone $query)->paginate(25); + $all = (clone $query)->get(); $heroStats = [ - 'total' => (clone $query)->count(), - 'online' => (clone $query) - ->where('status', Device::STATUS_ACTIVE) - ->where('last_seen_at', '>=', now()->subMinutes(10)) - ->count(), - 'agent' => (clone $query)->where('connection_mode', Device::MODE_AGENT)->count(), + 'total' => $all->count(), + 'online' => $all->filter(fn (Device $d) => $d->isOnline())->count(), + 'kiosks' => $all->where('type', 'kiosk')->count(), + 'displays' => $all->where('type', 'display')->count(), + 'agent' => $all->where('connection_mode', Device::MODE_AGENT)->count(), ]; return view('care.devices.index', [ @@ -51,9 +56,10 @@ class DeviceController extends Controller 'deviceTypes' => config('care.device_types'), 'connectionModes' => config('care.device_connection_modes'), 'heroStats' => $heroStats, - 'canManage' => app(\App\Services\Care\CarePermissions::class) + 'canManage' => app(CarePermissions::class) ->can($this->member($request), 'devices.manage'), 'hasAgentDevices' => $this->plans->hasPaidPlan($organization), + 'hasQueueDevices' => $this->plans->hasPaidPlan($organization), ]); } @@ -61,18 +67,29 @@ class DeviceController extends Controller { $this->authorizeAbility($request, 'devices.manage'); $organization = $this->organization($request); + $owner = $this->ownerRef($request); + $paid = $this->plans->hasPaidPlan($organization); return view('care.devices.create', [ 'organization' => $organization, 'deviceTypes' => config('care.device_types'), 'connectionModes' => config('care.device_connection_modes'), 'branches' => $this->branches($request, $organization->id), - 'hasAgentDevices' => $this->plans->hasPaidPlan($organization), + 'queues' => CareServiceQueue::owned($owner) + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->orderBy('name') + ->get(), + 'layouts' => config('care.display_layouts'), + 'hasAgentDevices' => $paid, + 'hasQueueDevices' => $paid, 'agentTypes' => config('care.device_agent_types'), + 'queueDeviceTypes' => config('care.queue_device_types', []), 'defaultModes' => collect(config('care.device_types')) ->keys() ->mapWithKeys(fn ($type) => [$type => $this->devices->defaultConnectionMode($type)]) ->all(), + 'prefillType' => $request->query('type'), ]); } @@ -80,33 +97,77 @@ class DeviceController extends Controller { $this->authorizeAbility($request, 'devices.manage'); $organization = $this->organization($request); + $owner = $this->ownerRef($request); $validated = $this->validatedDevice($request, $organization); if (! $this->devices->canRegisterType($organization, $validated['type'], $this->plans)) { - return back()->withInput()->with( - 'error', - 'Agent-connected clinical devices require Care Pro or Enterprise. USB barcode/QR scanners work on Free.', - ); + $message = $this->devices->isQueueDeviceType($validated['type']) + ? 'Queue kiosks and displays require Care Pro or Enterprise.' + : 'Agent-connected clinical devices require Care Pro or Enterprise. USB barcode/QR scanners work on Free.'; + + return back()->withInput()->with('error', $message); } $mode = $validated['connection_mode'] ?? $this->devices->defaultConnectionMode($validated['type']); + $metadata = []; + if ($validated['type'] === 'kiosk') { + $allowed = CareServiceQueue::owned($owner) + ->where('organization_id', $organization->id) + ->pluck('id') + ->all(); + $metadata = $this->kiosk->buildMetadata($validated, $allowed); + } + + if ($validated['type'] === 'display') { + if (empty($validated['branch_id'])) { + return back()->withInput()->with('error', 'Queue displays require a branch.'); + } + + $queueIds = array_map('intval', $validated['queue_ids'] ?? []); + $allowed = CareServiceQueue::owned($owner) + ->where('organization_id', $organization->id) + ->whereIn('id', $queueIds) + ->pluck('id') + ->all(); + + $screen = CareDisplayScreen::create([ + 'owner_ref' => $owner, + 'organization_id' => $organization->id, + 'branch_id' => $validated['branch_id'], + 'name' => $validated['name'], + 'layout' => $validated['layout'] ?? 'standard', + 'service_queue_ids' => array_values($allowed), + 'is_active' => true, + ]); + + $metadata = [ + 'display_screen_id' => $screen->id, + 'display_screen_uuid' => $screen->uuid, + 'service_queue_ids' => array_values($allowed), + ]; + } + $device = Device::create([ - 'owner_ref' => $this->ownerRef($request), + 'owner_ref' => $owner, 'organization_id' => $organization->id, 'branch_id' => $validated['branch_id'] ?? null, 'name' => $validated['name'], 'type' => $validated['type'], 'connection_mode' => $mode, 'status' => Device::STATUS_OFFLINE, - 'metadata' => [], + 'metadata' => $metadata, ]); $plainToken = null; - if ($mode === Device::MODE_AGENT || in_array($validated['type'], config('care.device_agent_types', []), true)) { - $plainToken = $this->devices->issueToken($device); + $needsToken = $this->devices->isQueueDeviceType($validated['type']) + || $mode === Device::MODE_AGENT + || in_array($validated['type'], config('care.device_agent_types', []), true); + + if ($needsToken) { + $plainToken = $this->devices->issueToken($device->fresh()); } $redirect = redirect()->route('care.devices.edit', $device) @@ -123,18 +184,49 @@ class DeviceController extends Controller { $this->authorizeAbility($request, 'devices.manage'); $this->authorizeDevice($request, $device); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $displayScreen = null; + $displayPublicUrl = null; + if ($device->type === 'display' && ! empty($device->metadata['display_screen_id'])) { + $displayScreen = CareDisplayScreen::query()->find($device->metadata['display_screen_id']); + if ($displayScreen) { + $displayPublicUrl = route('care.display.public', $displayScreen->access_token); + } + } + + $kioskUrl = null; + if ($device->type === 'kiosk') { + $token = $this->devices->publicAccessToken($device) ?? session('device_token'); + if ($token) { + $kioskUrl = route('care.kiosk.device', $token); + } + } return view('care.devices.edit', [ 'device' => $device, - 'organization' => $this->organization($request), + 'organization' => $organization, 'deviceTypes' => config('care.device_types'), 'connectionModes' => config('care.device_connection_modes'), 'deviceStatuses' => config('care.device_statuses'), 'branches' => $this->branches($request, $device->organization_id), - 'hasAgentDevices' => $this->plans->hasPaidPlan($this->organization($request)), + 'queues' => CareServiceQueue::owned($owner) + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->orderBy('name') + ->get(), + 'layouts' => config('care.display_layouts'), + 'hasAgentDevices' => $this->plans->hasPaidPlan($organization), + 'hasQueueDevices' => $this->plans->hasPaidPlan($organization), 'agentTypes' => config('care.device_agent_types'), + 'queueDeviceTypes' => config('care.queue_device_types', []), 'connectionHint' => $this->devices->connectionHint($device->type, $device->connection_mode), 'plainToken' => session('device_token'), + 'kioskUrl' => $kioskUrl, + 'displayScreen' => $displayScreen, + 'displayPublicUrl' => $displayPublicUrl, + 'kioskSettings' => $device->type === 'kiosk' ? $this->kiosk->settings($device) : null, ]); } @@ -143,6 +235,7 @@ class DeviceController extends Controller $this->authorizeAbility($request, 'devices.manage'); $this->authorizeDevice($request, $device); $organization = $this->organization($request); + $owner = $this->ownerRef($request); $validated = $this->validatedDevice($request, $organization, updating: true); @@ -153,10 +246,44 @@ class DeviceController extends Controller ) { return back()->withInput()->with( 'error', - 'Agent-connected clinical devices require Care Pro or Enterprise.', + 'That device type requires Care Pro or Enterprise.', ); } + $metadata = $device->metadata ?? []; + if (($validated['type'] ?? $device->type) === 'kiosk') { + $allowed = CareServiceQueue::owned($owner) + ->where('organization_id', $organization->id) + ->pluck('id') + ->all(); + $metadata = array_merge( + $metadata, + $this->kiosk->buildMetadata($validated, $allowed), + ); + if ($access = $this->devices->publicAccessToken($device)) { + $metadata['access_token'] = $access; + } + } + + if (($validated['type'] ?? $device->type) === 'display') { + $queueIds = array_map('intval', $validated['queue_ids'] ?? ($metadata['service_queue_ids'] ?? [])); + $allowed = CareServiceQueue::owned($owner) + ->where('organization_id', $organization->id) + ->whereIn('id', $queueIds) + ->pluck('id') + ->all(); + $metadata['service_queue_ids'] = array_values($allowed); + + if (! empty($metadata['display_screen_id'])) { + CareDisplayScreen::query()->where('id', $metadata['display_screen_id'])->update([ + 'name' => $validated['name'], + 'branch_id' => $validated['branch_id'] ?? null, + 'layout' => $validated['layout'] ?? 'standard', + 'service_queue_ids' => array_values($allowed), + ]); + } + } + $device->update([ 'name' => $validated['name'], 'type' => $validated['type'], @@ -165,6 +292,7 @@ class DeviceController extends Controller ?? $device->connection_mode ?? $this->devices->defaultConnectionMode($validated['type']), 'status' => $validated['status'] ?? $device->status, + 'metadata' => $metadata, ]); return redirect()->route('care.devices.index')->with('success', 'Device updated.'); @@ -221,6 +349,13 @@ class DeviceController extends Controller 'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.device_types')))], 'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'], 'connection_mode' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.device_connection_modes')))], + 'queue_ids' => ['nullable', 'array'], + 'queue_ids.*' => ['integer', 'exists:care_service_queues,id'], + 'collect_name' => ['nullable', 'boolean'], + 'collect_phone' => ['nullable', 'boolean'], + 'reset_seconds' => ['nullable', 'integer', 'min:5', 'max:120'], + 'welcome_message' => ['nullable', 'string', 'max:255'], + 'layout' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.display_layouts', ['standard' => 'Standard'])))], ]; if ($updating) { @@ -237,6 +372,9 @@ class DeviceController extends Controller abort_unless($ok, 422); } + $validated['collect_name'] = $request->boolean('collect_name'); + $validated['collect_phone'] = $request->boolean('collect_phone'); + return $validated; } diff --git a/app/Http/Controllers/Care/KioskDeviceController.php b/app/Http/Controllers/Care/KioskDeviceController.php new file mode 100644 index 0000000..6429704 --- /dev/null +++ b/app/Http/Controllers/Care/KioskDeviceController.php @@ -0,0 +1,86 @@ +attributes->get('care.device'); + $device->loadMissing('organization'); + $queues = $this->kiosk->queuesFor($device); + $settings = $this->kiosk->settings($device); + $organization = $device->organization; + $token = $this->devices->publicAccessToken($device) ?? (string) $request->route('token'); + + $this->devices->recordHeartbeat($device); + + return view('care.kiosk.device', [ + 'device' => $device, + 'organization' => $organization, + 'queues' => $queues, + 'settings' => $settings, + 'kioskToken' => $token, + 'logoUrl' => $organization + ? OrganizationBranding::logoUrl($organization) + : asset(OrganizationBranding::DEFAULT_LOGO), + 'logoAlt' => $organization + ? OrganizationBranding::logoAlt($organization) + : 'Ladill Care', + ]); + } + + public function issue(Request $request): JsonResponse + { + $device = $request->attributes->get('care.device'); + $this->devices->recordHeartbeat($device); + + $validated = $request->validate([ + 'queue_id' => ['required', 'integer', 'exists:care_service_queues,id'], + 'customer_name' => ['nullable', 'string', 'max:255'], + 'customer_phone' => ['nullable', 'string', 'max:50'], + 'priority' => ['nullable', 'string', 'max:50'], + ]); + + abort_unless($this->kiosk->queueAllowedForIssue($device, (int) $validated['queue_id']), 403); + + $queue = CareServiceQueue::query()->findOrFail($validated['queue_id']); + abort_unless($queue->is_active, 422, 'This queue is not accepting tickets right now.'); + + $ticket = $this->engine->issueTicket($device->organization, [ + 'service_queue_id' => $queue->uuid, + 'customer_name' => $validated['customer_name'] ?? null, + 'customer_phone' => $validated['customer_phone'] ?? null, + 'priority' => $validated['priority'] ?? 'walk_in', + 'source' => 'kiosk', + 'metadata' => [ + 'device_id' => $device->id, + 'device_name' => $device->name, + ], + ]); + + $ticket['queue'] = [ + 'uuid' => $queue->uuid, + 'name' => $queue->name, + 'prefix' => $queue->prefix, + ]; + + return response()->json(['data' => $ticket]); + } +} diff --git a/app/Http/Middleware/AuthenticateCareDevice.php b/app/Http/Middleware/AuthenticateCareDevice.php index cd17494..4c56f71 100644 --- a/app/Http/Middleware/AuthenticateCareDevice.php +++ b/app/Http/Middleware/AuthenticateCareDevice.php @@ -13,7 +13,7 @@ class AuthenticateCareDevice protected DeviceService $devices, ) {} - public function handle(Request $request, Closure $next): Response + public function handle(Request $request, Closure $next, ?string $type = null): Response { $token = $this->extractToken($request); @@ -22,6 +22,10 @@ class AuthenticateCareDevice $device = $this->devices->findByToken($token); abort_unless($device, 401, 'Invalid device token.'); + if ($type !== null && $type !== '' && $device->type !== $type) { + abort(403, 'Device type mismatch.'); + } + $request->attributes->set('care.device', $device); return $next($request); @@ -29,6 +33,11 @@ class AuthenticateCareDevice protected function extractToken(Request $request): ?string { + $routeToken = $request->route('token'); + if (is_string($routeToken) && $routeToken !== '') { + return $routeToken; + } + $header = $request->header('X-Care-Device-Token'); if (is_string($header) && $header !== '') { return $header; diff --git a/app/Services/Care/DeviceService.php b/app/Services/Care/DeviceService.php index d12eed8..6ac1ed5 100644 --- a/app/Services/Care/DeviceService.php +++ b/app/Services/Care/DeviceService.php @@ -33,14 +33,28 @@ class DeviceService public function issueToken(Device $device): string { $plain = $this->generateToken(); - $device->update(['device_token_hash' => $this->hashToken($plain)]); + $meta = $device->metadata ?? []; + if ($this->isQueueDeviceType($device->type)) { + $meta['access_token'] = $plain; + } + + $device->update([ + 'device_token_hash' => $this->hashToken($plain), + 'metadata' => $meta, + ]); return $plain; } public function revokeToken(Device $device): Device { - $device->update(['device_token_hash' => null]); + $meta = $device->metadata ?? []; + unset($meta['access_token']); + + $device->update([ + 'device_token_hash' => null, + 'metadata' => $meta, + ]); return $device->fresh(); } @@ -66,6 +80,7 @@ class DeviceService public function defaultConnectionMode(string $type): string { return match ($type) { + 'kiosk', 'display' => Device::MODE_MANUAL, 'barcode_scanner', 'qr_scanner' => Device::MODE_BROWSER_WEDGE, 'badge_printer' => Device::MODE_MANUAL, 'thermometer', 'bp_monitor', 'pulse_ox', 'weight_scale', 'lab_analyzer', 'agent' => Device::MODE_AGENT, @@ -73,6 +88,11 @@ class DeviceService }; } + public function isQueueDeviceType(string $type): bool + { + return in_array($type, config('care.queue_device_types', []), true); + } + public function typeRequiresPro(string $type): bool { return in_array($type, config('care.device_agent_types', []), true); @@ -80,6 +100,10 @@ class DeviceService public function canRegisterType(Organization $organization, string $type, PlanService $plans): bool { + if ($this->isQueueDeviceType($type)) { + return $plans->hasPaidPlan($organization); + } + if (! $this->typeRequiresPro($type)) { return true; } @@ -87,8 +111,23 @@ class DeviceService return $plans->hasPaidPlan($organization); } + public function publicAccessToken(Device $device): ?string + { + $token = $device->metadata['access_token'] ?? null; + + return is_string($token) && $token !== '' ? $token : null; + } + public function connectionHint(string $type, string $mode): string { + if ($type === 'kiosk') { + return 'Walk-up ticket kiosk. Open the public kiosk URL on a tablet in kiosk mode.'; + } + + if ($type === 'display') { + return 'Waiting-room / counter display. Opens the linked TV board URL on a screen or tablet.'; + } + return match ($mode) { Device::MODE_BROWSER_WEDGE => 'Works in the browser today as a USB keyboard wedge (scan into the focused field). No local agent required.', Device::MODE_AGENT => 'Needs the Care Device Agent on a local machine (serial / BLE / vendor SDK). Browser alone cannot talk to this hardware.', diff --git a/app/Services/Care/KioskService.php b/app/Services/Care/KioskService.php new file mode 100644 index 0000000..cc2bfd4 --- /dev/null +++ b/app/Services/Care/KioskService.php @@ -0,0 +1,90 @@ +, collect_name: bool, collect_phone: bool, reset_seconds: int, welcome_message: ?string} + */ + public function settings(Device $device): array + { + $config = $device->metadata ?? []; + + return [ + 'service_queue_ids' => array_values(array_filter(array_map( + 'intval', + $config['service_queue_ids'] ?? [], + ))), + 'collect_name' => (bool) ($config['collect_name'] ?? false), + 'collect_phone' => (bool) ($config['collect_phone'] ?? false), + 'reset_seconds' => max(5, (int) ($config['reset_seconds'] ?? 15)), + 'welcome_message' => filled($config['welcome_message'] ?? null) + ? (string) $config['welcome_message'] + : null, + ]; + } + + /** + * @return list + */ + public function assignedQueueIds(Device $device): array + { + return $this->settings($device)['service_queue_ids']; + } + + /** + * @return Collection + */ + public function queuesFor(Device $device): Collection + { + $ids = $this->assignedQueueIds($device); + + return CareServiceQueue::query() + ->where('organization_id', $device->organization_id) + ->where('is_active', true) + ->when($device->branch_id, fn ($q) => $q->where('branch_id', $device->branch_id)) + ->when($ids !== [], fn ($q) => $q->whereIn('id', $ids)) + ->orderBy('name') + ->get(); + } + + public function queueAllowedForIssue(Device $device, int $queueId): bool + { + $ids = $this->assignedQueueIds($device); + + return CareServiceQueue::query() + ->where('organization_id', $device->organization_id) + ->where('id', $queueId) + ->where('is_active', true) + ->when($device->branch_id, fn ($q) => $q->where('branch_id', $device->branch_id)) + ->when($ids !== [], fn ($q) => $q->whereIn('id', $ids)) + ->exists(); + } + + /** + * @param array $input + * @return array + */ + public function buildMetadata(array $input, array $allowedQueueIds = []): array + { + $queueIds = array_values(array_intersect( + array_map('intval', $input['queue_ids'] ?? []), + $allowedQueueIds, + )); + + return [ + 'service_queue_ids' => $queueIds, + 'collect_name' => (bool) ($input['collect_name'] ?? false), + 'collect_phone' => (bool) ($input['collect_phone'] ?? false), + 'reset_seconds' => max(5, min(120, (int) ($input['reset_seconds'] ?? 15))), + 'welcome_message' => filled($input['welcome_message'] ?? null) + ? (string) $input['welcome_message'] + : null, + ]; + } +} diff --git a/config/care.php b/config/care.php index 0c7d40d..40f1a33 100644 --- a/config/care.php +++ b/config/care.php @@ -546,6 +546,8 @@ return [ ], 'device_types' => [ + 'kiosk' => 'Queue kiosk', + 'display' => 'Queue display', 'barcode_scanner' => 'Barcode scanner', 'qr_scanner' => 'QR scanner', 'badge_printer' => 'Badge / label printer', @@ -558,6 +560,12 @@ return [ 'other' => 'Other', ], + /** Queue walk-up devices (not clinical agents). */ + 'queue_device_types' => [ + 'kiosk', + 'display', + ], + /** Types that need a local Care Device Agent (Pro+). Wedge scanners stay free. */ 'device_agent_types' => [ 'thermometer', diff --git a/docs/devices.md b/docs/devices.md index 531a8d9..f7a11f6 100644 --- a/docs/devices.md +++ b/docs/devices.md @@ -7,16 +7,27 @@ Ladill Care supports two realistic hardware paths: | USB barcode / QR scanner | **Browser keyboard wedge** — scan into Patients or Lab | All plans | | Badge / label printer | Browser print CSS (patient ID label) | All plans | | Thermometer, BP monitor, pulse ox, scale, lab analyzer | **Care Device Agent** (local machine) | Pro / Enterprise | +| **Queue kiosk** | Public `/kiosk/d/{token}` tablet URL — walk-up ticket issue | Pro / Enterprise | +| **Queue display** | Registers a waiting-room TV board (`/display/{token}`) | Pro / Enterprise | Browser pages cannot open serial ports or most BLE clinical devices without a local helper. The agent holds the vendor/serial connection and posts readings to Care with a device token. +## Queue devices + +**Devices → Add kiosk / Add display** (or Queue board shortcuts): + +1. **Kiosk** — assign service queues, optional name/phone collection. Open the public kiosk URL on a locked tablet. +2. **Display** — creates a linked waiting display screen for the selected queues. Open the board URL on a TV/tablet. + +Waiting displays can also be managed under **Displays** in the sidebar / Settings. + ## Device registry **Settings → Devices** (hospital admin): 1. Register a device (branch-scoped). -2. For agent types, Care shows a **plaintext token once** (create / regenerate). The DB stores only `device_token_hash`. -3. Status updates from agent heartbeats (`active` + `last_seen_at`). +2. For agent types and queue devices, Care shows a **plaintext token once** (create / regenerate). The DB stores `device_token_hash` (queue devices also keep `metadata.access_token` so the public URL can be reopened). +3. Status updates from agent/kiosk heartbeats (`active` + `last_seen_at`). ## Browser wedge (patients) diff --git a/resources/js/app.js b/resources/js/app.js index 384888f..50c109a 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,11 +1,13 @@ import Alpine from 'alpinejs'; import { registerLadillClipboard } from './ladill-clipboard'; import { registerCareDisplay } from './care-display'; +import { registerCareKiosk } from './care-kiosk'; import collapse from '@alpinejs/collapse'; Alpine.plugin(collapse); registerLadillClipboard(Alpine); registerCareDisplay(Alpine); +registerCareKiosk(Alpine); // In-app notification bell + dropdown. Alpine.data('notificationDropdown', (config = {}) => ({ diff --git a/resources/js/care-kiosk.js b/resources/js/care-kiosk.js new file mode 100644 index 0000000..5aa8231 --- /dev/null +++ b/resources/js/care-kiosk.js @@ -0,0 +1,162 @@ +export function registerCareKiosk(Alpine) { + Alpine.data('careKioskFlow', (config = {}) => ({ + step: 'welcome', + queueId: null, + customerName: '', + customerPhone: '', + ticket: null, + error: null, + loading: false, + idleTimer: null, + countdownInterval: null, + resetCountdown: null, + issueUrl: config.issueUrl, + csrf: config.csrf, + queues: config.queues ?? [], + collectName: config.collectName ?? false, + collectPhone: config.collectPhone ?? false, + resetSeconds: config.resetSeconds ?? 15, + welcomeMessage: config.welcomeMessage ?? null, + + startTimer() { + this.resetIdleTimer(); + }, + + resetIdleTimer() { + clearTimeout(this.idleTimer); + clearInterval(this.countdownInterval); + this.resetCountdown = this.resetSeconds; + + this.countdownInterval = setInterval(() => { + this.resetCountdown -= 1; + if (this.resetCountdown <= 0) { + clearInterval(this.countdownInterval); + this.countdownInterval = null; + } + }, 1000); + + this.idleTimer = setTimeout(() => this.reset(), this.resetSeconds * 1000); + }, + + clearIdleTimer() { + clearTimeout(this.idleTimer); + clearInterval(this.countdownInterval); + this.idleTimer = null; + this.countdownInterval = null; + this.resetCountdown = null; + }, + + beginKiosk() { + this.resetIdleTimer(); + + if (this.queues.length === 0) { + this.step = 'select'; + + return; + } + + if (this.queues.length === 1) { + this.queueId = this.queues[0].id; + + if (! this.collectName && ! this.collectPhone) { + this.issue(); + + return; + } + } + + this.step = 'select'; + }, + + goBack() { + this.resetIdleTimer(); + this.error = null; + + if (this.step === 'details') { + this.step = 'select'; + + return; + } + + if (this.step === 'select') { + this.step = 'welcome'; + this.queueId = null; + } + }, + + selectQueue(id) { + this.queueId = id; + this.error = null; + this.resetIdleTimer(); + + if (! this.collectName && ! this.collectPhone) { + this.issue(); + + return; + } + + this.step = 'details'; + }, + + async issue() { + if (! this.queueId || this.loading) { + return; + } + + this.loading = true; + this.error = null; + this.resetIdleTimer(); + + try { + const res = await fetch(this.issueUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': this.csrf, + }, + body: JSON.stringify({ + queue_id: this.queueId, + customer_name: this.customerName || null, + customer_phone: this.customerPhone || null, + }), + }); + const data = await res.json(); + + if (! res.ok) { + throw new Error(data.message || 'Could not issue ticket'); + } + + this.ticket = data.data; + this.step = 'ticket'; + this.resetIdleTimer(); + } catch (e) { + this.error = e.message || 'Something went wrong'; + } finally { + this.loading = false; + } + }, + + reset() { + this.clearIdleTimer(); + this.step = 'welcome'; + this.queueId = null; + this.customerName = ''; + this.customerPhone = ''; + this.ticket = null; + this.error = null; + this.loading = false; + this.startTimer(); + }, + + formatWait(seconds) { + if (! seconds) { + return null; + } + + const minutes = Math.ceil(seconds / 60); + + return minutes <= 1 ? 'About 1 minute' : `About ${minutes} minutes`; + }, + })); +} diff --git a/resources/views/care/devices/create.blade.php b/resources/views/care/devices/create.blade.php index c84e605..742255e 100644 --- a/resources/views/care/devices/create.blade.php +++ b/resources/views/care/devices/create.blade.php @@ -3,14 +3,14 @@
← Devices

Register device

-

Inventory is branch-scoped. Agent devices get a token shown once after create.

+

Queue kiosks/displays and clinical scanners/agents. Queue devices need Care Pro.

@csrf
-
@@ -18,39 +18,85 @@ - @if (! $hasAgentDevices) -

Clinical agent devices need Care Pro. USB barcode/QR scanners work on Free.

- @endif
-
+
-

USB scanners act as a keyboard in the browser. Serial/BLE needs the local agent.

+

USB scanners act as a keyboard in the browser.

- @foreach ($branches as $branch) @endforeach +
+ + +
@@ -59,18 +105,30 @@ const typeEl = document.getElementById('device-type'); const modeEl = document.getElementById('connection-mode'); const hintEl = document.getElementById('connection-hint'); + const connWrap = document.getElementById('connection-wrap'); + const queueConfig = document.getElementById('queue-config'); + const kioskOpts = document.getElementById('kiosk-options'); + const displayOpts = document.getElementById('display-options'); + const branchHint = document.getElementById('branch-hint'); const hints = { browser_wedge: 'Works in the browser today as a USB keyboard wedge. No local agent required.', agent: 'Needs the Care Device Agent on a local machine (serial / BLE / vendor SDK).', web_bluetooth: 'Experimental Web Bluetooth — prefer the local agent for clinical devices.', - manual: 'Manual / inventory only — no automated capture path.', + manual: 'Manual / inventory — or open the public URL for kiosk / display devices.', }; function sync() { const opt = typeEl.selectedOptions[0]; if (!opt) return; const mode = opt.dataset.mode || 'manual'; + const isQueue = opt.dataset.queue === '1'; + const isDisplay = opt.dataset.display === '1'; modeEl.value = mode; hintEl.textContent = hints[mode] || hints.manual; + connWrap.hidden = isQueue; + queueConfig.hidden = !isQueue; + kioskOpts.hidden = isDisplay; + displayOpts.hidden = !isDisplay; + branchHint.hidden = !isDisplay; } typeEl.addEventListener('change', sync); modeEl.addEventListener('change', () => { diff --git a/resources/views/care/devices/edit.blade.php b/resources/views/care/devices/edit.blade.php index 4dd3bed..815d9ee 100644 --- a/resources/views/care/devices/edit.blade.php +++ b/resources/views/care/devices/edit.blade.php @@ -8,9 +8,30 @@ @if ($plainToken)
-

Device token (copy now — shown once)

+

Device token (copy now)

{{ $plainToken }} -

Send as X-Care-Device-Token or Authorization: Bearer … from the Care Device Agent.

+ @if ($device->type === 'kiosk') +

Kiosk URL: + + {{ route('care.kiosk.device', $plainToken) }} + +

+ @elseif ($device->type !== 'display') +

Send as X-Care-Device-Token from the Care Device Agent.

+ @endif +
+ @elseif ($kioskUrl) +
+

Kiosk URL

+ {{ $kioskUrl }} +
+ @elseif ($displayPublicUrl) +
+

Display board URL

+ {{ $displayPublicUrl }} + @if ($displayScreen) + Manage waiting display + @endif
@elseif ($device->hasToken())
@@ -28,22 +49,33 @@
-
- - -
+ + @if (! in_array($device->type, $queueDeviceTypes, true)) +
+ + +
+ @else + + @endif +
+ + @if (in_array($device->type, $queueDeviceTypes, true)) + @php $selectedQueues = collect(old('queue_ids', $device->metadata['service_queue_ids'] ?? [])); @endphp +
+

Service queues

+
+ @foreach ($queues as $queue) + + @endforeach +
+
+ @if ($device->type === 'kiosk') + + +
+ + +
+
+ + +
+ @endif + @if ($device->type === 'display') +
+ + +
+ @endif + @endif +
Cancel @@ -71,7 +149,7 @@
@csrf
@if ($device->hasToken()) diff --git a/resources/views/care/devices/index.blade.php b/resources/views/care/devices/index.blade.php index adf7038..60170d0 100644 --- a/resources/views/care/devices/index.blade.php +++ b/resources/views/care/devices/index.blade.php @@ -1,26 +1,33 @@
@if ($canManage) + @if ($hasQueueDevices ?? false) + Add kiosk + Add display + @endif Add device @endif
-

How devices connect

+

Queue devices vs clinical devices

    -
  • Barcode / QR scanners — USB keyboard wedge works in Patients and Lab today. Free on all plans.
  • -
  • Thermometers, BP, SpO₂, scales, analyzers — need a local Care Device Agent (Pro / Enterprise). See docs/devices.md.
  • +
  • Queue kiosk — walk-up ticket issue on a tablet (Pro).
  • +
  • Queue display — registers a waiting-room TV board URL (Pro).
  • +
  • Barcode / QR scanners — USB keyboard wedge in Patients and Lab (all plans).
  • +
  • Vitals / analyzers — Care Device Agent (Pro).
diff --git a/resources/views/care/kiosk/device.blade.php b/resources/views/care/kiosk/device.blade.php new file mode 100644 index 0000000..f6e0fe8 --- /dev/null +++ b/resources/views/care/kiosk/device.blade.php @@ -0,0 +1,142 @@ + + + + + + + + {{ $device->name }} · Care Kiosk + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + + + + + +
+ +
+ {{ $logoAlt }} +
+ +
+ + + + + + + +
+ + diff --git a/resources/views/care/queue/index.blade.php b/resources/views/care/queue/index.blade.php index 0911410..b8c3d4b 100644 --- a/resources/views/care/queue/index.blade.php +++ b/resources/views/care/queue/index.blade.php @@ -20,6 +20,19 @@ @if ($canManageQueue) Walk-in + @if (app(\App\Services\Care\CarePermissions::class)->can( + auth()->user() ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user()) : null, + 'devices.view' + )) + Add kiosk + Add display + @endif + @if (app(\App\Services\Care\CarePermissions::class)->can( + auth()->user() ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user()) : null, + 'displays.view' + )) + Displays + @endif @endif diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index 0a035e3..d2bd3b2 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -94,14 +94,14 @@ Agent · Pro @endif - Barcode scanners (browser) · agent-connected vitals / lab hardware + Queue kiosks & displays · barcode scanners · agent vitals / lab hardware @endif - @if ($canViewDisplays && ($queueIntegrationEnabled || $canUseQueueIntegration)) + @if ($canViewDisplays && ! empty($hasPaidPlan))
  • @@ -116,6 +116,21 @@
  • @endif + @if ($canViewDevices && ! empty($hasPaidPlan)) +
  • + + + Queue kiosk + Pro + + Walk-up ticket issue on a tablet + + + + +
  • + @endif
  • diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 30db8a8..62ef978 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -32,6 +32,15 @@ } } + if (! empty($hasPaidPlan) && $permissions->can($member, 'displays.view')) { + $nav[] = ['name' => 'Displays', 'route' => route('care.displays.index'), 'active' => request()->routeIs('care.displays.*'), + 'icon' => '']; + } + if (! empty($hasPaidPlan) && $permissions->can($member, 'devices.view')) { + $nav[] = ['name' => 'Devices', 'route' => route('care.devices.index'), 'active' => request()->routeIs('care.devices.*') || request()->routeIs('care.kiosk.*'), + 'icon' => '']; + } + // Clinical assessments (layered intake / pathways) — on by default; toggle in Settings. $assessmentsEngineOn = $organization && app(\App\Services\Care\CareFeatures::class)->enabled( diff --git a/routes/web.php b/routes/web.php index 88c42e6..1eb4ef8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -96,6 +96,11 @@ Route::get('/display/{token}/data', [DisplayPublicController::class, 'data'])->n Route::post('/display/{token}/announcements/{announcement}/played', [DisplayPublicController::class, 'played']) ->name('care.display.announcement.played'); +Route::middleware(['care.device:kiosk', 'throttle:60,1'])->prefix('kiosk/d')->group(function () { + Route::get('/{token}', [\App\Http\Controllers\Care\KioskDeviceController::class, 'show'])->name('care.kiosk.device'); + Route::post('/{token}/issue', [\App\Http\Controllers\Care\KioskDeviceController::class, 'issue'])->name('care.kiosk.device.issue'); +}); + Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread'); diff --git a/tests/Feature/CareQueueDevicesTest.php b/tests/Feature/CareQueueDevicesTest.php new file mode 100644 index 0000000..54ade55 --- /dev/null +++ b/tests/Feature/CareQueueDevicesTest.php @@ -0,0 +1,171 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->owner = User::create([ + 'public_id' => 'queue-devices-owner', + 'name' => 'Owner', + 'email' => 'queue-devices@example.com', + ]); + + $this->organization = Organization::create([ + 'owner_ref' => $this->owner->public_id, + 'name' => 'Queue Devices Clinic', + 'slug' => 'queue-devices-clinic', + 'settings' => [ + 'onboarded' => true, + 'facility_type' => 'clinic', + 'plan' => 'pro', + 'plan_expires_at' => now()->addMonth()->toIso8601String(), + 'queue_integration_enabled' => true, + ], + ]); + + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $this->owner->public_id, + 'role' => 'hospital_admin', + ]); + + $this->branch = Branch::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'name' => 'Main', + 'is_active' => true, + ]); + + $this->queue = CareServiceQueue::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'context' => 'consultation', + 'name' => 'Reception', + 'prefix' => 'A', + 'routing_mode' => 'shared_pool', + 'is_active' => true, + ]); + } + + public function test_can_register_kiosk_and_issue_ticket(): void + { + $this->actingAs($this->owner) + ->post(route('care.devices.store'), [ + 'name' => 'Lobby Kiosk', + 'type' => 'kiosk', + 'branch_id' => $this->branch->id, + 'queue_ids' => [$this->queue->id], + 'reset_seconds' => 20, + ]) + ->assertRedirect(); + + $device = Device::query()->where('type', 'kiosk')->first(); + $this->assertNotNull($device); + $token = app(DeviceService::class)->publicAccessToken($device); + $this->assertNotEmpty($token); + + $this->get(route('care.kiosk.device', $token)) + ->assertOk() + ->assertSee('Welcome to Queue Devices Clinic', false) + ->assertSee('Tap to begin', false); + + $this->postJson(route('care.kiosk.device.issue', $token), [ + 'queue_id' => $this->queue->id, + 'customer_name' => 'Ama', + ]) + ->assertOk() + ->assertJsonPath('data.source', 'kiosk') + ->assertJsonPath('data.queue.name', 'Reception'); + + $this->assertDatabaseHas('care_queue_tickets', [ + 'service_queue_id' => $this->queue->id, + 'source' => 'kiosk', + 'customer_name' => 'Ama', + 'status' => CareQueueTicket::STATUS_WAITING, + ]); + } + + public function test_kiosk_rejects_unassigned_queue(): void + { + $other = CareServiceQueue::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'context' => 'billing', + 'name' => 'Billing', + 'prefix' => 'B', + 'routing_mode' => 'shared_pool', + 'is_active' => true, + ]); + + $device = Device::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'name' => 'Lobby Kiosk', + 'type' => 'kiosk', + 'connection_mode' => Device::MODE_MANUAL, + 'status' => Device::STATUS_OFFLINE, + 'metadata' => ['service_queue_ids' => [$this->queue->id]], + ]); + $token = app(DeviceService::class)->issueToken($device); + + $this->postJson(route('care.kiosk.device.issue', $token), [ + 'queue_id' => $other->id, + ])->assertForbidden(); + } + + public function test_can_register_display_device_with_waiting_screen(): void + { + $this->actingAs($this->owner) + ->post(route('care.devices.store'), [ + 'name' => 'Waiting Room TV', + 'type' => 'display', + 'branch_id' => $this->branch->id, + 'queue_ids' => [$this->queue->id], + 'layout' => 'standard', + ]) + ->assertRedirect(); + + $device = Device::query()->where('type', 'display')->first(); + $this->assertNotNull($device); + $this->assertNotEmpty($device->metadata['display_screen_id'] ?? null); + + $screen = CareDisplayScreen::query()->find($device->metadata['display_screen_id']); + $this->assertNotNull($screen); + $this->assertSame('Waiting Room TV', $screen->name); + $this->assertContains($this->queue->id, $screen->service_queue_ids); + + $this->get(route('care.display.public', $screen->access_token))->assertOk(); + } +}