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:
@@ -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')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & staff sign-in</p>
|
<p class="mt-4 text-lg text-slate-500">Visitor check-in, check-out & 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">
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user