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]); } }