Files
ladill-frontdesk/app/Http/Controllers/Frontdesk/VisitController.php
T
isaaccladandCursor 9e2d79936c
Deploy Ladill Frontdesk / deploy (push) Failing after 35s
Test / test (push) Failing after 2m45s
Initial Ladill Frontdesk release with deploy pipeline.
Visitor management app with SSO, kiosk, badges, reports, and Gitea CI deploy to frontdesk.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-27 20:37:15 +00:00

301 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Branch;
use App\Models\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'].'"');
}
}