Visitor management app with SSO, kiosk, badges, reports, and Gitea CI deploy to frontdesk.ladill.com. Co-authored-by: Cursor <cursoragent@cursor.com>
301 lines
12 KiB
PHP
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'].'"');
|
|
}
|
|
}
|