Add visitor self-checkout on the kiosk.
Deploy Ladill Frontdesk / deploy (push) Successful in 46s

Let guests check out by badge code or QR scan before leaving, with device and staff kiosk API routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 09:07:39 +00:00
co-authored by Cursor
parent fedc1b2763
commit 0c092b7711
8 changed files with 505 additions and 3 deletions
@@ -9,9 +9,11 @@ use App\Models\Visit;
use App\Services\Frontdesk\VisitCheckInService; use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\VisitCheckOutService; use App\Services\Frontdesk\VisitCheckOutService;
use App\Services\Frontdesk\VisitorTypeService; use App\Services\Frontdesk\VisitorTypeService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
use Illuminate\Validation\ValidationException;
class KioskController extends Controller class KioskController extends Controller
{ {
@@ -36,6 +38,7 @@ class KioskController extends Controller
'typeConfigs' => $visitorTypes->configsForFrontend(), 'typeConfigs' => $visitorTypes->configsForFrontend(),
'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)), 'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)),
'visitorPolicy' => $settings['visitor_policy'] ?? null, 'visitorPolicy' => $settings['visitor_policy'] ?? null,
'checkOutUrl' => route('frontdesk.kiosk.check-out'),
// Staff sign-in requires a registered kiosk device (/kiosk/d/{token}). // Staff sign-in requires a registered kiosk device (/kiosk/d/{token}).
'employeeKioskEnabled' => false, 'employeeKioskEnabled' => false,
'stepOutReasons' => collect(config('frontdesk.employee_step_out_reasons')) 'stepOutReasons' => collect(config('frontdesk.employee_step_out_reasons'))
@@ -86,4 +89,46 @@ class KioskController extends Controller
], ],
]); ]);
} }
public function checkOut(Request $request, VisitCheckOutService $checkOut): JsonResponse
{
$this->authorizeAbility($request, 'kiosk.use');
$organization = $this->organization($request);
$validated = $request->validate([
'badge_code' => ['nullable', 'string', 'max:32'],
'qr_token' => ['nullable', 'string', 'max:500'],
]);
if (trim((string) ($validated['badge_code'] ?? '')) === '' && trim((string) ($validated['qr_token'] ?? '')) === '') {
throw ValidationException::withMessages([
'credentials' => 'Enter your badge code or scan your badge QR code.',
]);
}
$branchScope = app(\App\Services\Frontdesk\OrganizationResolver::class)
->branchScope($this->member($request));
try {
$visit = $checkOut->checkOutFromKiosk(
$this->ownerRef($request),
$organization,
$validated['badge_code'] ?? null,
$validated['qr_token'] ?? null,
$branchScope,
);
} catch (ModelNotFoundException) {
throw ValidationException::withMessages([
'credentials' => 'No active visit found for that badge. Ask reception if you need help.',
]);
}
return response()->json([
'visit' => [
'visitor_name' => $visit->visitor->full_name,
'badge_code' => $visit->badge_code,
'checked_out_at' => $visit->checked_out_at?->toIso8601String(),
],
]);
}
} }
@@ -8,7 +8,9 @@ use App\Models\Host;
use App\Models\Organization; use App\Models\Organization;
use App\Services\Frontdesk\EmployeePresenceService; use App\Services\Frontdesk\EmployeePresenceService;
use App\Services\Frontdesk\VisitCheckInService; use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\VisitCheckOutService;
use App\Services\Frontdesk\VisitorTypeService; use App\Services\Frontdesk\VisitorTypeService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
@@ -44,6 +46,7 @@ class KioskDeviceController extends Controller
'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)), 'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)),
'visitorPolicy' => $settings['visitor_policy'] ?? null, 'visitorPolicy' => $settings['visitor_policy'] ?? null,
'checkInUrl' => route('frontdesk.kiosk.device.check-in', $device->device_token), 'checkInUrl' => route('frontdesk.kiosk.device.check-in', $device->device_token),
'checkOutUrl' => route('frontdesk.kiosk.device.check-out', $device->device_token),
'employeeKioskEnabled' => $employeeKioskEnabled, 'employeeKioskEnabled' => $employeeKioskEnabled,
'stepOutReasons' => collect(config('frontdesk.employee_step_out_reasons')) 'stepOutReasons' => collect(config('frontdesk.employee_step_out_reasons'))
->map(fn ($label, $key) => ['key' => $key, 'label' => $label]) ->map(fn ($label, $key) => ['key' => $key, 'label' => $label])
@@ -160,6 +163,45 @@ class KioskDeviceController extends Controller
]); ]);
} }
public function checkOut(Request $request, VisitCheckOutService $checkOut): JsonResponse
{
$device = $this->device($request);
$organization = Organization::findOrFail($device->organization_id);
$validated = $request->validate([
'badge_code' => ['nullable', 'string', 'max:32'],
'qr_token' => ['nullable', 'string', 'max:500'],
]);
if (trim((string) ($validated['badge_code'] ?? '')) === '' && trim((string) ($validated['qr_token'] ?? '')) === '') {
throw ValidationException::withMessages([
'credentials' => 'Enter your badge code or scan your badge QR code.',
]);
}
try {
$visit = $checkOut->checkOutFromKiosk(
$device->owner_ref,
$organization,
$validated['badge_code'] ?? null,
$validated['qr_token'] ?? null,
$device->branch_id,
);
} catch (ModelNotFoundException) {
throw ValidationException::withMessages([
'credentials' => 'No active visit found for that badge. Ask reception if you need help.',
]);
}
return response()->json([
'visit' => [
'visitor_name' => $visit->visitor->full_name,
'badge_code' => $visit->badge_code,
'checked_out_at' => $visit->checked_out_at?->toIso8601String(),
],
]);
}
protected function device(Request $request): Device protected function device(Request $request): Device
{ {
return $request->attributes->get('frontdesk.device') return $request->attributes->get('frontdesk.device')
+1 -1
View File
@@ -45,7 +45,7 @@ class DeviceService
{ {
return match ($type) { return match ($type) {
'badge_printer' => ['driver' => config('frontdesk.printers.default_driver', 'pdf')], 'badge_printer' => ['driver' => config('frontdesk.printers.default_driver', 'pdf')],
'kiosk' => ['mode' => 'self_service', 'allow_checkout' => false], 'kiosk' => ['mode' => 'self_service', 'allow_checkout' => true],
default => [], default => [],
}; };
} }
@@ -3,6 +3,7 @@
namespace App\Services\Frontdesk; namespace App\Services\Frontdesk;
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\Organization;
use App\Models\Visit; use App\Models\Visit;
class VisitCheckOutService class VisitCheckOutService
@@ -47,4 +48,63 @@ class VisitCheckOutService
return $this->checkOut($visit, $actorRef); return $this->checkOut($visit, $actorRef);
} }
public function checkOutFromKiosk(
string $ownerRef,
Organization $organization,
?string $badgeCode = null,
?string $qrInput = null,
?int $branchId = null,
): Visit {
$visit = $this->resolveCheckedInVisitForKiosk($ownerRef, $organization, $badgeCode, $qrInput, $branchId);
return $this->checkOut($visit);
}
protected function resolveCheckedInVisitForKiosk(
string $ownerRef,
Organization $organization,
?string $badgeCode,
?string $qrInput,
?int $branchId,
): Visit {
$query = Visit::owned($ownerRef)
->where('organization_id', $organization->id)
->currentlyInside()
->with('visitor');
if ($branchId !== null) {
$query->where(function ($q) use ($branchId) {
$q->whereNull('branch_id')->orWhere('branch_id', $branchId);
});
}
$badgeCode = strtoupper(trim((string) $badgeCode));
if ($badgeCode !== '') {
return (clone $query)->where('badge_code', $badgeCode)->firstOrFail();
}
$qrToken = $this->parseVisitQrToken((string) $qrInput);
abort_unless($qrToken !== null, 422, 'Invalid badge scan.');
return (clone $query)->where('qr_token', $qrToken)->firstOrFail();
}
protected function parseVisitQrToken(string $input): ?string
{
$trimmed = trim($input);
if ($trimmed === '') {
return null;
}
if (preg_match('#/q/([A-Za-z0-9]+)#', $trimmed, $matches)) {
return $matches[1];
}
if (preg_match('/^[A-Za-z0-9]{16,64}$/', $trimmed)) {
return $trimmed;
}
return null;
}
} }
+201
View File
@@ -29,6 +29,14 @@ export function registerKioskFlow(Alpine) {
typeConfigs: config.typeConfigs || {}, typeConfigs: config.typeConfigs || {},
resetSeconds: config.resetSeconds || 120, resetSeconds: config.resetSeconds || 120,
checkInUrl: config.checkInUrl || '', checkInUrl: config.checkInUrl || '',
checkOutUrl: config.checkOutUrl || '',
checkoutIdentifyMode: 'code',
checkoutForm: { badge_code: '', qr_token: '' },
checkoutQrScanner: null,
checkoutQrScanning: false,
checkoutQrScanned: false,
checkoutQrError: '',
checkoutResult: null,
signing: { active: false, field: null }, signing: { active: false, field: null },
canvases: {}, canvases: {},
cameraActive: false, cameraActive: false,
@@ -181,6 +189,9 @@ export function registerKioskFlow(Alpine) {
}, },
async goBack() { async goBack() {
if (this.step === 'checkout_identify') {
await this.stopCheckoutQrScanner(true);
}
if (this.step === 'staff_identify') { if (this.step === 'staff_identify') {
if (this.staffFormStep === 2) { if (this.staffFormStep === 2) {
this.staffFormStep = 1; this.staffFormStep = 1;
@@ -209,6 +220,8 @@ export function registerKioskFlow(Alpine) {
this.step = 'staff_identify'; this.step = 'staff_identify';
} else if (this.step === 'staff_step_out') { } else if (this.step === 'staff_step_out') {
this.step = 'staff_actions'; this.step = 'staff_actions';
} else if (this.step === 'checkout_identify') {
this.step = this.employeeKioskEnabled ? 'choose' : 'welcome';
} }
this.errorMessage = ''; this.errorMessage = '';
this.resetTimer(); this.resetTimer();
@@ -225,12 +238,18 @@ export function registerKioskFlow(Alpine) {
reset() { reset() {
this.stopStaffQrScanner(true); this.stopStaffQrScanner(true);
this.stopCheckoutQrScanner(true);
this.stopCamera(); this.stopCamera();
this.step = 'welcome'; this.step = 'welcome';
this.formStep = 1; this.formStep = 1;
this.typeIndex = 0; this.typeIndex = 0;
this.errorMessage = ''; this.errorMessage = '';
this.result = null; this.result = null;
this.checkoutResult = null;
this.checkoutIdentifyMode = 'code';
this.checkoutForm = { badge_code: '', qr_token: '' };
this.checkoutQrScanned = false;
this.checkoutQrError = '';
this.staffEmployee = null; this.staffEmployee = null;
this.staffMessage = ''; this.staffMessage = '';
this.staffBranchWarning = ''; this.staffBranchWarning = '';
@@ -262,6 +281,188 @@ export function registerKioskFlow(Alpine) {
this.resetTimer(); this.resetTimer();
}, },
async startCheckoutFlow() {
await this.stopCheckoutQrScanner(true);
this.step = 'checkout_identify';
this.errorMessage = '';
this.checkoutIdentifyMode = 'code';
this.checkoutForm = { badge_code: '', qr_token: '' };
this.checkoutQrScanned = false;
this.checkoutQrError = '';
this.resetTimer();
},
async setCheckoutIdentifyMode(mode) {
if (this.checkoutIdentifyMode === mode) {
return;
}
await this.stopCheckoutQrScanner(true);
this.checkoutIdentifyMode = mode;
this.checkoutForm = { badge_code: '', qr_token: '' };
this.checkoutQrError = '';
if (mode === 'badge') {
await this.$nextTick();
await this.startCheckoutQrScanner();
}
},
parseVisitQr(text) {
const trimmed = String(text || '').trim();
if (trimmed === '') {
return null;
}
const urlMatch = trimmed.match(/\/q\/([A-Za-z0-9]+)/);
if (urlMatch) {
return urlMatch[1];
}
if (/^[A-Za-z0-9]{16,64}$/.test(trimmed)) {
return trimmed;
}
return null;
},
async startCheckoutQrScanner() {
if (this.checkoutQrScanning || this.checkoutQrScanned || this.checkoutIdentifyMode !== 'badge' || this.step !== 'checkout_identify') {
return;
}
this.checkoutQrError = '';
try {
const { Html5Qrcode } = await import('html5-qrcode');
await this.$nextTick();
if (! document.getElementById('checkout-qr-reader')) {
return;
}
if (this.checkoutQrScanner) {
await this.stopCheckoutQrScanner(false);
}
this.checkoutQrScanner = new Html5Qrcode('checkout-qr-reader');
await this.checkoutQrScanner.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 260, height: 260 } },
(decodedText) => this.handleCheckoutBadgeScan(decodedText),
() => {},
);
this.checkoutQrScanning = true;
} catch (e) {
this.checkoutQrError = 'Camera unavailable. Enter your badge code instead.';
console.error('Checkout QR scanner failed', e);
}
},
async handleCheckoutBadgeScan(decodedText) {
if (! this.parseVisitQr(decodedText)) {
return;
}
this.checkoutForm.qr_token = decodedText.trim();
this.checkoutQrScanned = true;
this.errorMessage = '';
await this.stopCheckoutQrScanner(false);
await this.submitCheckOut();
},
async rescanCheckoutBadge() {
this.checkoutQrScanned = false;
this.checkoutForm.qr_token = '';
this.checkoutQrError = '';
await this.$nextTick();
await this.startCheckoutQrScanner();
},
async stopCheckoutQrScanner(clearScanned = false) {
if (this.checkoutQrScanner) {
try {
if (this.checkoutQrScanning) {
await this.checkoutQrScanner.stop();
}
await this.checkoutQrScanner.clear();
} catch (e) {
// Scanner may already be stopped when leaving the step.
}
this.checkoutQrScanner = null;
this.checkoutQrScanning = false;
}
if (clearScanned) {
this.checkoutQrScanned = false;
this.checkoutForm.qr_token = '';
this.checkoutQrError = '';
}
},
validateCheckoutIdentify() {
if (this.checkoutIdentifyMode === 'code') {
if (! String(this.checkoutForm.badge_code || '').trim()) {
this.showError('Please enter your badge code.');
return false;
}
} else if (! String(this.checkoutForm.qr_token || '').trim()) {
this.showError('Scan your badge QR code to continue.');
return false;
}
this.errorMessage = '';
return true;
},
checkoutCredentials() {
const body = {};
if (this.checkoutIdentifyMode === 'badge' && this.checkoutForm.qr_token.trim()) {
body.qr_token = this.checkoutForm.qr_token.trim();
} else {
body.badge_code = this.checkoutForm.badge_code.trim();
}
return body;
},
async submitCheckOut() {
if (! this.validateCheckoutIdentify()) {
return;
}
if (! this.checkOutUrl) {
this.showError('Check-out is not available on this kiosk.');
return;
}
this.loading = true;
this.errorMessage = '';
try {
await this.stopCheckoutQrScanner(false);
const res = await fetch(this.checkOutUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'application/json',
},
body: JSON.stringify(this.checkoutCredentials()),
});
const data = await res.json();
if (! res.ok) {
const msg = data.message || data.errors?.credentials?.[0] || 'Check-out failed.';
throw new Error(msg);
}
this.checkoutResult = data.visit;
this.step = 'checkout_done';
this.resetTimer();
} catch (e) {
this.showError(e.message || 'Check-out failed. Please ask reception for help.');
} finally {
this.loading = false;
}
},
startStaffFlow() { startStaffFlow() {
this.stopStaffQrScanner(true); this.stopStaffQrScanner(true);
this.step = 'staff_identify'; this.step = 'staff_identify';
@@ -19,6 +19,7 @@
typeConfigs: @json($typeConfigs), typeConfigs: @json($typeConfigs),
resetSeconds: {{ $resetSeconds }}, resetSeconds: {{ $resetSeconds }},
checkInUrl: @json($checkInUrl ?? route('frontdesk.kiosk.check-in')), checkInUrl: @json($checkInUrl ?? route('frontdesk.kiosk.check-in')),
checkOutUrl: @json($checkOutUrl ?? route('frontdesk.kiosk.check-out')),
employeeKioskEnabled: @json($employeeKioskEnabled ?? false), employeeKioskEnabled: @json($employeeKioskEnabled ?? false),
staffActionUrl: @json($staffActionUrl ?? ''), staffActionUrl: @json($staffActionUrl ?? ''),
stepOutReasons: @json($stepOutReasons ?? []), stepOutReasons: @json($stepOutReasons ?? []),
@@ -33,6 +34,8 @@
.kiosk-tap-btn { animation: kiosk-tap-pulse 2.4s ease-in-out infinite; } .kiosk-tap-btn { animation: kiosk-tap-pulse 2.4s ease-in-out infinite; }
#staff-qr-reader { min-height: 280px; } #staff-qr-reader { min-height: 280px; }
#staff-qr-reader video { border-radius: 0.75rem; width: 100% !important; } #staff-qr-reader video { border-radius: 0.75rem; width: 100% !important; }
#checkout-qr-reader { min-height: 280px; }
#checkout-qr-reader video { border-radius: 0.75rem; width: 100% !important; }
</style> </style>
</head> </head>
<body class="min-h-screen bg-slate-100 font-sans text-slate-900 antialiased" x-data="kioskFlow" x-init="startTimer()"> <body class="min-h-screen bg-slate-100 font-sans text-slate-900 antialiased" x-data="kioskFlow" x-init="startTimer()">
@@ -53,13 +56,19 @@
<h1 class="mt-3 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl"> <h1 class="mt-3 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
Welcome to {{ $organization->name }} Welcome to {{ $organization->name }}
</h1> </h1>
<p class="mt-4 text-lg text-slate-500">Visitor check-in &amp; staff sign-in</p> <p class="mt-4 text-lg text-slate-500">Visitor check-in, check-out &amp; staff sign-in</p>
</div> </div>
<button type="button" <button type="button"
@click="beginKiosk()" @click="beginKiosk()"
class="kiosk-tap-btn btn-primary btn-primary-lg mt-16 w-full max-w-lg py-8 text-xl font-bold"> class="kiosk-tap-btn btn-primary btn-primary-lg mt-16 w-full max-w-lg py-8 text-xl font-bold">
Tap to begin Tap to check in
</button>
<button type="button"
@click="startCheckoutFlow()"
class="mt-4 w-full max-w-lg rounded-2xl border-2 border-slate-200 bg-white py-5 text-lg font-semibold text-slate-700 shadow-sm transition hover:border-indigo-300 hover:bg-slate-50 active:scale-[0.98]">
Check out
</button> </button>
</div> </div>
</template> </template>
@@ -115,6 +124,12 @@
</button> </button>
</template> </template>
</div> </div>
<button type="button"
@click="startCheckoutFlow()"
class="mt-8 text-sm font-medium text-slate-500 underline decoration-slate-300 underline-offset-4 hover:text-indigo-600">
Leaving? Check out here
</button>
</div> </div>
</template> </template>
@@ -578,6 +593,85 @@
</div> </div>
</template> </template>
{{-- Visitor checkout --}}
<template x-if="step === 'checkout_identify'">
<div class="relative flex min-h-[calc(100vh-5rem)] flex-col px-6 py-8">
<div class="absolute left-6 top-8 z-10">
@include('frontdesk.partials.kiosk-back')
</div>
<div class="mx-auto w-full max-w-lg pt-16">
<h2 class="text-2xl font-bold text-slate-900">Check out</h2>
<p class="mt-2 text-slate-500">Enter your badge code or scan the QR code on your visitor badge.</p>
<div class="mt-8 grid grid-cols-2 gap-2 rounded-xl bg-slate-100 p-1">
<button type="button"
@click="setCheckoutIdentifyMode('code')"
:class="checkoutIdentifyMode === 'code' ? 'bg-white shadow text-slate-900' : 'text-slate-500'"
class="rounded-lg px-4 py-3 text-sm font-semibold transition">
Badge code
</button>
<button type="button"
@click="setCheckoutIdentifyMode('badge')"
:class="checkoutIdentifyMode === 'badge' ? 'bg-white shadow text-slate-900' : 'text-slate-500'"
class="rounded-lg px-4 py-3 text-sm font-semibold transition">
Scan badge
</button>
</div>
<template x-if="checkoutIdentifyMode === 'code'">
<div class="mt-6">
<label class="block text-sm font-medium text-slate-700">Badge code</label>
<input type="text"
x-model="checkoutForm.badge_code"
placeholder="e.g. ABC12345"
autocomplete="off"
class="mt-2 w-full rounded-xl border-slate-200 px-4 py-4 text-lg font-mono uppercase tracking-widest">
</div>
</template>
<template x-if="checkoutIdentifyMode === 'badge'">
<div class="mt-6">
<template x-if="!checkoutQrScanned">
<div>
<div id="checkout-qr-reader" class="overflow-hidden rounded-xl border border-slate-200 bg-slate-50"></div>
<p x-show="checkoutQrError" x-text="checkoutQrError" class="mt-3 text-sm text-amber-700"></p>
</div>
</template>
<template x-if="checkoutQrScanned">
<div class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-800">
Badge scanned. Checking you out…
</div>
</template>
</div>
</template>
<p x-show="errorMessage" x-text="errorMessage" class="mt-4 text-sm text-red-600"></p>
<button type="button"
x-show="checkoutIdentifyMode === 'code'"
@click="submitCheckOut()"
:disabled="loading"
class="btn-primary btn-primary-lg mt-8 w-full disabled:opacity-50">
<span x-text="loading ? 'Checking out…' : 'Check out'"></span>
</button>
</div>
</div>
</template>
{{-- Visitor checkout done --}}
<template x-if="step === 'checkout_done'">
<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-emerald-100 text-4xl"></div>
<h2 class="text-2xl font-bold">You're checked out</h2>
<p class="mt-2 text-slate-600" x-text="checkoutResult?.visitor_name"></p>
<p class="mt-4 text-sm text-slate-500">Thank you for visiting. Have a safe trip.</p>
<button type="button" @click="reset()" class="btn-primary btn-primary-lg mt-8 w-full">Done</button>
</div>
</div>
</template>
{{-- Done --}} {{-- Done --}}
<template x-if="step === 'done'"> <template x-if="step === 'done'">
<div class="flex min-h-[calc(100vh-5rem)] flex-col items-center justify-center px-6 py-8"> <div class="flex min-h-[calc(100vh-5rem)] flex-col items-center justify-center px-6 py-8">
+2
View File
@@ -53,6 +53,7 @@ Route::get('/integrations/ical/{organization}', IcalFeedController::class)->name
Route::middleware(['frontdesk.device:kiosk', 'throttle:kiosk-device'])->prefix('kiosk/d')->group(function () { Route::middleware(['frontdesk.device:kiosk', 'throttle:kiosk-device'])->prefix('kiosk/d')->group(function () {
Route::get('/{token}', [KioskDeviceController::class, 'show'])->name('frontdesk.kiosk.device'); Route::get('/{token}', [KioskDeviceController::class, 'show'])->name('frontdesk.kiosk.device');
Route::post('/{token}/check-in', [KioskDeviceController::class, 'checkIn'])->name('frontdesk.kiosk.device.check-in'); Route::post('/{token}/check-in', [KioskDeviceController::class, 'checkIn'])->name('frontdesk.kiosk.device.check-in');
Route::post('/{token}/check-out', [KioskDeviceController::class, 'checkOut'])->name('frontdesk.kiosk.device.check-out');
Route::post('/{token}/staff/action', [KioskDeviceController::class, 'staffAction'])->name('frontdesk.kiosk.device.staff.action'); Route::post('/{token}/staff/action', [KioskDeviceController::class, 'staffAction'])->name('frontdesk.kiosk.device.staff.action');
}); });
@@ -127,6 +128,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/kiosk', [KioskController::class, 'show'])->name('frontdesk.kiosk'); Route::get('/kiosk', [KioskController::class, 'show'])->name('frontdesk.kiosk');
Route::post('/kiosk/check-in', [KioskController::class, 'checkIn'])->name('frontdesk.kiosk.check-in'); Route::post('/kiosk/check-in', [KioskController::class, 'checkIn'])->name('frontdesk.kiosk.check-in');
Route::post('/kiosk/check-out', [KioskController::class, 'checkOut'])->name('frontdesk.kiosk.check-out');
Route::get('/host', [HostPortalController::class, 'index'])->name('frontdesk.host.index'); Route::get('/host', [HostPortalController::class, 'index'])->name('frontdesk.host.index');
Route::get('/host/schedule', [HostPortalController::class, 'scheduleForm'])->name('frontdesk.host.schedule'); Route::get('/host/schedule', [HostPortalController::class, 'scheduleForm'])->name('frontdesk.host.schedule');
+58
View File
@@ -8,6 +8,8 @@ use App\Models\Device;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Models\Visit;
use App\Models\Visitor;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\RateLimiter;
@@ -122,4 +124,60 @@ class FrontdeskPhase11Test extends TestCase
->assertOk() ->assertOk()
->assertJsonPath('status', 'already_synced'); ->assertJsonPath('status', 'already_synced');
} }
public function test_kiosk_device_can_check_out_visitor_by_badge_code(): void
{
$device = Device::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'Checkout Kiosk',
'type' => 'kiosk',
'device_token' => 'checkout-kiosk-token',
'status' => 'online',
]);
$visitor = Visitor::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'full_name' => 'Leaving Guest',
]);
$visit = Visit::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'visitor_id' => $visitor->id,
'visitor_type' => 'visitor',
'status' => Visit::STATUS_CHECKED_IN,
'checked_in_at' => now(),
'badge_code' => 'LEAVE123',
'qr_token' => 'qr-checkout-token-32chars-long-ok',
]);
$this->postJson(route('frontdesk.kiosk.device.check-out', $device->device_token), [
'badge_code' => 'leave123',
])
->assertOk()
->assertJsonPath('visit.visitor_name', 'Leaving Guest')
->assertJsonPath('visit.badge_code', 'LEAVE123');
$this->assertSame(Visit::STATUS_CHECKED_OUT, $visit->fresh()->status);
}
public function test_kiosk_device_check_out_rejects_unknown_badge(): void
{
$device = Device::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'name' => 'Checkout Kiosk',
'type' => 'kiosk',
'device_token' => 'checkout-kiosk-token-2',
'status' => 'online',
]);
$this->postJson(route('frontdesk.kiosk.device.check-out', $device->device_token), [
'badge_code' => 'UNKNOWN1',
])
->assertStatus(422)
->assertJsonValidationErrors('credentials');
}
} }