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
+138
View File
@@ -0,0 +1,138 @@
<?php
namespace App\Services\Frontdesk;
use App\Models\AuditLog;
use App\Models\Host;
use App\Models\Organization;
use App\Models\Visit;
use App\Models\Visitor;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
class ReportService
{
/**
* @return array<string, mixed>
*/
public function summary(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$visits = $this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId);
$checkedIn = (clone $visits)->whereNotNull('checked_in_at');
$durations = (clone $checkedIn)
->whereNotNull('checked_out_at')
->get(['checked_in_at', 'checked_out_at'])
->map(fn (Visit $v) => $v->checked_in_at->diffInMinutes($v->checked_out_at));
return [
'total_visits' => (clone $visits)->count(),
'checked_in' => (clone $checkedIn)->count(),
'checked_out' => (clone $visits)->where('status', Visit::STATUS_CHECKED_OUT)->count(),
'cancelled' => (clone $visits)->where('status', Visit::STATUS_CANCELLED)->count(),
'contractors' => (clone $checkedIn)->where('visitor_type', 'contractor')->count(),
'deliveries' => (clone $checkedIn)->where('visitor_type', 'delivery')->count(),
'avg_duration_minutes' => $durations->isEmpty() ? 0 : (int) round($durations->avg()),
'unique_visitors' => (clone $checkedIn)->distinct('visitor_id')->count('visitor_id'),
];
}
/** @return array<int, array{hour: int, count: int}> */
public function peakHours(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$counts = array_fill(0, 24, 0);
$this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
->whereNotNull('checked_in_at')
->pluck('checked_in_at')
->each(function ($checkedInAt) use (&$counts) {
$counts[(int) Carbon::parse($checkedInAt)->format('G')]++;
});
$hours = [];
for ($h = 0; $h < 24; $h++) {
$hours[] = ['hour' => $h, 'count' => $counts[$h]];
}
return $hours;
}
/** @return Collection<int, object{department: string, count: int}> */
public function visitsByDepartment(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): Collection
{
return $this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
->whereNotNull('frontdesk_visits.checked_in_at')
->join('frontdesk_hosts', 'frontdesk_visits.host_id', '=', 'frontdesk_hosts.id')
->selectRaw("coalesce(frontdesk_hosts.department, 'Unassigned') as department, count(*) as total")
->groupBy('department')
->orderByDesc('total')
->get()
->map(fn ($row) => (object) ['department' => $row->department, 'count' => (int) $row->total]);
}
/** @return Collection<int, Visitor> */
public function frequentVisitors(string $ownerRef, Organization $organization, int $limit = 10): Collection
{
return Visitor::owned($ownerRef)
->where('organization_id', $organization->id)
->where('is_frequent', true)
->orderByDesc('visit_count')
->limit($limit)
->get();
}
/** @return array<string, int> */
public function securityIncidents(string $ownerRef, Organization $organization, Carbon $from, Carbon $to): array
{
$query = AuditLog::owned($ownerRef)
->where('organization_id', $organization->id)
->whereBetween('created_at', [$from, $to])
->whereIn('action', [
'watchlist.blocked_attempt',
'watchlist.flagged_checkin',
'badge.expired',
]);
return [
'blocked_attempts' => (clone $query)->where('action', 'watchlist.blocked_attempt')->count(),
'flagged_checkins' => (clone $query)->where('action', 'watchlist.flagged_checkin')->count(),
'expired_badges' => (clone $query)->where('action', 'badge.expired')->count(),
];
}
/** @return array<int, array{date: string, count: int}> */
public function dailyCounts(string $ownerRef, Organization $organization, Carbon $from, Carbon $to, ?int $branchId = null): array
{
$rows = [];
$this->visitQuery($ownerRef, $organization->id, $from, $to, $branchId)
->whereNotNull('checked_in_at')
->pluck('checked_in_at')
->each(function ($checkedInAt) use (&$rows) {
$key = Carbon::parse($checkedInAt)->toDateString();
$rows[$key] = ($rows[$key] ?? 0) + 1;
});
$days = [];
for ($date = $from->copy()->startOfDay(); $date->lte($to); $date->addDay()) {
$key = $date->toDateString();
$days[] = ['date' => $key, 'count' => (int) ($rows[$key] ?? 0)];
}
return $days;
}
protected function visitQuery(string $ownerRef, int $organizationId, Carbon $from, Carbon $to, ?int $branchId): Builder
{
return Visit::query()
->from('frontdesk_visits')
->where('frontdesk_visits.owner_ref', $ownerRef)
->where('frontdesk_visits.organization_id', $organizationId)
->when($branchId, fn ($q) => $q->where('frontdesk_visits.branch_id', $branchId))
->where(function ($q) use ($from, $to) {
$q->whereBetween('frontdesk_visits.checked_in_at', [$from, $to])
->orWhereBetween('frontdesk_visits.scheduled_at', [$from, $to]);
});
}
}