diff --git a/app/Http/Controllers/Frontdesk/KioskController.php b/app/Http/Controllers/Frontdesk/KioskController.php index 97f4654..a77a3bc 100644 --- a/app/Http/Controllers/Frontdesk/KioskController.php +++ b/app/Http/Controllers/Frontdesk/KioskController.php @@ -9,9 +9,11 @@ use App\Models\Visit; use App\Services\Frontdesk\VisitCheckInService; use App\Services\Frontdesk\VisitCheckOutService; use App\Services\Frontdesk\VisitorTypeService; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\View\View; +use Illuminate\Validation\ValidationException; class KioskController extends Controller { @@ -36,6 +38,7 @@ class KioskController extends Controller 'typeConfigs' => $visitorTypes->configsForFrontend(), 'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)), 'visitorPolicy' => $settings['visitor_policy'] ?? null, + 'checkOutUrl' => route('frontdesk.kiosk.check-out'), // Staff sign-in requires a registered kiosk device (/kiosk/d/{token}). 'employeeKioskEnabled' => false, '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(), + ], + ]); + } } diff --git a/app/Http/Controllers/Frontdesk/KioskDeviceController.php b/app/Http/Controllers/Frontdesk/KioskDeviceController.php index b3e44c7..48a3f2c 100644 --- a/app/Http/Controllers/Frontdesk/KioskDeviceController.php +++ b/app/Http/Controllers/Frontdesk/KioskDeviceController.php @@ -8,7 +8,9 @@ use App\Models\Host; use App\Models\Organization; use App\Services\Frontdesk\EmployeePresenceService; use App\Services\Frontdesk\VisitCheckInService; +use App\Services\Frontdesk\VisitCheckOutService; use App\Services\Frontdesk\VisitorTypeService; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; 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)), 'visitorPolicy' => $settings['visitor_policy'] ?? null, 'checkInUrl' => route('frontdesk.kiosk.device.check-in', $device->device_token), + 'checkOutUrl' => route('frontdesk.kiosk.device.check-out', $device->device_token), 'employeeKioskEnabled' => $employeeKioskEnabled, 'stepOutReasons' => collect(config('frontdesk.employee_step_out_reasons')) ->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 { return $request->attributes->get('frontdesk.device') diff --git a/app/Services/Frontdesk/DeviceService.php b/app/Services/Frontdesk/DeviceService.php index c71dc50..8d6db67 100644 --- a/app/Services/Frontdesk/DeviceService.php +++ b/app/Services/Frontdesk/DeviceService.php @@ -45,7 +45,7 @@ class DeviceService { return match ($type) { 'badge_printer' => ['driver' => config('frontdesk.printers.default_driver', 'pdf')], - 'kiosk' => ['mode' => 'self_service', 'allow_checkout' => false], + 'kiosk' => ['mode' => 'self_service', 'allow_checkout' => true], default => [], }; } diff --git a/app/Services/Frontdesk/VisitCheckOutService.php b/app/Services/Frontdesk/VisitCheckOutService.php index c42f9c7..49d2e52 100644 --- a/app/Services/Frontdesk/VisitCheckOutService.php +++ b/app/Services/Frontdesk/VisitCheckOutService.php @@ -3,6 +3,7 @@ namespace App\Services\Frontdesk; use App\Models\AuditLog; +use App\Models\Organization; use App\Models\Visit; class VisitCheckOutService @@ -47,4 +48,63 @@ class VisitCheckOutService 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; + } } diff --git a/resources/js/kiosk-flow.js b/resources/js/kiosk-flow.js index 90c931c..8799be4 100644 --- a/resources/js/kiosk-flow.js +++ b/resources/js/kiosk-flow.js @@ -29,6 +29,14 @@ export function registerKioskFlow(Alpine) { typeConfigs: config.typeConfigs || {}, resetSeconds: config.resetSeconds || 120, 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 }, canvases: {}, cameraActive: false, @@ -181,6 +189,9 @@ export function registerKioskFlow(Alpine) { }, async goBack() { + if (this.step === 'checkout_identify') { + await this.stopCheckoutQrScanner(true); + } if (this.step === 'staff_identify') { if (this.staffFormStep === 2) { this.staffFormStep = 1; @@ -209,6 +220,8 @@ export function registerKioskFlow(Alpine) { this.step = 'staff_identify'; } else if (this.step === 'staff_step_out') { this.step = 'staff_actions'; + } else if (this.step === 'checkout_identify') { + this.step = this.employeeKioskEnabled ? 'choose' : 'welcome'; } this.errorMessage = ''; this.resetTimer(); @@ -225,12 +238,18 @@ export function registerKioskFlow(Alpine) { reset() { this.stopStaffQrScanner(true); + this.stopCheckoutQrScanner(true); this.stopCamera(); this.step = 'welcome'; this.formStep = 1; this.typeIndex = 0; this.errorMessage = ''; this.result = null; + this.checkoutResult = null; + this.checkoutIdentifyMode = 'code'; + this.checkoutForm = { badge_code: '', qr_token: '' }; + this.checkoutQrScanned = false; + this.checkoutQrError = ''; this.staffEmployee = null; this.staffMessage = ''; this.staffBranchWarning = ''; @@ -262,6 +281,188 @@ export function registerKioskFlow(Alpine) { 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() { this.stopStaffQrScanner(true); this.step = 'staff_identify'; diff --git a/resources/views/frontdesk/kiosk/index.blade.php b/resources/views/frontdesk/kiosk/index.blade.php index 0b258dc..0847437 100644 --- a/resources/views/frontdesk/kiosk/index.blade.php +++ b/resources/views/frontdesk/kiosk/index.blade.php @@ -19,6 +19,7 @@ typeConfigs: @json($typeConfigs), resetSeconds: {{ $resetSeconds }}, checkInUrl: @json($checkInUrl ?? route('frontdesk.kiosk.check-in')), + checkOutUrl: @json($checkOutUrl ?? route('frontdesk.kiosk.check-out')), employeeKioskEnabled: @json($employeeKioskEnabled ?? false), staffActionUrl: @json($staffActionUrl ?? ''), stepOutReasons: @json($stepOutReasons ?? []), @@ -33,6 +34,8 @@ .kiosk-tap-btn { animation: kiosk-tap-pulse 2.4s ease-in-out infinite; } #staff-qr-reader { min-height: 280px; } #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; } @@ -53,13 +56,19 @@

Welcome to {{ $organization->name }}

-

Visitor check-in & staff sign-in

+

Visitor check-in, check-out & staff sign-in

+ + @@ -115,6 +124,12 @@ + + @@ -578,6 +593,85 @@ + {{-- Visitor checkout --}} + + + {{-- Visitor checkout done --}} + + {{-- Done --}}