Add employee presence with kiosk sign-in, QR badges, and mobile step-out.
Deploy Ladill Frontdesk / deploy (push) Failing after 26s
Deploy Ladill Frontdesk / deploy (push) Failing after 26s
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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',
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
<?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\EmployeePresenceEvent;
|
||||
use App\Models\Host;
|
||||
use App\Services\Frontdesk\EmployeePresenceService;
|
||||
use App\Services\Frontdesk\QrCodeService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
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);
|
||||
|
||||
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<string, mixed> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Frontdesk;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
|
||||
use App\Models\Employee;
|
||||
use App\Services\Frontdesk\EmployeePresenceService;
|
||||
use App\Services\Frontdesk\OrganizationResolver;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EmployeePortalController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function index(Request $request, EmployeePresenceService $presence): View
|
||||
{
|
||||
$employee = $this->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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', []));
|
||||
|
||||
Reference in New Issue
Block a user