Files
ladill-frontdesk/app/Http/Controllers/Frontdesk/EmployeeController.php
T
isaaccladandCursor f995606649
Deploy Ladill Frontdesk / deploy (push) Successful in 1m3s
Add hero sections to Visits, Hosts, Employees, Devices, Branches, and Team index pages.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 20:45:30 +00:00

417 lines
15 KiB
PHP

<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\Employee;
use App\Models\EmployeePresence;
use App\Models\EmployeePresenceEvent;
use App\Models\Host;
use App\Models\Member;
use App\Models\User;
use App\Services\Frontdesk\EmployeePresenceService;
use App\Services\Frontdesk\QrCodeService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class EmployeeController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->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<string, mixed> */
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<array{user_ref: string, label: string, taken: bool}>
*/
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();
}
}