Add queue kiosk and display devices for Care Queue management.
Deploy Ladill Care / deploy (push) Successful in 39s
Deploy Ladill Care / deploy (push) Successful in 39s
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Care;
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
use App\Http\Controllers\Care\Concerns\ScopesToAccount;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Branch;
|
use App\Models\Branch;
|
||||||
|
use App\Models\CareDisplayScreen;
|
||||||
|
use App\Models\CareServiceQueue;
|
||||||
use App\Models\Device;
|
use App\Models\Device;
|
||||||
|
use App\Services\Care\CarePermissions;
|
||||||
use App\Services\Care\DeviceService;
|
use App\Services\Care\DeviceService;
|
||||||
|
use App\Services\Care\KioskService;
|
||||||
use App\Services\Care\OrganizationResolver;
|
use App\Services\Care\OrganizationResolver;
|
||||||
use App\Services\Care\PlanService;
|
use App\Services\Care\PlanService;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
@@ -20,6 +24,7 @@ class DeviceController extends Controller
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
protected DeviceService $devices,
|
protected DeviceService $devices,
|
||||||
protected PlanService $plans,
|
protected PlanService $plans,
|
||||||
|
protected KioskService $kiosk,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(Request $request): View
|
public function index(Request $request): View
|
||||||
@@ -35,14 +40,14 @@ class DeviceController extends Controller
|
|||||||
->orderBy('name');
|
->orderBy('name');
|
||||||
|
|
||||||
$devices = (clone $query)->paginate(25);
|
$devices = (clone $query)->paginate(25);
|
||||||
|
$all = (clone $query)->get();
|
||||||
|
|
||||||
$heroStats = [
|
$heroStats = [
|
||||||
'total' => (clone $query)->count(),
|
'total' => $all->count(),
|
||||||
'online' => (clone $query)
|
'online' => $all->filter(fn (Device $d) => $d->isOnline())->count(),
|
||||||
->where('status', Device::STATUS_ACTIVE)
|
'kiosks' => $all->where('type', 'kiosk')->count(),
|
||||||
->where('last_seen_at', '>=', now()->subMinutes(10))
|
'displays' => $all->where('type', 'display')->count(),
|
||||||
->count(),
|
'agent' => $all->where('connection_mode', Device::MODE_AGENT)->count(),
|
||||||
'agent' => (clone $query)->where('connection_mode', Device::MODE_AGENT)->count(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return view('care.devices.index', [
|
return view('care.devices.index', [
|
||||||
@@ -51,9 +56,10 @@ class DeviceController extends Controller
|
|||||||
'deviceTypes' => config('care.device_types'),
|
'deviceTypes' => config('care.device_types'),
|
||||||
'connectionModes' => config('care.device_connection_modes'),
|
'connectionModes' => config('care.device_connection_modes'),
|
||||||
'heroStats' => $heroStats,
|
'heroStats' => $heroStats,
|
||||||
'canManage' => app(\App\Services\Care\CarePermissions::class)
|
'canManage' => app(CarePermissions::class)
|
||||||
->can($this->member($request), 'devices.manage'),
|
->can($this->member($request), 'devices.manage'),
|
||||||
'hasAgentDevices' => $this->plans->hasPaidPlan($organization),
|
'hasAgentDevices' => $this->plans->hasPaidPlan($organization),
|
||||||
|
'hasQueueDevices' => $this->plans->hasPaidPlan($organization),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,18 +67,29 @@ class DeviceController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorizeAbility($request, 'devices.manage');
|
$this->authorizeAbility($request, 'devices.manage');
|
||||||
$organization = $this->organization($request);
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
$paid = $this->plans->hasPaidPlan($organization);
|
||||||
|
|
||||||
return view('care.devices.create', [
|
return view('care.devices.create', [
|
||||||
'organization' => $organization,
|
'organization' => $organization,
|
||||||
'deviceTypes' => config('care.device_types'),
|
'deviceTypes' => config('care.device_types'),
|
||||||
'connectionModes' => config('care.device_connection_modes'),
|
'connectionModes' => config('care.device_connection_modes'),
|
||||||
'branches' => $this->branches($request, $organization->id),
|
'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'),
|
'agentTypes' => config('care.device_agent_types'),
|
||||||
|
'queueDeviceTypes' => config('care.queue_device_types', []),
|
||||||
'defaultModes' => collect(config('care.device_types'))
|
'defaultModes' => collect(config('care.device_types'))
|
||||||
->keys()
|
->keys()
|
||||||
->mapWithKeys(fn ($type) => [$type => $this->devices->defaultConnectionMode($type)])
|
->mapWithKeys(fn ($type) => [$type => $this->devices->defaultConnectionMode($type)])
|
||||||
->all(),
|
->all(),
|
||||||
|
'prefillType' => $request->query('type'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,33 +97,77 @@ class DeviceController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorizeAbility($request, 'devices.manage');
|
$this->authorizeAbility($request, 'devices.manage');
|
||||||
$organization = $this->organization($request);
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
$validated = $this->validatedDevice($request, $organization);
|
$validated = $this->validatedDevice($request, $organization);
|
||||||
|
|
||||||
if (! $this->devices->canRegisterType($organization, $validated['type'], $this->plans)) {
|
if (! $this->devices->canRegisterType($organization, $validated['type'], $this->plans)) {
|
||||||
return back()->withInput()->with(
|
$message = $this->devices->isQueueDeviceType($validated['type'])
|
||||||
'error',
|
? '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.',
|
: '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']
|
$mode = $validated['connection_mode']
|
||||||
?? $this->devices->defaultConnectionMode($validated['type']);
|
?? $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([
|
$device = Device::create([
|
||||||
'owner_ref' => $this->ownerRef($request),
|
'owner_ref' => $owner,
|
||||||
'organization_id' => $organization->id,
|
'organization_id' => $organization->id,
|
||||||
'branch_id' => $validated['branch_id'] ?? null,
|
'branch_id' => $validated['branch_id'] ?? null,
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'type' => $validated['type'],
|
'type' => $validated['type'],
|
||||||
'connection_mode' => $mode,
|
'connection_mode' => $mode,
|
||||||
'status' => Device::STATUS_OFFLINE,
|
'status' => Device::STATUS_OFFLINE,
|
||||||
'metadata' => [],
|
'metadata' => $metadata,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$plainToken = null;
|
$plainToken = null;
|
||||||
if ($mode === Device::MODE_AGENT || in_array($validated['type'], config('care.device_agent_types', []), true)) {
|
$needsToken = $this->devices->isQueueDeviceType($validated['type'])
|
||||||
$plainToken = $this->devices->issueToken($device);
|
|| $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)
|
$redirect = redirect()->route('care.devices.edit', $device)
|
||||||
@@ -123,18 +184,49 @@ class DeviceController extends Controller
|
|||||||
{
|
{
|
||||||
$this->authorizeAbility($request, 'devices.manage');
|
$this->authorizeAbility($request, 'devices.manage');
|
||||||
$this->authorizeDevice($request, $device);
|
$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', [
|
return view('care.devices.edit', [
|
||||||
'device' => $device,
|
'device' => $device,
|
||||||
'organization' => $this->organization($request),
|
'organization' => $organization,
|
||||||
'deviceTypes' => config('care.device_types'),
|
'deviceTypes' => config('care.device_types'),
|
||||||
'connectionModes' => config('care.device_connection_modes'),
|
'connectionModes' => config('care.device_connection_modes'),
|
||||||
'deviceStatuses' => config('care.device_statuses'),
|
'deviceStatuses' => config('care.device_statuses'),
|
||||||
'branches' => $this->branches($request, $device->organization_id),
|
'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'),
|
'agentTypes' => config('care.device_agent_types'),
|
||||||
|
'queueDeviceTypes' => config('care.queue_device_types', []),
|
||||||
'connectionHint' => $this->devices->connectionHint($device->type, $device->connection_mode),
|
'connectionHint' => $this->devices->connectionHint($device->type, $device->connection_mode),
|
||||||
'plainToken' => session('device_token'),
|
'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->authorizeAbility($request, 'devices.manage');
|
||||||
$this->authorizeDevice($request, $device);
|
$this->authorizeDevice($request, $device);
|
||||||
$organization = $this->organization($request);
|
$organization = $this->organization($request);
|
||||||
|
$owner = $this->ownerRef($request);
|
||||||
|
|
||||||
$validated = $this->validatedDevice($request, $organization, updating: true);
|
$validated = $this->validatedDevice($request, $organization, updating: true);
|
||||||
|
|
||||||
@@ -153,10 +246,44 @@ class DeviceController extends Controller
|
|||||||
) {
|
) {
|
||||||
return back()->withInput()->with(
|
return back()->withInput()->with(
|
||||||
'error',
|
'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([
|
$device->update([
|
||||||
'name' => $validated['name'],
|
'name' => $validated['name'],
|
||||||
'type' => $validated['type'],
|
'type' => $validated['type'],
|
||||||
@@ -165,6 +292,7 @@ class DeviceController extends Controller
|
|||||||
?? $device->connection_mode
|
?? $device->connection_mode
|
||||||
?? $this->devices->defaultConnectionMode($validated['type']),
|
?? $this->devices->defaultConnectionMode($validated['type']),
|
||||||
'status' => $validated['status'] ?? $device->status,
|
'status' => $validated['status'] ?? $device->status,
|
||||||
|
'metadata' => $metadata,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return redirect()->route('care.devices.index')->with('success', 'Device updated.');
|
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')))],
|
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.device_types')))],
|
||||||
'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'],
|
'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'],
|
||||||
'connection_mode' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('care.device_connection_modes')))],
|
'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) {
|
if ($updating) {
|
||||||
@@ -237,6 +372,9 @@ class DeviceController extends Controller
|
|||||||
abort_unless($ok, 422);
|
abort_unless($ok, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$validated['collect_name'] = $request->boolean('collect_name');
|
||||||
|
$validated['collect_phone'] = $request->boolean('collect_phone');
|
||||||
|
|
||||||
return $validated;
|
return $validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Care;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\CareServiceQueue;
|
||||||
|
use App\Services\Care\CareQueueEngine;
|
||||||
|
use App\Services\Care\DeviceService;
|
||||||
|
use App\Services\Care\KioskService;
|
||||||
|
use App\Support\OrganizationBranding;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class KioskDeviceController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected CareQueueEngine $engine,
|
||||||
|
protected DeviceService $devices,
|
||||||
|
protected KioskService $kiosk,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function show(Request $request): View
|
||||||
|
{
|
||||||
|
$device = $request->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ class AuthenticateCareDevice
|
|||||||
protected DeviceService $devices,
|
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);
|
$token = $this->extractToken($request);
|
||||||
|
|
||||||
@@ -22,6 +22,10 @@ class AuthenticateCareDevice
|
|||||||
$device = $this->devices->findByToken($token);
|
$device = $this->devices->findByToken($token);
|
||||||
abort_unless($device, 401, 'Invalid device 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);
|
$request->attributes->set('care.device', $device);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
@@ -29,6 +33,11 @@ class AuthenticateCareDevice
|
|||||||
|
|
||||||
protected function extractToken(Request $request): ?string
|
protected function extractToken(Request $request): ?string
|
||||||
{
|
{
|
||||||
|
$routeToken = $request->route('token');
|
||||||
|
if (is_string($routeToken) && $routeToken !== '') {
|
||||||
|
return $routeToken;
|
||||||
|
}
|
||||||
|
|
||||||
$header = $request->header('X-Care-Device-Token');
|
$header = $request->header('X-Care-Device-Token');
|
||||||
if (is_string($header) && $header !== '') {
|
if (is_string($header) && $header !== '') {
|
||||||
return $header;
|
return $header;
|
||||||
|
|||||||
@@ -33,14 +33,28 @@ class DeviceService
|
|||||||
public function issueToken(Device $device): string
|
public function issueToken(Device $device): string
|
||||||
{
|
{
|
||||||
$plain = $this->generateToken();
|
$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;
|
return $plain;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function revokeToken(Device $device): Device
|
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();
|
return $device->fresh();
|
||||||
}
|
}
|
||||||
@@ -66,6 +80,7 @@ class DeviceService
|
|||||||
public function defaultConnectionMode(string $type): string
|
public function defaultConnectionMode(string $type): string
|
||||||
{
|
{
|
||||||
return match ($type) {
|
return match ($type) {
|
||||||
|
'kiosk', 'display' => Device::MODE_MANUAL,
|
||||||
'barcode_scanner', 'qr_scanner' => Device::MODE_BROWSER_WEDGE,
|
'barcode_scanner', 'qr_scanner' => Device::MODE_BROWSER_WEDGE,
|
||||||
'badge_printer' => Device::MODE_MANUAL,
|
'badge_printer' => Device::MODE_MANUAL,
|
||||||
'thermometer', 'bp_monitor', 'pulse_ox', 'weight_scale', 'lab_analyzer', 'agent' => Device::MODE_AGENT,
|
'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
|
public function typeRequiresPro(string $type): bool
|
||||||
{
|
{
|
||||||
return in_array($type, config('care.device_agent_types', []), true);
|
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
|
public function canRegisterType(Organization $organization, string $type, PlanService $plans): bool
|
||||||
{
|
{
|
||||||
|
if ($this->isQueueDeviceType($type)) {
|
||||||
|
return $plans->hasPaidPlan($organization);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $this->typeRequiresPro($type)) {
|
if (! $this->typeRequiresPro($type)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -87,8 +111,23 @@ class DeviceService
|
|||||||
return $plans->hasPaidPlan($organization);
|
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
|
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) {
|
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_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.',
|
Device::MODE_AGENT => 'Needs the Care Device Agent on a local machine (serial / BLE / vendor SDK). Browser alone cannot talk to this hardware.',
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Care;
|
||||||
|
|
||||||
|
use App\Models\CareServiceQueue;
|
||||||
|
use App\Models\Device;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class KioskService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{service_queue_ids: list<int>, 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<int>
|
||||||
|
*/
|
||||||
|
public function assignedQueueIds(Device $device): array
|
||||||
|
{
|
||||||
|
return $this->settings($device)['service_queue_ids'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, CareServiceQueue>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $input
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -546,6 +546,8 @@ return [
|
|||||||
],
|
],
|
||||||
|
|
||||||
'device_types' => [
|
'device_types' => [
|
||||||
|
'kiosk' => 'Queue kiosk',
|
||||||
|
'display' => 'Queue display',
|
||||||
'barcode_scanner' => 'Barcode scanner',
|
'barcode_scanner' => 'Barcode scanner',
|
||||||
'qr_scanner' => 'QR scanner',
|
'qr_scanner' => 'QR scanner',
|
||||||
'badge_printer' => 'Badge / label printer',
|
'badge_printer' => 'Badge / label printer',
|
||||||
@@ -558,6 +560,12 @@ return [
|
|||||||
'other' => 'Other',
|
'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. */
|
/** Types that need a local Care Device Agent (Pro+). Wedge scanners stay free. */
|
||||||
'device_agent_types' => [
|
'device_agent_types' => [
|
||||||
'thermometer',
|
'thermometer',
|
||||||
|
|||||||
+13
-2
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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.
|
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
|
## Device registry
|
||||||
|
|
||||||
**Settings → Devices** (hospital admin):
|
**Settings → Devices** (hospital admin):
|
||||||
|
|
||||||
1. Register a device (branch-scoped).
|
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`.
|
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 heartbeats (`active` + `last_seen_at`).
|
3. Status updates from agent/kiosk heartbeats (`active` + `last_seen_at`).
|
||||||
|
|
||||||
## Browser wedge (patients)
|
## Browser wedge (patients)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import Alpine from 'alpinejs';
|
import Alpine from 'alpinejs';
|
||||||
import { registerLadillClipboard } from './ladill-clipboard';
|
import { registerLadillClipboard } from './ladill-clipboard';
|
||||||
import { registerCareDisplay } from './care-display';
|
import { registerCareDisplay } from './care-display';
|
||||||
|
import { registerCareKiosk } from './care-kiosk';
|
||||||
import collapse from '@alpinejs/collapse';
|
import collapse from '@alpinejs/collapse';
|
||||||
|
|
||||||
Alpine.plugin(collapse);
|
Alpine.plugin(collapse);
|
||||||
registerLadillClipboard(Alpine);
|
registerLadillClipboard(Alpine);
|
||||||
registerCareDisplay(Alpine);
|
registerCareDisplay(Alpine);
|
||||||
|
registerCareKiosk(Alpine);
|
||||||
|
|
||||||
// In-app notification bell + dropdown.
|
// In-app notification bell + dropdown.
|
||||||
Alpine.data('notificationDropdown', (config = {}) => ({
|
Alpine.data('notificationDropdown', (config = {}) => ({
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -3,14 +3,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<a href="{{ route('care.devices.index') }}" class="text-sm text-slate-500 hover:text-slate-800">← Devices</a>
|
<a href="{{ route('care.devices.index') }}" class="text-sm text-slate-500 hover:text-slate-800">← Devices</a>
|
||||||
<h1 class="mt-2 text-xl font-semibold text-slate-900">Register device</h1>
|
<h1 class="mt-2 text-xl font-semibold text-slate-900">Register device</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500">Inventory is branch-scoped. Agent devices get a token shown once after create.</p>
|
<p class="mt-1 text-sm text-slate-500">Queue kiosks/displays and clinical scanners/agents. Queue devices need Care Pro.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="POST" action="{{ route('care.devices.store') }}" class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
<form method="POST" action="{{ route('care.devices.store') }}" class="space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||||
@csrf
|
@csrf
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">Name</label>
|
<label class="block text-sm font-medium text-slate-700">Name</label>
|
||||||
<input type="text" name="name" value="{{ old('name') }}" required placeholder="Nurse station scanner"
|
<input type="text" name="name" value="{{ old('name') }}" required placeholder="Lobby kiosk"
|
||||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -18,39 +18,85 @@
|
|||||||
<select name="type" id="device-type" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="type" id="device-type" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
@foreach ($deviceTypes as $value => $label)
|
@foreach ($deviceTypes as $value => $label)
|
||||||
@php
|
@php
|
||||||
$needsPro = in_array($value, $agentTypes, true);
|
$needsPro = in_array($value, $agentTypes, true) || in_array($value, $queueDeviceTypes, true);
|
||||||
|
$allowed = ! $needsPro || ($value === 'kiosk' || $value === 'display' ? $hasQueueDevices : $hasAgentDevices);
|
||||||
@endphp
|
@endphp
|
||||||
<option value="{{ $value }}"
|
<option value="{{ $value }}"
|
||||||
data-mode="{{ $defaultModes[$value] ?? 'manual' }}"
|
data-mode="{{ $defaultModes[$value] ?? 'manual' }}"
|
||||||
data-pro="{{ $needsPro ? '1' : '0' }}"
|
data-queue="{{ in_array($value, $queueDeviceTypes, true) ? '1' : '0' }}"
|
||||||
@selected(old('type', 'barcode_scanner') === $value)
|
data-display="{{ $value === 'display' ? '1' : '0' }}"
|
||||||
@disabled($needsPro && ! $hasAgentDevices)>
|
@selected(old('type', $prefillType ?? 'barcode_scanner') === $value)
|
||||||
{{ $label }}@if ($needsPro && ! $hasAgentDevices) (Pro)@endif
|
@disabled(! $allowed)>
|
||||||
|
{{ $label }}@if ($needsPro && ! $allowed) (Pro)@endif
|
||||||
</option>
|
</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
@if (! $hasAgentDevices)
|
|
||||||
<p class="mt-1 text-xs text-amber-700">Clinical agent devices need Care Pro. USB barcode/QR scanners work on Free.</p>
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div id="connection-wrap">
|
||||||
<label class="block text-sm font-medium text-slate-700">Connection mode</label>
|
<label class="block text-sm font-medium text-slate-700">Connection mode</label>
|
||||||
<select name="connection_mode" id="connection-mode" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="connection_mode" id="connection-mode" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
@foreach ($connectionModes as $value => $label)
|
@foreach ($connectionModes as $value => $label)
|
||||||
<option value="{{ $value }}" @selected(old('connection_mode', 'browser_wedge') === $value)>{{ $label }}</option>
|
<option value="{{ $value }}" @selected(old('connection_mode', 'browser_wedge') === $value)>{{ $label }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
<p class="mt-1 text-xs text-slate-500" id="connection-hint">USB scanners act as a keyboard in the browser. Serial/BLE needs the local agent.</p>
|
<p class="mt-1 text-xs text-slate-500" id="connection-hint">USB scanners act as a keyboard in the browser.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">Branch</label>
|
<label class="block text-sm font-medium text-slate-700">Branch</label>
|
||||||
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="branch_id" id="branch-id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
<option value="">All branches</option>
|
<option value="">All branches</option>
|
||||||
@foreach ($branches as $branch)
|
@foreach ($branches as $branch)
|
||||||
<option value="{{ $branch->id }}" @selected(old('branch_id') == $branch->id)>{{ $branch->name }}</option>
|
<option value="{{ $branch->id }}" @selected(old('branch_id') == $branch->id)>{{ $branch->name }}</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-slate-500" id="branch-hint" hidden>Queue displays require a branch.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="queue-config" class="space-y-3 border-t border-slate-100 pt-4" hidden>
|
||||||
|
<p class="text-sm font-medium text-slate-800">Service queues</p>
|
||||||
|
<p class="text-xs text-slate-500">Leave unchecked to allow all active queues for the branch.</p>
|
||||||
|
<div class="max-h-40 space-y-2 overflow-y-auto rounded-lg border border-slate-100 p-3">
|
||||||
|
@forelse ($queues as $queue)
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="queue_ids[]" value="{{ $queue->id }}"
|
||||||
|
@checked(collect(old('queue_ids', []))->contains($queue->id))
|
||||||
|
class="rounded border-slate-300">
|
||||||
|
{{ $queue->name }} <span class="text-xs text-slate-400">({{ $queue->prefix }})</span>
|
||||||
|
</label>
|
||||||
|
@empty
|
||||||
|
<p class="text-xs text-amber-700">No service queues yet — enable service queues in Settings and check patients in.</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="kiosk-options" class="space-y-3">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="collect_name" value="1" @checked(old('collect_name')) class="rounded border-slate-300">
|
||||||
|
Collect visitor name
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="collect_phone" value="1" @checked(old('collect_phone')) class="rounded border-slate-300">
|
||||||
|
Collect phone
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Reset after (seconds)</label>
|
||||||
|
<input type="number" name="reset_seconds" min="5" max="120" value="{{ old('reset_seconds', 15) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Welcome message</label>
|
||||||
|
<input type="text" name="welcome_message" value="{{ old('welcome_message') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="Optional">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="display-options" hidden>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Display layout</label>
|
||||||
|
<select name="layout" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
@foreach ($layouts as $value => $label)
|
||||||
|
<option value="{{ $value }}" @selected(old('layout', 'standard') === $value)>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn-primary w-full">Register device</button>
|
<button type="submit" class="btn-primary w-full">Register device</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,18 +105,30 @@
|
|||||||
const typeEl = document.getElementById('device-type');
|
const typeEl = document.getElementById('device-type');
|
||||||
const modeEl = document.getElementById('connection-mode');
|
const modeEl = document.getElementById('connection-mode');
|
||||||
const hintEl = document.getElementById('connection-hint');
|
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 = {
|
const hints = {
|
||||||
browser_wedge: 'Works in the browser today as a USB keyboard wedge. No local agent required.',
|
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).',
|
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.',
|
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() {
|
function sync() {
|
||||||
const opt = typeEl.selectedOptions[0];
|
const opt = typeEl.selectedOptions[0];
|
||||||
if (!opt) return;
|
if (!opt) return;
|
||||||
const mode = opt.dataset.mode || 'manual';
|
const mode = opt.dataset.mode || 'manual';
|
||||||
|
const isQueue = opt.dataset.queue === '1';
|
||||||
|
const isDisplay = opt.dataset.display === '1';
|
||||||
modeEl.value = mode;
|
modeEl.value = mode;
|
||||||
hintEl.textContent = hints[mode] || hints.manual;
|
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);
|
typeEl.addEventListener('change', sync);
|
||||||
modeEl.addEventListener('change', () => {
|
modeEl.addEventListener('change', () => {
|
||||||
|
|||||||
@@ -8,9 +8,30 @@
|
|||||||
|
|
||||||
@if ($plainToken)
|
@if ($plainToken)
|
||||||
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
<div class="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950">
|
||||||
<p class="font-medium">Device token (copy now — shown once)</p>
|
<p class="font-medium">Device token (copy now)</p>
|
||||||
<code class="mt-2 block break-all rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800">{{ $plainToken }}</code>
|
<code class="mt-2 block break-all rounded-lg bg-white px-3 py-2 font-mono text-xs text-slate-800">{{ $plainToken }}</code>
|
||||||
<p class="mt-2 text-xs text-amber-800">Send as <code class="font-mono">X-Care-Device-Token</code> or <code class="font-mono">Authorization: Bearer …</code> from the Care Device Agent.</p>
|
@if ($device->type === 'kiosk')
|
||||||
|
<p class="mt-2 text-xs text-amber-800">Kiosk URL:
|
||||||
|
<a class="font-medium underline" href="{{ route('care.kiosk.device', $plainToken) }}" target="_blank" rel="noopener">
|
||||||
|
{{ route('care.kiosk.device', $plainToken) }}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
@elseif ($device->type !== 'display')
|
||||||
|
<p class="mt-2 text-xs text-amber-800">Send as <code class="font-mono">X-Care-Device-Token</code> from the Care Device Agent.</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@elseif ($kioskUrl)
|
||||||
|
<div class="rounded-2xl border border-teal-200 bg-teal-50 px-4 py-3 text-sm text-teal-950">
|
||||||
|
<p class="font-medium">Kiosk URL</p>
|
||||||
|
<a class="mt-2 block break-all font-mono text-xs underline" href="{{ $kioskUrl }}" target="_blank" rel="noopener">{{ $kioskUrl }}</a>
|
||||||
|
</div>
|
||||||
|
@elseif ($displayPublicUrl)
|
||||||
|
<div class="rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-950">
|
||||||
|
<p class="font-medium">Display board URL</p>
|
||||||
|
<a class="mt-2 block break-all font-mono text-xs underline" href="{{ $displayPublicUrl }}" target="_blank" rel="noopener">{{ $displayPublicUrl }}</a>
|
||||||
|
@if ($displayScreen)
|
||||||
|
<a href="{{ route('care.displays.show', $displayScreen) }}" class="mt-2 inline-block text-xs font-medium text-sky-800 underline">Manage waiting display</a>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@elseif ($device->hasToken())
|
@elseif ($device->hasToken())
|
||||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
|
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
|
||||||
@@ -28,14 +49,21 @@
|
|||||||
<label class="block text-sm font-medium text-slate-700">Type</label>
|
<label class="block text-sm font-medium text-slate-700">Type</label>
|
||||||
<select name="type" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="type" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
@foreach ($deviceTypes as $value => $label)
|
@foreach ($deviceTypes as $value => $label)
|
||||||
@php $needsPro = in_array($value, $agentTypes, true); @endphp
|
@php
|
||||||
|
$needsPro = in_array($value, $agentTypes, true) || in_array($value, $queueDeviceTypes, true);
|
||||||
|
$allowed = ! $needsPro
|
||||||
|
|| $device->type === $value
|
||||||
|
|| ($value === 'kiosk' || $value === 'display' ? $hasQueueDevices : $hasAgentDevices);
|
||||||
|
@endphp
|
||||||
<option value="{{ $value }}" @selected(old('type', $device->type) === $value)
|
<option value="{{ $value }}" @selected(old('type', $device->type) === $value)
|
||||||
@disabled($needsPro && ! $hasAgentDevices && $device->type !== $value)>
|
@disabled(! $allowed)>
|
||||||
{{ $label }}
|
{{ $label }}
|
||||||
</option>
|
</option>
|
||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (! in_array($device->type, $queueDeviceTypes, true))
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">Connection mode</label>
|
<label class="block text-sm font-medium text-slate-700">Connection mode</label>
|
||||||
<select name="connection_mode" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="connection_mode" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
@@ -44,6 +72,10 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
@else
|
||||||
|
<input type="hidden" name="connection_mode" value="{{ $device->connection_mode }}">
|
||||||
|
@endif
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-700">Status</label>
|
<label class="block text-sm font-medium text-slate-700">Status</label>
|
||||||
<select name="status" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<select name="status" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
@@ -61,6 +93,52 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (in_array($device->type, $queueDeviceTypes, true))
|
||||||
|
@php $selectedQueues = collect(old('queue_ids', $device->metadata['service_queue_ids'] ?? [])); @endphp
|
||||||
|
<div class="space-y-2 border-t border-slate-100 pt-4">
|
||||||
|
<p class="text-sm font-medium text-slate-800">Service queues</p>
|
||||||
|
<div class="max-h-40 space-y-2 overflow-y-auto rounded-lg border border-slate-100 p-3">
|
||||||
|
@foreach ($queues as $queue)
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="queue_ids[]" value="{{ $queue->id }}"
|
||||||
|
@checked($selectedQueues->contains($queue->id))
|
||||||
|
class="rounded border-slate-300">
|
||||||
|
{{ $queue->name }}
|
||||||
|
</label>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if ($device->type === 'kiosk')
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="collect_name" value="1" @checked(old('collect_name', $kioskSettings['collect_name'] ?? false)) class="rounded border-slate-300">
|
||||||
|
Collect visitor name
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" name="collect_phone" value="1" @checked(old('collect_phone', $kioskSettings['collect_phone'] ?? false)) class="rounded border-slate-300">
|
||||||
|
Collect phone
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Reset after (seconds)</label>
|
||||||
|
<input type="number" name="reset_seconds" min="5" max="120" value="{{ old('reset_seconds', $kioskSettings['reset_seconds'] ?? 15) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Welcome message</label>
|
||||||
|
<input type="text" name="welcome_message" value="{{ old('welcome_message', $kioskSettings['welcome_message'] ?? '') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@if ($device->type === 'display')
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Display layout</label>
|
||||||
|
<select name="layout" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
|
@foreach ($layouts as $value => $label)
|
||||||
|
<option value="{{ $value }}" @selected(old('layout', $displayScreen?->layout ?? 'standard') === $value)>{{ $label }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button type="submit" class="btn-primary">Save</button>
|
<button type="submit" class="btn-primary">Save</button>
|
||||||
<a href="{{ route('care.devices.index') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600">Cancel</a>
|
<a href="{{ route('care.devices.index') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm text-slate-600">Cancel</a>
|
||||||
@@ -71,7 +149,7 @@
|
|||||||
<form method="POST" action="{{ route('care.devices.token.regenerate', $device) }}">
|
<form method="POST" action="{{ route('care.devices.token.regenerate', $device) }}">
|
||||||
@csrf
|
@csrf
|
||||||
<button type="submit" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
|
<button type="submit" class="rounded-lg border border-slate-200 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-50">
|
||||||
{{ $device->hasToken() ? 'Regenerate token' : 'Issue agent token' }}
|
{{ $device->hasToken() ? 'Regenerate token' : 'Issue token' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@if ($device->hasToken())
|
@if ($device->hasToken())
|
||||||
|
|||||||
@@ -1,26 +1,33 @@
|
|||||||
<x-app-layout title="Devices">
|
<x-app-layout title="Devices">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<x-care.page-hero
|
<x-care.page-hero
|
||||||
badge="Scanners · Vitals · Lab agents"
|
badge="Queue · Scanners · Agents"
|
||||||
title="Devices"
|
title="Devices"
|
||||||
description="Branch inventory for barcode scanners (browser wedge) and agent-connected clinical devices. USB scanners work in the browser; serial/BLE hardware needs the Care Device Agent."
|
description="Register queue kiosks and waiting displays, plus barcode scanners and agent-connected clinical hardware."
|
||||||
:stats="[
|
:stats="[
|
||||||
['value' => number_format($heroStats['total']), 'label' => 'Devices'],
|
['value' => number_format($heroStats['total']), 'label' => 'Devices'],
|
||||||
|
['value' => number_format($heroStats['kiosks'] ?? 0), 'label' => 'Kiosks'],
|
||||||
|
['value' => number_format($heroStats['displays'] ?? 0), 'label' => 'Displays'],
|
||||||
['value' => number_format($heroStats['online']), 'label' => 'Online'],
|
['value' => number_format($heroStats['online']), 'label' => 'Online'],
|
||||||
['value' => number_format($heroStats['agent']), 'label' => 'Agent-linked'],
|
|
||||||
]">
|
]">
|
||||||
@if ($canManage)
|
@if ($canManage)
|
||||||
<x-slot name="actions">
|
<x-slot name="actions">
|
||||||
|
@if ($hasQueueDevices ?? false)
|
||||||
|
<a href="{{ route('care.devices.create', ['type' => 'kiosk']) }}" class="btn-secondary">Add kiosk</a>
|
||||||
|
<a href="{{ route('care.devices.create', ['type' => 'display']) }}" class="btn-secondary">Add display</a>
|
||||||
|
@endif
|
||||||
<a href="{{ route('care.devices.create') }}" class="btn-primary">Add device</a>
|
<a href="{{ route('care.devices.create') }}" class="btn-primary">Add device</a>
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@endif
|
@endif
|
||||||
</x-care.page-hero>
|
</x-care.page-hero>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm text-sky-900">
|
<div class="rounded-2xl border border-sky-100 bg-sky-50 px-4 py-3 text-sm text-sky-900">
|
||||||
<p class="font-medium">How devices connect</p>
|
<p class="font-medium">Queue devices vs clinical devices</p>
|
||||||
<ul class="mt-1 list-disc space-y-1 pl-5 text-sky-800/90">
|
<ul class="mt-1 list-disc space-y-1 pl-5 text-sky-800/90">
|
||||||
<li><strong>Barcode / QR scanners</strong> — USB keyboard wedge works in Patients and Lab today. Free on all plans.</li>
|
<li><strong>Queue kiosk</strong> — walk-up ticket issue on a tablet (Pro).</li>
|
||||||
<li><strong>Thermometers, BP, SpO₂, scales, analyzers</strong> — need a local Care Device Agent (Pro / Enterprise). See docs/devices.md.</li>
|
<li><strong>Queue display</strong> — registers a waiting-room TV board URL (Pro).</li>
|
||||||
|
<li><strong>Barcode / QR scanners</strong> — USB keyboard wedge in Patients and Lab (all plans).</li>
|
||||||
|
<li><strong>Vitals / analyzers</strong> — Care Device Agent (Pro).</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
<meta name="theme-color" content="#f1f5f9">
|
||||||
|
<title>{{ $device->name }} · Care Kiosk</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet">
|
||||||
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
<style>
|
||||||
|
@keyframes kiosk-tap-pulse {
|
||||||
|
0%, 100% { transform: scale(1); box-shadow: 0 20px 40px -12px rgb(13 148 136 / 0.45); }
|
||||||
|
50% { transform: scale(1.02); box-shadow: 0 24px 48px -10px rgb(13 148 136 / 0.55); }
|
||||||
|
}
|
||||||
|
.kiosk-tap-btn { animation: kiosk-tap-pulse 2.4s ease-in-out infinite; }
|
||||||
|
[x-cloak] { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
class="min-h-screen bg-slate-100 font-sans text-slate-900 antialiased"
|
||||||
|
x-data="careKioskFlow(@js([
|
||||||
|
'issueUrl' => route('care.kiosk.device.issue', $kioskToken),
|
||||||
|
'csrf' => csrf_token(),
|
||||||
|
'queues' => $queues->map(fn ($q) => [
|
||||||
|
'id' => $q->id,
|
||||||
|
'name' => $q->name,
|
||||||
|
])->values()->all(),
|
||||||
|
'collectName' => $settings['collect_name'],
|
||||||
|
'collectPhone' => $settings['collect_phone'],
|
||||||
|
'resetSeconds' => $settings['reset_seconds'],
|
||||||
|
'welcomeMessage' => $settings['welcome_message'] ?? null,
|
||||||
|
]))"
|
||||||
|
x-init="startTimer()"
|
||||||
|
>
|
||||||
|
<div class="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
|
||||||
|
<div class="absolute -left-24 top-0 h-72 w-72 rounded-full bg-teal-200/40 blur-3xl"></div>
|
||||||
|
<div class="absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-sky-200/40 blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed inset-x-0 top-0 z-10 h-1.5 bg-gradient-to-r from-teal-600 to-sky-600"></div>
|
||||||
|
|
||||||
|
<div class="fixed left-0 top-0 z-20 p-6">
|
||||||
|
<img src="{{ $logoUrl }}" alt="{{ $logoAlt }}" class="h-8 w-auto max-w-[220px] object-contain object-left">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col pt-20">
|
||||||
|
<template x-if="step === 'welcome'">
|
||||||
|
<div class="flex min-h-[calc(100vh-5rem)] flex-col items-center justify-center px-6 py-12 text-center">
|
||||||
|
<div class="max-w-xl">
|
||||||
|
<p class="text-sm font-semibold uppercase tracking-wider text-teal-700">Ladill Care</p>
|
||||||
|
<h1 class="mt-3 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
|
||||||
|
Welcome to {{ $organization?->name ?? 'Care' }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-4 text-lg text-slate-500" x-text="welcomeMessage || 'Tap below to get your ticket'"></p>
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="beginKiosk()" class="kiosk-tap-btn btn-primary mt-16 w-full max-w-lg py-8 text-xl font-bold" :disabled="loading">
|
||||||
|
<span x-show="!loading">Tap to begin</span>
|
||||||
|
<span x-show="loading">Please wait…</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="step === 'select'">
|
||||||
|
<div class="relative flex min-h-[calc(100vh-5rem)] flex-col items-center justify-center px-6 py-12">
|
||||||
|
<button type="button" @click="goBack()" class="absolute left-6 top-8 z-10 text-sm font-medium text-slate-500 hover:text-slate-800">← Back</button>
|
||||||
|
<div class="max-w-xl text-center">
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900 sm:text-3xl">Choose a service</h2>
|
||||||
|
<p class="mt-2 text-slate-500">Select an option to get your ticket</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="queues.length >= 1">
|
||||||
|
<div class="mt-12 grid w-full max-w-2xl gap-4" :class="queues.length > 1 ? 'sm:grid-cols-2' : ''">
|
||||||
|
<template x-for="queue in queues" :key="queue.id">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="group flex flex-col items-center rounded-3xl border-2 border-slate-200 bg-white px-6 py-10 text-center shadow-sm transition hover:border-teal-300 hover:shadow-md active:scale-[0.98]"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="selectQueue(queue.id)"
|
||||||
|
>
|
||||||
|
<span class="text-xl font-bold text-slate-900" x-text="queue.name"></span>
|
||||||
|
<span class="mt-1 text-sm text-slate-500">Tap to get ticket</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="queues.length === 0">
|
||||||
|
<div class="mt-12 w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
||||||
|
<p class="text-lg font-semibold text-slate-900">No services available</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Ask a staff member for assistance.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p x-show="error" x-cloak class="mt-6 max-w-lg rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="error"></p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="step === 'details'">
|
||||||
|
<div class="relative flex min-h-[calc(100vh-5rem)] flex-col justify-center px-6 py-8">
|
||||||
|
<button type="button" @click="goBack()" class="absolute left-6 top-8 z-10 text-sm font-medium text-slate-500 hover:text-slate-800">← Back</button>
|
||||||
|
<div class="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<p class="text-sm font-semibold text-slate-900">Your details</p>
|
||||||
|
<p x-show="error" x-cloak class="mt-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="error"></p>
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<div x-show="collectName">
|
||||||
|
<label class="block text-sm font-medium text-slate-600">Name</label>
|
||||||
|
<input type="text" x-model="customerName" placeholder="Your name" autocomplete="name" class="mt-1 w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||||
|
</div>
|
||||||
|
<div x-show="collectPhone">
|
||||||
|
<label class="block text-sm font-medium text-slate-600">Phone</label>
|
||||||
|
<input type="tel" x-model="customerPhone" placeholder="Phone number" autocomplete="tel" class="mt-1 w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-primary mt-6 w-full py-3" @click="issue()" :disabled="loading">
|
||||||
|
<span x-show="!loading">Get ticket</span>
|
||||||
|
<span x-show="loading">Please wait…</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template x-if="step === 'ticket' && ticket">
|
||||||
|
<div class="flex min-h-[calc(100vh-5rem)] flex-col items-center justify-center px-6 py-8">
|
||||||
|
<div class="w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
||||||
|
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-teal-100 text-4xl text-teal-700">✓</div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900">You're in the queue!</h2>
|
||||||
|
<p class="mt-4 text-sm font-semibold uppercase tracking-wider text-slate-500">Your ticket number</p>
|
||||||
|
<p class="mt-2 text-5xl font-bold tracking-tight text-slate-900" x-text="ticket?.ticket_number"></p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500" x-show="ticket?.queue?.name" x-text="ticket?.queue?.name"></p>
|
||||||
|
<p class="mt-6 text-sm text-slate-500">Please take a seat. Your number will be called on the display.</p>
|
||||||
|
<button type="button" class="btn-primary mt-8 w-full py-3 text-lg" @click="reset()">
|
||||||
|
Done
|
||||||
|
<span x-show="resetCountdown" x-text="'(' + resetCountdown + 's)'"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -20,6 +20,19 @@
|
|||||||
@if ($canManageQueue)
|
@if ($canManageQueue)
|
||||||
<x-slot name="actions">
|
<x-slot name="actions">
|
||||||
<a href="{{ route('care.appointments.walk-in.create') }}" class="btn-primary">Walk-in</a>
|
<a href="{{ route('care.appointments.walk-in.create') }}" class="btn-primary">Walk-in</a>
|
||||||
|
@if (app(\App\Services\Care\CarePermissions::class)->can(
|
||||||
|
auth()->user() ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user()) : null,
|
||||||
|
'devices.view'
|
||||||
|
))
|
||||||
|
<a href="{{ route('care.devices.create', ['type' => 'kiosk']) }}" class="btn-secondary">Add kiosk</a>
|
||||||
|
<a href="{{ route('care.devices.create', ['type' => 'display']) }}" class="btn-secondary">Add display</a>
|
||||||
|
@endif
|
||||||
|
@if (app(\App\Services\Care\CarePermissions::class)->can(
|
||||||
|
auth()->user() ? app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user()) : null,
|
||||||
|
'displays.view'
|
||||||
|
))
|
||||||
|
<a href="{{ route('care.displays.index') }}" class="btn-secondary">Displays</a>
|
||||||
|
@endif
|
||||||
</x-slot>
|
</x-slot>
|
||||||
@endif
|
@endif
|
||||||
</x-care.page-hero>
|
</x-care.page-hero>
|
||||||
|
|||||||
@@ -94,14 +94,14 @@
|
|||||||
<span class="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Agent · Pro</span>
|
<span class="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Agent · Pro</span>
|
||||||
@endif
|
@endif
|
||||||
<span class="mt-0.5 block text-xs text-slate-500">
|
<span class="mt-0.5 block text-xs text-slate-500">
|
||||||
Barcode scanners (browser) · agent-connected vitals / lab hardware
|
Queue kiosks & displays · barcode scanners · agent vitals / lab hardware
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-slate-400">→</span>
|
<span class="text-slate-400">→</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@endif
|
@endif
|
||||||
@if ($canViewDisplays && ($queueIntegrationEnabled || $canUseQueueIntegration))
|
@if ($canViewDisplays && ! empty($hasPaidPlan))
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ route('care.displays.index') }}"
|
<a href="{{ route('care.displays.index') }}"
|
||||||
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
|
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
|
||||||
@@ -116,6 +116,21 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@endif
|
@endif
|
||||||
|
@if ($canViewDevices && ! empty($hasPaidPlan))
|
||||||
|
<li>
|
||||||
|
<a href="{{ route('care.devices.create', ['type' => 'kiosk']) }}"
|
||||||
|
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
|
||||||
|
<span>
|
||||||
|
Queue kiosk
|
||||||
|
<span class="ml-2 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">Pro</span>
|
||||||
|
<span class="mt-0.5 block text-xs text-slate-500">
|
||||||
|
Walk-up ticket issue on a tablet
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-slate-400">→</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
@endif
|
||||||
<li>
|
<li>
|
||||||
<a href="{{ route('care.settings.gp-pricing') }}"
|
<a href="{{ route('care.settings.gp-pricing') }}"
|
||||||
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
|
class="flex items-center justify-between rounded-xl border border-slate-100 bg-slate-50 px-4 py-3 text-sm text-slate-700 hover:text-indigo-700">
|
||||||
|
|||||||
@@ -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' => '<path stroke-linecap="round" stroke-linejoin="round" d="M6 20.25h12m-7.5-4v4m3-4v4M6.75 4.5h10.5a2.25 2.25 0 0 1 2.25 2.25v6.75a2.25 2.25 0 0 1-2.25 2.25H6.75A2.25 2.25 0 0 1 4.5 13.5V6.75A2.25 2.25 0 0 1 6.75 4.5Z" />'];
|
||||||
|
}
|
||||||
|
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' => '<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />'];
|
||||||
|
}
|
||||||
|
|
||||||
// Clinical assessments (layered intake / pathways) — on by default; toggle in Settings.
|
// Clinical assessments (layered intake / pathways) — on by default; toggle in Settings.
|
||||||
$assessmentsEngineOn = $organization
|
$assessmentsEngineOn = $organization
|
||||||
&& app(\App\Services\Care\CareFeatures::class)->enabled(
|
&& app(\App\Services\Care\CareFeatures::class)->enabled(
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ Route::get('/display/{token}/data', [DisplayPublicController::class, 'data'])->n
|
|||||||
Route::post('/display/{token}/announcements/{announcement}/played', [DisplayPublicController::class, 'played'])
|
Route::post('/display/{token}/announcements/{announcement}/played', [DisplayPublicController::class, 'played'])
|
||||||
->name('care.display.announcement.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::middleware(['auth', 'platform.session'])->group(function () {
|
||||||
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
|
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
|
||||||
Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread');
|
Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread');
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsurePlatformSession;
|
||||||
|
use App\Models\Branch;
|
||||||
|
use App\Models\CareDisplayScreen;
|
||||||
|
use App\Models\CareQueueTicket;
|
||||||
|
use App\Models\CareServiceQueue;
|
||||||
|
use App\Models\Device;
|
||||||
|
use App\Models\Member;
|
||||||
|
use App\Models\Organization;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\Care\DeviceService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CareQueueDevicesTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected User $owner;
|
||||||
|
|
||||||
|
protected Organization $organization;
|
||||||
|
|
||||||
|
protected Branch $branch;
|
||||||
|
|
||||||
|
protected CareServiceQueue $queue;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user