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:
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\EmployeePresence;
|
||||
use App\Services\Frontdesk\NotificationDispatcher;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class EmployeeReturnAlertsCommand extends Command
|
||||
{
|
||||
protected $signature = 'frontdesk:employee-return-alerts';
|
||||
|
||||
protected $description = 'Alert staff when employees are overdue returning from step-out';
|
||||
|
||||
public function handle(NotificationDispatcher $notifications): int
|
||||
{
|
||||
$overdue = EmployeePresence::query()
|
||||
->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;
|
||||
}
|
||||
}
|
||||
@@ -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', []));
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Employee extends Model
|
||||
{
|
||||
use BelongsToOwner, SoftDeletes;
|
||||
|
||||
protected $table = 'frontdesk_employees';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'organization_id', 'branch_id', 'employee_code', 'qr_token', 'full_name',
|
||||
'department', 'email', 'phone', 'pin_hash', 'host_id', 'user_ref', 'active',
|
||||
];
|
||||
|
||||
protected $hidden = ['pin_hash'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['active' => '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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EmployeePresence extends Model
|
||||
{
|
||||
public const STATUS_OFF_SITE = 'off_site';
|
||||
|
||||
public const STATUS_ON_SITE = 'on_site';
|
||||
|
||||
public const STATUS_STEPPED_OUT = 'stepped_out';
|
||||
|
||||
protected $table = 'frontdesk_employee_presence';
|
||||
|
||||
protected $fillable = [
|
||||
'employee_id', 'status', 'branch_id', 'device_id', 'destination',
|
||||
'step_out_reason', 'notes', 'expected_return_at', 'return_alert_sent_at',
|
||||
'signed_in_at', 'last_event_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'signed_in_at' => '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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToOwner;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EmployeePresenceEvent extends Model
|
||||
{
|
||||
use BelongsToOwner;
|
||||
public const EVENT_SIGN_IN = 'sign_in';
|
||||
|
||||
public const EVENT_SIGN_OUT = 'sign_out';
|
||||
|
||||
public const EVENT_STEP_OUT = 'step_out';
|
||||
|
||||
public const EVENT_STEP_IN = 'step_in';
|
||||
|
||||
protected $table = 'frontdesk_employee_presence_events';
|
||||
|
||||
protected $fillable = [
|
||||
'owner_ref', 'organization_id', 'employee_id', 'event', 'status_after',
|
||||
'branch_id', 'device_id', 'destination', 'step_out_reason', 'notes',
|
||||
];
|
||||
|
||||
public function employee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Employee::class, 'employee_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Employee;
|
||||
use App\Models\EmployeePresence;
|
||||
use App\Models\EmployeePresenceEvent;
|
||||
use App\Models\Host;
|
||||
use App\Models\Organization;
|
||||
use App\Services\Integrations\WebhookDispatcher;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EmployeePresenceService
|
||||
{
|
||||
public function __construct(
|
||||
protected WebhookDispatcher $webhooks,
|
||||
) {}
|
||||
|
||||
public function hashPin(string $pin): string
|
||||
{
|
||||
return Hash::make($pin);
|
||||
}
|
||||
|
||||
public function verifyPin(Employee $employee, string $pin): bool
|
||||
{
|
||||
return Hash::check($pin, $employee->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<string> */
|
||||
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<string, mixed> */
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Frontdesk;
|
||||
|
||||
use App\Models\EmployeePresence;
|
||||
use App\Models\EmployeePresenceEvent;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EmployeeReportService
|
||||
{
|
||||
/** @return array<string, int> */
|
||||
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<int, EmployeePresenceEvent> */
|
||||
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<int, EmployeePresenceEvent> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
|
||||
@@ -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<string, mixed> $meta */
|
||||
protected function dispatchVisitEvent(
|
||||
Visit $visit,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, mixed> $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);
|
||||
}
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('frontdesk_employees', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('frontdesk_employees', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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
|
||||
</section>
|
||||
|
||||
@if ($staffSteppedOut->isNotEmpty())
|
||||
<section class="rounded-2xl border border-slate-200 bg-white lg:col-span-2">
|
||||
<div class="flex items-center justify-between border-b border-slate-100 px-5 py-4">
|
||||
<h2 class="text-sm font-semibold text-slate-900">Staff stepped out</h2>
|
||||
@include('partials.mobile-icon-link', [
|
||||
'href' => route('frontdesk.employees.index'),
|
||||
'label' => 'Employees',
|
||||
'icon' => 'arrow',
|
||||
])
|
||||
</div>
|
||||
@foreach ($staffSteppedOut as $presence)
|
||||
<div class="flex items-center gap-3 border-b border-slate-50 px-5 py-3 last:border-0">
|
||||
<span class="flex h-10 w-10 items-center justify-center rounded-full bg-amber-50 text-sm font-semibold text-amber-800">{{ strtoupper(substr($presence->employee->full_name, 0, 1)) }}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium text-slate-800">{{ $presence->employee->full_name }}</p>
|
||||
<p class="truncate text-xs text-slate-400">{{ $presence->destination ?? 'Stepped out' }} · {{ $presence->last_event_at?->diffForHumans() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if ($pendingApprovals->isNotEmpty())
|
||||
<section class="rounded-2xl border border-amber-200 bg-white lg:col-span-2">
|
||||
<div class="border-b border-amber-100 px-5 py-4">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<x-app-layout title="My presence">
|
||||
@php
|
||||
$presence = $employee->presence;
|
||||
$status = $presence->status;
|
||||
@endphp
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">My presence</h1>
|
||||
<p class="text-sm text-slate-500">{{ $organization->name }}</p>
|
||||
|
||||
@if (session('success'))
|
||||
<p class="mt-4 rounded-xl bg-emerald-50 px-4 py-3 text-sm text-emerald-800">{{ session('success') }}</p>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<p class="text-sm font-medium text-indigo-600">{{ $statusLabel }}</p>
|
||||
<h2 class="mt-1 text-2xl font-bold text-slate-900">{{ $employee->full_name }}</h2>
|
||||
@if ($presence->destination)
|
||||
<p class="mt-2 text-sm text-slate-600">{{ $presence->destination }}</p>
|
||||
@endif
|
||||
@if ($presence->expected_return_at)
|
||||
<p class="mt-1 text-xs text-slate-400">Expected back {{ $presence->expected_return_at->format('M j, g:i A') }}</p>
|
||||
@endif
|
||||
|
||||
@if ($status === 'off_site')
|
||||
<p class="mt-6 text-sm text-slate-500">Sign in at the reception kiosk when you arrive.</p>
|
||||
@endif
|
||||
|
||||
@if ($status === 'on_site')
|
||||
<form method="POST" action="{{ route('frontdesk.employee.portal.action') }}" class="mt-6 space-y-4">
|
||||
@csrf
|
||||
<input type="hidden" name="action" value="step_out">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Reason</label>
|
||||
<select name="step_out_reason" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@foreach ($stepOutReasons as $key => $label)
|
||||
<option value="{{ $key }}">{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Destination</label>
|
||||
<input type="text" name="destination" class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="Where are you going?">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Expected return</label>
|
||||
<input type="datetime-local" name="expected_return_at" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Step out</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('frontdesk.employee.portal.action') }}" class="mt-3">
|
||||
@csrf
|
||||
<input type="hidden" name="action" value="sign_out">
|
||||
<button type="submit" class="w-full rounded-lg border border-slate-200 py-2.5 text-sm font-medium text-slate-700">Sign out for the day</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if ($status === 'stepped_out')
|
||||
<form method="POST" action="{{ route('frontdesk.employee.portal.action') }}" class="mt-6">
|
||||
@csrf
|
||||
<input type="hidden" name="action" value="step_in">
|
||||
<button type="submit" class="btn-primary w-full">I'm back</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('frontdesk.employee.portal.action') }}" class="mt-3">
|
||||
@csrf
|
||||
<input type="hidden" name="action" value="sign_out">
|
||||
<button type="submit" class="w-full rounded-lg border border-slate-200 py-2.5 text-sm font-medium text-slate-700">Sign out for the day</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Employee Badge · {{ $employee->full_name }}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 0; padding: 1rem; background: #f8fafc; }
|
||||
.badge { width: 3.5in; border: 2px solid #4f46e5; border-radius: 12px; padding: 1rem; background: #fff; }
|
||||
.org { font-size: 0.75rem; color: #666; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.name { font-size: 1.5rem; font-weight: 700; margin: 0.5rem 0; color: #0f172a; }
|
||||
.meta { font-size: 0.875rem; color: #444; line-height: 1.5; }
|
||||
.code { font-family: monospace; font-size: 1.25rem; font-weight: 700; color: #4f46e5; margin-top: 1rem; }
|
||||
.qr { margin-top: 0.75rem; }
|
||||
@media print { body { padding: 0; background: #fff; } .no-print { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body onload="window.print()">
|
||||
<button class="no-print" onclick="window.print()" style="margin-bottom:1rem">Print badge</button>
|
||||
<div class="badge">
|
||||
<div class="org">{{ $employee->organization->name ?? 'Staff' }}</div>
|
||||
<div class="name">{{ $employee->full_name }}</div>
|
||||
<div class="meta">
|
||||
@if ($employee->department){{ $employee->department }}<br>@endif
|
||||
Staff badge — scan at kiosk
|
||||
</div>
|
||||
<div class="code">{{ $employee->employee_code }}</div>
|
||||
@if ($qrSvg)
|
||||
<div class="qr">{!! $qrSvg !!}</div>
|
||||
@endif
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,61 @@
|
||||
<x-app-layout title="Add employee">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Add employee</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Staff use their employee code and PIN at the reception kiosk.</p>
|
||||
<form method="POST" action="{{ route('frontdesk.employees.store') }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Employee code</label>
|
||||
<input type="text" name="employee_code" value="{{ old('employee_code') }}" required class="mt-1 w-full rounded-lg border-slate-300 font-mono text-sm uppercase">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">PIN (4–6 digits)</label>
|
||||
<input type="password" name="pin" inputmode="numeric" pattern="\d{4,6}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Full name</label>
|
||||
<input type="text" name="full_name" value="{{ old('full_name') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Department</label>
|
||||
<input type="text" name="department" value="{{ old('department') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Email</label>
|
||||
<input type="email" name="email" value="{{ old('email') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Phone</label>
|
||||
<input type="tel" name="phone" value="{{ old('phone') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">All branches</option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}" @selected(old('branch_id') == $branch->id)>{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Linked host (optional)</label>
|
||||
<select name="host_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">None</option>
|
||||
@foreach ($hosts as $host)
|
||||
<option value="{{ $host->id }}" @selected(old('host_id') == $host->id)>{{ $host->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500">When stepped out, linked hosts show as unavailable to visitors.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Linked Ladill user (optional)</label>
|
||||
<input type="text" name="user_ref" value="{{ old('user_ref') }}" placeholder="User public ID" class="mt-1 w-full rounded-lg border-slate-300 font-mono text-sm">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Save employee</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,80 @@
|
||||
<x-app-layout title="Edit employee">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Edit employee</h1>
|
||||
<form method="POST" action="{{ route('frontdesk.employees.update', $employee) }}" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf @method('PUT')
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Employee code</label>
|
||||
<input type="text" name="employee_code" value="{{ old('employee_code', $employee->employee_code) }}" required class="mt-1 w-full rounded-lg border-slate-300 font-mono text-sm uppercase">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">New PIN (optional)</label>
|
||||
<input type="password" name="pin" inputmode="numeric" pattern="\d{4,6}" class="mt-1 w-full rounded-lg border-slate-300 text-sm" placeholder="Leave blank to keep">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Full name</label>
|
||||
<input type="text" name="full_name" value="{{ old('full_name', $employee->full_name) }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Department</label>
|
||||
<input type="text" name="department" value="{{ old('department', $employee->department) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Email</label>
|
||||
<input type="email" name="email" value="{{ old('email', $employee->email) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Phone</label>
|
||||
<input type="tel" name="phone" value="{{ old('phone', $employee->phone) }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Branch</label>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">All branches</option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}" @selected(old('branch_id', $employee->branch_id) == $branch->id)>{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Linked host (optional)</label>
|
||||
<select name="host_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">None</option>
|
||||
@foreach ($hosts as $host)
|
||||
<option value="{{ $host->id }}" @selected(old('host_id', $employee->host_id) == $host->id)>{{ $host->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Linked Ladill user (for mobile step-out)</label>
|
||||
<input type="text" name="user_ref" value="{{ old('user_ref', $employee->user_ref) }}" placeholder="User public ID" class="mt-1 w-full rounded-lg border-slate-300 font-mono text-sm">
|
||||
<p class="mt-1 text-xs text-slate-400">Enables the My presence portal for this employee.</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="active" value="1" @checked(old('active', $employee->active)) class="rounded border-slate-300">
|
||||
Active (can use kiosk)
|
||||
</label>
|
||||
@if ($employee->presence)
|
||||
<div class="rounded-lg bg-slate-50 p-3 text-sm text-slate-600">
|
||||
Current status:
|
||||
<span class="font-medium">{{ config('frontdesk.employee_presence_statuses.'.$employee->presence->status, $employee->presence->status) }}</span>
|
||||
@if ($employee->presence->destination)
|
||||
· {{ $employee->presence->destination }}
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
<div class="flex gap-3 pt-2">
|
||||
<a href="{{ route('frontdesk.employees.index') }}" class="flex-1 rounded-lg border border-slate-200 py-2 text-center text-sm">Back</a>
|
||||
<button type="submit" class="btn-primary flex-1">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
<form method="POST" action="{{ route('frontdesk.employees.destroy', $employee) }}" class="mt-4" onsubmit="return confirm('Remove this employee?')">
|
||||
@csrf @method('DELETE')
|
||||
<button type="submit" class="text-sm text-red-600 hover:underline">Remove employee</button>
|
||||
</form>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,22 @@
|
||||
<x-app-layout title="Import employees">
|
||||
<div class="mx-auto max-w-lg">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Import employees</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Upload a CSV with columns: <code class="text-xs">employee_code, full_name, pin</code> and optional <code class="text-xs">department, email, phone, branch</code>.</p>
|
||||
|
||||
<form method="POST" action="{{ route('frontdesk.employees.import.store') }}" enctype="multipart/form-data" class="mt-6 space-y-4 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">CSV file</label>
|
||||
<input type="file" name="csv" accept=".csv,text/csv" required class="mt-1 w-full text-sm">
|
||||
</div>
|
||||
<button type="submit" class="btn-primary w-full">Import</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4 text-xs text-slate-600">
|
||||
<p class="font-medium text-slate-800">Example</p>
|
||||
<pre class="mt-2 overflow-x-auto">employee_code,full_name,pin,department
|
||||
EMP001,Jane Akoto,1234,Finance
|
||||
EMP002,Kwame Mensah,5678,Operations</pre>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,54 @@
|
||||
<x-app-layout title="Employees">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Employees</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ route('frontdesk.employees.export.attendance') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Export attendance</a>
|
||||
<a href="{{ route('frontdesk.employees.import') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Import CSV</a>
|
||||
<a href="{{ route('frontdesk.employees.create') }}" class="btn-primary">Add employee</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Name</th>
|
||||
<th class="px-4 py-3">Code</th>
|
||||
<th class="px-4 py-3">Department</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@forelse ($employees as $employee)
|
||||
@php
|
||||
$presence = $employee->presence;
|
||||
$status = $presence?->status ?? 'off_site';
|
||||
$statusLabel = config('frontdesk.employee_presence_statuses.'.$status, $status);
|
||||
@endphp
|
||||
<tr>
|
||||
<td class="px-4 py-3 font-medium">{{ $employee->full_name }}</td>
|
||||
<td class="px-4 py-3 font-mono text-slate-600">{{ $employee->employee_code }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $employee->department ?? '—' }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="rounded-full px-2.5 py-0.5 text-xs font-medium
|
||||
{{ $status === 'on_site' ? 'bg-emerald-50 text-emerald-700' : ($status === 'stepped_out' ? 'bg-amber-50 text-amber-800' : 'bg-slate-100 text-slate-600') }}">
|
||||
{{ $statusLabel }}
|
||||
</span>
|
||||
@if ($presence?->destination)
|
||||
<span class="mt-0.5 block text-xs text-slate-400">{{ $presence->destination }}</span>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="{{ route('frontdesk.employees.badge', $employee) }}" target="_blank" class="text-sm text-slate-600">Badge</a>
|
||||
<a href="{{ route('frontdesk.employees.edit', $employee) }}" class="ml-3 text-sm text-indigo-600">Edit</a>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-400">No employees yet. Add staff who will sign in at the kiosk.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4">{{ $employees->links() }}</div>
|
||||
</x-app-layout>
|
||||
@@ -9,7 +9,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>Visitor Check-in · {{ $organization->name }}</title>
|
||||
<title>Reception · {{ $organization->name }}</title>
|
||||
@include('partials.favicon')
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
|
||||
@@ -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 ?? []),
|
||||
};
|
||||
</script>
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@@ -44,17 +47,26 @@
|
||||
<template x-if="step === 'welcome'">
|
||||
<div class="flex min-h-[calc(100vh-5rem)] flex-col items-center justify-center px-6 py-12 text-center">
|
||||
<div class="max-w-xl">
|
||||
<p class="text-sm font-semibold uppercase tracking-wider text-indigo-600">Visitor check-in</p>
|
||||
<p class="text-sm font-semibold uppercase tracking-wider text-indigo-600">Reception</p>
|
||||
<h1 class="mt-3 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
|
||||
Welcome to {{ $organization->name }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
@click="step = 'type'; resetTimer()"
|
||||
class="kiosk-tap-btn btn-primary btn-primary-lg mt-16 w-full max-w-lg py-8 text-2xl font-bold">
|
||||
Tap to begin
|
||||
</button>
|
||||
<div class="mt-16 grid w-full max-w-lg gap-4" :class="employeeKioskEnabled ? 'sm:grid-cols-2' : ''">
|
||||
<button type="button"
|
||||
@click="startVisitorFlow()"
|
||||
class="kiosk-tap-btn btn-primary btn-primary-lg w-full py-8 text-xl font-bold">
|
||||
I'm a visitor
|
||||
</button>
|
||||
<template x-if="employeeKioskEnabled">
|
||||
<button type="button"
|
||||
@click="startStaffFlow()"
|
||||
class="w-full rounded-3xl border-2 border-slate-200 bg-white py-8 text-xl font-bold text-slate-900 shadow-sm transition hover:border-indigo-300 hover:shadow-md active:scale-[0.98]">
|
||||
I'm an employee
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -233,6 +245,114 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Employee: identify --}}
|
||||
<template x-if="step === 'staff_identify'">
|
||||
<div class="relative flex min-h-[calc(100vh-5rem)] flex-col justify-center px-6 py-8">
|
||||
<div class="absolute left-6 top-8 z-10">
|
||||
@include('frontdesk.partials.kiosk-back')
|
||||
</div>
|
||||
<div class="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-2xl font-bold text-slate-900">Employee sign-in</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Enter your employee code and PIN, or scan your badge.</p>
|
||||
<p x-show="errorMessage" x-cloak class="mt-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="errorMessage"></p>
|
||||
<div class="mt-4 flex gap-2 text-sm">
|
||||
<button type="button" @click="staffIdentifyMode = 'code'" :class="staffIdentifyMode === 'code' ? 'bg-indigo-100 text-indigo-800' : 'bg-slate-100 text-slate-600'" class="rounded-lg px-3 py-1.5 font-medium">Code</button>
|
||||
<button type="button" @click="staffIdentifyMode = 'badge'" :class="staffIdentifyMode === 'badge' ? 'bg-indigo-100 text-indigo-800' : 'bg-slate-100 text-slate-600'" class="rounded-lg px-3 py-1.5 font-medium">Badge scan</button>
|
||||
</div>
|
||||
<div class="mt-6 space-y-4">
|
||||
<template x-if="staffIdentifyMode === 'code'">
|
||||
<input type="text" x-model="staffForm.employee_code" placeholder="Employee code" autocomplete="off" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg uppercase">
|
||||
</template>
|
||||
<template x-if="staffIdentifyMode === 'badge'">
|
||||
<input type="text" x-model="staffForm.qr_token" placeholder="Scan badge QR code" autocomplete="off" x-ref="staffQrInput" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
</template>
|
||||
<input type="password" inputmode="numeric" x-model="staffForm.pin" placeholder="PIN (4–6 digits)" maxlength="6" autocomplete="off" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg tracking-widest">
|
||||
</div>
|
||||
<button type="button" @click="staffIdentify()" :disabled="loading" class="btn-primary mt-6 w-full py-3 disabled:opacity-50">
|
||||
<span x-text="loading ? 'Checking…' : 'Continue'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Employee: actions --}}
|
||||
<template x-if="step === 'staff_actions'">
|
||||
<div class="relative flex min-h-[calc(100vh-5rem)] flex-col justify-center px-6 py-8">
|
||||
<div class="absolute left-6 top-8 z-10">
|
||||
@include('frontdesk.partials.kiosk-back')
|
||||
</div>
|
||||
<div class="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-6 text-center shadow-sm">
|
||||
<p class="text-sm font-medium text-indigo-600" x-text="staffEmployee?.status_label"></p>
|
||||
<h2 class="mt-1 text-2xl font-bold text-slate-900" x-text="staffEmployee?.full_name"></h2>
|
||||
<p class="mt-1 text-sm text-slate-500" x-show="staffEmployee?.destination" x-text="'Currently: ' + staffEmployee?.destination"></p>
|
||||
<p x-show="errorMessage" x-cloak class="mt-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="errorMessage"></p>
|
||||
<div class="mt-6 space-y-3">
|
||||
<template x-for="action in staffEmployee?.available_actions || []" :key="action">
|
||||
<button type="button"
|
||||
@click="action === 'step_out' ? openStaffStepOut() : staffPerform(action)"
|
||||
:disabled="loading"
|
||||
class="w-full rounded-xl border border-slate-200 py-4 text-lg font-semibold text-slate-800 transition hover:border-indigo-300 hover:bg-indigo-50 disabled:opacity-50"
|
||||
x-text="staffActionLabel(action)"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Employee: step out --}}
|
||||
<template x-if="step === 'staff_step_out'">
|
||||
<div class="relative flex min-h-[calc(100vh-5rem)] flex-col justify-center px-6 py-8">
|
||||
<div class="absolute left-6 top-8 z-10">
|
||||
@include('frontdesk.partials.kiosk-back')
|
||||
</div>
|
||||
<div class="mx-auto w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-2xl font-bold text-slate-900">Where are you going?</h2>
|
||||
<p class="mt-1 text-sm text-slate-500">Let reception know you're stepping out.</p>
|
||||
<p x-show="errorMessage" x-cloak class="mt-4 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700" x-text="errorMessage"></p>
|
||||
<div class="mt-6 space-y-4">
|
||||
<select x-model="staffStepOut.step_out_reason" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<template x-for="reason in stepOutReasons" :key="reason.key">
|
||||
<option :value="reason.key" x-text="reason.label"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input type="text" x-model="staffStepOut.destination" placeholder="Destination (required for Other)" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<input type="datetime-local" x-model="staffStepOut.expected_return_at" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
<input type="text" x-model="staffStepOut.notes" placeholder="Notes (optional)" class="w-full rounded-xl border-slate-200 px-4 py-4 text-lg">
|
||||
</div>
|
||||
<button type="button" @click="submitStaffStepOut()" :disabled="loading" class="btn-primary mt-6 w-full py-3 disabled:opacity-50">
|
||||
<span x-text="loading ? 'Saving…' : 'Confirm step out'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Employee: branch mismatch confirm --}}
|
||||
<template x-if="step === 'staff_branch_confirm'">
|
||||
<div class="relative flex min-h-[calc(100vh-5rem)] flex-col justify-center px-6 py-8">
|
||||
<div class="mx-auto w-full max-w-lg rounded-3xl border border-amber-200 bg-white p-6 shadow-sm">
|
||||
<h2 class="text-xl font-bold text-slate-900">Different branch</h2>
|
||||
<p class="mt-3 text-sm text-slate-600" x-text="staffBranchWarning"></p>
|
||||
<div class="mt-6 flex gap-3">
|
||||
<button type="button" @click="step = 'staff_actions'" class="flex-1 rounded-xl border border-slate-200 py-3 font-medium">Cancel</button>
|
||||
<button type="button" @click="staffConfirmSignIn()" :disabled="loading" class="btn-primary flex-1 py-3 disabled:opacity-50">Sign in here</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Employee: done --}}
|
||||
<template x-if="step === 'staff_done'">
|
||||
<div class="flex min-h-[calc(100vh-5rem)] flex-col items-center justify-center px-6 py-8">
|
||||
<div class="w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
||||
<div class="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-indigo-100 text-4xl">✓</div>
|
||||
<h2 class="text-2xl font-bold" x-text="staffMessage"></h2>
|
||||
<p class="mt-2 text-slate-600" x-text="staffEmployee?.full_name"></p>
|
||||
<p class="mt-1 text-sm text-slate-500" x-text="staffEmployee?.status_label"></p>
|
||||
<button type="button" @click="reset()" class="btn-primary btn-primary-lg mt-8 w-full">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{{-- Done --}}
|
||||
<template x-if="step === 'done'">
|
||||
<div class="flex min-h-[calc(100vh-5rem)] flex-col items-center justify-center px-6 py-8">
|
||||
|
||||
@@ -71,4 +71,51 @@
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">Staff attendance</h2>
|
||||
@if ($canExport)
|
||||
<a href="{{ route('frontdesk.employees.export.attendance', request()->query()) }}" class="text-sm font-medium text-indigo-600">Export HR CSV</a>
|
||||
@endif
|
||||
</div>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@foreach ($employeeSummary as $key => $value)
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
|
||||
<p class="text-xs uppercase tracking-wide text-slate-500">{{ str_replace('_', ' ', $key) }}</p>
|
||||
<p class="mt-1 text-2xl font-semibold text-slate-900 dark:text-slate-100">{{ $value }}</p>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-2">
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h3 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Recent staff events</h3>
|
||||
<table class="mt-4 min-w-full text-sm">
|
||||
@forelse ($employeeAttendance->take(10) as $event)
|
||||
<tr class="border-t border-slate-100 dark:border-slate-800">
|
||||
<td class="py-2">{{ $event->employee->full_name }}</td>
|
||||
<td class="py-2 text-slate-500">{{ config('frontdesk.employee_presence_events.'.$event->event, $event->event) }}</td>
|
||||
<td class="py-2 text-right text-slate-400">{{ $event->created_at->format('M j, g:i A') }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td class="py-4 text-slate-500">No staff events in this period.</td></tr>
|
||||
@endforelse
|
||||
</table>
|
||||
</section>
|
||||
<section class="rounded-2xl border border-slate-200 bg-white p-5 dark:border-slate-700 dark:bg-slate-900">
|
||||
<h3 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Step-out log</h3>
|
||||
<ul class="mt-4 space-y-2 text-sm">
|
||||
@forelse ($employeeStepOuts as $event)
|
||||
<li>
|
||||
<span class="font-medium">{{ $event->employee->full_name }}</span>
|
||||
<span class="text-slate-500"> → {{ $event->destination ?? '—' }}</span>
|
||||
<span class="block text-xs text-slate-400">{{ $event->created_at->format('M j, g:i A') }}</span>
|
||||
</li>
|
||||
@empty
|
||||
<li class="text-slate-500">No step-outs in this period.</li>
|
||||
@endforelse
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -6,18 +6,23 @@
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin: 2rem; color: #111; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
|
||||
h2 { font-size: 1.125rem; margin: 2rem 0 0.75rem; }
|
||||
.meta { color: #666; margin-bottom: 2rem; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 1.5rem; }
|
||||
th, td { border: 1px solid #ddd; padding: 0.5rem 0.75rem; text-align: left; font-size: 0.875rem; }
|
||||
th { background: #f5f5f5; }
|
||||
@media print { .no-print { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<button class="no-print" onclick="window.print()">Print report</button>
|
||||
<div class="no-print" style="margin-bottom:1rem;display:flex;gap:0.75rem">
|
||||
<button onclick="window.print()">Print report</button>
|
||||
<a href="{{ route('frontdesk.security.evacuation.export') }}">Export CSV</a>
|
||||
</div>
|
||||
<h1>Emergency Evacuation Report</h1>
|
||||
<p class="meta">{{ $organization->name }} · Generated {{ now()->format('M j, Y g:i A') }} · {{ $occupancy->count() }} occupants</p>
|
||||
<p class="meta">{{ $organization->name }} · Generated {{ now()->format('M j, Y g:i A') }} · {{ $occupancy->count() + $staffOnSite->count() }} occupants</p>
|
||||
|
||||
<h2>Visitors ({{ $occupancy->count() }})</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -30,7 +35,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($occupancy as $visit)
|
||||
@forelse ($occupancy as $visit)
|
||||
<tr>
|
||||
<td>{{ $visit->visitor->full_name }}</td>
|
||||
<td>{{ ucfirst(str_replace('_', ' ', $visit->visitor_type)) }}</td>
|
||||
@@ -39,7 +44,40 @@
|
||||
<td>{{ $visit->checked_in_at?->format('g:i A') }}</td>
|
||||
<td>{{ $visit->visitor->phone ?? '—' }}</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@empty
|
||||
<tr><td colspan="6">No visitors on site.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Staff ({{ $staffOnSite->count() }})</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Department</th>
|
||||
<th>Branch</th>
|
||||
<th>Signed in</th>
|
||||
<th>Status</th>
|
||||
<th>Phone</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@forelse ($staffOnSite as $presence)
|
||||
<tr>
|
||||
<td>{{ $presence->employee->full_name }}</td>
|
||||
<td>{{ $presence->employee->department ?? '—' }}</td>
|
||||
<td>{{ $presence->branch?->name ?? $presence->employee->branch?->name ?? '—' }}</td>
|
||||
<td>{{ $presence->signed_in_at?->format('g:i A') ?? '—' }}</td>
|
||||
<td>
|
||||
{{ config('frontdesk.employee_presence_statuses.'.$presence->status, $presence->status) }}
|
||||
@if ($presence->destination) · {{ $presence->destination }} @endif
|
||||
</td>
|
||||
<td>{{ $presence->employee->phone ?? '—' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="6">No staff on site.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<x-app-layout title="Security Dashboard">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold text-slate-900">Security dashboard</h1>
|
||||
<p class="text-sm text-slate-500">{{ $occupancy->count() }} people currently inside</p>
|
||||
<p class="text-sm text-slate-500">{{ $occupancy->count() }} visitors · {{ $staffOnSite->where('status', 'on_site')->count() }} staff on site · {{ $staffOnSite->where('status', 'stepped_out')->count() }} stepped out</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a href="{{ route('frontdesk.security.evacuation') }}" class="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100">
|
||||
Evacuation report
|
||||
</a>
|
||||
<a href="{{ route('frontdesk.security.verify') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Verify badge
|
||||
</a>
|
||||
</div>
|
||||
<a href="{{ route('frontdesk.security.evacuation') }}" class="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100">
|
||||
Evacuation report
|
||||
</a>
|
||||
<a href="{{ route('frontdesk.security.verify') }}" class="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||
Verify badge
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if ($expiredBadges->isNotEmpty())
|
||||
@@ -18,7 +20,8 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<h2 class="mt-6 text-sm font-semibold uppercase tracking-wide text-slate-500">Visitors inside</h2>
|
||||
<div class="mt-2 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
@@ -31,23 +34,53 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@foreach ($occupancy as $visit)
|
||||
@forelse ($occupancy as $visit)
|
||||
<tr class="{{ $visit->isBadgeExpired() ? 'bg-amber-50' : '' }}">
|
||||
<td class="px-4 py-3 font-medium">{{ $visit->visitor->full_name }}</td>
|
||||
<td class="px-4 py-3">{{ $visit->host?->name ?? '—' }}</td>
|
||||
<td class="px-4 py-3 capitalize">{{ str_replace('_', ' ', $visit->visitor_type) }}</td>
|
||||
<td class="px-4 py-3">{{ $visit->checked_in_at?->format('g:i A') }}</td>
|
||||
<td class="px-4 py-3 font-mono">{{ $visit->badge_code }}</td>
|
||||
<td class="px-4 py-3">
|
||||
@if ($canCheckout)
|
||||
<form method="POST" action="{{ route('frontdesk.security.checkout', $visit) }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-sm text-indigo-600 hover:text-indigo-800">Check out</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@if ($canCheckout)
|
||||
<form method="POST" action="{{ route('frontdesk.security.checkout', $visit) }}">
|
||||
@csrf
|
||||
<button type="submit" class="text-sm text-indigo-600 hover:text-indigo-800">Check out</button>
|
||||
</form>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
@empty
|
||||
<tr><td colspan="6" class="px-4 py-6 text-center text-slate-400">No visitors currently inside.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-8 text-sm font-semibold uppercase tracking-wide text-slate-500">Staff on premises</h2>
|
||||
<div class="mt-2 overflow-hidden rounded-2xl border border-slate-200 bg-white">
|
||||
<table class="min-w-full divide-y divide-slate-100 text-sm">
|
||||
<thead class="bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||
<tr>
|
||||
<th class="px-4 py-3">Employee</th>
|
||||
<th class="px-4 py-3">Department</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Destination</th>
|
||||
<th class="px-4 py-3">Since</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-50">
|
||||
@forelse ($staffOnSite as $presence)
|
||||
<tr class="{{ $presence->status === 'stepped_out' ? 'bg-amber-50/60' : '' }}">
|
||||
<td class="px-4 py-3 font-medium">{{ $presence->employee->full_name }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $presence->employee->department ?? '—' }}</td>
|
||||
<td class="px-4 py-3">{{ config('frontdesk.employee_presence_statuses.'.$presence->status, $presence->status) }}</td>
|
||||
<td class="px-4 py-3 text-slate-600">{{ $presence->destination ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-slate-500">{{ $presence->last_event_at?->format('g:i A') ?? '—' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="px-4 py-6 text-center text-slate-400">No staff signed in.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -109,6 +109,11 @@
|
||||
<label class="block text-sm font-medium text-slate-700">Visitor policy</label>
|
||||
<textarea name="visitor_policy" rows="4" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('visitor_policy', $settings['visitor_policy'] ?? '') }}</textarea>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="employee_kiosk_enabled" value="1"
|
||||
@checked(old('employee_kiosk_enabled', $settings['employee_kiosk_enabled'] ?? true))>
|
||||
Enable employee sign-in on kiosk (code + PIN)
|
||||
</label>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-slate-700">Notification channels</p>
|
||||
<p class="mt-1 text-xs text-slate-500">Alerts go to each host's email and/or phone — no Ladill account required.</p>
|
||||
@@ -169,6 +174,10 @@
|
||||
<h3 class="font-semibold text-slate-900">Integrations</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Webhooks and calendar feeds</p>
|
||||
</a>
|
||||
<a href="{{ route('frontdesk.employees.index') }}" class="rounded-2xl border border-slate-200 bg-white p-5 hover:border-indigo-300">
|
||||
<h3 class="font-semibold text-slate-900">Employees</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Staff roster and kiosk PINs</p>
|
||||
</a>
|
||||
<a href="{{ route('frontdesk.devices.index') }}" class="rounded-2xl border border-slate-200 bg-white p-5 hover:border-indigo-300">
|
||||
<h3 class="font-semibold text-slate-900">Devices</h3>
|
||||
<p class="mt-1 text-sm text-slate-500">Kiosks, printers, and hardware</p>
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
$linkedHost = auth()->user()
|
||||
? app(\App\Services\Frontdesk\OrganizationResolver::class)->hostFor(auth()->user())
|
||||
: null;
|
||||
$linkedEmployee = auth()->user()
|
||||
? app(\App\Services\Frontdesk\OrganizationResolver::class)->employeeFor(auth()->user())
|
||||
: null;
|
||||
|
||||
$nav = [
|
||||
['name' => 'Reception', 'route' => route('frontdesk.dashboard'), 'active' => request()->routeIs('frontdesk.dashboard'),
|
||||
@@ -28,15 +31,28 @@
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />'],
|
||||
['name' => 'Hosts', 'route' => route('frontdesk.hosts.index'), 'active' => request()->routeIs('frontdesk.hosts.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />'],
|
||||
];
|
||||
|
||||
if ($permissions->can($member, 'employees.view')) {
|
||||
$nav[] = ['name' => 'Employees', 'route' => route('frontdesk.employees.index'), 'active' => request()->routeIs('frontdesk.employees.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />'];
|
||||
}
|
||||
|
||||
$nav = array_merge($nav, [
|
||||
['name' => 'Kiosk', 'route' => route('frontdesk.kiosk'), 'active' => request()->routeIs('frontdesk.kiosk') && !request()->routeIs('frontdesk.kiosk.device*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 0 0 6 3.75v16.5a2.25 2.25 0 0 0 2.25 2.25h7.5A2.25 2.25 0 0 0 18 20.25V3.75a2.25 2.25 0 0 0-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />'],
|
||||
];
|
||||
]);
|
||||
|
||||
if ($linkedHost) {
|
||||
$nav[] = ['name' => 'Host portal', 'route' => route('frontdesk.host.index'), 'active' => request()->routeIs('frontdesk.host.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />'];
|
||||
}
|
||||
|
||||
if ($linkedEmployee) {
|
||||
$nav[] = ['name' => 'My presence', 'route' => route('frontdesk.employee.portal'), 'active' => request()->routeIs('frontdesk.employee.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" />'];
|
||||
}
|
||||
|
||||
$nav = array_merge($nav, [
|
||||
['name' => 'Security', 'route' => route('frontdesk.security.index'), 'active' => request()->routeIs('frontdesk.security.*'),
|
||||
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />'],
|
||||
|
||||
@@ -11,4 +11,5 @@ Artisan::command('inspire', function () {
|
||||
Schedule::command('frontdesk:mark-overdue-visits')->everyFiveMinutes();
|
||||
Schedule::command('frontdesk:mark-expired-badges')->everyFifteenMinutes();
|
||||
Schedule::command('frontdesk:mark-devices-offline')->everyFiveMinutes();
|
||||
Schedule::command('frontdesk:employee-return-alerts')->everyFiveMinutes();
|
||||
Schedule::command('frontdesk:send-daily-reports')->dailyAt('07:00');
|
||||
|
||||
@@ -9,6 +9,8 @@ use App\Http\Controllers\Frontdesk\BuildingController;
|
||||
use App\Http\Controllers\Frontdesk\ComplianceController;
|
||||
use App\Http\Controllers\Frontdesk\DashboardController;
|
||||
use App\Http\Controllers\Frontdesk\DeviceController;
|
||||
use App\Http\Controllers\Frontdesk\EmployeeController;
|
||||
use App\Http\Controllers\Frontdesk\EmployeePortalController;
|
||||
use App\Http\Controllers\Frontdesk\HostController;
|
||||
use App\Http\Controllers\Frontdesk\HostPortalController;
|
||||
use App\Http\Controllers\Frontdesk\IntegrationController;
|
||||
@@ -17,6 +19,7 @@ use App\Http\Controllers\Frontdesk\KioskDeviceController;
|
||||
use App\Http\Controllers\Frontdesk\MemberController;
|
||||
use App\Http\Controllers\Frontdesk\OnboardingController;
|
||||
use App\Http\Controllers\Frontdesk\ProController;
|
||||
use App\Http\Controllers\Frontdesk\QrScanController;
|
||||
use App\Http\Controllers\Frontdesk\ReceptionDeskController;
|
||||
use App\Http\Controllers\Frontdesk\ReportController;
|
||||
use App\Http\Controllers\Frontdesk\SecurityController;
|
||||
@@ -50,6 +53,7 @@ Route::get('/integrations/ical/{organization}', IcalFeedController::class)->name
|
||||
Route::middleware(['frontdesk.device:kiosk', 'throttle:kiosk-device'])->prefix('kiosk/d')->group(function () {
|
||||
Route::get('/{token}', [KioskDeviceController::class, 'show'])->name('frontdesk.kiosk.device');
|
||||
Route::post('/{token}/check-in', [KioskDeviceController::class, 'checkIn'])->name('frontdesk.kiosk.device.check-in');
|
||||
Route::post('/{token}/staff/action', [KioskDeviceController::class, 'staffAction'])->name('frontdesk.kiosk.device.staff.action');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
@@ -107,6 +111,20 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::put('/hosts/{host}', [HostController::class, 'update'])->name('frontdesk.hosts.update');
|
||||
Route::delete('/hosts/{host}', [HostController::class, 'destroy'])->name('frontdesk.hosts.destroy');
|
||||
|
||||
Route::get('/employees', [EmployeeController::class, 'index'])->name('frontdesk.employees.index');
|
||||
Route::get('/employees/create', [EmployeeController::class, 'create'])->name('frontdesk.employees.create');
|
||||
Route::post('/employees', [EmployeeController::class, 'store'])->name('frontdesk.employees.store');
|
||||
Route::get('/employees/import', [EmployeeController::class, 'importForm'])->name('frontdesk.employees.import');
|
||||
Route::post('/employees/import', [EmployeeController::class, 'import'])->name('frontdesk.employees.import.store');
|
||||
Route::get('/employees/{employee}/edit', [EmployeeController::class, 'edit'])->name('frontdesk.employees.edit');
|
||||
Route::get('/employees/{employee}/badge', [EmployeeController::class, 'badge'])->name('frontdesk.employees.badge');
|
||||
Route::put('/employees/{employee}', [EmployeeController::class, 'update'])->name('frontdesk.employees.update');
|
||||
Route::delete('/employees/{employee}', [EmployeeController::class, 'destroy'])->name('frontdesk.employees.destroy');
|
||||
Route::get('/employees/export/attendance', [EmployeeController::class, 'exportAttendance'])->name('frontdesk.employees.export.attendance');
|
||||
|
||||
Route::get('/employee', [EmployeePortalController::class, 'index'])->name('frontdesk.employee.portal');
|
||||
Route::post('/employee/action', [EmployeePortalController::class, 'action'])->name('frontdesk.employee.portal.action');
|
||||
|
||||
Route::get('/kiosk', [KioskController::class, 'show'])->name('frontdesk.kiosk');
|
||||
Route::post('/kiosk/check-in', [KioskController::class, 'checkIn'])->name('frontdesk.kiosk.check-in');
|
||||
|
||||
@@ -126,6 +144,7 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
|
||||
Route::get('/security', [SecurityController::class, 'index'])->name('frontdesk.security.index');
|
||||
Route::get('/security/evacuation', [SecurityController::class, 'evacuation'])->name('frontdesk.security.evacuation');
|
||||
Route::get('/security/evacuation/export', [SecurityController::class, 'evacuationExport'])->name('frontdesk.security.evacuation.export');
|
||||
Route::get('/security/evacuation/badges', [SecurityController::class, 'evacuationBadges'])->name('frontdesk.security.evacuation.badges');
|
||||
Route::get('/security/verify', [SecurityController::class, 'verifyForm'])->name('frontdesk.security.verify');
|
||||
Route::post('/security/verify', [SecurityController::class, 'verify'])->name('frontdesk.security.verify.lookup');
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\Branch;
|
||||
use App\Models\Device;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Models\WebhookEndpoint;
|
||||
use App\Services\Frontdesk\EmployeePresenceService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FrontdeskEmployeePhase23Test extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $user;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
protected Branch $homeBranch;
|
||||
|
||||
protected Branch $otherBranch;
|
||||
|
||||
protected string $deviceToken;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||
|
||||
$this->user = User::create([
|
||||
'public_id' => 'phase23-user',
|
||||
'name' => 'Phase 23 User',
|
||||
'email' => 'phase23@example.com',
|
||||
]);
|
||||
|
||||
$this->organization = Organization::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'name' => 'Phase 23 Org',
|
||||
'slug' => 'phase23-org',
|
||||
'settings' => ['onboarded' => true, 'employee_kiosk_enabled' => true],
|
||||
]);
|
||||
|
||||
Member::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => $this->user->public_id,
|
||||
'role' => 'org_admin',
|
||||
]);
|
||||
|
||||
$this->homeBranch = Branch::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'name' => 'HQ',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->otherBranch = Branch::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'name' => 'Annex',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->deviceToken = 'kiosk-phase23-'.str_repeat('z', 32);
|
||||
|
||||
Device::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->otherBranch->id,
|
||||
'name' => 'Annex Kiosk',
|
||||
'type' => 'kiosk',
|
||||
'status' => 'offline',
|
||||
'device_token' => $this->deviceToken,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createEmployee(array $overrides = []): Employee
|
||||
{
|
||||
$service = app(EmployeePresenceService::class);
|
||||
|
||||
$employee = Employee::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->homeBranch->id,
|
||||
'employee_code' => 'P23001',
|
||||
'full_name' => 'Alex Staff',
|
||||
'pin_hash' => $service->hashPin('5678'),
|
||||
'active' => true,
|
||||
...$overrides,
|
||||
]);
|
||||
|
||||
$service->presenceFor($employee);
|
||||
|
||||
return $employee->fresh();
|
||||
}
|
||||
|
||||
public function test_kiosk_identifies_employee_by_qr_token(): void
|
||||
{
|
||||
$employee = $this->createEmployee();
|
||||
|
||||
$this->postJson(route('frontdesk.kiosk.device.staff.action', $this->deviceToken), [
|
||||
'qr_token' => $employee->qr_token,
|
||||
'pin' => '5678',
|
||||
'action' => 'identify',
|
||||
])->assertOk()
|
||||
->assertJsonPath('employee.full_name', 'Alex Staff');
|
||||
}
|
||||
|
||||
public function test_branch_mismatch_requires_confirmation(): void
|
||||
{
|
||||
$this->createEmployee();
|
||||
|
||||
$this->postJson(route('frontdesk.kiosk.device.staff.action', $this->deviceToken), [
|
||||
'employee_code' => 'P23001',
|
||||
'pin' => '5678',
|
||||
'action' => 'sign_in',
|
||||
])->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['branch_mismatch']);
|
||||
|
||||
$this->postJson(route('frontdesk.kiosk.device.staff.action', $this->deviceToken), [
|
||||
'employee_code' => 'P23001',
|
||||
'pin' => '5678',
|
||||
'action' => 'sign_in',
|
||||
'confirm_branch_mismatch' => true,
|
||||
])->assertOk()
|
||||
->assertJsonPath('employee.status', 'on_site');
|
||||
}
|
||||
|
||||
public function test_employee_portal_step_out_without_kiosk(): void
|
||||
{
|
||||
$employee = $this->createEmployee(['user_ref' => $this->user->public_id]);
|
||||
app(EmployeePresenceService::class)->signIn($employee)['presence'];
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('frontdesk.employee.portal.action'), [
|
||||
'action' => 'step_out',
|
||||
'step_out_reason' => 'lunch',
|
||||
'destination' => 'Cafeteria',
|
||||
])
|
||||
->assertRedirect(route('frontdesk.employee.portal'));
|
||||
|
||||
$this->assertDatabaseHas('frontdesk_employee_presence', [
|
||||
'employee_id' => $employee->id,
|
||||
'status' => 'stepped_out',
|
||||
'destination' => 'Cafeteria',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_evacuation_report_includes_staff(): void
|
||||
{
|
||||
$employee = $this->createEmployee();
|
||||
app(EmployeePresenceService::class)->signIn($employee, null, true)['presence'];
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('frontdesk.security.evacuation'))
|
||||
->assertOk()
|
||||
->assertSee('Alex Staff', false)
|
||||
->assertSee('Staff', false);
|
||||
}
|
||||
|
||||
public function test_employee_webhook_dispatched_on_sign_in(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
WebhookEndpoint::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'url' => 'https://example.com/hooks/frontdesk',
|
||||
'secret' => 'secret',
|
||||
'events' => ['employee.signed_in'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$employee = $this->createEmployee(['branch_id' => $this->otherBranch->id]);
|
||||
|
||||
$this->postJson(route('frontdesk.kiosk.device.staff.action', $this->deviceToken), [
|
||||
'employee_code' => 'P23001',
|
||||
'pin' => '5678',
|
||||
'action' => 'sign_in',
|
||||
])->assertOk();
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://example.com/hooks/frontdesk'
|
||||
&& $request['event'] === 'employee.signed_in'
|
||||
&& $request['employee']['employee_code'] === 'P23001');
|
||||
}
|
||||
|
||||
public function test_hr_attendance_export(): void
|
||||
{
|
||||
$employee = $this->createEmployee();
|
||||
app(EmployeePresenceService::class)->signIn($employee, null, true);
|
||||
|
||||
$response = $this->actingAs($this->user)
|
||||
->get(route('frontdesk.employees.export.attendance'));
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringContainsString('Alex Staff', $response->streamedContent());
|
||||
$this->assertStringContainsString('Sign in', $response->streamedContent());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\EnsurePlatformSession;
|
||||
use App\Models\Device;
|
||||
use App\Models\Employee;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Services\Frontdesk\EmployeePresenceService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FrontdeskEmployeePresenceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected User $user;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
protected string $deviceToken;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutMiddleware(EnsurePlatformSession::class);
|
||||
|
||||
$this->user = User::create([
|
||||
'public_id' => 'emp-user-001',
|
||||
'name' => 'Employee Test User',
|
||||
'email' => 'employee-test@example.com',
|
||||
]);
|
||||
|
||||
$this->organization = Organization::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'name' => 'Employee Org',
|
||||
'slug' => 'employee-org',
|
||||
'settings' => ['onboarded' => true, 'employee_kiosk_enabled' => true],
|
||||
]);
|
||||
|
||||
Member::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'user_ref' => $this->user->public_id,
|
||||
'role' => 'org_admin',
|
||||
]);
|
||||
|
||||
$this->deviceToken = 'kiosk-employee-'.str_repeat('y', 32);
|
||||
|
||||
Device::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'name' => 'Lobby Kiosk',
|
||||
'type' => 'kiosk',
|
||||
'status' => 'offline',
|
||||
'device_token' => $this->deviceToken,
|
||||
]);
|
||||
}
|
||||
|
||||
protected function createEmployee(string $code = 'EMP001', string $pin = '1234'): Employee
|
||||
{
|
||||
$service = app(EmployeePresenceService::class);
|
||||
|
||||
$employee = Employee::create([
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'employee_code' => $code,
|
||||
'full_name' => 'Jane Staff',
|
||||
'department' => 'Operations',
|
||||
'pin_hash' => $service->hashPin($pin),
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
$service->presenceFor($employee);
|
||||
|
||||
return $employee;
|
||||
}
|
||||
|
||||
public function test_admin_can_create_employee(): void
|
||||
{
|
||||
$this->actingAs($this->user)
|
||||
->post(route('frontdesk.employees.store'), [
|
||||
'employee_code' => 'EMP100',
|
||||
'full_name' => 'Kwame Ops',
|
||||
'pin' => '4321',
|
||||
'department' => 'Ops',
|
||||
])
|
||||
->assertRedirect(route('frontdesk.employees.index'));
|
||||
|
||||
$this->assertDatabaseHas('frontdesk_employees', [
|
||||
'employee_code' => 'EMP100',
|
||||
'full_name' => 'Kwame Ops',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_kiosk_staff_sign_in_and_step_out_flow(): void
|
||||
{
|
||||
$this->createEmployee();
|
||||
|
||||
$this->postJson(route('frontdesk.kiosk.device.staff.action', $this->deviceToken), [
|
||||
'employee_code' => 'EMP001',
|
||||
'pin' => '1234',
|
||||
'action' => 'sign_in',
|
||||
])->assertOk()
|
||||
->assertJsonPath('employee.status', 'on_site');
|
||||
|
||||
$this->postJson(route('frontdesk.kiosk.device.staff.action', $this->deviceToken), [
|
||||
'employee_code' => 'EMP001',
|
||||
'pin' => '1234',
|
||||
'action' => 'step_out',
|
||||
'step_out_reason' => 'client_visit',
|
||||
'destination' => 'Client office',
|
||||
])->assertOk()
|
||||
->assertJsonPath('employee.status', 'stepped_out')
|
||||
->assertJsonPath('employee.destination', 'Client office');
|
||||
|
||||
$this->postJson(route('frontdesk.kiosk.device.staff.action', $this->deviceToken), [
|
||||
'employee_code' => 'EMP001',
|
||||
'pin' => '1234',
|
||||
'action' => 'step_in',
|
||||
])->assertOk()
|
||||
->assertJsonPath('employee.status', 'on_site');
|
||||
|
||||
$this->postJson(route('frontdesk.kiosk.device.staff.action', $this->deviceToken), [
|
||||
'employee_code' => 'EMP001',
|
||||
'pin' => '1234',
|
||||
'action' => 'sign_out',
|
||||
])->assertOk()
|
||||
->assertJsonPath('employee.status', 'off_site');
|
||||
|
||||
$this->assertDatabaseCount('frontdesk_employee_presence_events', 4);
|
||||
}
|
||||
|
||||
public function test_kiosk_rejects_invalid_pin(): void
|
||||
{
|
||||
$this->createEmployee();
|
||||
|
||||
$this->postJson(route('frontdesk.kiosk.device.staff.action', $this->deviceToken), [
|
||||
'employee_code' => 'EMP001',
|
||||
'pin' => '9999',
|
||||
'action' => 'sign_in',
|
||||
])->assertUnprocessable();
|
||||
}
|
||||
|
||||
public function test_security_dashboard_lists_staff_on_site(): void
|
||||
{
|
||||
$employee = $this->createEmployee();
|
||||
app(EmployeePresenceService::class)->signIn($employee)['presence'];
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->get(route('frontdesk.security.index'))
|
||||
->assertOk()
|
||||
->assertSee('Jane Staff', false)
|
||||
->assertSee('Staff on premises', false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user