Initial Ladill Frontdesk release with deploy pipeline.
Deploy Ladill Frontdesk / deploy (push) Failing after 35s
Test / test (push) Failing after 2m45s

Visitor management app with SSO, kiosk, badges, reports, and Gitea CI deploy to frontdesk.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-27 20:37:15 +00:00
co-authored by Cursor
commit 9e2d79936c
284 changed files with 29134 additions and 0 deletions
@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\AuditLog;
use App\Models\Visit;
use App\Models\Visitor;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AuditLogController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'audit.view');
$organization = $this->organization($request);
$logs = $this->query($request, $organization)->paginate(50)->withQueryString();
return view('frontdesk.audit.index', [
'logs' => $logs,
'organization' => $organization,
'actions' => config('frontdesk.audit_actions'),
'canExport' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
->can($this->member($request), 'audit.export'),
]);
}
public function export(Request $request): StreamedResponse
{
$this->authorizeAbility($request, 'audit.export');
$organization = $this->organization($request);
$filename = 'frontdesk-audit-'.now()->format('Y-m-d-His').'.csv';
return response()->streamDownload(function () use ($request, $organization) {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['Time', 'Action', 'Actor', 'Subject', 'Metadata', 'IP']);
$this->query($request, $organization)->chunk(200, function ($logs) use ($handle) {
foreach ($logs as $log) {
fputcsv($handle, [
$log->created_at?->toDateTimeString(),
$log->action,
$log->actor_ref ?? '—',
$log->subject_type ? class_basename($log->subject_type).' #'.$log->subject_id : '—',
json_encode($log->metadata ?? []),
$log->ip_address ?? '—',
]);
}
});
fclose($handle);
}, $filename, ['Content-Type' => 'text/csv']);
}
protected function query(Request $request, $organization)
{
return AuditLog::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->when($request->action, fn ($q, $action) => $q->where('action', $action))
->when($request->from, fn ($q, $from) => $q->where('created_at', '>=', Carbon::parse($from)->startOfDay()))
->when($request->to, fn ($q, $to) => $q->where('created_at', '<=', Carbon::parse($to)->endOfDay()))
->when($request->q, function ($q, $search) {
$q->where(function ($inner) use ($search) {
$inner->where('action', 'like', "%{$search}%")
->orWhere('metadata', 'like', "%{$search}%");
});
})
->orderByDesc('created_at');
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Http\Controllers\Frontdesk;
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\FrontdeskPermissions;
use App\Services\Printers\PrinterManager;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BadgeController extends Controller
{
use ScopesToAccount;
public function editTemplate(Request $request): View
{
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(FrontdeskPermissions::class)->isAdmin($this->member($request));
return view('frontdesk.badges.template', [
'organization' => $organization,
'canManage' => $canManage,
'template' => array_merge(
config('frontdesk.default_badge_template', []),
$organization->settings['badge_template'] ?? [],
),
'sampleVisit' => Visit::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('status', Visit::STATUS_CHECKED_IN)
->with(['visitor', 'host'])
->latest('checked_in_at')
->first(),
]);
}
public function updateTemplate(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'show_photo' => ['boolean'],
'show_qr' => ['boolean'],
'show_host' => ['boolean'],
'show_company' => ['boolean'],
'show_type' => ['boolean'],
'primary_color' => ['nullable', 'string', 'max:7'],
'footer_text' => ['nullable', 'string', 'max:255'],
]);
$settings = $organization->settings ?? [];
$settings['badge_template'] = [
'show_photo' => $request->boolean('show_photo'),
'show_qr' => $request->boolean('show_qr'),
'show_host' => $request->boolean('show_host'),
'show_company' => $request->boolean('show_company'),
'show_type' => $request->boolean('show_type'),
'primary_color' => $validated['primary_color'] ?? '#0d9488',
'footer_text' => $validated['footer_text'] ?? '',
];
$organization->update(['settings' => $settings]);
return back()->with('success', 'Badge template saved.');
}
public function preview(Request $request, Visit $visit, BadgeRenderService $badges): View
{
$this->authorizeAbility($request, 'visits.view');
$this->authorizeOwner($request, $visit);
$visit->load(['visitor', 'host', 'organization']);
return view('frontdesk.badges.render', [
'visit' => $visit,
'template' => $badges->templateFor($visit->organization),
'photoUrl' => $badges->photoUrl($visit),
'qrSvg' => $visit->qr_token ? app(\App\Services\Frontdesk\QrCodeService::class)->svg($visit, 120) : null,
'autoPrint' => false,
'preview' => true,
]);
}
public function print(Request $request, Visit $visit, PrinterManager $printers)
{
$this->authorizeAbility($request, 'visits.view');
$this->authorizeOwner($request, $visit);
$rendered = $printers->renderBadge($visit, $request->query('driver'));
return response($rendered['content'], 200, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BranchController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.branches.view');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->withCount(['buildings'])
->orderBy('name')
->get();
return view('frontdesk.admin.branches.index', compact('branches', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.branches.manage');
return view('frontdesk.admin.branches.create', ['organization' => $this->organization($request)]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:500'],
'phone' => ['nullable', 'string', 'max:50'],
]);
Branch::create([
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
...$validated,
'is_active' => true,
]);
return redirect()->route('frontdesk.branches.index')->with('success', 'Branch created.');
}
public function edit(Request $request, Branch $branch): View
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
return view('frontdesk.admin.branches.edit', compact('branch'));
}
public function update(Request $request, Branch $branch): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'code' => ['nullable', 'string', 'max:50'],
'address' => ['nullable', 'string', 'max:500'],
'phone' => ['nullable', 'string', 'max:50'],
'is_active' => ['boolean'],
]);
$branch->update([
...$validated,
'is_active' => $request->boolean('is_active'),
]);
return redirect()->route('frontdesk.branches.index')->with('success', 'Branch updated.');
}
}
@@ -0,0 +1,56 @@
<?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\Building;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BuildingController extends Controller
{
use ScopesToAccount;
public function index(Request $request, Branch $branch): View
{
$this->authorizeAbility($request, 'admin.branches.view');
$this->authorizeOwner($request, $branch);
$buildings = $branch->buildings()->withCount('receptionDesks')->orderBy('name')->get();
return view('frontdesk.admin.buildings.index', compact('branch', 'buildings'));
}
public function store(Request $request, Branch $branch): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $branch);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'floor_count' => ['nullable', 'string', 'max:20'],
]);
Building::create([
'owner_ref' => $this->ownerRef($request),
'branch_id' => $branch->id,
...$validated,
]);
return back()->with('success', 'Building added.');
}
public function destroy(Request $request, Branch $branch, Building $building): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $building);
abort_unless($building->branch_id === $branch->id, 404);
$building->delete();
return back()->with('success', 'Building removed.');
}
}
@@ -0,0 +1,139 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\AuditLog;
use App\Models\Visit;
use App\Models\Visitor;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ComplianceController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'compliance.restore');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$deletedVisitors = Visitor::owned($owner)
->onlyTrashed()
->where('organization_id', $organization->id)
->latest('deleted_at')
->limit(25)
->get();
$deletedVisits = Visit::owned($owner)
->onlyTrashed()
->where('organization_id', $organization->id)
->with('visitor')
->latest('deleted_at')
->limit(25)
->get();
return view('frontdesk.compliance.recovery', compact('deletedVisitors', 'deletedVisits', 'organization'));
}
public function restoreVisitor(Request $request, int $visitorId): RedirectResponse
{
$this->authorizeAbility($request, 'compliance.restore');
$organization = $this->organization($request);
$visitor = Visitor::owned($this->ownerRef($request))
->onlyTrashed()
->where('organization_id', $organization->id)
->findOrFail($visitorId);
$visitor->restore();
AuditLog::record(
$this->ownerRef($request),
'visitor.restored',
$organization->id,
$this->ownerRef($request),
Visitor::class,
$visitor->id,
['full_name' => $visitor->full_name],
);
return back()->with('success', 'Visitor restored.');
}
public function restoreVisit(Request $request, int $visitId): RedirectResponse
{
$this->authorizeAbility($request, 'compliance.restore');
$organization = $this->organization($request);
$visit = Visit::owned($this->ownerRef($request))
->onlyTrashed()
->where('organization_id', $organization->id)
->findOrFail($visitId);
$visit->restore();
AuditLog::record(
$this->ownerRef($request),
'visit.restored',
$organization->id,
$this->ownerRef($request),
Visit::class,
$visit->id,
['visitor_id' => $visit->visitor_id],
);
return back()->with('success', 'Visit restored.');
}
public function destroyVisitor(Request $request, Visitor $visitor): RedirectResponse
{
$this->authorizeAbility($request, 'visitors.manage');
$this->authorizeOwner($request, $visitor);
$organization = $this->organization($request);
$visitor->delete();
AuditLog::record(
$this->ownerRef($request),
'visitor.deleted',
$organization->id,
$this->ownerRef($request),
Visitor::class,
$visitor->id,
['full_name' => $visitor->full_name],
);
return redirect()
->route('frontdesk.visitors.index')
->with('success', 'Visitor archived.');
}
public function destroyVisit(Request $request, Visit $visit): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$organization = $this->organization($request);
abort_if($visit->isInside(), 422, 'Check out the visitor before archiving the visit.');
$visit->delete();
AuditLog::record(
$this->ownerRef($request),
'visit.deleted',
$organization->id,
$this->ownerRef($request),
Visit::class,
$visit->id,
['visitor_id' => $visit->visitor_id],
);
return redirect()
->route('frontdesk.visits.index')
->with('success', 'Visit archived.');
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Controllers\Frontdesk\Concerns;
use App\Models\Member;
use App\Models\Organization;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Frontdesk\OrganizationResolver;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
trait ScopesToAccount
{
protected function ownerRef(Request $request): string
{
return (string) $request->user()->public_id;
}
protected function organization(Request $request): Organization
{
$organization = $request->attributes->get('frontdesk.organization')
?? app(OrganizationResolver::class)->resolveForUser($request->user());
abort_unless($organization, 404);
return $organization;
}
protected function member(Request $request): ?Member
{
return $request->attributes->get('frontdesk.member')
?? app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
}
protected function authorizeAbility(Request $request, string $ability): void
{
abort_unless(
app(FrontdeskPermissions::class)->can($this->member($request), $ability),
403,
);
}
protected function authorizeOwner(Request $request, Model $model): void
{
abort_unless($model->getAttribute('owner_ref') === $this->ownerRef($request), 404);
}
protected function scopeToBranch(Request $request, Builder $query, string $column = 'branch_id'): Builder
{
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
if ($branchId !== null) {
$query->where($column, $branchId);
}
return $query;
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Visit;
use App\Services\Frontdesk\OrganizationResolver;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
class DashboardController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'dashboard.view');
$owner = $this->ownerRef($request);
$organization = $this->organization($request);
$visitQuery = Visit::owned($owner)->where('organization_id', $organization->id);
$this->scopeToBranch($request, $visitQuery);
$branchScope = app(OrganizationResolver::class)->branchScope($this->member($request));
$cacheKey = "fd:dashboard:{$owner}:{$organization->id}:".($branchScope ?? 'all');
$stats = Cache::remember($cacheKey, 60, function () use ($visitQuery) {
$today = (clone $visitQuery)->where(function ($q) {
$q->whereDate('checked_in_at', today())
->orWhereDate('scheduled_at', today());
});
return [
'visitors_today' => (clone $today)->count(),
'currently_inside' => (clone $visitQuery)->currentlyInside()->count(),
'expected_arrivals' => (clone $visitQuery)->whereIn('status', [
Visit::STATUS_EXPECTED,
Visit::STATUS_SCHEDULED,
Visit::STATUS_OVERDUE,
])->whereDate('scheduled_at', today())->count(),
'waiting' => (clone $visitQuery)->where('status', Visit::STATUS_WAITING)->count(),
'overdue' => (clone $visitQuery)->where('status', Visit::STATUS_OVERDUE)->count(),
'checked_out_today' => (clone $visitQuery)->where('status', Visit::STATUS_CHECKED_OUT)
->whereDate('checked_out_at', today())->count(),
'deliveries_today' => (clone $visitQuery)->where('visitor_type', 'delivery')
->whereDate('checked_in_at', today())->count(),
'contractors_today' => (clone $visitQuery)->where('visitor_type', 'contractor')
->whereDate('checked_in_at', today())->count(),
'pending_approvals' => (clone $visitQuery)->where('status', Visit::STATUS_WAITING)
->whereNull('checked_in_at')
->where(function ($q) {
$q->whereJsonContains('contractor_details', ['_awaiting_approval' => true])
->orWhereJsonContains('delivery_details', ['_awaiting_approval' => true]);
})->count(),
];
});
$currentVisitors = (clone $visitQuery)->currentlyInside()
->with(['visitor', 'host'])
->latest('checked_in_at')
->limit(10)
->get();
$expectedVisitors = (clone $visitQuery)
->whereIn('status', [Visit::STATUS_EXPECTED, Visit::STATUS_SCHEDULED, Visit::STATUS_WAITING, Visit::STATUS_OVERDUE])
->whereDate('scheduled_at', today())
->with(['visitor', 'host'])
->orderBy('scheduled_at')
->limit(10)
->get();
$pendingApprovals = (clone $visitQuery)
->where('status', Visit::STATUS_WAITING)
->whereNull('checked_in_at')
->where(function ($q) {
$q->whereJsonContains('contractor_details', ['_awaiting_approval' => true])
->orWhereJsonContains('delivery_details', ['_awaiting_approval' => true]);
})
->with(['visitor', 'host'])
->latest()
->limit(10)
->get();
return view('frontdesk.dashboard', compact('stats', 'currentVisitors', 'expectedVisitors', 'pendingApprovals', 'organization'));
}
}
@@ -0,0 +1,152 @@
<?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\Device;
use App\Models\ReceptionDesk;
use App\Services\Frontdesk\DeviceService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DeviceController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'devices.view');
$organization = $this->organization($request);
$devices = Device::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with(['branch', 'receptionDesk'])
->orderBy('name')
->paginate(25);
return view('frontdesk.devices.index', [
'organization' => $organization,
'devices' => $devices,
'deviceTypes' => config('frontdesk.device_types'),
'canManage' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
->can($this->member($request), 'devices.manage'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'devices.manage');
$organization = $this->organization($request);
return view('frontdesk.devices.create', [
'organization' => $organization,
'deviceTypes' => config('frontdesk.device_types'),
'branches' => $this->branches($request, $organization->id),
'desks' => $this->desks($request, $organization->id),
]);
}
public function store(Request $request, DeviceService $devices): RedirectResponse
{
$this->authorizeAbility($request, 'devices.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.device_types')))],
'branch_id' => ['nullable', 'integer'],
'reception_desk_id' => ['nullable', 'integer'],
]);
$token = in_array($validated['type'], ['kiosk', 'badge_printer', 'qr_scanner'], true)
? $devices->generateToken()
: null;
Device::create([
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
'name' => $validated['name'],
'type' => $validated['type'],
'branch_id' => $validated['branch_id'] ?? null,
'reception_desk_id' => $validated['reception_desk_id'] ?? null,
'device_token' => $token,
'status' => 'offline',
'config' => $devices->defaultConfigForType($validated['type']),
]);
return redirect()->route('frontdesk.devices.index')->with('success', 'Device registered.');
}
public function edit(Request $request, Device $device): View
{
$this->authorizeAbility($request, 'devices.manage');
$this->authorizeOwner($request, $device);
return view('frontdesk.devices.edit', [
'device' => $device,
'deviceTypes' => config('frontdesk.device_types'),
'branches' => $this->branches($request, $device->organization_id),
'desks' => $this->desks($request, $device->organization_id),
]);
}
public function update(Request $request, Device $device): RedirectResponse
{
$this->authorizeAbility($request, 'devices.manage');
$this->authorizeOwner($request, $device);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.device_types')))],
'branch_id' => ['nullable', 'integer'],
'reception_desk_id' => ['nullable', 'integer'],
'status' => ['nullable', 'string', 'in:online,offline,maintenance'],
]);
$device->update($validated);
return redirect()->route('frontdesk.devices.index')->with('success', 'Device updated.');
}
public function destroy(Request $request, Device $device): RedirectResponse
{
$this->authorizeAbility($request, 'devices.manage');
$this->authorizeOwner($request, $device);
$device->delete();
return redirect()->route('frontdesk.devices.index')->with('success', 'Device removed.');
}
public function regenerateToken(Request $request, Device $device, DeviceService $devices): RedirectResponse
{
$this->authorizeAbility($request, 'devices.manage');
$this->authorizeOwner($request, $device);
$device->update(['device_token' => $devices->generateToken()]);
return back()->with('success', 'Device token regenerated.');
}
/** @return \Illuminate\Database\Eloquent\Collection<int, Branch> */
protected function branches(Request $request, int $organizationId)
{
return Branch::owned($this->ownerRef($request))
->where('organization_id', $organizationId)
->where('is_active', true)
->orderBy('name')
->get();
}
/** @return \Illuminate\Database\Eloquent\Collection<int, ReceptionDesk> */
protected function desks(Request $request, int $organizationId)
{
return ReceptionDesk::owned($this->ownerRef($request))
->whereHas('building.branch', fn ($q) => $q->where('organization_id', $organizationId))
->where('is_active', true)
->orderBy('name')
->get();
}
}
@@ -0,0 +1,117 @@
<?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\Host;
use App\Models\Visitor;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class HostController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'hosts.view');
$organization = $this->organization($request);
$hosts = Host::owned($this->ownerRef($request))
->where('organization_id', $organization->id);
$this->scopeToBranch($request, $hosts);
$hosts = $hosts->orderBy('name')->paginate(25);
return view('frontdesk.hosts.index', compact('hosts', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'hosts.manage');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('frontdesk.hosts.create', compact('organization', 'branches'));
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'hosts.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'department' => ['nullable', 'string', 'max:255'],
'office' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'extension' => ['nullable', 'string', 'max:20'],
'user_ref' => ['nullable', 'string', 'max:64'],
'branch_id' => ['nullable', 'integer'],
]);
Host::create([
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
...$validated,
]);
return redirect()->route('frontdesk.hosts.index')->with('success', 'Host added.');
}
public function edit(Request $request, Host $host): View
{
$this->authorizeAbility($request, 'hosts.manage');
$this->authorizeOwner($request, $host);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $host->organization_id)
->where('is_active', true)
->orderBy('name')
->get();
return view('frontdesk.hosts.edit', compact('host', 'branches'));
}
public function update(Request $request, Host $host): RedirectResponse
{
$this->authorizeAbility($request, 'hosts.manage');
$this->authorizeOwner($request, $host);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'department' => ['nullable', 'string', 'max:255'],
'office' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'extension' => ['nullable', 'string', 'max:20'],
'user_ref' => ['nullable', 'string', 'max:64'],
'branch_id' => ['nullable', 'integer'],
'is_available' => ['boolean'],
]);
$host->update([
...$validated,
'is_available' => $request->boolean('is_available', true),
]);
return redirect()->route('frontdesk.hosts.index')->with('success', 'Host updated.');
}
public function destroy(Request $request, Host $host): RedirectResponse
{
$this->authorizeAbility($request, 'hosts.manage');
$this->authorizeOwner($request, $host);
$host->delete();
return redirect()->route('frontdesk.hosts.index')->with('success', 'Host removed.');
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Host;
use App\Models\Visit;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Frontdesk\OrganizationResolver;
use App\Services\Frontdesk\VisitLifecycleService;
use App\Services\Frontdesk\VisitScheduleService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class HostPortalController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$host = $this->resolveLinkedHost($request);
$organization = $this->organization($request);
$pending = Visit::owned($host->owner_ref)
->where('organization_id', $organization->id)
->where('host_id', $host->id)
->where('status', Visit::STATUS_WAITING)
->with('visitor')
->orderByDesc('updated_at')
->get()
->filter(fn (Visit $v) => $v->awaitingApproval());
$upcoming = Visit::owned($host->owner_ref)
->where('organization_id', $organization->id)
->where('host_id', $host->id)
->whereIn('status', [Visit::STATUS_SCHEDULED, Visit::STATUS_EXPECTED, Visit::STATUS_OVERDUE])
->with('visitor')
->orderBy('scheduled_at')
->limit(10)
->get();
$recent = Visit::owned($host->owner_ref)
->where('organization_id', $organization->id)
->where('host_id', $host->id)
->whereIn('status', [Visit::STATUS_CHECKED_IN, Visit::STATUS_CHECKED_OUT, Visit::STATUS_CANCELLED])
->with('visitor')
->orderByDesc('updated_at')
->limit(15)
->get();
return view('frontdesk.host-portal.index', compact('host', 'organization', 'pending', 'upcoming', 'recent'));
}
public function scheduleForm(Request $request): View
{
$host = $this->resolveLinkedHost($request);
$organization = $this->organization($request);
return view('frontdesk.host-portal.schedule', compact('host', 'organization'));
}
public function scheduleStore(Request $request, VisitScheduleService $scheduler): RedirectResponse
{
$host = $this->resolveLinkedHost($request);
$organization = $this->organization($request);
$validated = $request->validate([
'full_name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'expected_duration_minutes' => ['nullable', 'integer', 'min:5', 'max:480'],
'scheduled_at' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$scheduler->schedule(
$host->owner_ref,
$organization,
[
...$validated,
'host_id' => $host->id,
'branch_id' => $host->branch_id,
],
$this->ownerRef($request),
);
return redirect()->route('frontdesk.host.index')->with('success', 'Visit scheduled.');
}
public function approve(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
{
$host = $this->resolveLinkedHost($request);
$this->authorizeOwner($request, $visit);
abort_unless($visit->host_id === $host->id, 403);
abort_unless($visit->awaitingApproval(), 422);
$lifecycle->approve($visit, $this->ownerRef($request));
return redirect()->route('frontdesk.host.index')->with('success', 'Visit approved.');
}
public function toggleAvailability(Request $request): RedirectResponse
{
$host = $this->resolveLinkedHost($request);
$host->update(['is_available' => ! $host->is_available]);
return back()->with('success', $host->is_available ? 'You are now available.' : 'You are marked unavailable.');
}
protected function resolveLinkedHost(Request $request): Host
{
$permissions = app(FrontdeskPermissions::class);
$member = $this->member($request);
abort_unless(
$permissions->can($member, 'host.portal') || app(OrganizationResolver::class)->hostFor($request->user()) !== null,
403,
);
$host = app(OrganizationResolver::class)->hostFor($request->user());
abort_unless($host, 403, 'No host profile linked to your account.');
$this->authorizeOwner($request, $host);
return $host;
}
}
@@ -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\WebhookEndpoint;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class IntegrationController extends Controller
{
use ScopesToAccount;
public function edit(Request $request): View
{
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(\App\Services\Frontdesk\FrontdeskPermissions::class)
->isAdmin($this->member($request));
$webhook = WebhookEndpoint::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->first();
return view('frontdesk.integrations.edit', [
'organization' => $organization,
'canManage' => $canManage,
'webhook' => $webhook,
'webhookEvents' => config('frontdesk.webhook_events', []),
'integrations' => config('frontdesk.integrations', []),
'icalUrl' => $this->signedIcalUrl($organization->id, $this->ownerRef($request)),
]);
}
public function update(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'webhook_url' => ['nullable', 'url', 'max:500'],
'webhook_secret' => ['nullable', 'string', 'max:128'],
'webhook_events' => ['nullable', 'array'],
'webhook_events.*' => ['string'],
'webhook_active' => ['boolean'],
]);
if (empty($validated['webhook_url'])) {
WebhookEndpoint::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->delete();
return back()->with('success', 'Integration settings saved.');
}
WebhookEndpoint::updateOrCreate(
[
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
],
[
'url' => $validated['webhook_url'],
'secret' => $validated['webhook_secret'] ?? null,
'events' => array_values($validated['webhook_events'] ?? config('frontdesk.webhook_events')),
'is_active' => $request->boolean('webhook_active', true),
],
);
return back()->with('success', 'Integration settings saved.');
}
protected function signedIcalUrl(int $organizationId, string $ownerRef): string
{
$token = hash_hmac('sha256', "{$organizationId}:{$ownerRef}", (string) config('app.key'));
return route('frontdesk.integrations.ical', [
'organization' => $organizationId,
'owner' => $ownerRef,
'token' => $token,
]);
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Host;
use App\Models\Visit;
use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\VisitCheckOutService;
use App\Services\Frontdesk\VisitorTypeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class KioskController extends Controller
{
use ScopesToAccount;
public function show(Request $request, VisitorTypeService $visitorTypes): View
{
$this->authorizeAbility($request, 'kiosk.use');
$organization = $this->organization($request);
$settings = $organization->settings ?? [];
$hosts = Host::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_available', true);
$this->scopeToBranch($request, $hosts);
$hosts = $hosts->orderBy('name')->get();
return view('frontdesk.kiosk.index', [
'organization' => $organization,
'hosts' => $hosts,
'visitorTypes' => config('frontdesk.visitor_types'),
'typeConfigs' => $visitorTypes->configsForFrontend(),
'resetSeconds' => (int) ($settings['kiosk_reset_seconds'] ?? config('frontdesk.kiosk.inactivity_reset_seconds', 120)),
'visitorPolicy' => $settings['visitor_policy'] ?? null,
]);
}
public function checkIn(Request $request, VisitCheckInService $checkIn, VisitorTypeService $visitorTypes): JsonResponse
{
$this->authorizeAbility($request, 'kiosk.use');
$organization = $this->organization($request);
$visitorType = $request->input('visitor_type', 'visitor');
$validated = $request->validate(array_merge([
'full_name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'host_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'expected_duration_minutes' => ['nullable', 'integer'],
'policies_accepted' => ['accepted'],
'photo_data' => ['nullable', 'string'],
'signature_path' => ['nullable', 'string'],
], $visitorTypes->validationRules($visitorType)));
$payload = $visitorTypes->enrichCheckInData($visitorType, $validated, $organization);
if (! empty($validated['photo_data'])) {
$payload['photo_data'] = $validated['photo_data'];
}
$visit = $checkIn->checkIn($this->ownerRef($request), $organization, $payload);
return response()->json([
'visit' => [
'id' => $visit->id,
'public_id' => $visit->public_id,
'badge_code' => $visit->badge_code,
'visitor_name' => $visit->visitor->full_name,
'host_name' => $visit->host?->name,
'status' => $visit->status,
'awaiting_approval' => $visit->awaitingApproval(),
'checked_in_at' => $visit->checked_in_at?->toIso8601String(),
'badge_url' => $visit->isInside() ? route('frontdesk.visits.badge', $visit) : null,
],
]);
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\Host;
use App\Models\Organization;
use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\VisitorTypeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class KioskDeviceController extends Controller
{
public function show(Request $request, VisitorTypeService $visitorTypes): View
{
$device = $this->device($request);
$organization = Organization::findOrFail($device->organization_id);
$settings = $organization->settings ?? [];
$hosts = Host::owned($device->owner_ref)
->where('organization_id', $organization->id)
->where('is_available', true);
if ($device->branch_id) {
$hosts->where(function ($q) use ($device) {
$q->whereNull('branch_id')->orWhere('branch_id', $device->branch_id);
});
}
$hosts = $hosts->orderBy('name')->get();
return view('frontdesk.kiosk.index', [
'organization' => $organization,
'hosts' => $hosts,
'visitorTypes' => config('frontdesk.visitor_types'),
'typeConfigs' => $visitorTypes->configsForFrontend(),
'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),
]);
}
public function checkIn(Request $request, VisitCheckInService $checkIn, VisitorTypeService $visitorTypes): JsonResponse
{
$device = $this->device($request);
$organization = Organization::findOrFail($device->organization_id);
$visitorType = $request->input('visitor_type', 'visitor');
$validated = $request->validate(array_merge([
'full_name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'host_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'expected_duration_minutes' => ['nullable', 'integer'],
'policies_accepted' => ['accepted'],
'photo_data' => ['nullable', 'string'],
'vehicle_info' => ['nullable', 'array'],
'signature_path' => ['nullable', 'string'],
], $visitorTypes->validationRules($visitorType)));
$payload = $visitorTypes->enrichCheckInData($visitorType, $validated, $organization);
if (! empty($validated['photo_data'])) {
$payload['photo_data'] = $validated['photo_data'];
}
$payload['branch_id'] = $device->branch_id;
$payload['reception_desk_id'] = $device->reception_desk_id;
$visit = $checkIn->checkIn($device->owner_ref, $organization, $payload);
return response()->json([
'visit' => [
'id' => $visit->id,
'public_id' => $visit->public_id,
'badge_code' => $visit->badge_code,
'visitor_name' => $visit->visitor->full_name,
'host_name' => $visit->host?->name,
'status' => $visit->status,
'awaiting_approval' => $visit->awaitingApproval(),
'checked_in_at' => $visit->checked_in_at?->toIso8601String(),
'badge_url' => $visit->isInside() ? route('frontdesk.visits.badge', $visit) : null,
],
]);
}
protected function device(Request $request): Device
{
return $request->attributes->get('frontdesk.device')
?? abort(404);
}
}
@@ -0,0 +1,90 @@
<?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\Member;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class MemberController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'admin.members.view');
$organization = $this->organization($request);
$members = Member::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with('branch')
->orderBy('created_at')
->get();
return view('frontdesk.admin.members.index', [
'members' => $members,
'organization' => $organization,
'roles' => config('frontdesk.roles'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'admin.members.manage');
$organization = $this->organization($request);
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('frontdesk.admin.members.create', [
'organization' => $organization,
'branches' => $branches,
'roles' => config('frontdesk.roles'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'user_ref' => ['required', 'string', 'max:255'],
'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.roles')))],
'branch_id' => ['nullable', 'integer', 'exists:frontdesk_branches,id'],
]);
Member::updateOrCreate(
[
'organization_id' => $organization->id,
'user_ref' => $validated['user_ref'],
],
[
'owner_ref' => $this->ownerRef($request),
'role' => $validated['role'],
'branch_id' => $validated['branch_id'] ?? null,
],
);
return redirect()->route('frontdesk.members.index')->with('success', 'Member saved.');
}
public function destroy(Request $request, Member $member): RedirectResponse
{
$this->authorizeAbility($request, 'admin.members.manage');
$this->authorizeOwner($request, $member);
abort_if($member->user_ref === $this->ownerRef($request), 422, 'You cannot remove yourself.');
$member->delete();
return redirect()->route('frontdesk.members.index')->with('success', 'Member removed.');
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Services\Frontdesk\OrganizationResolver;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class OnboardingController extends Controller
{
use ScopesToAccount;
public function __construct(
protected OrganizationResolver $organizations,
) {}
public function show(Request $request): View|RedirectResponse
{
if ($this->organizations->isOnboarded($request->user())) {
return redirect()->route('frontdesk.dashboard');
}
return view('frontdesk.onboarding.show', [
'user' => $request->user(),
'timezones' => timezone_identifiers_list(),
]);
}
public function store(Request $request): RedirectResponse
{
if ($this->organizations->isOnboarded($request->user())) {
return redirect()->route('frontdesk.dashboard');
}
$validated = $request->validate([
'organization_name' => ['required', 'string', 'max:255'],
'branch_name' => ['required', 'string', 'max:255'],
'branch_address' => ['nullable', 'string', 'max:500'],
'timezone' => ['required', 'timezone'],
'visitor_policy' => ['nullable', 'string', 'max:5000'],
'badge_expiry_hours' => ['nullable', 'integer', 'min:1', 'max:24'],
'kiosk_reset_seconds' => ['nullable', 'integer', 'min:30', 'max:600'],
]);
$this->organizations->completeOnboarding($request->user(), $validated);
return redirect()->route('frontdesk.dashboard')->with('success', 'Welcome to Ladill Frontdesk!');
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Models\Visit;
use App\Services\Frontdesk\QrCodeService;
use App\Services\Frontdesk\VisitCheckOutService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class QrScanController extends Controller
{
public function show(string $token, QrCodeService $qr): View
{
$visit = Visit::where('qr_token', $token)->with(['visitor', 'host', 'organization'])->firstOrFail();
return view('frontdesk.qr.show', [
'visit' => $visit,
'qrSvg' => $qr->svg($visit),
]);
}
public function checkOut(string $token, VisitCheckOutService $checkOut): View
{
$visit = Visit::where('qr_token', $token)->firstOrFail();
$checkOut->checkOut($visit);
return view('frontdesk.qr.checked-out', ['visit' => $visit->load('visitor')]);
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Building;
use App\Models\ReceptionDesk;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ReceptionDeskController extends Controller
{
use ScopesToAccount;
public function index(Request $request, Building $building): View
{
$this->authorizeAbility($request, 'admin.desks.view');
$this->authorizeOwner($request, $building);
$desks = $building->receptionDesks()->orderBy('name')->get();
return view('frontdesk.admin.desks.index', compact('building', 'desks'));
}
public function store(Request $request, Building $building): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $building);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'location' => ['nullable', 'string', 'max:255'],
]);
ReceptionDesk::create([
'owner_ref' => $this->ownerRef($request),
'building_id' => $building->id,
...$validated,
'is_active' => true,
]);
return back()->with('success', 'Reception desk added.');
}
public function destroy(Request $request, Building $building, ReceptionDesk $desk): RedirectResponse
{
$this->authorizeAbility($request, 'admin.branches.manage');
$this->authorizeOwner($request, $desk);
abort_unless($desk->building_id === $building->id, 404);
$desk->delete();
return back()->with('success', 'Reception desk removed.');
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Frontdesk;
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\FrontdeskPermissions;
use App\Services\Frontdesk\OrganizationResolver;
use App\Services\Frontdesk\ReportService;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ReportController extends Controller
{
use ScopesToAccount;
public function index(Request $request, ReportService $reports): View
{
$this->authorizeAbility($request, 'reports.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
[$from, $to] = $this->dateRange($request);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
return view('frontdesk.reports.index', [
'organization' => $organization,
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'summary' => $reports->summary($owner, $organization, $from, $to, $branchId),
'peakHours' => $reports->peakHours($owner, $organization, $from, $to, $branchId),
'departments' => $reports->visitsByDepartment($owner, $organization, $from, $to, $branchId),
'frequentVisitors' => $reports->frequentVisitors($owner, $organization),
'security' => $reports->securityIncidents($owner, $organization, $from, $to),
'dailyCounts' => $reports->dailyCounts($owner, $organization, $from, $to, $branchId),
'canExport' => app(FrontdeskPermissions::class)->can($this->member($request), 'reports.export'),
]);
}
public function export(Request $request, ReportService $reports): StreamedResponse
{
$this->authorizeAbility($request, 'reports.export');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
[$from, $to] = $this->dateRange($request);
$branchId = app(OrganizationResolver::class)->branchScope($this->member($request));
$summary = $reports->summary($owner, $organization, $from, $to, $branchId);
$departments = $reports->visitsByDepartment($owner, $organization, $from, $to, $branchId);
$filename = 'frontdesk-report-'.$from->format('Y-m-d').'-'.$to->format('Y-m-d').'.csv';
return response()->streamDownload(function () use ($summary, $departments, $from, $to) {
$handle = fopen('php://output', 'w');
fputcsv($handle, ['Frontdesk report', $from->toDateString(), 'to', $to->toDateString()]);
fputcsv($handle, []);
fputcsv($handle, ['Metric', 'Value']);
foreach ($summary as $key => $value) {
fputcsv($handle, [str_replace('_', ' ', $key), $value]);
}
fputcsv($handle, []);
fputcsv($handle, ['Department', 'Visits']);
foreach ($departments as $row) {
fputcsv($handle, [$row->department, $row->count]);
}
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];
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Visit;
use App\Services\Frontdesk\VisitCheckOutService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SecurityController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$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']);
$this->scopeToBranch($request, $occupancy);
$occupancy = $occupancy->orderBy('checked_in_at')->get();
$expiredBadges = $occupancy->filter(fn (Visit $v) => $v->isBadgeExpired());
return view('frontdesk.security.index', [
'occupancy' => $occupancy,
'expiredBadges' => $expiredBadges,
'organization' => $organization,
'canCheckout' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
->can($this->member($request), 'security.checkout'),
]);
}
public function evacuation(Request $request): View
{
$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();
return view('frontdesk.security.evacuation', compact('occupancy', 'organization'));
}
public function evacuationBadges(Request $request, \App\Services\Frontdesk\BadgeRenderService $badges): View
{
$this->authorizeAbility($request, 'security.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$visits = Visit::owned($owner)
->where('organization_id', $organization->id)
->currentlyInside()
->with(['visitor', 'host', 'organization']);
$this->scopeToBranch($request, $visits);
$visits = $visits->orderBy('checked_in_at')->get();
$rendered = $visits->map(fn (Visit $visit) => $badges->renderHtml($visit));
return view('frontdesk.security.evacuation-badges', compact('visits', 'rendered', 'organization'));
}
public function checkOut(Request $request, Visit $visit, VisitCheckOutService $checkOut): RedirectResponse
{
$this->authorizeAbility($request, 'security.checkout');
$this->authorizeOwner($request, $visit);
$checkOut->checkOut($visit, $this->ownerRef($request));
return back()->with('success', 'Visitor checked out.');
}
public function verifyForm(Request $request): View
{
$this->authorizeAbility($request, 'security.verify');
return view('frontdesk.security.verify', [
'organization' => $this->organization($request),
]);
}
public function verify(Request $request): View
{
$this->authorizeAbility($request, 'security.verify');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$validated = $request->validate([
'lookup' => ['required', 'string', 'max:64'],
]);
$lookup = trim($validated['lookup']);
$visit = Visit::owned($owner)
->where('organization_id', $organization->id)
->where(function ($q) use ($lookup) {
$q->where('badge_code', strtoupper($lookup))
->orWhere('qr_token', $lookup);
})
->with(['visitor', 'host', 'branch'])
->latest('checked_in_at')
->first();
return view('frontdesk.security.verify', [
'organization' => $organization,
'lookup' => $lookup,
'visit' => $visit,
]);
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Services\Frontdesk\FrontdeskPermissions;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SettingsController extends Controller
{
use ScopesToAccount;
public function edit(Request $request): View
{
$this->authorizeAbility($request, 'settings.view');
$organization = $this->organization($request);
$canManage = app(FrontdeskPermissions::class)->isAdmin($this->member($request));
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->count();
return view('frontdesk.settings.edit', [
'organization' => $organization,
'canManage' => $canManage,
'branchCount' => $branches,
'roles' => config('frontdesk.roles'),
'deviceTypes' => config('frontdesk.device_types'),
'notificationChannels' => config('frontdesk.notification_channels'),
'notificationEvents' => config('frontdesk.notification_events'),
]);
}
public function update(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'settings.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'timezone' => ['required', 'timezone'],
'badge_expiry_hours' => ['required', 'integer', 'min:1', 'max:24'],
'kiosk_reset_seconds' => ['required', 'integer', 'min:30', 'max:600'],
'contractor_badge_expiry_hours' => ['nullable', 'integer', 'min:1', 'max:24'],
'visitor_policy' => ['nullable', 'string', 'max:5000'],
'notification_channels' => ['nullable', 'array'],
'notification_channels.*' => ['string', 'in:'.implode(',', array_keys(config('frontdesk.notification_channels')))],
'notification_events' => ['nullable', 'array'],
'report_daily_recipients' => ['nullable', 'string', 'max:2000'],
]);
$settings = $organization->settings ?? [];
$settings['onboarded'] = true;
$settings['badge_expiry_hours'] = $validated['badge_expiry_hours'];
$settings['kiosk_reset_seconds'] = $validated['kiosk_reset_seconds'];
$settings['visitor_policy'] = $validated['visitor_policy'] ?? null;
$settings['notification_channels'] = $validated['notification_channels'] ?? ['email'];
$eventKeys = array_keys(config('frontdesk.notification_events', []));
$submittedEvents = $validated['notification_events'] ?? [];
$settings['notification_events'] = [];
foreach ($eventKeys as $key) {
$settings['notification_events'][$key] = (bool) ($submittedEvents[$key] ?? false);
}
if (array_key_exists('report_daily_recipients', $validated)) {
$settings['report_daily_recipients'] = array_values(array_filter(array_map(
'trim',
explode(',', (string) ($validated['report_daily_recipients'] ?? '')),
)));
}
if (! empty($validated['contractor_badge_expiry_hours'])) {
$settings['type_badge_expiry_hours'] = array_merge(
$settings['type_badge_expiry_hours'] ?? [],
['contractor' => (int) $validated['contractor_badge_expiry_hours']],
);
}
$organization->update([
'name' => $validated['name'],
'timezone' => $validated['timezone'],
'settings' => $settings,
]);
return back()->with('success', 'Settings saved.');
}
}
@@ -0,0 +1,300 @@
<?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\Host;
use App\Models\Visit;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\VisitCheckOutService;
use App\Services\Frontdesk\VisitLifecycleService;
use App\Services\Frontdesk\VisitScheduleService;
use App\Services\Frontdesk\VisitorSearchService;
use App\Services\Frontdesk\VisitorTypeService;
use App\Services\Printers\PrinterManager;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\View\View;
class VisitController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'visits.view');
$organization = $this->organization($request);
$visits = Visit::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with(['visitor', 'host']);
$this->scopeToBranch($request, $visits);
$visits = $visits
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->when($request->q, function ($q, $search) {
$q->whereHas('visitor', fn ($v) => $v->where('full_name', 'like', "%{$search}%"));
})
->latest()
->paginate(25)
->withQueryString();
return view('frontdesk.visits.index', compact('visits', 'organization'));
}
public function calendar(Request $request): View
{
$this->authorizeAbility($request, 'visits.view');
$organization = $this->organization($request);
$start = $request->filled('start')
? Carbon::parse($request->string('start')->toString())->startOfWeek()
: now()->startOfWeek();
$end = $start->copy()->endOfWeek();
$visits = Visit::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->whereNotNull('scheduled_at')
->whereBetween('scheduled_at', [$start, $end])
->whereNotIn('status', [Visit::STATUS_CANCELLED])
->with(['visitor', 'host']);
$this->scopeToBranch($request, $visits);
$visits = $visits->orderBy('scheduled_at')->get()->groupBy(
fn (Visit $visit) => $visit->scheduled_at->toDateString(),
);
return view('frontdesk.visits.calendar', compact('visits', 'organization', 'start', 'end'));
}
public function create(Request $request, VisitorSearchService $search): View
{
$this->authorizeAbility($request, 'visits.manage');
$organization = $this->organization($request);
$hosts = Host::owned($this->ownerRef($request))
->where('organization_id', $organization->id);
$this->scopeToBranch($request, $hosts);
$hosts = $hosts->orderBy('name')->get();
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
$returningVisitors = $request->filled('q')
? $search->search($this->ownerRef($request), $organization->id, $request->string('q')->toString())
: collect();
return view('frontdesk.visits.create', [
'organization' => $organization,
'hosts' => $hosts,
'branches' => $branches,
'returningVisitors' => $returningVisitors,
'typeConfigs' => app(VisitorTypeService::class)->configsForFrontend(),
]);
}
public function scheduleForm(Request $request, VisitorSearchService $search): View
{
$this->authorizeAbility($request, 'visits.manage');
$organization = $this->organization($request);
$hosts = Host::owned($this->ownerRef($request))
->where('organization_id', $organization->id);
$this->scopeToBranch($request, $hosts);
$hosts = $hosts->orderBy('name')->get();
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
$returningVisitors = $request->filled('q')
? $search->search($this->ownerRef($request), $organization->id, $request->string('q')->toString())
: collect();
return view('frontdesk.visits.schedule', compact('organization', 'hosts', 'branches', 'returningVisitors'));
}
public function store(Request $request, VisitCheckInService $checkIn, VisitorTypeService $visitorTypes): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$organization = $this->organization($request);
$visitorType = $request->input('visitor_type', 'visitor');
$validated = $request->validate(array_merge([
'visitor_id' => ['nullable', 'integer'],
'full_name' => ['required_without:visitor_id', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'host_id' => ['nullable', 'integer'],
'branch_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'expected_duration_minutes' => ['nullable', 'integer', 'min:5', 'max:480'],
'policies_accepted' => ['accepted'],
'photo_data' => ['nullable', 'string'],
], $visitorTypes->validationRules($visitorType)));
$branchScope = app(\App\Services\Frontdesk\OrganizationResolver::class)
->branchScope($this->member($request));
if ($branchScope !== null) {
$validated['branch_id'] = $branchScope;
}
$payload = $visitorTypes->enrichCheckInData($visitorType, $validated, $organization, $request);
$visit = $checkIn->checkIn(
$this->ownerRef($request),
$organization,
$payload,
$this->ownerRef($request),
);
$message = $visit->awaitingApproval()
? 'Visit submitted for approval.'
: 'Visitor checked in successfully.';
return redirect()
->route('frontdesk.visits.show', $visit)
->with('success', $message);
}
public function scheduleStore(Request $request, VisitScheduleService $scheduler): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'visitor_id' => ['nullable', 'integer'],
'full_name' => ['required_without:visitor_id', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'host_id' => ['nullable', 'integer'],
'branch_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'expected_duration_minutes' => ['nullable', 'integer', 'min:5', 'max:480'],
'scheduled_at' => ['required', 'date'],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$branchScope = app(\App\Services\Frontdesk\OrganizationResolver::class)
->branchScope($this->member($request));
if ($branchScope !== null) {
$validated['branch_id'] = $branchScope;
}
$visit = $scheduler->schedule(
$this->ownerRef($request),
$organization,
$validated,
$this->ownerRef($request),
);
return redirect()
->route('frontdesk.visits.show', $visit)
->with('success', 'Visit scheduled successfully.');
}
public function show(Request $request, Visit $visit): View
{
$this->authorizeAbility($request, 'visits.view');
$this->authorizeOwner($request, $visit);
$visit->load(['visitor', 'host', 'organization', 'branch']);
$canManage = app(FrontdeskPermissions::class)->can(
$this->member($request),
'visits.manage',
);
return view('frontdesk.visits.show', compact('visit', 'canManage'));
}
public function activate(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$request->validate(['policies_accepted' => ['accepted']]);
$lifecycle->checkInFromSchedule(
$visit,
$this->ownerRef($request),
true,
);
return redirect()
->route('frontdesk.visits.show', $visit)
->with('success', 'Visitor checked in successfully.');
}
public function markWaiting(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$lifecycle->markWaiting($visit, $this->ownerRef($request));
return back()->with('success', 'Visitor marked as waiting.');
}
public function cancel(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$validated = $request->validate([
'reason' => ['nullable', 'string', 'max:500'],
]);
$lifecycle->cancel($visit, $this->ownerRef($request), $validated['reason'] ?? null);
return redirect()
->route('frontdesk.visits.index', ['status' => 'cancelled'])
->with('success', 'Visit cancelled.');
}
public function approve(Request $request, Visit $visit, VisitLifecycleService $lifecycle): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$lifecycle->approve($visit, $this->ownerRef($request));
return redirect()
->route('frontdesk.visits.show', $visit)
->with('success', 'Visit approved and visitor checked in.');
}
public function checkOut(Request $request, Visit $visit, VisitCheckOutService $checkOut): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visit);
$checkOut->checkOut($visit, $this->ownerRef($request));
return back()->with('success', 'Visitor checked out.');
}
public function badge(Request $request, Visit $visit, PrinterManager $printers)
{
$this->authorizeAbility($request, 'visits.view');
$this->authorizeOwner($request, $visit);
$rendered = $printers->renderBadge($visit, $request->query('driver'));
if ($rendered['format'] === 'html') {
return response($rendered['content'])->header('Content-Type', 'text/html');
}
return response($rendered['content'])
->header('Content-Type', 'text/plain')
->header('Content-Disposition', 'attachment; filename="'.$rendered['filename'].'"');
}
}
@@ -0,0 +1,200 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\AuditLog;
use App\Models\Branch;
use App\Models\Host;
use App\Models\Visitor;
use App\Services\Frontdesk\FrontdeskPermissions;
use App\Services\Frontdesk\VisitCheckInService;
use App\Services\Frontdesk\WatchlistService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\View\View;
class VisitorController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'visitors.view');
$organization = $this->organization($request);
$visitors = Visitor::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->when($request->q, fn ($q, $search) => $q->where('full_name', 'like', "%{$search}%"))
->orderByDesc('is_frequent')
->orderBy('full_name')
->paginate(25)
->withQueryString();
return view('frontdesk.visitors.index', compact('visitors', 'organization'));
}
public function show(Request $request, Visitor $visitor): View
{
$this->authorizeAbility($request, 'visitors.view');
$this->authorizeOwner($request, $visitor);
$visitor->load(['visits' => fn ($q) => $q->latest()->limit(20), 'visits.host']);
$visitIds = $visitor->visits()->pluck('id');
$activity = AuditLog::owned($this->ownerRef($request))
->where('organization_id', $visitor->organization_id)
->where(function ($q) use ($visitor, $visitIds) {
$q->where(function ($inner) use ($visitor) {
$inner->where('subject_type', Visitor::class)
->where('subject_id', $visitor->id);
});
if ($visitIds->isNotEmpty()) {
$q->orWhere(function ($inner) use ($visitIds) {
$inner->where('subject_type', Visit::class)
->whereIn('subject_id', $visitIds);
});
}
})
->orderByDesc('created_at')
->limit(30)
->get();
return view('frontdesk.visitors.show', [
'visitor' => $visitor,
'activity' => $activity,
'auditActions' => config('frontdesk.audit_actions'),
'watchlistStatuses' => config('frontdesk.watchlist_statuses'),
'canManage' => app(FrontdeskPermissions::class)->can(
$this->member($request),
'visitors.manage'
),
]);
}
public function checkInForm(Request $request, Visitor $visitor): View
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visitor);
$organization = $this->organization($request);
$hosts = Host::owned($this->ownerRef($request))
->where('organization_id', $organization->id);
$this->scopeToBranch($request, $hosts);
$hosts = $hosts->orderBy('name')->get();
$branches = Branch::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('is_active', true)
->orderBy('name')
->get();
return view('frontdesk.visitors.check-in', compact('visitor', 'organization', 'hosts', 'branches'));
}
public function checkIn(Request $request, Visitor $visitor, VisitCheckInService $checkIn): RedirectResponse
{
$this->authorizeAbility($request, 'visits.manage');
$this->authorizeOwner($request, $visitor);
$organization = $this->organization($request);
$validated = $request->validate([
'host_id' => ['nullable', 'integer'],
'branch_id' => ['nullable', 'integer'],
'visitor_type' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.visitor_types')))],
'purpose' => ['nullable', 'string', 'max:500'],
'policies_accepted' => ['accepted'],
]);
$branchScope = app(\App\Services\Frontdesk\OrganizationResolver::class)
->branchScope($this->member($request));
if ($branchScope !== null) {
$validated['branch_id'] = $branchScope;
}
$validated['visitor_id'] = $visitor->id;
$visit = $checkIn->checkIn(
$this->ownerRef($request),
$organization,
$validated,
$this->ownerRef($request),
);
return redirect()
->route('frontdesk.visits.show', $visit)
->with('success', 'Returning visitor checked in.');
}
public function update(Request $request, Visitor $visitor): RedirectResponse
{
$this->authorizeAbility($request, 'visitors.manage');
$this->authorizeOwner($request, $visitor);
$validated = $request->validate([
'full_name' => ['required', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:50'],
'email' => ['nullable', 'email', 'max:255'],
'notes' => ['nullable', 'string', 'max:2000'],
'photo' => ['nullable', 'image', 'max:5120'],
'id_document' => ['nullable', 'file', 'mimes:pdf,jpg,jpeg,png', 'max:10240'],
]);
if ($request->hasFile('photo')) {
if ($visitor->photo_path) {
Storage::disk('public')->delete($visitor->photo_path);
}
$validated['photo_path'] = $request->file('photo')->store('frontdesk/visitors/photos', 'public');
}
if ($request->hasFile('id_document')) {
if ($visitor->id_document_path) {
Storage::disk('public')->delete($visitor->id_document_path);
}
$validated['id_document_path'] = $request->file('id_document')->store('frontdesk/visitors/documents', 'public');
}
unset($validated['photo'], $validated['id_document']);
$visitor->update($validated);
return back()->with('success', 'Visitor profile updated.');
}
public function updateWatchlist(Request $request, Visitor $visitor, WatchlistService $watchlist): RedirectResponse
{
$this->authorizeAbility($request, 'visitors.manage');
$this->authorizeOwner($request, $visitor);
$organization = $this->organization($request);
$validated = $request->validate([
'watchlist_status' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.watchlist_statuses')))],
'notes' => ['nullable', 'string', 'max:2000'],
]);
$visitor->update($validated);
$watchlist->syncEntryForVisitor(
$visitor,
$validated['watchlist_status'],
$validated['notes'] ?? null,
$this->ownerRef($request),
);
AuditLog::record(
$this->ownerRef($request),
'watchlist.entry_created',
$organization->id,
$this->ownerRef($request),
Visitor::class,
$visitor->id,
['watchlist_status' => $validated['watchlist_status']],
);
return back()->with('success', 'Visitor watchlist status updated.');
}
}
@@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\AuditLog;
use App\Models\Visitor;
use App\Models\WatchlistEntry;
use App\Services\Frontdesk\WatchlistService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class WatchlistController extends Controller
{
use ScopesToAccount;
public function index(Request $request): View
{
$this->authorizeAbility($request, 'watchlist.view');
$organization = $this->organization($request);
$entries = WatchlistEntry::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->with('visitor')
->when($request->status, fn ($q, $status) => $q->where('status', $status))
->when($request->q, function ($q, $search) {
$q->where(function ($inner) use ($search) {
$inner->where('full_name', 'like', "%{$search}%")
->orWhere('company', 'like', "%{$search}%");
});
})
->latest()
->paginate(25)
->withQueryString();
return view('frontdesk.watchlist.index', [
'entries' => $entries,
'organization' => $organization,
'statuses' => config('frontdesk.watchlist_statuses'),
'canManage' => app(\App\Services\Frontdesk\FrontdeskPermissions::class)
->can($this->member($request), 'watchlist.manage'),
]);
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'watchlist.manage');
$organization = $this->organization($request);
$visitors = Visitor::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->orderBy('full_name')
->limit(100)
->get();
return view('frontdesk.watchlist.create', [
'organization' => $organization,
'visitors' => $visitors,
'statuses' => config('frontdesk.watchlist_statuses'),
]);
}
public function store(Request $request, WatchlistService $watchlist): RedirectResponse
{
$this->authorizeAbility($request, 'watchlist.manage');
$organization = $this->organization($request);
$validated = $request->validate([
'visitor_id' => ['nullable', 'integer'],
'full_name' => ['required_without:visitor_id', 'string', 'max:255'],
'company' => ['nullable', 'string', 'max:255'],
'status' => ['required', 'string', 'in:'.implode(',', array_keys(config('frontdesk.watchlist_statuses')))],
'reason' => ['nullable', 'string', 'max:2000'],
]);
if ($validated['status'] === Visitor::WATCHLIST_ALLOWED) {
return back()->withErrors(['status' => 'Choose requires approval or blacklisted for watchlist entries.']);
}
$visitor = null;
if (! empty($validated['visitor_id'])) {
$visitor = Visitor::owned($this->ownerRef($request))->findOrFail($validated['visitor_id']);
$validated['full_name'] = $visitor->full_name;
$validated['company'] = $visitor->company;
}
$entry = WatchlistEntry::create([
'owner_ref' => $this->ownerRef($request),
'organization_id' => $organization->id,
'visitor_id' => $visitor?->id,
'full_name' => $validated['full_name'],
'company' => $validated['company'] ?? null,
'status' => $validated['status'],
'reason' => $validated['reason'] ?? null,
'created_by' => $this->ownerRef($request),
]);
if ($visitor) {
$visitor->update(['watchlist_status' => $validated['status'], 'notes' => $validated['reason'] ?? $visitor->notes]);
}
AuditLog::record(
$this->ownerRef($request),
'watchlist.entry_created',
$organization->id,
$this->ownerRef($request),
WatchlistEntry::class,
$entry->id,
['full_name' => $entry->full_name, 'status' => $entry->status],
);
return redirect()
->route('frontdesk.watchlist.index')
->with('success', 'Watchlist entry added.');
}
public function destroy(Request $request, WatchlistEntry $entry, WatchlistService $watchlist): RedirectResponse
{
$this->authorizeAbility($request, 'watchlist.manage');
$this->authorizeOwner($request, $entry);
$organization = $this->organization($request);
if ($entry->visitor_id) {
$visitor = Visitor::owned($this->ownerRef($request))->find($entry->visitor_id);
$visitor?->update(['watchlist_status' => Visitor::WATCHLIST_ALLOWED]);
}
AuditLog::record(
$this->ownerRef($request),
'watchlist.entry_removed',
$organization->id,
$this->ownerRef($request),
WatchlistEntry::class,
$entry->id,
['full_name' => $entry->full_name],
);
$entry->delete();
return back()->with('success', 'Watchlist entry removed.');
}
}