From 890c2c71e33b4f008f6a1339972885a78cb2965d Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 28 Jun 2026 21:11:18 +0000 Subject: [PATCH] Add employee presence with kiosk sign-in, QR badges, and mobile step-out. Enables staff roster, PIN/code kiosk flow, attendance reports, evacuation staff roll call, webhooks, branch mismatch warnings, and a linked-user My presence portal. Co-authored-by: Cursor --- .../Commands/EmployeeReturnAlertsCommand.php | 37 ++ .../Frontdesk/DashboardController.php | 31 +- .../Frontdesk/EmployeeController.php | 323 ++++++++++++++++++ .../Frontdesk/EmployeePortalController.php | 84 +++++ .../Controllers/Frontdesk/KioskController.php | 5 + .../Frontdesk/KioskDeviceController.php | 71 ++++ .../Frontdesk/ReportController.php | 6 +- .../Frontdesk/SecurityController.php | 76 ++++- .../Frontdesk/SettingsController.php | 2 + app/Models/Employee.php | 92 +++++ app/Models/EmployeePresence.php | 63 ++++ app/Models/EmployeePresenceEvent.php | 31 ++ .../Frontdesk/EmployeePresenceService.php | 314 +++++++++++++++++ .../Frontdesk/EmployeeReportService.php | 87 +++++ .../Frontdesk/FrontdeskPermissions.php | 10 +- .../Frontdesk/NotificationDispatcher.php | 15 + .../Frontdesk/OrganizationResolver.php | 14 + app/Services/Frontdesk/QrCodeService.php | 20 +- .../Integrations/WebhookDispatcher.php | 47 ++- config/frontdesk.php | 32 ++ ...00000_create_frontdesk_employee_tables.php | 67 ++++ ...employee_presence_phase2_phase3_fields.php | 32 ++ resources/js/kiosk-flow.js | 150 ++++++++ resources/views/frontdesk/dashboard.blade.php | 26 +- .../frontdesk/employee-portal/index.blade.php | 71 ++++ .../views/frontdesk/employees/badge.blade.php | 32 ++ .../frontdesk/employees/create.blade.php | 61 ++++ .../views/frontdesk/employees/edit.blade.php | 80 +++++ .../frontdesk/employees/import.blade.php | 22 ++ .../views/frontdesk/employees/index.blade.php | 54 +++ .../views/frontdesk/kiosk/index.blade.php | 134 +++++++- .../views/frontdesk/reports/index.blade.php | 47 +++ .../frontdesk/security/evacuation.blade.php | 48 ++- .../views/frontdesk/security/index.blade.php | 71 ++-- .../views/frontdesk/settings/edit.blade.php | 9 + resources/views/partials/sidebar.blade.php | 18 +- routes/console.php | 1 + routes/web.php | 19 ++ .../Feature/FrontdeskEmployeePhase23Test.php | 206 +++++++++++ .../Feature/FrontdeskEmployeePresenceTest.php | 158 +++++++++ 40 files changed, 2613 insertions(+), 53 deletions(-) create mode 100644 app/Console/Commands/EmployeeReturnAlertsCommand.php create mode 100644 app/Http/Controllers/Frontdesk/EmployeeController.php create mode 100644 app/Http/Controllers/Frontdesk/EmployeePortalController.php create mode 100644 app/Models/Employee.php create mode 100644 app/Models/EmployeePresence.php create mode 100644 app/Models/EmployeePresenceEvent.php create mode 100644 app/Services/Frontdesk/EmployeePresenceService.php create mode 100644 app/Services/Frontdesk/EmployeeReportService.php create mode 100644 database/migrations/2026_06_28_100000_create_frontdesk_employee_tables.php create mode 100644 database/migrations/2026_06_29_100000_add_employee_presence_phase2_phase3_fields.php create mode 100644 resources/views/frontdesk/employee-portal/index.blade.php create mode 100644 resources/views/frontdesk/employees/badge.blade.php create mode 100644 resources/views/frontdesk/employees/create.blade.php create mode 100644 resources/views/frontdesk/employees/edit.blade.php create mode 100644 resources/views/frontdesk/employees/import.blade.php create mode 100644 resources/views/frontdesk/employees/index.blade.php create mode 100644 tests/Feature/FrontdeskEmployeePhase23Test.php create mode 100644 tests/Feature/FrontdeskEmployeePresenceTest.php diff --git a/app/Console/Commands/EmployeeReturnAlertsCommand.php b/app/Console/Commands/EmployeeReturnAlertsCommand.php new file mode 100644 index 0000000..d93fa72 --- /dev/null +++ b/app/Console/Commands/EmployeeReturnAlertsCommand.php @@ -0,0 +1,37 @@ +where('status', EmployeePresence::STATUS_STEPPED_OUT) + ->whereNotNull('expected_return_at') + ->where('expected_return_at', '<', now()) + ->whereNull('return_alert_sent_at') + ->with(['employee.organization']) + ->get(); + + $count = 0; + + foreach ($overdue as $presence) { + $notifications->employeeOverdueReturn($presence); + $presence->update(['return_alert_sent_at' => now()]); + $count++; + } + + $this->info("Sent {$count} overdue return alert(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Frontdesk/DashboardController.php b/app/Http/Controllers/Frontdesk/DashboardController.php index aa56b80..30793b9 100644 --- a/app/Http/Controllers/Frontdesk/DashboardController.php +++ b/app/Http/Controllers/Frontdesk/DashboardController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\Frontdesk; use App\Http\Controllers\Controller; use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount; +use App\Models\Employee; +use App\Models\EmployeePresence; use App\Models\Visit; use App\Services\Frontdesk\OrganizationResolver; use Illuminate\Http\Request; @@ -26,15 +28,27 @@ class DashboardController extends Controller $branchScope = app(OrganizationResolver::class)->branchScope($this->member($request)); $cacheKey = "fd:dashboard:{$owner}:{$organization->id}:".($branchScope ?? 'all'); - $stats = Cache::remember($cacheKey, 60, function () use ($visitQuery) { + $stats = Cache::remember($cacheKey, 60, function () use ($visitQuery, $owner, $organization, $branchScope) { $today = (clone $visitQuery)->where(function ($q) { $q->whereDate('checked_in_at', today()) ->orWhereDate('scheduled_at', today()); }); + $employeeIds = Employee::owned($owner) + ->where('organization_id', $organization->id) + ->where('active', true); + if ($branchScope !== null) { + $employeeIds->where('branch_id', $branchScope); + } + $employeeIds = $employeeIds->pluck('id'); + + $presenceQuery = EmployeePresence::query()->whereIn('employee_id', $employeeIds); + return [ 'visitors_today' => (clone $today)->count(), 'currently_inside' => (clone $visitQuery)->currentlyInside()->count(), + 'staff_on_site' => (clone $presenceQuery)->where('status', EmployeePresence::STATUS_ON_SITE)->count(), + 'staff_stepped_out' => (clone $presenceQuery)->where('status', EmployeePresence::STATUS_STEPPED_OUT)->count(), 'expected_arrivals' => (clone $visitQuery)->whereIn('status', [ Visit::STATUS_EXPECTED, Visit::STATUS_SCHEDULED, @@ -83,6 +97,19 @@ class DashboardController extends Controller ->limit(10) ->get(); - return view('frontdesk.dashboard', compact('stats', 'currentVisitors', 'expectedVisitors', 'pendingApprovals', 'organization')); + $staffSteppedOut = EmployeePresence::query() + ->where('status', EmployeePresence::STATUS_STEPPED_OUT) + ->whereHas('employee', function ($q) use ($owner, $organization, $request) { + $q->owned($owner)->where('organization_id', $organization->id)->where('active', true); + $this->scopeToBranch($request, $q, 'branch_id'); + }) + ->with('employee') + ->orderByDesc('last_event_at') + ->limit(10) + ->get(); + + return view('frontdesk.dashboard', compact( + 'stats', 'currentVisitors', 'expectedVisitors', 'pendingApprovals', 'staffSteppedOut', 'organization', + )); } } diff --git a/app/Http/Controllers/Frontdesk/EmployeeController.php b/app/Http/Controllers/Frontdesk/EmployeeController.php new file mode 100644 index 0000000..488547a --- /dev/null +++ b/app/Http/Controllers/Frontdesk/EmployeeController.php @@ -0,0 +1,323 @@ +authorizeAbility($request, 'employees.view'); + $organization = $this->organization($request); + + $employees = Employee::owned($this->ownerRef($request)) + ->where('organization_id', $organization->id) + ->with(['branch', 'presence']) + ->orderBy('full_name'); + $this->scopeToBranch($request, $employees); + $employees = $employees->paginate(25); + + return view('frontdesk.employees.index', compact('employees', 'organization')); + } + + public function create(Request $request): View + { + $this->authorizeAbility($request, 'employees.manage'); + $organization = $this->organization($request); + + return view('frontdesk.employees.create', [ + 'organization' => $organization, + 'branches' => $this->branches($request, $organization->id), + 'hosts' => $this->hosts($request, $organization->id), + ]); + } + + public function store(Request $request, EmployeePresenceService $presence): RedirectResponse + { + $this->authorizeAbility($request, 'employees.manage'); + $organization = $this->organization($request); + + $validated = $this->validatedEmployee($request); + $pin = $validated['pin']; + unset($validated['pin']); + + $employee = Employee::create([ + 'owner_ref' => $this->ownerRef($request), + 'organization_id' => $organization->id, + 'pin_hash' => $presence->hashPin($pin), + ...$this->employeePayload($validated), + ]); + + $presence->presenceFor($employee); + + return redirect()->route('frontdesk.employees.index')->with('success', 'Employee added.'); + } + + public function edit(Request $request, Employee $employee): View + { + $this->authorizeAbility($request, 'employees.manage'); + $this->authorizeOwner($request, $employee); + + return view('frontdesk.employees.edit', [ + 'employee' => $employee->load('presence'), + 'branches' => $this->branches($request, $employee->organization_id), + 'hosts' => $this->hosts($request, $employee->organization_id), + ]); + } + + public function update(Request $request, Employee $employee, EmployeePresenceService $presence): RedirectResponse + { + $this->authorizeAbility($request, 'employees.manage'); + $this->authorizeOwner($request, $employee); + + $validated = $this->validatedEmployee($request, updating: true); + + $payload = $this->employeePayload($validated); + if (! empty($validated['pin'])) { + $payload['pin_hash'] = $presence->hashPin($validated['pin']); + } + + $employee->update([ + ...$payload, + 'active' => $request->boolean('active', true), + ]); + + return redirect()->route('frontdesk.employees.index')->with('success', 'Employee updated.'); + } + + public function destroy(Request $request, Employee $employee): RedirectResponse + { + $this->authorizeAbility($request, 'employees.manage'); + $this->authorizeOwner($request, $employee); + $employee->delete(); + + return redirect()->route('frontdesk.employees.index')->with('success', 'Employee removed.'); + } + + public function importForm(Request $request): View + { + $this->authorizeAbility($request, 'employees.manage'); + $organization = $this->organization($request); + + return view('frontdesk.employees.import', compact('organization')); + } + + public function import(Request $request, EmployeePresenceService $presence): RedirectResponse + { + $this->authorizeAbility($request, 'employees.manage'); + $organization = $this->organization($request); + + $request->validate([ + 'csv' => ['required', 'file', 'mimes:csv,txt', 'max:2048'], + ]); + + $handle = fopen($request->file('csv')->getRealPath(), 'r'); + if ($handle === false) { + return back()->with('error', 'Could not read the CSV file.'); + } + + $header = fgetcsv($handle); + if ($header === false) { + fclose($handle); + + return back()->with('error', 'CSV file is empty.'); + } + + $header = array_map(fn ($col) => strtolower(trim((string) $col)), $header); + $required = ['employee_code', 'full_name', 'pin']; + foreach ($required as $column) { + if (! in_array($column, $header, true)) { + fclose($handle); + + return back()->with('error', "CSV must include columns: employee_code, full_name, pin."); + } + } + + $ownerRef = $this->ownerRef($request); + $imported = 0; + $rowNumber = 1; + + while (($row = fgetcsv($handle)) !== false) { + $rowNumber++; + if ($row === [null] || $row === []) { + continue; + } + + $data = array_combine($header, array_pad($row, count($header), null)); + if ($data === false) { + continue; + } + + $code = trim((string) ($data['employee_code'] ?? '')); + $name = trim((string) ($data['full_name'] ?? '')); + $pin = trim((string) ($data['pin'] ?? '')); + + if ($code === '' || $name === '' || $pin === '') { + continue; + } + + if (! preg_match('/^\d{4,6}$/', $pin)) { + continue; + } + + $branchId = null; + if (! empty($data['branch'])) { + $branchId = Branch::owned($ownerRef) + ->where('organization_id', $organization->id) + ->where('name', trim((string) $data['branch'])) + ->value('id'); + } + + $employee = Employee::query()->updateOrCreate( + [ + 'organization_id' => $organization->id, + 'employee_code' => $code, + ], + [ + 'owner_ref' => $ownerRef, + 'full_name' => $name, + 'department' => trim((string) ($data['department'] ?? '')) ?: null, + 'email' => trim((string) ($data['email'] ?? '')) ?: null, + 'phone' => trim((string) ($data['phone'] ?? '')) ?: null, + 'branch_id' => $branchId, + 'pin_hash' => $presence->hashPin($pin), + 'active' => true, + ], + ); + + $presence->presenceFor($employee); + $imported++; + } + + fclose($handle); + + return redirect()->route('frontdesk.employees.index')->with('success', "{$imported} employee(s) imported."); + } + + public function badge(Request $request, Employee $employee, QrCodeService $qr): View + { + $this->authorizeAbility($request, 'employees.view'); + $this->authorizeOwner($request, $employee); + + return view('frontdesk.employees.badge', [ + 'employee' => $employee->load('organization'), + 'qrSvg' => $qr->employeeSvg($employee), + ]); + } + + public function exportAttendance(Request $request): StreamedResponse + { + $this->authorizeAbility($request, 'reports.export'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + [$from, $to] = $this->dateRange($request); + $branchId = app(\App\Services\Frontdesk\OrganizationResolver::class)->branchScope($this->member($request)); + + $query = EmployeePresenceEvent::owned($owner) + ->where('organization_id', $organization->id) + ->whereBetween('created_at', [$from, $to]) + ->with(['employee']) + ->orderBy('created_at'); + + if ($branchId) { + $query->where('branch_id', $branchId); + } + + $events = $query->get(); + $filename = 'employee-attendance-'.$from->format('Y-m-d').'-'.$to->format('Y-m-d').'.csv'; + + return response()->streamDownload(function () use ($events) { + $handle = fopen('php://output', 'w'); + fputcsv($handle, ['Date', 'Time', 'Employee', 'Code', 'Department', 'Event', 'Destination', 'Notes']); + + foreach ($events as $event) { + fputcsv($handle, [ + $event->created_at->toDateString(), + $event->created_at->format('H:i'), + $event->employee->full_name, + $event->employee->employee_code, + $event->employee->department ?? '', + config('frontdesk.employee_presence_events.'.$event->event, $event->event), + $event->destination ?? '', + $event->notes ?? '', + ]); + } + + fclose($handle); + }, $filename, ['Content-Type' => 'text/csv']); + } + + /** @return array{0: Carbon, 1: Carbon} */ + protected function dateRange(Request $request): array + { + $from = $request->filled('from') + ? Carbon::parse($request->string('from'))->startOfDay() + : now()->subDays(30)->startOfDay(); + $to = $request->filled('to') + ? Carbon::parse($request->string('to'))->endOfDay() + : now()->endOfDay(); + + return [$from, $to]; + } + + /** @return array */ + protected function validatedEmployee(Request $request, bool $updating = false): array + { + $pinRules = $updating + ? ['nullable', 'string', 'regex:/^\d{4,6}$/'] + : ['required', 'string', 'regex:/^\d{4,6}$/']; + + return $request->validate([ + 'employee_code' => ['required', 'string', 'max:32'], + 'full_name' => ['required', 'string', 'max:255'], + 'department' => ['nullable', 'string', 'max:255'], + 'email' => ['nullable', 'email', 'max:255'], + 'phone' => ['nullable', 'string', 'max:50'], + 'pin' => $pinRules, + 'branch_id' => ['nullable', 'integer'], + 'host_id' => ['nullable', 'integer'], + 'user_ref' => ['nullable', 'string', 'max:64'], + ]); + } + + protected function employeePayload(array $validated): array + { + $payload = $validated; + unset($payload['pin']); + + return array_filter($payload, fn ($value) => $value !== null && $value !== ''); + } + + protected function branches(Request $request, int $organizationId) + { + return Branch::owned($this->ownerRef($request)) + ->where('organization_id', $organizationId) + ->where('is_active', true) + ->orderBy('name') + ->get(); + } + + protected function hosts(Request $request, int $organizationId) + { + return Host::owned($this->ownerRef($request)) + ->where('organization_id', $organizationId) + ->orderBy('name') + ->get(); + } +} diff --git a/app/Http/Controllers/Frontdesk/EmployeePortalController.php b/app/Http/Controllers/Frontdesk/EmployeePortalController.php new file mode 100644 index 0000000..bacd0f0 --- /dev/null +++ b/app/Http/Controllers/Frontdesk/EmployeePortalController.php @@ -0,0 +1,84 @@ +resolveLinkedEmployee($request); + $organization = $this->organization($request); + $presence->presenceFor($employee); + $employee->load(['presence', 'branch']); + + return view('frontdesk.employee-portal.index', [ + 'employee' => $employee, + 'organization' => $organization, + 'stepOutReasons' => config('frontdesk.employee_step_out_reasons'), + 'statusLabel' => config('frontdesk.employee_presence_statuses.'.$employee->presence->status, $employee->presence->status), + ]); + } + + public function action(Request $request, EmployeePresenceService $presence): RedirectResponse + { + $employee = $this->resolveLinkedEmployee($request); + + $validated = $request->validate([ + 'action' => ['required', 'string', 'in:step_out,step_in,sign_out'], + 'step_out_reason' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('frontdesk.employee_step_out_reasons')))], + 'destination' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string', 'max:500'], + 'expected_return_at' => ['nullable', 'date'], + ]); + + match ($validated['action']) { + 'step_out' => $presence->stepOut($employee, [ + 'step_out_reason' => $validated['step_out_reason'] ?? 'other', + 'destination' => $validated['destination'] ?? null, + 'notes' => $validated['notes'] ?? null, + 'expected_return_at' => $validated['expected_return_at'] ?? null, + ]), + 'step_in' => $presence->stepIn($employee), + 'sign_out' => $presence->signOut($employee), + default => null, + }; + + $message = match ($validated['action']) { + 'step_out' => 'Stepped out recorded.', + 'step_in' => 'Welcome back!', + 'sign_out' => 'Signed out for the day.', + default => 'Done.', + }; + + return redirect()->route('frontdesk.employee.portal')->with('success', $message); + } + + protected function resolveLinkedEmployee(Request $request): Employee + { + $employee = app(OrganizationResolver::class)->employeeFor($request->user()); + + if (! $employee) { + throw new HttpResponseException( + redirect() + ->route('frontdesk.dashboard') + ->with('error', 'No employee profile is linked to your account. Ask an administrator to link your Ladill user on the Employees screen.'), + ); + } + + $this->authorizeOwner($request, $employee); + + return $employee; + } +} diff --git a/app/Http/Controllers/Frontdesk/KioskController.php b/app/Http/Controllers/Frontdesk/KioskController.php index c74f80c..97f4654 100644 --- a/app/Http/Controllers/Frontdesk/KioskController.php +++ b/app/Http/Controllers/Frontdesk/KioskController.php @@ -36,6 +36,11 @@ 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, + // Staff sign-in requires a registered kiosk device (/kiosk/d/{token}). + 'employeeKioskEnabled' => false, + 'stepOutReasons' => collect(config('frontdesk.employee_step_out_reasons')) + ->map(fn ($label, $key) => ['key' => $key, 'label' => $label]) + ->values(), ]); } diff --git a/app/Http/Controllers/Frontdesk/KioskDeviceController.php b/app/Http/Controllers/Frontdesk/KioskDeviceController.php index 17770f4..b3e44c7 100644 --- a/app/Http/Controllers/Frontdesk/KioskDeviceController.php +++ b/app/Http/Controllers/Frontdesk/KioskDeviceController.php @@ -6,11 +6,13 @@ use App\Http\Controllers\Controller; use App\Models\Device; use App\Models\Host; use App\Models\Organization; +use App\Services\Frontdesk\EmployeePresenceService; use App\Services\Frontdesk\VisitCheckInService; use App\Services\Frontdesk\VisitorTypeService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\View\View; +use Illuminate\Validation\ValidationException; class KioskDeviceController extends Controller { @@ -32,6 +34,8 @@ class KioskDeviceController extends Controller $hosts = $hosts->orderBy('name')->get(); + $employeeKioskEnabled = (bool) ($settings['employee_kiosk_enabled'] ?? true); + return view('frontdesk.kiosk.index', [ 'organization' => $organization, 'hosts' => $hosts, @@ -40,9 +44,76 @@ 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), + 'employeeKioskEnabled' => $employeeKioskEnabled, + 'stepOutReasons' => collect(config('frontdesk.employee_step_out_reasons')) + ->map(fn ($label, $key) => ['key' => $key, 'label' => $label]) + ->values(), + 'staffActionUrl' => route('frontdesk.kiosk.device.staff.action', $device->device_token), ]); } + public function staffAction(Request $request, EmployeePresenceService $presence): JsonResponse + { + $device = $this->device($request); + $organization = Organization::findOrFail($device->organization_id); + + $validated = $request->validate([ + 'employee_code' => ['nullable', 'string', 'max:32'], + 'qr_token' => ['nullable', 'string', 'max:500'], + 'pin' => ['required', 'string', 'regex:/^\d{4,6}$/'], + 'action' => ['required', 'string', 'in:identify,sign_in,sign_out,step_out,step_in'], + 'step_out_reason' => ['nullable', 'string', 'in:'.implode(',', array_keys(config('frontdesk.employee_step_out_reasons')))], + 'destination' => ['nullable', 'string', 'max:255'], + 'notes' => ['nullable', 'string', 'max:500'], + 'expected_return_at' => ['nullable', 'date'], + 'confirm_branch_mismatch' => ['boolean'], + ]); + + if (empty($validated['employee_code']) && empty($validated['qr_token'])) { + throw ValidationException::withMessages([ + 'credentials' => 'Enter your employee code or scan your badge.', + ]); + } + + $employee = $presence->resolveEmployee($organization, $validated); + + if ($validated['action'] === 'identify') { + return response()->json([ + 'employee' => $presence->employeePayload($employee), + ]); + } + + match ($validated['action']) { + 'sign_in' => $result = $presence->signIn($employee, $device, (bool) ($validated['confirm_branch_mismatch'] ?? false)), + 'sign_out' => $presence->signOut($employee, $device), + 'step_out' => $presence->stepOut($employee, [ + 'step_out_reason' => $validated['step_out_reason'] ?? 'other', + 'destination' => $validated['destination'] ?? null, + 'notes' => $validated['notes'] ?? null, + 'expected_return_at' => $validated['expected_return_at'] ?? null, + ], $device), + 'step_in' => $presence->stepIn($employee, $device), + default => null, + }; + + $response = [ + 'employee' => $presence->employeePayload($employee->fresh()), + 'message' => match ($validated['action']) { + 'sign_in' => 'Signed in successfully.', + 'sign_out' => 'Signed out for the day.', + 'step_out' => 'Stepped out recorded.', + 'step_in' => 'Welcome back!', + default => 'Done.', + }, + ]; + + if ($validated['action'] === 'sign_in' && isset($result['warning']) && $result['warning']) { + $response['warning'] = $result['warning']; + } + + return response()->json($response); + } + public function checkIn(Request $request, VisitCheckInService $checkIn, VisitorTypeService $visitorTypes): JsonResponse { $device = $this->device($request); diff --git a/app/Http/Controllers/Frontdesk/ReportController.php b/app/Http/Controllers/Frontdesk/ReportController.php index c2fb62a..086255a 100644 --- a/app/Http/Controllers/Frontdesk/ReportController.php +++ b/app/Http/Controllers/Frontdesk/ReportController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount; use App\Models\Visit; use App\Services\Frontdesk\BadgeRenderService; +use App\Services\Frontdesk\EmployeeReportService; use App\Services\Frontdesk\FrontdeskPermissions; use App\Services\Frontdesk\OrganizationResolver; use App\Services\Frontdesk\ReportService; @@ -18,7 +19,7 @@ class ReportController extends Controller { use ScopesToAccount; - public function index(Request $request, ReportService $reports): View + public function index(Request $request, ReportService $reports, EmployeeReportService $employeeReports): View { $this->authorizeAbility($request, 'reports.view'); $organization = $this->organization($request); @@ -37,6 +38,9 @@ class ReportController extends Controller 'frequentVisitors' => $reports->frequentVisitors($owner, $organization), 'security' => $reports->securityIncidents($owner, $organization, $from, $to), 'dailyCounts' => $reports->dailyCounts($owner, $organization, $from, $to, $branchId), + 'employeeSummary' => $employeeReports->summary($owner, $organization, $from, $to, $branchId), + 'employeeAttendance' => $employeeReports->attendanceLog($owner, $organization, $from, $to, $branchId), + 'employeeStepOuts' => $employeeReports->stepOutLog($owner, $organization, $from, $to, $branchId), 'canExport' => app(FrontdeskPermissions::class)->can($this->member($request), 'reports.export'), ]); } diff --git a/app/Http/Controllers/Frontdesk/SecurityController.php b/app/Http/Controllers/Frontdesk/SecurityController.php index 43807f3..e1d95af 100644 --- a/app/Http/Controllers/Frontdesk/SecurityController.php +++ b/app/Http/Controllers/Frontdesk/SecurityController.php @@ -4,11 +4,13 @@ namespace App\Http\Controllers\Frontdesk; use App\Http\Controllers\Controller; use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount; +use App\Models\EmployeePresence; use App\Models\Visit; use App\Services\Frontdesk\VisitCheckOutService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\View\View; +use Symfony\Component\HttpFoundation\StreamedResponse; class SecurityController extends Controller { @@ -27,10 +29,13 @@ class SecurityController extends Controller $this->scopeToBranch($request, $occupancy); $occupancy = $occupancy->orderBy('checked_in_at')->get(); + $staffOnSite = $this->staffOccupancy($request, $owner, $organization); + $expiredBadges = $occupancy->filter(fn (Visit $v) => $v->isBadgeExpired()); return view('frontdesk.security.index', [ 'occupancy' => $occupancy, + 'staffOnSite' => $staffOnSite, 'expiredBadges' => $expiredBadges, 'organization' => $organization, 'canCheckout' => app(\App\Services\Frontdesk\FrontdeskPermissions::class) @@ -51,7 +56,59 @@ class SecurityController extends Controller $this->scopeToBranch($request, $occupancy); $occupancy = $occupancy->orderBy('checked_in_at')->get(); - return view('frontdesk.security.evacuation', compact('occupancy', 'organization')); + $staffOnSite = $this->staffOccupancy($request, $owner, $organization, withBranch: true); + + return view('frontdesk.security.evacuation', compact('occupancy', 'staffOnSite', 'organization')); + } + + public function evacuationExport(Request $request): StreamedResponse + { + $this->authorizeAbility($request, 'security.view'); + $organization = $this->organization($request); + $owner = $this->ownerRef($request); + + $occupancy = Visit::owned($owner) + ->where('organization_id', $organization->id) + ->currentlyInside() + ->with(['visitor', 'host', 'branch']); + $this->scopeToBranch($request, $occupancy); + $occupancy = $occupancy->orderBy('checked_in_at')->get(); + + $staffOnSite = $this->staffOccupancy($request, $owner, $organization, withBranch: true); + + $filename = 'evacuation-'.now()->format('Y-m-d-His').'.csv'; + + return response()->streamDownload(function () use ($occupancy, $staffOnSite) { + $handle = fopen('php://output', 'w'); + fputcsv($handle, ['Type', 'Name', 'Detail', 'Branch', 'Since', 'Phone', 'Status']); + + foreach ($occupancy as $visit) { + fputcsv($handle, [ + 'Visitor', + $visit->visitor->full_name, + ucfirst(str_replace('_', ' ', $visit->visitor_type)).($visit->host ? ' · Host: '.$visit->host->name : ''), + $visit->branch?->name ?? '—', + $visit->checked_in_at?->format('Y-m-d g:i A') ?? '—', + $visit->visitor->phone ?? '—', + 'On site', + ]); + } + + foreach ($staffOnSite as $presence) { + fputcsv($handle, [ + 'Staff', + $presence->employee->full_name, + $presence->employee->department ?? '—', + $presence->branch?->name ?? $presence->employee->branch?->name ?? '—', + $presence->signed_in_at?->format('Y-m-d g:i A') ?? '—', + $presence->employee->phone ?? '—', + config('frontdesk.employee_presence_statuses.'.$presence->status, $presence->status) + .($presence->destination ? ' · '.$presence->destination : ''), + ]); + } + + fclose($handle); + }, $filename, ['Content-Type' => 'text/csv']); } public function evacuationBadges(Request $request, \App\Services\Frontdesk\BadgeRenderService $badges): View @@ -118,4 +175,21 @@ class SecurityController extends Controller 'visit' => $visit, ]); } + + protected function staffOccupancy(Request $request, string $owner, $organization, bool $withBranch = false) + { + $query = EmployeePresence::query() + ->whereIn('status', [EmployeePresence::STATUS_ON_SITE, EmployeePresence::STATUS_STEPPED_OUT]) + ->whereHas('employee', function ($q) use ($owner, $organization, $request) { + $q->owned($owner)->where('organization_id', $organization->id)->where('active', true); + $this->scopeToBranch($request, $q, 'branch_id'); + }) + ->with(['employee']); + + if ($withBranch) { + $query->with(['branch', 'employee.branch']); + } + + return $query->orderBy('employee_id')->get(); + } } diff --git a/app/Http/Controllers/Frontdesk/SettingsController.php b/app/Http/Controllers/Frontdesk/SettingsController.php index a24c43c..34434be 100644 --- a/app/Http/Controllers/Frontdesk/SettingsController.php +++ b/app/Http/Controllers/Frontdesk/SettingsController.php @@ -70,6 +70,7 @@ class SettingsController extends Controller 'report_daily_recipients' => ['nullable', 'string', 'max:2000'], 'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'], 'remove_logo' => ['nullable', 'boolean'], + 'employee_kiosk_enabled' => ['nullable', 'boolean'], ]); $settings = $organization->settings ?? []; @@ -77,6 +78,7 @@ class SettingsController extends Controller $settings['badge_expiry_hours'] = $validated['badge_expiry_hours']; $settings['kiosk_reset_seconds'] = $validated['kiosk_reset_seconds']; $settings['visitor_policy'] = $validated['visitor_policy'] ?? null; + $settings['employee_kiosk_enabled'] = $request->boolean('employee_kiosk_enabled'); $settings['notification_channels'] = $validated['notification_channels'] ?? ['email']; $eventKeys = array_keys(config('frontdesk.notification_events', [])); diff --git a/app/Models/Employee.php b/app/Models/Employee.php new file mode 100644 index 0000000..d6e4008 --- /dev/null +++ b/app/Models/Employee.php @@ -0,0 +1,92 @@ + 'boolean']; + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class, 'branch_id'); + } + + public function host(): BelongsTo + { + return $this->belongsTo(Host::class, 'host_id'); + } + + public function presence(): HasOne + { + return $this->hasOne(EmployeePresence::class, 'employee_id'); + } + + protected static function booted(): void + { + static::creating(function (Employee $employee) { + if (! $employee->qr_token) { + $employee->qr_token = Str::random(32); + } + }); + } + + public static function findByQrLookup(string $lookup, int $organizationId): ?self + { + $token = self::parseQrLookup($lookup); + + if ($token === null) { + return null; + } + + return self::query() + ->where('organization_id', $organizationId) + ->where('qr_token', $token) + ->where('active', true) + ->first(); + } + + public static function parseQrLookup(string $lookup): ?string + { + $lookup = trim($lookup); + + if ($lookup === '') { + return null; + } + + if (preg_match('#/eq/([A-Za-z0-9]+)#', $lookup, $matches)) { + return $matches[1]; + } + + if (preg_match('/^[A-Za-z0-9]{16,64}$/', $lookup)) { + return $lookup; + } + + return null; + } +} diff --git a/app/Models/EmployeePresence.php b/app/Models/EmployeePresence.php new file mode 100644 index 0000000..801b6f8 --- /dev/null +++ b/app/Models/EmployeePresence.php @@ -0,0 +1,63 @@ + 'datetime', + 'last_event_at' => 'datetime', + 'expected_return_at' => 'datetime', + 'return_alert_sent_at' => 'datetime', + ]; + } + + public function employee(): BelongsTo + { + return $this->belongsTo(Employee::class, 'employee_id'); + } + + public function branch(): BelongsTo + { + return $this->belongsTo(Branch::class, 'branch_id'); + } + + public function device(): BelongsTo + { + return $this->belongsTo(Device::class, 'device_id'); + } + + public function isOnSite(): bool + { + return $this->status === self::STATUS_ON_SITE; + } + + public function isSteppedOut(): bool + { + return $this->status === self::STATUS_STEPPED_OUT; + } + + public function isOffSite(): bool + { + return $this->status === self::STATUS_OFF_SITE; + } +} diff --git a/app/Models/EmployeePresenceEvent.php b/app/Models/EmployeePresenceEvent.php new file mode 100644 index 0000000..33b103e --- /dev/null +++ b/app/Models/EmployeePresenceEvent.php @@ -0,0 +1,31 @@ +belongsTo(Employee::class, 'employee_id'); + } +} diff --git a/app/Services/Frontdesk/EmployeePresenceService.php b/app/Services/Frontdesk/EmployeePresenceService.php new file mode 100644 index 0000000..a4db433 --- /dev/null +++ b/app/Services/Frontdesk/EmployeePresenceService.php @@ -0,0 +1,314 @@ +pin_hash); + } + + public function findActiveByCode(Organization $organization, string $employeeCode): ?Employee + { + return Employee::query() + ->where('organization_id', $organization->id) + ->where('employee_code', $employeeCode) + ->where('active', true) + ->first(); + } + + public function authenticate(Organization $organization, string $employeeCode, string $pin): Employee + { + $employee = $this->findActiveByCode($organization, $employeeCode); + + if ($employee === null || ! $this->verifyPin($employee, $pin)) { + throw ValidationException::withMessages([ + 'credentials' => 'Invalid employee code or PIN.', + ]); + } + + return $employee; + } + + public function authenticateByQr(Organization $organization, string $qrLookup, string $pin): Employee + { + $employee = Employee::findByQrLookup($qrLookup, $organization->id); + + if ($employee === null || ! $this->verifyPin($employee, $pin)) { + throw ValidationException::withMessages([ + 'credentials' => 'Invalid badge or PIN.', + ]); + } + + return $employee; + } + + public function resolveEmployee(Organization $organization, array $credentials): Employee + { + $qrLookup = trim((string) ($credentials['qr_token'] ?? '')); + + if ($qrLookup !== '') { + return $this->authenticateByQr($organization, $qrLookup, (string) ($credentials['pin'] ?? '')); + } + + return $this->authenticate($organization, (string) ($credentials['employee_code'] ?? ''), (string) ($credentials['pin'] ?? '')); + } + + /** @return list */ + public function availableActions(EmployeePresence $presence): array + { + return match ($presence->status) { + EmployeePresence::STATUS_OFF_SITE => ['sign_in'], + EmployeePresence::STATUS_ON_SITE => ['sign_out', 'step_out'], + EmployeePresence::STATUS_STEPPED_OUT => ['step_in', 'sign_out'], + default => [], + }; + } + + public function presenceFor(Employee $employee): EmployeePresence + { + return EmployeePresence::query()->firstOrCreate( + ['employee_id' => $employee->id], + ['status' => EmployeePresence::STATUS_OFF_SITE], + ); + } + + /** @return array{presence: EmployeePresence, warning: string|null} */ + public function signIn(Employee $employee, ?Device $device = null, bool $confirmBranchMismatch = false): array + { + $presence = $this->presenceFor($employee); + + if ($presence->status === EmployeePresence::STATUS_ON_SITE) { + throw ValidationException::withMessages([ + 'action' => 'You are already signed in.', + ]); + } + + $branchId = $device?->branch_id ?? $employee->branch_id; + $warning = $this->branchMismatchWarning($employee, $device); + + if ($warning !== null && ! $confirmBranchMismatch) { + throw ValidationException::withMessages([ + 'branch_mismatch' => $warning, + ]); + } + + $now = now(); + + $presence->update([ + 'status' => EmployeePresence::STATUS_ON_SITE, + 'branch_id' => $branchId, + 'device_id' => $device?->id, + 'destination' => null, + 'step_out_reason' => null, + 'notes' => null, + 'expected_return_at' => null, + 'return_alert_sent_at' => null, + 'signed_in_at' => $now, + 'last_event_at' => $now, + ]); + + $this->recordEvent($employee, EmployeePresenceEvent::EVENT_SIGN_IN, EmployeePresence::STATUS_ON_SITE, $presence); + $this->syncHostAvailability($employee, true); + $this->webhooks->dispatchEmployee('employee.signed_in', $employee, $presence->fresh()); + + return ['presence' => $presence->fresh(), 'warning' => $warning]; + } + + public function signOut(Employee $employee, ?Device $device = null): EmployeePresence + { + $presence = $this->presenceFor($employee); + + if ($presence->status === EmployeePresence::STATUS_OFF_SITE) { + throw ValidationException::withMessages([ + 'action' => 'You are not signed in.', + ]); + } + + $now = now(); + + $presence->update([ + 'status' => EmployeePresence::STATUS_OFF_SITE, + 'device_id' => $device?->id, + 'destination' => null, + 'step_out_reason' => null, + 'notes' => null, + 'expected_return_at' => null, + 'return_alert_sent_at' => null, + 'signed_in_at' => null, + 'last_event_at' => $now, + ]); + + $this->recordEvent($employee, EmployeePresenceEvent::EVENT_SIGN_OUT, EmployeePresence::STATUS_OFF_SITE, $presence); + $this->syncHostAvailability($employee, true); + $this->webhooks->dispatchEmployee('employee.signed_out', $employee, $presence->fresh()); + + return $presence->fresh(); + } + + /** @param array{step_out_reason: string, destination?: string|null, notes?: string|null, expected_return_at?: string|null} $payload */ + public function stepOut(Employee $employee, array $payload, ?Device $device = null): EmployeePresence + { + $presence = $this->presenceFor($employee); + + if ($presence->status !== EmployeePresence::STATUS_ON_SITE) { + throw ValidationException::withMessages([ + 'action' => 'You must be signed in to step out.', + ]); + } + + $destination = trim((string) ($payload['destination'] ?? '')); + $reasonKey = $payload['step_out_reason']; + $reasonLabel = config('frontdesk.employee_step_out_reasons.'.$reasonKey, $reasonKey); + + if ($reasonKey === 'other' && $destination === '') { + throw ValidationException::withMessages([ + 'destination' => 'Please say where you are going.', + ]); + } + + $displayDestination = $destination !== '' ? $destination : $reasonLabel; + $now = now(); + $expectedReturn = ! empty($payload['expected_return_at']) + ? Carbon::parse($payload['expected_return_at']) + : null; + + $presence->update([ + 'status' => EmployeePresence::STATUS_STEPPED_OUT, + 'device_id' => $device?->id, + 'destination' => $displayDestination, + 'step_out_reason' => $reasonKey, + 'notes' => $payload['notes'] ?? null, + 'expected_return_at' => $expectedReturn, + 'return_alert_sent_at' => null, + 'last_event_at' => $now, + ]); + + $this->recordEvent($employee, EmployeePresenceEvent::EVENT_STEP_OUT, EmployeePresence::STATUS_STEPPED_OUT, $presence); + $this->syncHostAvailability($employee, false); + $this->webhooks->dispatchEmployee('employee.stepped_out', $employee, $presence->fresh()); + + return $presence->fresh(); + } + + public function stepIn(Employee $employee, ?Device $device = null): EmployeePresence + { + $presence = $this->presenceFor($employee); + + if ($presence->status !== EmployeePresence::STATUS_STEPPED_OUT) { + throw ValidationException::withMessages([ + 'action' => 'You are not marked as stepped out.', + ]); + } + + $now = now(); + + $presence->update([ + 'status' => EmployeePresence::STATUS_ON_SITE, + 'device_id' => $device?->id, + 'destination' => null, + 'step_out_reason' => null, + 'notes' => null, + 'expected_return_at' => null, + 'return_alert_sent_at' => null, + 'last_event_at' => $now, + ]); + + $this->recordEvent($employee, EmployeePresenceEvent::EVENT_STEP_IN, EmployeePresence::STATUS_ON_SITE, $presence); + $this->syncHostAvailability($employee, true); + $this->webhooks->dispatchEmployee('employee.stepped_in', $employee, $presence->fresh()); + + return $presence->fresh(); + } + + /** @return array */ + public function employeePayload(Employee $employee): array + { + $presence = $this->presenceFor($employee); + + return [ + 'id' => $employee->id, + 'full_name' => $employee->full_name, + 'department' => $employee->department, + 'employee_code' => $employee->employee_code, + 'status' => $presence->status, + 'status_label' => config('frontdesk.employee_presence_statuses.'.$presence->status, $presence->status), + 'destination' => $presence->destination, + 'expected_return_at' => $presence->expected_return_at?->toIso8601String(), + 'signed_in_at' => $presence->signed_in_at?->toIso8601String(), + 'available_actions' => $this->availableActions($presence), + ]; + } + + protected function branchMismatchWarning(Employee $employee, ?Device $device): ?string + { + if ($employee->branch_id === null || $device?->branch_id === null) { + return null; + } + + if ((int) $employee->branch_id === (int) $device->branch_id) { + return null; + } + + $employee->loadMissing('branch'); + $device->loadMissing('branch'); + + $homeBranch = $employee->branch?->name ?? 'your home branch'; + $kioskBranch = $device->branch?->name ?? 'this branch'; + + return "Your home branch is {$homeBranch}, but this kiosk is at {$kioskBranch}. Confirm to sign in here anyway."; + } + + protected function recordEvent( + Employee $employee, + string $event, + string $statusAfter, + EmployeePresence $presence, + ): void { + EmployeePresenceEvent::create([ + 'owner_ref' => $employee->owner_ref, + 'organization_id' => $employee->organization_id, + 'employee_id' => $employee->id, + 'event' => $event, + 'status_after' => $statusAfter, + 'branch_id' => $presence->branch_id, + 'device_id' => $presence->device_id, + 'destination' => $presence->destination, + 'step_out_reason' => $presence->step_out_reason, + 'notes' => $presence->notes, + ]); + } + + protected function syncHostAvailability(Employee $employee, bool $available): void + { + if ($employee->host_id === null) { + return; + } + + Host::query() + ->where('id', $employee->host_id) + ->where('organization_id', $employee->organization_id) + ->update(['is_available' => $available]); + } +} diff --git a/app/Services/Frontdesk/EmployeeReportService.php b/app/Services/Frontdesk/EmployeeReportService.php new file mode 100644 index 0000000..b8d2559 --- /dev/null +++ b/app/Services/Frontdesk/EmployeeReportService.php @@ -0,0 +1,87 @@ + */ + public function summary( + string $ownerRef, + Organization $organization, + Carbon $from, + Carbon $to, + ?int $branchId = null, + ): array { + $events = $this->eventsQuery($ownerRef, $organization->id, $from, $to, $branchId); + + return [ + 'sign_ins' => (clone $events)->where('event', EmployeePresenceEvent::EVENT_SIGN_IN)->count(), + 'sign_outs' => (clone $events)->where('event', EmployeePresenceEvent::EVENT_SIGN_OUT)->count(), + 'step_outs' => (clone $events)->where('event', EmployeePresenceEvent::EVENT_STEP_OUT)->count(), + 'staff_on_site_now' => EmployeePresence::query() + ->where('status', EmployeePresence::STATUS_ON_SITE) + ->whereHas('employee', function ($q) use ($ownerRef, $organization, $branchId) { + $q->owned($ownerRef)->where('organization_id', $organization->id)->where('active', true); + if ($branchId) { + $q->where('branch_id', $branchId); + } + }) + ->count(), + ]; + } + + /** @return Collection */ + public function attendanceLog( + string $ownerRef, + Organization $organization, + Carbon $from, + Carbon $to, + ?int $branchId = null, + ): Collection { + return $this->eventsQuery($ownerRef, $organization->id, $from, $to, $branchId) + ->with(['employee']) + ->orderByDesc('created_at') + ->limit(200) + ->get(); + } + + /** @return Collection */ + public function stepOutLog( + string $ownerRef, + Organization $organization, + Carbon $from, + Carbon $to, + ?int $branchId = null, + ): Collection { + return $this->eventsQuery($ownerRef, $organization->id, $from, $to, $branchId) + ->where('event', EmployeePresenceEvent::EVENT_STEP_OUT) + ->with(['employee']) + ->orderByDesc('created_at') + ->limit(100) + ->get(); + } + + protected function eventsQuery( + string $ownerRef, + int $organizationId, + Carbon $from, + Carbon $to, + ?int $branchId, + ) { + $query = EmployeePresenceEvent::owned($ownerRef) + ->where('organization_id', $organizationId) + ->whereBetween('created_at', [$from, $to]); + + if ($branchId) { + $query->where('branch_id', $branchId); + } + + return $query; + } +} diff --git a/app/Services/Frontdesk/FrontdeskPermissions.php b/app/Services/Frontdesk/FrontdeskPermissions.php index 96b1f0a..0d3612d 100644 --- a/app/Services/Frontdesk/FrontdeskPermissions.php +++ b/app/Services/Frontdesk/FrontdeskPermissions.php @@ -12,24 +12,26 @@ class FrontdeskPermissions 'org_admin' => ['*'], 'branch_admin' => [ 'dashboard.view', 'visits.view', 'visits.manage', 'visitors.view', 'visitors.manage', - 'hosts.view', 'hosts.manage', 'kiosk.use', 'security.view', 'security.checkout', 'security.verify', + 'hosts.view', 'hosts.manage', 'employees.view', 'employees.manage', 'kiosk.use', + 'security.view', 'security.checkout', 'security.verify', 'settings.view', 'admin.branches.view', 'admin.desks.view', 'admin.desks.manage', 'watchlist.view', 'watchlist.manage', 'audit.view', 'audit.export', 'devices.view', 'devices.manage', 'reports.view', 'reports.export', ], 'receptionist' => [ 'dashboard.view', 'visits.view', 'visits.manage', 'visitors.view', 'visitors.manage', - 'hosts.view', 'kiosk.use', 'watchlist.view', 'devices.view', + 'hosts.view', 'employees.view', 'kiosk.use', 'watchlist.view', 'devices.view', ], 'security_officer' => [ - 'dashboard.view', 'visits.view', 'visitors.view', 'security.view', 'security.checkout', 'security.verify', + 'dashboard.view', 'visits.view', 'visitors.view', 'employees.view', + 'security.view', 'security.checkout', 'security.verify', 'watchlist.view', 'audit.view', ], 'host' => [ 'dashboard.view', 'visits.view', 'visitors.view', 'host.portal', ], 'auditor' => [ - 'dashboard.view', 'visits.view', 'visitors.view', 'security.view', 'settings.view', + 'dashboard.view', 'visits.view', 'visitors.view', 'employees.view', 'security.view', 'settings.view', 'audit.view', 'audit.export', 'watchlist.view', 'compliance.restore', 'reports.view', 'reports.export', ], diff --git a/app/Services/Frontdesk/NotificationDispatcher.php b/app/Services/Frontdesk/NotificationDispatcher.php index ab53a44..7d87953 100644 --- a/app/Services/Frontdesk/NotificationDispatcher.php +++ b/app/Services/Frontdesk/NotificationDispatcher.php @@ -5,6 +5,7 @@ namespace App\Services\Frontdesk; use App\Models\Host; use App\Models\Member; use App\Models\Organization; +use App\Models\EmployeePresence; use App\Models\User; use App\Models\Visitor; use App\Models\Visit; @@ -143,6 +144,20 @@ class NotificationDispatcher ); } + public function employeeOverdueReturn(EmployeePresence $presence): void + { + $presence->load(['employee.organization']); + $employee = $presence->employee; + $expected = $presence->expected_return_at?->format('g:i A') ?? 'the expected time'; + + $this->notifyStaffUsers( + $employee->organization, + 'employee_overdue_return', + 'Employee overdue to return', + "{$employee->full_name} was expected back by {$expected} ({$presence->destination}).", + ); + } + /** @param array $meta */ protected function dispatchVisitEvent( Visit $visit, diff --git a/app/Services/Frontdesk/OrganizationResolver.php b/app/Services/Frontdesk/OrganizationResolver.php index 8fcd405..4fe4abc 100644 --- a/app/Services/Frontdesk/OrganizationResolver.php +++ b/app/Services/Frontdesk/OrganizationResolver.php @@ -3,6 +3,7 @@ namespace App\Services\Frontdesk; use App\Models\Branch; +use App\Models\Employee; use App\Models\Host; use App\Models\Member; use App\Models\Organization; @@ -109,6 +110,19 @@ class OrganizationResolver ->first(); } + public function employeeFor(User $user): ?Employee + { + $organization = $this->resolveForUser($user); + if (! $organization) { + return null; + } + + return Employee::where('organization_id', $organization->id) + ->where('user_ref', $user->ownerRef()) + ->where('active', true) + ->first(); + } + /** Branch ID the member may access; null = all branches. */ public function branchScope(?Member $member): ?int { diff --git a/app/Services/Frontdesk/QrCodeService.php b/app/Services/Frontdesk/QrCodeService.php index 8e68250..6203d8a 100644 --- a/app/Services/Frontdesk/QrCodeService.php +++ b/app/Services/Frontdesk/QrCodeService.php @@ -2,6 +2,7 @@ namespace App\Services\Frontdesk; +use App\Models\Employee; use App\Models\Visit; use chillerlan\QRCode\QRCode; use chillerlan\QRCode\QROptions; @@ -15,7 +16,24 @@ class QrCodeService return url($path.'/'.$visit->qr_token); } + public function employeeUrl(Employee $employee): string + { + $path = config('frontdesk.badge.employee_qr_url_path', '/eq'); + + return url($path.'/'.$employee->qr_token); + } + public function svg(Visit $visit, int $size = 200): string + { + return $this->renderSvg($this->visitUrl($visit), $size); + } + + public function employeeSvg(Employee $employee, int $size = 200): string + { + return $this->renderSvg($this->employeeUrl($employee), $size); + } + + protected function renderSvg(string $url, int $size = 200): string { $options = new QROptions([ 'outputType' => QRCode::OUTPUT_MARKUP_SVG, @@ -23,6 +41,6 @@ class QrCodeService 'imageBase64' => false, ]); - return (new QRCode($options))->render($this->visitUrl($visit)); + return (new QRCode($options))->render($url); } } diff --git a/app/Services/Integrations/WebhookDispatcher.php b/app/Services/Integrations/WebhookDispatcher.php index c9024d7..a02f256 100644 --- a/app/Services/Integrations/WebhookDispatcher.php +++ b/app/Services/Integrations/WebhookDispatcher.php @@ -2,6 +2,8 @@ namespace App\Services\Integrations; +use App\Models\Employee; +use App\Models\EmployeePresence; use App\Models\Visit; use App\Models\WebhookEndpoint; use Illuminate\Support\Facades\Http; @@ -13,16 +15,6 @@ class WebhookDispatcher { $visit->loadMissing('organization'); - $endpoints = WebhookEndpoint::query() - ->where('organization_id', $visit->organization_id) - ->where('is_active', true) - ->get() - ->filter(fn (WebhookEndpoint $endpoint) => $endpoint->subscribesTo($event)); - - if ($endpoints->isEmpty()) { - return; - } - $payload = [ 'event' => $event, 'visit' => [ @@ -41,6 +33,41 @@ class WebhookDispatcher 'timestamp' => now()->toIso8601String(), ]; + $this->dispatchPayload($visit->organization_id, $event, $payload); + } + + public function dispatchEmployee(string $event, Employee $employee, ?EmployeePresence $presence = null): void + { + $presence ??= $employee->presence; + + $payload = [ + 'event' => $event, + 'employee' => [ + 'id' => $employee->id, + 'employee_code' => $employee->employee_code, + 'full_name' => $employee->full_name, + 'department' => $employee->department, + 'branch_id' => $employee->branch_id, + 'status' => $presence?->status, + 'destination' => $presence?->destination, + 'signed_in_at' => $presence?->signed_in_at?->toIso8601String(), + 'expected_return_at' => $presence?->expected_return_at?->toIso8601String(), + ], + 'timestamp' => now()->toIso8601String(), + ]; + + $this->dispatchPayload($employee->organization_id, $event, $payload); + } + + /** @param array $payload */ + protected function dispatchPayload(int $organizationId, string $event, array $payload): void + { + $endpoints = WebhookEndpoint::query() + ->where('organization_id', $organizationId) + ->where('is_active', true) + ->get() + ->filter(fn (WebhookEndpoint $endpoint) => $endpoint->subscribesTo($event)); + foreach ($endpoints as $endpoint) { $this->send($endpoint, $payload); } diff --git a/config/frontdesk.php b/config/frontdesk.php index 5e50a37..02d21c1 100644 --- a/config/frontdesk.php +++ b/config/frontdesk.php @@ -65,6 +65,10 @@ return [ 'watchlist.entry_created' => 'Watchlist entry added', 'watchlist.entry_removed' => 'Watchlist entry removed', 'badge.expired' => 'Badge expired', + 'employee.signed_in' => 'Employee signed in', + 'employee.signed_out' => 'Employee signed out', + 'employee.stepped_out' => 'Employee stepped out', + 'employee.stepped_in' => 'Employee returned', ], 'roles' => [ @@ -160,6 +164,7 @@ return [ 'approval_needed' => 'Approval required', 'watchlist_alert' => 'Watchlist / security alerts', 'badge_expired' => 'Expired badge while checked in', + 'employee_overdue_return' => 'Employee overdue to return from step-out', ], 'default_notification_events' => [ @@ -171,6 +176,7 @@ return [ 'approval_needed' => true, 'watchlist_alert' => true, 'badge_expired' => true, + 'employee_overdue_return' => true, ], 'kiosk' => [ @@ -178,9 +184,31 @@ return [ 'default_visit_duration_minutes' => 60, ], + 'employee_presence_statuses' => [ + 'off_site' => 'Off site', + 'on_site' => 'On site', + 'stepped_out' => 'Stepped out', + ], + + 'employee_step_out_reasons' => [ + 'lunch' => 'Lunch', + 'client_visit' => 'Client / external meeting', + 'errand' => 'Bank / errands', + 'personal' => 'Personal', + 'other' => 'Other', + ], + + 'employee_presence_events' => [ + 'sign_in' => 'Sign in', + 'sign_out' => 'Sign out', + 'step_out' => 'Step out', + 'step_in' => 'Return', + ], + 'badge' => [ 'default_expiry_hours' => 8, 'qr_url_path' => '/q', + 'employee_qr_url_path' => '/eq', ], 'default_badge_template' => [ @@ -197,6 +225,10 @@ return [ 'visit.checked_in', 'visit.checked_out', 'visit.awaiting_approval', + 'employee.signed_in', + 'employee.signed_out', + 'employee.stepped_out', + 'employee.stepped_in', ], 'integrations' => [ diff --git a/database/migrations/2026_06_28_100000_create_frontdesk_employee_tables.php b/database/migrations/2026_06_28_100000_create_frontdesk_employee_tables.php new file mode 100644 index 0000000..eb298a7 --- /dev/null +++ b/database/migrations/2026_06_28_100000_create_frontdesk_employee_tables.php @@ -0,0 +1,67 @@ +id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('frontdesk_organizations')->cascadeOnDelete(); + $table->foreignId('branch_id')->nullable()->constrained('frontdesk_branches')->nullOnDelete(); + $table->string('employee_code'); + $table->string('full_name'); + $table->string('department')->nullable(); + $table->string('email')->nullable(); + $table->string('phone')->nullable(); + $table->string('pin_hash'); + $table->foreignId('host_id')->nullable()->constrained('frontdesk_hosts')->nullOnDelete(); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->softDeletes(); + $table->unique(['organization_id', 'employee_code']); + $table->index(['owner_ref', 'organization_id']); + }); + + Schema::create('frontdesk_employee_presence', function (Blueprint $table) { + $table->id(); + $table->foreignId('employee_id')->unique()->constrained('frontdesk_employees')->cascadeOnDelete(); + $table->string('status')->default('off_site'); // off_site, on_site, stepped_out + $table->foreignId('branch_id')->nullable()->constrained('frontdesk_branches')->nullOnDelete(); + $table->foreignId('device_id')->nullable()->constrained('frontdesk_devices')->nullOnDelete(); + $table->string('destination')->nullable(); + $table->string('step_out_reason')->nullable(); + $table->text('notes')->nullable(); + $table->timestamp('signed_in_at')->nullable(); + $table->timestamp('last_event_at')->nullable(); + $table->timestamps(); + }); + + Schema::create('frontdesk_employee_presence_events', function (Blueprint $table) { + $table->id(); + $table->string('owner_ref')->index(); + $table->foreignId('organization_id')->constrained('frontdesk_organizations')->cascadeOnDelete(); + $table->foreignId('employee_id')->constrained('frontdesk_employees')->cascadeOnDelete(); + $table->string('event'); // sign_in, sign_out, step_out, step_in + $table->string('status_after'); + $table->foreignId('branch_id')->nullable()->constrained('frontdesk_branches')->nullOnDelete(); + $table->foreignId('device_id')->nullable()->constrained('frontdesk_devices')->nullOnDelete(); + $table->string('destination')->nullable(); + $table->string('step_out_reason')->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + $table->index(['organization_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('frontdesk_employee_presence_events'); + Schema::dropIfExists('frontdesk_employee_presence'); + Schema::dropIfExists('frontdesk_employees'); + } +}; diff --git a/database/migrations/2026_06_29_100000_add_employee_presence_phase2_phase3_fields.php b/database/migrations/2026_06_29_100000_add_employee_presence_phase2_phase3_fields.php new file mode 100644 index 0000000..eb9eac1 --- /dev/null +++ b/database/migrations/2026_06_29_100000_add_employee_presence_phase2_phase3_fields.php @@ -0,0 +1,32 @@ +string('qr_token')->nullable()->unique()->after('employee_code'); + $table->string('user_ref')->nullable()->index()->after('host_id'); + }); + + Schema::table('frontdesk_employee_presence', function (Blueprint $table) { + $table->timestamp('expected_return_at')->nullable()->after('notes'); + $table->timestamp('return_alert_sent_at')->nullable()->after('expected_return_at'); + }); + } + + public function down(): void + { + Schema::table('frontdesk_employee_presence', function (Blueprint $table) { + $table->dropColumn(['expected_return_at', 'return_alert_sent_at']); + }); + + Schema::table('frontdesk_employees', function (Blueprint $table) { + $table->dropColumn(['qr_token', 'user_ref']); + }); + } +}; diff --git a/resources/js/kiosk-flow.js b/resources/js/kiosk-flow.js index 1e67ef5..0733def 100644 --- a/resources/js/kiosk-flow.js +++ b/resources/js/kiosk-flow.js @@ -4,6 +4,15 @@ export function registerKioskFlow(Alpine) { return { step: 'welcome', + employeeKioskEnabled: config.employeeKioskEnabled ?? false, + staffActionUrl: config.staffActionUrl || '', + stepOutReasons: config.stepOutReasons || [], + staffIdentifyMode: 'code', + staffForm: { employee_code: '', qr_token: '', pin: '' }, + staffEmployee: null, + staffMessage: '', + staffBranchWarning: '', + staffStepOut: { step_out_reason: 'lunch', destination: '', notes: '', expected_return_at: '' }, formStep: 1, typeIndex: 0, typeTouchStart: null, @@ -172,7 +181,14 @@ export function registerKioskFlow(Alpine) { this.typeIndex = 0; } else if (this.step === 'details') { this.prevFormStep(); + } else if (this.step === 'staff_identify') { + this.step = 'welcome'; + } else if (this.step === 'staff_actions') { + this.step = 'staff_identify'; + } else if (this.step === 'staff_step_out') { + this.step = 'staff_actions'; } + this.errorMessage = ''; this.resetTimer(); }, @@ -192,6 +208,12 @@ export function registerKioskFlow(Alpine) { this.typeIndex = 0; this.errorMessage = ''; this.result = null; + this.staffEmployee = null; + this.staffMessage = ''; + this.staffBranchWarning = ''; + this.staffIdentifyMode = 'code'; + this.staffForm = { employee_code: '', qr_token: '', pin: '' }; + this.staffStepOut = { step_out_reason: 'lunch', destination: '', notes: '', expected_return_at: '' }; this.form = { full_name: '', company: '', phone: '', email: '', host_id: '', visitor_type: 'visitor', purpose: '', photo_data: '', @@ -200,6 +222,134 @@ export function registerKioskFlow(Alpine) { this.resetTimer(); }, + startVisitorFlow() { + this.step = 'type'; + this.resetTimer(); + }, + + startStaffFlow() { + this.step = 'staff_identify'; + this.errorMessage = ''; + this.staffIdentifyMode = 'code'; + this.staffForm = { employee_code: '', qr_token: '', pin: '' }; + this.resetTimer(); + }, + + staffCredentials() { + const body = { pin: this.staffForm.pin }; + if (this.staffIdentifyMode === 'badge' && this.staffForm.qr_token.trim()) { + body.qr_token = this.staffForm.qr_token.trim(); + } else { + body.employee_code = this.staffForm.employee_code.trim(); + } + return body; + }, + + async staffPost(action, extra = {}) { + this.loading = true; + this.errorMessage = ''; + try { + const res = await fetch(this.staffActionUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content, + 'Accept': 'application/json', + }, + body: JSON.stringify({ + ...this.staffCredentials(), + action, + ...extra, + }), + }); + const data = await res.json(); + if (!res.ok) { + const branchMsg = data.errors?.branch_mismatch?.[0]; + if (branchMsg && action === 'sign_in') { + this.staffBranchWarning = branchMsg; + this.step = 'staff_branch_confirm'; + return null; + } + const msg = data.message || data.errors?.credentials?.[0] || data.errors?.action?.[0] || data.errors?.destination?.[0] || 'Request failed.'; + throw new Error(msg); + } + return data; + } finally { + this.loading = false; + } + }, + + async staffIdentify() { + const hasCode = this.staffIdentifyMode === 'code' && this.staffForm.employee_code.trim(); + const hasBadge = this.staffIdentifyMode === 'badge' && this.staffForm.qr_token.trim(); + if ((!hasCode && !hasBadge) || !/^\d{4,6}$/.test(this.staffForm.pin)) { + this.showError('Enter your employee code or scan your badge, plus your PIN.'); + return; + } + try { + const data = await this.staffPost('identify'); + if (!data) return; + this.staffEmployee = data.employee; + this.step = 'staff_actions'; + this.resetTimer(); + } catch (e) { + this.showError(e.message || 'Invalid employee code or PIN.'); + } + }, + + async staffConfirmSignIn() { + try { + const data = await this.staffPost('sign_in', { confirm_branch_mismatch: true }); + if (!data) return; + this.staffEmployee = data.employee; + this.staffMessage = data.message || 'Signed in successfully.'; + this.step = 'staff_done'; + this.staffForm.pin = ''; + this.resetTimer(); + } catch (e) { + this.showError(e.message || 'Sign in failed.'); + } + }, + + async staffPerform(action, extra = {}) { + try { + const data = await this.staffPost(action, extra); + if (!data) return; + this.staffEmployee = data.employee; + this.staffMessage = data.message || 'Done.'; + this.step = 'staff_done'; + this.staffForm.pin = ''; + this.resetTimer(); + } catch (e) { + this.showError(e.message || 'Action failed.'); + } + }, + + openStaffStepOut() { + this.staffStepOut = { step_out_reason: 'lunch', destination: '', notes: '', expected_return_at: '' }; + this.step = 'staff_step_out'; + this.errorMessage = ''; + this.resetTimer(); + }, + + submitStaffStepOut() { + if (this.staffStepOut.step_out_reason === 'other' && !this.staffStepOut.destination.trim()) { + this.showError('Please say where you are going.'); + return; + } + this.staffPerform('step_out', { ...this.staffStepOut }); + }, + + staffActionLabel(action) { + const labels = { + sign_in: 'Sign in', + sign_out: 'Sign out for the day', + step_out: 'Step out', + step_in: "I'm back", + }; + return labels[action] || action; + }, + async startCamera() { try { this.cameraStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' } }); diff --git a/resources/views/frontdesk/dashboard.blade.php b/resources/views/frontdesk/dashboard.blade.php index 3252e90..36e53b2 100644 --- a/resources/views/frontdesk/dashboard.blade.php +++ b/resources/views/frontdesk/dashboard.blade.php @@ -3,12 +3,12 @@ $cards = [ ['label' => 'Visitors Today', 'value' => number_format($stats['visitors_today']), 'href' => route('frontdesk.visits.index')], ['label' => 'Currently Inside', 'value' => number_format($stats['currently_inside']), 'href' => route('frontdesk.visits.index', ['status' => 'checked_in'])], + ['label' => 'Staff On Site', 'value' => number_format($stats['staff_on_site']), 'href' => route('frontdesk.employees.index')], + ['label' => 'Staff Stepped Out', 'value' => number_format($stats['staff_stepped_out']), 'href' => route('frontdesk.employees.index')], ['label' => 'Expected Arrivals', 'value' => number_format($stats['expected_arrivals']), 'href' => route('frontdesk.visits.index', ['status' => 'expected'])], ['label' => 'Waiting', 'value' => number_format($stats['waiting']), 'href' => route('frontdesk.visits.index', ['status' => 'waiting'])], ['label' => 'Overdue', 'value' => number_format($stats['overdue']), 'href' => route('frontdesk.visits.index', ['status' => 'overdue'])], ['label' => 'Pending Approval', 'value' => number_format($stats['pending_approvals']), 'href' => route('frontdesk.visits.index', ['status' => 'waiting'])], - ['label' => 'Checked Out', 'value' => number_format($stats['checked_out_today']), 'href' => route('frontdesk.visits.index', ['status' => 'checked_out'])], - ['label' => 'Deliveries', 'value' => number_format($stats['deliveries_today']), 'href' => route('frontdesk.visits.index')], ]; @endphp @@ -73,6 +73,28 @@ @endforelse + @if ($staffSteppedOut->isNotEmpty()) +
+
+

Staff stepped out

+ @include('partials.mobile-icon-link', [ + 'href' => route('frontdesk.employees.index'), + 'label' => 'Employees', + 'icon' => 'arrow', + ]) +
+ @foreach ($staffSteppedOut as $presence) +
+ {{ strtoupper(substr($presence->employee->full_name, 0, 1)) }} +
+

{{ $presence->employee->full_name }}

+

{{ $presence->destination ?? 'Stepped out' }} · {{ $presence->last_event_at?->diffForHumans() }}

+
+
+ @endforeach +
+ @endif + @if ($pendingApprovals->isNotEmpty())
diff --git a/resources/views/frontdesk/employee-portal/index.blade.php b/resources/views/frontdesk/employee-portal/index.blade.php new file mode 100644 index 0000000..2673057 --- /dev/null +++ b/resources/views/frontdesk/employee-portal/index.blade.php @@ -0,0 +1,71 @@ + + @php + $presence = $employee->presence; + $status = $presence->status; + @endphp +
+

My presence

+

{{ $organization->name }}

+ + @if (session('success')) +

{{ session('success') }}

+ @endif + +
+

{{ $statusLabel }}

+

{{ $employee->full_name }}

+ @if ($presence->destination) +

{{ $presence->destination }}

+ @endif + @if ($presence->expected_return_at) +

Expected back {{ $presence->expected_return_at->format('M j, g:i A') }}

+ @endif + + @if ($status === 'off_site') +

Sign in at the reception kiosk when you arrive.

+ @endif + + @if ($status === 'on_site') +
+ @csrf + +
+ + +
+
+ + +
+
+ + +
+ +
+
+ @csrf + + +
+ @endif + + @if ($status === 'stepped_out') +
+ @csrf + + +
+
+ @csrf + + +
+ @endif +
+
+
diff --git a/resources/views/frontdesk/employees/badge.blade.php b/resources/views/frontdesk/employees/badge.blade.php new file mode 100644 index 0000000..97ebd96 --- /dev/null +++ b/resources/views/frontdesk/employees/badge.blade.php @@ -0,0 +1,32 @@ + + + + + Employee Badge · {{ $employee->full_name }} + + + + +
+
{{ $employee->organization->name ?? 'Staff' }}
+
{{ $employee->full_name }}
+
+ @if ($employee->department){{ $employee->department }}
@endif + Staff badge — scan at kiosk +
+
{{ $employee->employee_code }}
+ @if ($qrSvg) +
{!! $qrSvg !!}
+ @endif +
+ + diff --git a/resources/views/frontdesk/employees/create.blade.php b/resources/views/frontdesk/employees/create.blade.php new file mode 100644 index 0000000..14fc6d2 --- /dev/null +++ b/resources/views/frontdesk/employees/create.blade.php @@ -0,0 +1,61 @@ + +
+

Add employee

+

Staff use their employee code and PIN at the reception kiosk.

+
+ @csrf +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +

When stepped out, linked hosts show as unavailable to visitors.

+
+
+ + +
+ +
+
+
diff --git a/resources/views/frontdesk/employees/edit.blade.php b/resources/views/frontdesk/employees/edit.blade.php new file mode 100644 index 0000000..96220b9 --- /dev/null +++ b/resources/views/frontdesk/employees/edit.blade.php @@ -0,0 +1,80 @@ + +
+

Edit employee

+
+ @csrf @method('PUT') +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +

Enables the My presence portal for this employee.

+
+ + @if ($employee->presence) +
+ Current status: + {{ config('frontdesk.employee_presence_statuses.'.$employee->presence->status, $employee->presence->status) }} + @if ($employee->presence->destination) + · {{ $employee->presence->destination }} + @endif +
+ @endif +
+ Back + +
+
+
+ @csrf @method('DELETE') + +
+
+
diff --git a/resources/views/frontdesk/employees/import.blade.php b/resources/views/frontdesk/employees/import.blade.php new file mode 100644 index 0000000..9902b06 --- /dev/null +++ b/resources/views/frontdesk/employees/import.blade.php @@ -0,0 +1,22 @@ + +
+

Import employees

+

Upload a CSV with columns: employee_code, full_name, pin and optional department, email, phone, branch.

+ +
+ @csrf +
+ + +
+ +
+ +
+

Example

+
employee_code,full_name,pin,department
+EMP001,Jane Akoto,1234,Finance
+EMP002,Kwame Mensah,5678,Operations
+
+
+
diff --git a/resources/views/frontdesk/employees/index.blade.php b/resources/views/frontdesk/employees/index.blade.php new file mode 100644 index 0000000..f3af7d3 --- /dev/null +++ b/resources/views/frontdesk/employees/index.blade.php @@ -0,0 +1,54 @@ + +
+

Employees

+ +
+ +
+ + + + + + + + + + + + @forelse ($employees as $employee) + @php + $presence = $employee->presence; + $status = $presence?->status ?? 'off_site'; + $statusLabel = config('frontdesk.employee_presence_statuses.'.$status, $status); + @endphp + + + + + + + + @empty + + @endforelse + +
NameCodeDepartmentStatus
{{ $employee->full_name }}{{ $employee->employee_code }}{{ $employee->department ?? '—' }} + + {{ $statusLabel }} + + @if ($presence?->destination) + {{ $presence->destination }} + @endif + + Badge + Edit +
No employees yet. Add staff who will sign in at the kiosk.
+
+
{{ $employees->links() }}
+
diff --git a/resources/views/frontdesk/kiosk/index.blade.php b/resources/views/frontdesk/kiosk/index.blade.php index 7a9787a..fa53aaf 100644 --- a/resources/views/frontdesk/kiosk/index.blade.php +++ b/resources/views/frontdesk/kiosk/index.blade.php @@ -9,7 +9,7 @@ - Visitor Check-in · {{ $organization->name }} + Reception · {{ $organization->name }} @include('partials.favicon') @@ -19,6 +19,9 @@ typeConfigs: @json($typeConfigs), resetSeconds: {{ $resetSeconds }}, checkInUrl: @json($checkInUrl ?? route('frontdesk.kiosk.check-in')), + employeeKioskEnabled: @json($employeeKioskEnabled ?? false), + staffActionUrl: @json($staffActionUrl ?? ''), + stepOutReasons: @json($stepOutReasons ?? []), }; @vite(['resources/css/app.css', 'resources/js/app.js']) @@ -44,17 +47,26 @@ @@ -233,6 +245,114 @@
+ {{-- Employee: identify --}} + + + {{-- Employee: actions --}} + + + {{-- Employee: step out --}} + + + {{-- Employee: branch mismatch confirm --}} + + + {{-- Employee: done --}} + + {{-- Done --}}