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); $statsQuery = Employee::owned($this->ownerRef($request)) ->where('organization_id', $organization->id); $this->scopeToBranch($request, $statsQuery); $heroStats = [ 'total' => (clone $statsQuery)->count(), 'on_site' => (clone $statsQuery)->whereHas('presence', fn ($q) => $q->where('status', EmployeePresence::STATUS_ON_SITE))->count(), 'active' => (clone $statsQuery)->where('active', true)->count(), ]; return view('frontdesk.employees.index', compact('employees', 'organization', 'heroStats')); } 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), 'linkableUsers' => $this->linkableUsers($request, $organization->id), ]); } public function store(Request $request, EmployeePresenceService $presence): RedirectResponse { $this->authorizeAbility($request, 'employees.manage'); $organization = $this->organization($request); $validated = $this->validatedEmployee($request, $organization->id); $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), 'linkableUsers' => $this->linkableUsers($request, $employee->organization_id, $employee), ]); } public function update(Request $request, Employee $employee, EmployeePresenceService $presence): RedirectResponse { $this->authorizeAbility($request, 'employees.manage'); $this->authorizeOwner($request, $employee); $validated = $this->validatedEmployee($request, $employee->organization_id, updating: true, employee: $employee); $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, int $organizationId, bool $updating = false, ?Employee $employee = null): array { $pinRules = $updating ? ['nullable', 'string', 'regex:/^\d{4,6}$/'] : ['required', 'string', 'regex:/^\d{4,6}$/']; $linkableRefs = collect($this->linkableUsers($request, $organizationId, $employee)) ->reject(fn (array $user) => $user['taken']) ->pluck('user_ref') ->all(); 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', Rule::when( filled($request->input('user_ref')), [Rule::in($linkableRefs)] ), ], ]); } /** * Ladill users that can be linked to an employee (team members and linked hosts). * * @return list */ protected function linkableUsers(Request $request, int $organizationId, ?Employee $employee = null): array { $ownerRef = $this->ownerRef($request); $refs = collect() ->merge( Member::owned($ownerRef) ->where('organization_id', $organizationId) ->pluck('user_ref') ) ->merge( Host::owned($ownerRef) ->where('organization_id', $organizationId) ->whereNotNull('user_ref') ->pluck('user_ref') ) ->merge( Employee::owned($ownerRef) ->where('organization_id', $organizationId) ->whereNotNull('user_ref') ->pluck('user_ref') ); if ($employee?->user_ref) { $refs->push($employee->user_ref); } $refs = $refs->filter()->unique()->values(); $users = User::query() ->whereIn('public_id', $refs) ->get(['public_id', 'name', 'email']) ->keyBy('public_id'); $takenRefs = Employee::owned($ownerRef) ->where('organization_id', $organizationId) ->whereNotNull('user_ref') ->when($employee, fn ($query) => $query->where('id', '!=', $employee->id)) ->pluck('user_ref') ->flip(); return $refs ->map(function (string $ref) use ($users, $takenRefs, $employee) { $user = $users->get($ref); $label = $user ? trim($user->name.' ยท '.$user->email) : $ref; return [ 'user_ref' => $ref, 'label' => $label, 'taken' => $takenRefs->has($ref) && $employee?->user_ref !== $ref, ]; }) ->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE) ->values() ->all(); } 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(); } }