diff --git a/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php b/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php index 4f33f07..e329b59 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminHouseholdController.php @@ -36,6 +36,7 @@ class AdminHouseholdController extends ApiController $request->validate([ 'verification_status' => ['nullable', 'in:pending,approved,rejected'], 'barangay_id' => ['nullable', 'integer'], + 'service_area_id' => ['nullable', 'integer'], 'q' => ['nullable', 'string', 'max:100'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:500'], ]); @@ -43,7 +44,7 @@ class AdminHouseholdController extends ApiController $perPage = (int) $request->input('per_page', 25); $households = Household::query() - ->with(['head', 'barangay']) + ->with(['head', 'barangay.serviceAreas']) ->withCount('members') ->when( $request->filled('verification_status'), @@ -53,6 +54,10 @@ class AdminHouseholdController extends ApiController $request->filled('barangay_id'), fn ($q) => $q->where('barangay_id', $request->integer('barangay_id')), ) + ->when( + $request->filled('service_area_id'), + fn ($q) => $q->whereHas('barangay.serviceAreas', fn ($sa) => $sa->where('service_areas.id', $request->integer('service_area_id'))), + ) ->when($request->filled('q'), function ($q) use ($request) { $term = '%'.$request->string('q').'%'; $q->where(function ($qq) use ($term) { @@ -81,7 +86,7 @@ class AdminHouseholdController extends ApiController public function show(Household $household): JsonResponse { - $household->load(['head', 'barangay.cityMunicipality.province', 'members.user']) + $household->load(['head', 'barangay.cityMunicipality.province', 'barangay.serviceAreas', 'members.user']) ->loadCount('members'); return $this->ok(new HouseholdResource($household)); @@ -104,7 +109,7 @@ class AdminHouseholdController extends ApiController $household->markVerified($request->user()); HouseholdVerified::dispatch($household->fresh(), $request->user()); - $household->load(['head', 'barangay'])->loadCount('members'); + $household->load(['head', 'barangay.serviceAreas'])->loadCount('members'); return $this->ok(new HouseholdResource($household), 'Household approved'); } @@ -121,7 +126,7 @@ class AdminHouseholdController extends ApiController ); } - $household->load(['head', 'barangay'])->loadCount('members'); + $household->load(['head', 'barangay.serviceAreas'])->loadCount('members'); return $this->ok(new HouseholdResource($household), 'Household rejected'); } @@ -225,7 +230,7 @@ class AdminHouseholdController extends ApiController return $h; }); - $household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members'); + $household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members'); return $this->created( new HouseholdResource($household), @@ -276,7 +281,7 @@ class AdminHouseholdController extends ApiController ->update(['user_id' => $newHeadId]); } - $household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members'); + $household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members'); return $this->ok(new HouseholdResource($household), 'Household updated successfully.'); } @@ -300,7 +305,7 @@ class AdminHouseholdController extends ApiController 'assigned_drop_off_point_id' => $request->integer('drop_off_point_id'), ]); - $household->load(['head', 'barangay', 'assignedDropOffPoint'])->loadCount('members'); + $household->load(['head', 'barangay.serviceAreas', 'assignedDropOffPoint'])->loadCount('members'); return $this->ok(new HouseholdResource($household), 'Drop-off point updated.'); } @@ -406,7 +411,7 @@ class AdminHouseholdController extends ApiController return $m; }); - $household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members'); + $household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members'); return $this->created(new HouseholdResource($household), 'Household member added successfully.'); } @@ -447,7 +452,7 @@ class AdminHouseholdController extends ApiController 'date_of_birth' => $data['date_of_birth'] ?? null, ]); - $household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members'); + $household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members'); return $this->ok(new HouseholdResource($household), 'Household member updated successfully.'); } @@ -464,7 +469,7 @@ class AdminHouseholdController extends ApiController $member->delete(); - $household->load(['head', 'barangay', 'members.user', 'assignedDropOffPoint'])->loadCount('members'); + $household->load(['head', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint'])->loadCount('members'); return $this->ok(new HouseholdResource($household), 'Household member removed successfully.'); } diff --git a/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php index 103fefa..64c6d45 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminPartnerStoreController.php @@ -8,6 +8,8 @@ use App\Models\Household; use App\Models\PartnerStore; use App\Models\QrCode; use App\Models\StoreInventoryAdjustment; +use App\Models\StoreSale; +use App\Models\User; use App\Services\Store\StoreOperations; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -146,9 +148,10 @@ class AdminPartnerStoreController extends ApiController try { $household = Household::findOrFail($data['household_id']); - $sale = $this->ops->sellToHousehold( + $sale = $this->ops->sell( $store, $household, + null, (int) $data['quantity'], $retailPrice, ); @@ -370,4 +373,173 @@ class AdminPartnerStoreController extends ApiController 'total_sold_codes' => (int) $store->sales()->sum('quantity'), ]); } + + public function distributionChart(Request $request, PartnerStore $store): JsonResponse + { + $period = $request->input('period', 'daily'); + + $driver = \DB::getDriverName(); + $isSqlite = $driver === 'sqlite'; + + $query = $store->sales(); + + switch ($period) { + case 'weekly': + $dateExpression = $isSqlite + ? "strftime('%Y-W%W', COALESCE(sold_at, created_at))" + : "DATE_FORMAT(COALESCE(sold_at, created_at), '%x-W%v')"; + + $rows = $query + ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") + ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subWeeks(12)->startOfWeek()]) + ->groupByRaw($dateExpression) + ->orderByRaw($dateExpression) + ->get(); + break; + + case 'monthly': + $dateExpression = $isSqlite + ? "strftime('%Y-%m', COALESCE(sold_at, created_at))" + : "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')"; + + $rows = $query + ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") + ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subMonths(12)->startOfMonth()]) + ->groupByRaw($dateExpression) + ->orderByRaw($dateExpression) + ->get(); + break; + + case 'overall': + $dateExpression = $isSqlite + ? "strftime('%Y-%m', COALESCE(sold_at, created_at))" + : "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')"; + + $rows = $query + ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") + ->groupByRaw($dateExpression) + ->orderByRaw($dateExpression) + ->get(); + break; + + default: // daily — last 30 days + $dateExpression = $isSqlite + ? "date(COALESCE(sold_at, created_at))" + : "DATE(COALESCE(sold_at, created_at))"; + + $rows = $query + ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") + ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subDays(29)->startOfDay()]) + ->groupByRaw($dateExpression) + ->orderByRaw($dateExpression) + ->get(); + break; + } + + return $this->ok([ + 'period' => $period, + 'labels' => $rows->pluck('label'), + 'values' => $rows->pluck('total')->map(fn ($v) => (int) $v), + 'total_distributed' => (int) $store->sales()->sum('quantity'), + ]); + } + + public function overallDistributionChart(Request $request): JsonResponse + { + $period = $request->input('period', 'daily'); + + $user = $request->user(); + $isSuperAdmin = $user && $user->role === User::ROLE_SUPER_ADMIN; + + $storeQuery = PartnerStore::query(); + + if ($isSuperAdmin) { + if ($request->filled('tenant_id')) { + $storeQuery->withoutGlobalScopes()->where('tenant_id', $request->integer('tenant_id')); + } else { + $storeQuery->withoutGlobalScopes(); + } + } else { + if ($user && $user->tenant_id) { + $storeQuery->where('tenant_id', $user->tenant_id); + } + } + + if ($request->filled('service_area_id')) { + $storeQuery->whereHas('barangay.serviceAreas', function ($q) use ($request) { + $q->where('service_areas.id', $request->integer('service_area_id')); + }); + } + + if ($request->filled('barangay_id')) { + $storeQuery->where('barangay_id', $request->integer('barangay_id')); + } + + $storeIds = $storeQuery->pluck('id'); + + $driver = \DB::getDriverName(); + $isSqlite = $driver === 'sqlite'; + + $query = StoreSale::query()->whereIn('store_id', $storeIds); + + switch ($period) { + case 'weekly': + $dateExpression = $isSqlite + ? "strftime('%Y-W%W', COALESCE(sold_at, created_at))" + : "DATE_FORMAT(COALESCE(sold_at, created_at), '%x-W%v')"; + + $rows = $query + ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") + ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subWeeks(12)->startOfWeek()]) + ->groupByRaw($dateExpression) + ->orderByRaw($dateExpression) + ->get(); + break; + + case 'monthly': + $dateExpression = $isSqlite + ? "strftime('%Y-%m', COALESCE(sold_at, created_at))" + : "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')"; + + $rows = $query + ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") + ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subMonths(12)->startOfMonth()]) + ->groupByRaw($dateExpression) + ->orderByRaw($dateExpression) + ->get(); + break; + + case 'overall': + $dateExpression = $isSqlite + ? "strftime('%Y-%m', COALESCE(sold_at, created_at))" + : "DATE_FORMAT(COALESCE(sold_at, created_at), '%Y-%m')"; + + $rows = $query + ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") + ->groupByRaw($dateExpression) + ->orderByRaw($dateExpression) + ->get(); + break; + + default: // daily — last 30 days + $dateExpression = $isSqlite + ? "date(COALESCE(sold_at, created_at))" + : "DATE(COALESCE(sold_at, created_at))"; + + $rows = $query + ->selectRaw("{$dateExpression} as label, SUM(quantity) as total") + ->whereRaw("COALESCE(sold_at, created_at) >= ?", [now()->subDays(29)->startOfDay()]) + ->groupByRaw($dateExpression) + ->orderByRaw($dateExpression) + ->get(); + break; + } + + return $this->ok([ + 'period' => $period, + 'labels' => $rows->pluck('label'), + 'values' => $rows->pluck('total')->map(fn ($v) => (int) $v), + 'total_distributed' => (int) $query->sum('quantity'), + ]); + } } diff --git a/app/Http/Controllers/Api/V1/Admin/AdminTeamReportController.php b/app/Http/Controllers/Api/V1/Admin/AdminTeamReportController.php new file mode 100644 index 0000000..e9e0ffd --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AdminTeamReportController.php @@ -0,0 +1,373 @@ +validate([ + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date'], + 'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:200'], + ]); + + $from = Carbon::parse($data['from'] ?? now()->subDays(30))->startOfDay(); + $to = Carbon::parse($data['to'] ?? now())->endOfDay(); + $perPage = (int) ($data['per_page'] ?? 50); + + $tenant = $this->resolveTargetTenant($request); + + // For the leaderboard we run a single DB query per team to avoid N+1. + // We use subquery aggregates to compute everything in one pass. + $rows = Tenancy::withoutScope(function () use ($from, $to, $tenant, $perPage) { + $query = CollectionTeam::withoutTrashed() + ->with(['driver', 'scanner', 'truck']) + ->when($tenant, fn ($q) => $q->where('collection_teams.tenant_id', $tenant->id)); + + // Eager-load trip IDs, then compute aggregates in PHP to avoid + // complex cross-table subqueries that differ by DB engine. + return $query->orderBy('name')->paginate($perPage); + }); + + $teamIds = $rows->pluck('id'); + $fromStr = $from->toDateTimeString(); + $toStr = $to->toDateTimeString(); + + // Batch-load all aggregate data using a single query per metric. + $tripStats = Tenancy::withoutScope(function () use ($teamIds, $from, $to) { + return DB::table('trips') + ->select( + 'team_id', + DB::raw('COUNT(*) as total_trips'), + DB::raw("SUM(CASE WHEN status IN ('completed','handed_off') THEN 1 ELSE 0 END) as completed_trips"), + DB::raw("SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_trips"), + DB::raw('SUM(COALESCE(total_load_kg, 0)) as total_weight_kg'), + DB::raw('MAX(scheduled_date) as last_trip_date') + ) + ->whereIn('team_id', $teamIds) + ->whereBetween('scheduled_date', [$from->toDateString(), $to->toDateString()]) + ->whereNull('deleted_at') + ->groupBy('team_id') + ->get() + ->keyBy('team_id'); + }); + + $scanStats = Tenancy::withoutScope(function () use ($teamIds, $fromStr, $toStr) { + return DB::table('collection_logs') + ->join('trips', 'trips.id', '=', 'collection_logs.trip_id') + ->select('trips.team_id', DB::raw('COUNT(collection_logs.id) as total_scans')) + ->whereIn('trips.team_id', $teamIds) + ->whereBetween('collection_logs.scanned_at', [$fromStr, $toStr]) + ->whereNull('trips.deleted_at') + ->groupBy('trips.team_id') + ->get() + ->keyBy('team_id'); + }); + + $eventStats = Tenancy::withoutScope(function () use ($teamIds, $fromStr, $toStr) { + return DB::table('trip_timeline_events') + ->join('trips', 'trips.id', '=', 'trip_timeline_events.trip_id') + ->select('trips.team_id', DB::raw('COUNT(trip_timeline_events.id) as event_count')) + ->whereIn('trips.team_id', $teamIds) + ->whereBetween('trip_timeline_events.event_at', [$fromStr, $toStr]) + ->whereNull('trips.deleted_at') + ->groupBy('trips.team_id') + ->get() + ->keyBy('team_id'); + }); + + // Map teams to leaderboard rows. + $leaderboard = $rows->map(function (CollectionTeam $team) use ($tripStats, $scanStats, $eventStats) { + $ts = $tripStats->get($team->id); + $ss = $scanStats->get($team->id); + $es = $eventStats->get($team->id); + + $totalTrips = (int) ($ts->total_trips ?? 0); + $completedTrips = (int) ($ts->completed_trips ?? 0); + $totalWeight = (int) ($ts->total_weight_kg ?? 0); + $totalScans = (int) ($ss->total_scans ?? 0); + + return [ + 'team_uuid' => $team->uuid, + 'team_name' => $team->name, + 'driver_name' => $team->driver?->full_name, + 'scanner_name' => $team->scanner?->full_name, + 'truck_plate' => $team->truck?->plate_number, + 'status' => $team->status, + 'total_trips' => $totalTrips, + 'completed_trips' => $completedTrips, + 'cancelled_trips' => (int) ($ts->cancelled_trips ?? 0), + 'completion_rate_percent'=> $totalTrips > 0 + ? round(($completedTrips / $totalTrips) * 100, 1) + : 0, + 'total_scans' => $totalScans, + 'total_weight_kg' => $totalWeight, + 'avg_load_per_trip_kg' => $totalTrips > 0 + ? (int) round($totalWeight / $totalTrips) + : 0, + 'event_count' => (int) ($es->event_count ?? 0), + 'last_trip_date' => $ts?->last_trip_date, + ]; + })->sortByDesc('total_scans')->values(); + + return $this->ok($leaderboard, null, [ + 'page' => $rows->currentPage(), + 'per_page' => $rows->perPage(), + 'total' => $rows->total(), + 'last_page' => $rows->lastPage(), + 'from' => $from->toDateString(), + 'to' => $to->toDateString(), + ]); + } + + /** + * GET /api/v1/admin/reports/teams/{team:uuid}/profile + * + * Returns the full analytics profile for a single team: + * KPIs, daily scan trend, weekly scan trend, trip history, and full event log. + */ + public function teamProfile(Request $request, CollectionTeam $team): JsonResponse + { + $data = $request->validate([ + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date'], + 'trip_page' => ['nullable', 'integer', 'min:1'], + 'tenant_id' => ['nullable', 'integer', 'exists:tenants,id'], + ]); + + $from = Carbon::parse($data['from'] ?? now()->subDays(30))->startOfDay(); + $to = Carbon::parse($data['to'] ?? now())->endOfDay(); + $tripPage = (int) ($data['trip_page'] ?? 1); + $fromDate = $from->toDateString(); + $toDate = $to->toDateString(); + $fromDT = $from->toDateTimeString(); + $toDT = $to->toDateTimeString(); + + // Re-load team fully (route model binding already resolved it, but load relations) + $team->load(['driver', 'scanner', 'truck', 'area']); + + // Get all trip IDs for this team in range. + $tripIds = Tenancy::withoutScope(function () use ($team, $fromDate, $toDate) { + return Trip::withoutGlobalScopes() + ->where('team_id', $team->id) + ->whereBetween('scheduled_date', [$fromDate, $toDate]) + ->pluck('id'); + }); + + // --- KPIs --- + $kpis = $this->buildKpis($team->id, $tripIds, $fromDate, $toDate, $fromDT, $toDT); + + // --- Daily scans (group by DATE) --- + $dailyScans = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) { + return DB::table('collection_logs') + ->selectRaw('DATE(scanned_at) as date, COUNT(*) as scans') + ->whereIn('trip_id', $tripIds) + ->whereBetween('scanned_at', [$fromDT, $toDT]) + ->groupByRaw('DATE(scanned_at)') + ->orderBy('date') + ->get() + ->map(fn ($r) => ['date' => $r->date, 'scans' => (int) $r->scans]); + }); + + // --- Weekly scans (group by YEARWEEK) --- + $weeklyScans = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) { + return DB::table('collection_logs') + ->selectRaw("DATE(DATE_SUB(scanned_at, INTERVAL WEEKDAY(scanned_at) DAY)) as week_start, COUNT(*) as scans") + ->whereIn('trip_id', $tripIds) + ->whereBetween('scanned_at', [$fromDT, $toDT]) + ->groupByRaw("DATE(DATE_SUB(scanned_at, INTERVAL WEEKDAY(scanned_at) DAY))") + ->orderBy('week_start') + ->get() + ->map(fn ($r) => ['week_start' => $r->week_start, 'scans' => (int) $r->scans]); + }); + + // --- Trip history (paginated, 10/page) --- + $tripsData = Tenancy::withoutScope(function () use ($team, $fromDate, $toDate, $tripPage, $tripIds) { + $perPage = 10; + $trips = Trip::withoutGlobalScopes() + ->withCount('collectionLogs') + ->where('team_id', $team->id) + ->whereBetween('scheduled_date', [$fromDate, $toDate]) + ->orderByDesc('scheduled_date') + ->paginate($perPage, ['*'], 'trip_page', $tripPage); + + return [ + 'data' => $trips->map(fn (Trip $t) => [ + 'trip_number' => $t->trip_number, + 'scheduled_date' => $t->scheduled_date?->toDateString(), + 'status' => $t->status, + 'scans_count' => $t->collection_logs_count, + 'total_load_kg' => (int) ($t->total_load_kg ?? 0), + 'duration_minutes' => ($t->actual_start_time && $t->actual_end_time) + ? (int) $t->actual_start_time->diffInMinutes($t->actual_end_time) + : null, + 'actual_start_time' => $t->actual_start_time?->toIso8601String(), + 'actual_end_time' => $t->actual_end_time?->toIso8601String(), + ]), + 'meta' => [ + 'page' => $trips->currentPage(), + 'per_page' => $trips->perPage(), + 'total' => $trips->total(), + 'last_page' => $trips->lastPage(), + ], + ]; + }); + + // --- Event log (all event types, last 200) --- + $events = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) { + return DB::table('trip_timeline_events') + ->join('trips', 'trips.id', '=', 'trip_timeline_events.trip_id') + ->select( + 'trip_timeline_events.event_type', + 'trip_timeline_events.event_at', + 'trip_timeline_events.notes', + 'trip_timeline_events.metadata', + 'trips.trip_number' + ) + ->whereIn('trip_timeline_events.trip_id', $tripIds) + ->whereBetween('trip_timeline_events.event_at', [$fromDT, $toDT]) + ->orderByDesc('trip_timeline_events.event_at') + ->limit(200) + ->get() + ->map(fn ($e) => [ + 'event_type' => $e->event_type, + 'event_at' => $e->event_at, + 'trip_number' => $e->trip_number, + 'notes' => $e->notes, + 'metadata' => $e->metadata ? json_decode($e->metadata, true) : null, + ]); + }); + + return $this->ok([ + 'team' => [ + 'uuid' => $team->uuid, + 'name' => $team->name, + 'status' => $team->status, + 'area' => $team->area?->name, + 'driver' => $team->driver ? [ + 'name' => $team->driver->full_name, + 'email' => $team->driver->email, + ] : null, + 'scanner' => $team->scanner ? [ + 'name' => $team->scanner->full_name, + 'email' => $team->scanner->email, + ] : null, + 'truck' => $team->truck ? [ + 'plate' => $team->truck->plate_number, + 'model' => $team->truck->model, + 'capacity_kg' => $team->truck->capacity_kg, + ] : null, + ], + 'kpis' => $kpis, + 'daily_scans' => $dailyScans, + 'weekly_scans' => $weeklyScans, + 'trips' => $tripsData, + 'events' => $events, + ]); + } + + /** + * Compute all KPI metrics for a team within the given date range. + */ + private function buildKpis(int $teamId, $tripIds, string $fromDate, string $toDate, string $fromDT, string $toDT): array + { + $tripStats = Tenancy::withoutScope(function () use ($teamId, $fromDate, $toDate) { + return DB::table('trips') + ->select( + DB::raw('COUNT(*) as total_trips'), + DB::raw("SUM(CASE WHEN status IN ('completed','handed_off') THEN 1 ELSE 0 END) as completed_trips"), + DB::raw("SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_trips"), + DB::raw('SUM(COALESCE(total_load_kg,0)) as total_weight_kg'), + // On-time: actual_start_time <= scheduled_start_time + 30 minutes + DB::raw("SUM(CASE + WHEN actual_start_time IS NOT NULL AND scheduled_start_time IS NOT NULL + AND actual_start_time <= DATE_ADD( + CONCAT(scheduled_date, ' ', scheduled_start_time), + INTERVAL 30 MINUTE + ) + THEN 1 ELSE 0 END) as on_time_trips") + ) + ->where('team_id', $teamId) + ->whereBetween('scheduled_date', [$fromDate, $toDate]) + ->whereNull('deleted_at') + ->first(); + }); + + $scanStats = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) { + return DB::table('collection_logs') + ->selectRaw('COUNT(*) as total_scans, SUM(COALESCE(weight_kg,0)) as total_scan_weight') + ->whereIn('trip_id', $tripIds) + ->whereBetween('scanned_at', [$fromDT, $toDT]) + ->first(); + }); + + $eventStats = Tenancy::withoutScope(function () use ($tripIds, $fromDT, $toDT) { + return DB::table('trip_timeline_events') + ->selectRaw(" + COUNT(*) as total_events, + SUM(CASE WHEN event_type IN ('incident_reported','breakdown') THEN 1 ELSE 0 END) as incident_count, + SUM(CASE WHEN event_type = 'stop_skipped' THEN 1 ELSE 0 END) as stops_skipped_count, + SUM(CASE WHEN event_type = 'detour_to_dumpsite' THEN 1 ELSE 0 END) as detours_count, + SUM(CASE WHEN event_type = 'breakdown' THEN 1 ELSE 0 END) as breakdowns_count, + SUM(CASE WHEN event_type = 'truck_full_warning' THEN 1 ELSE 0 END) as truck_full_count + ") + ->whereIn('trip_id', $tripIds) + ->whereBetween('event_at', [$fromDT, $toDT]) + ->first(); + }); + + $totalTrips = (int) ($tripStats->total_trips ?? 0); + $completedTrips = (int) ($tripStats->completed_trips ?? 0); + $onTimeTrips = (int) ($tripStats->on_time_trips ?? 0); + $totalWeight = (int) ($tripStats->total_weight_kg ?? 0); + $totalScans = (int) ($scanStats->total_scans ?? 0); + + return [ + 'total_trips' => $totalTrips, + 'completed_trips' => $completedTrips, + 'cancelled_trips' => (int) ($tripStats->cancelled_trips ?? 0), + 'completion_rate_percent' => $totalTrips > 0 ? round(($completedTrips / $totalTrips) * 100, 1) : 0, + 'on_time_trips' => $onTimeTrips, + 'on_time_rate_percent' => $completedTrips > 0 ? round(($onTimeTrips / $completedTrips) * 100, 1) : 0, + 'total_scans' => $totalScans, + 'total_weight_kg' => $totalWeight, + 'avg_load_per_trip_kg' => $totalTrips > 0 ? (int) round($totalWeight / $totalTrips) : 0, + 'incident_count' => (int) ($eventStats->incident_count ?? 0), + 'stops_skipped_count' => (int) ($eventStats->stops_skipped_count ?? 0), + 'detours_count' => (int) ($eventStats->detours_count ?? 0), + 'breakdowns_count' => (int) ($eventStats->breakdowns_count ?? 0), + 'truck_full_count' => (int) ($eventStats->truck_full_count ?? 0), + 'total_events' => (int) ($eventStats->total_events ?? 0), + ]; + } + + private function resolveTargetTenant(Request $request): ?Tenant + { + if (optional($request->user())->isSuperAdmin() && $request->filled('tenant_id')) { + return Tenant::find($request->input('tenant_id')); + } + + return Tenancy::current(); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AdminUserController.php b/app/Http/Controllers/Api/V1/Admin/AdminUserController.php index a2a72f2..319e9c6 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminUserController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminUserController.php @@ -290,7 +290,7 @@ class AdminUserController extends ApiController return $this->ok([ 'user' => new UserDetailResource($user), - 'household' => new HouseholdResource($household->load(['head', 'barangay'])), + 'household' => new HouseholdResource($household->load(['head', 'barangay.serviceAreas'])), ], 'User successfully grouped into household'); } } diff --git a/app/Http/Controllers/Api/V1/Geo/BarangayController.php b/app/Http/Controllers/Api/V1/Geo/BarangayController.php index cd4ba28..cc02a79 100644 --- a/app/Http/Controllers/Api/V1/Geo/BarangayController.php +++ b/app/Http/Controllers/Api/V1/Geo/BarangayController.php @@ -21,7 +21,13 @@ class BarangayController extends ApiController $user = $request->user(); $barangays = Barangay::query() - ->when($user && $user->tenant_id, function ($q) use ($user) { + ->when($request->filled('tenant_id'), function ($q) use ($request) { + $tenant = Tenant::find($request->integer('tenant_id')); + if ($tenant) { + $q->where('city_municipality_id', $tenant->city_municipality_id); + } + }) + ->when(!$request->filled('tenant_id') && $user && $user->tenant_id, function ($q) use ($user) { $tenant = $user->tenant ?: Tenant::find($user->tenant_id); if ($tenant) { $q->where('city_municipality_id', $tenant->city_municipality_id); diff --git a/app/Http/Controllers/Api/V1/Geo/FetchBoundaryController.php b/app/Http/Controllers/Api/V1/Geo/FetchBoundaryController.php new file mode 100644 index 0000000..ba83061 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Geo/FetchBoundaryController.php @@ -0,0 +1,68 @@ +validate([ + 'q' => ['required', 'string', 'max:100'], + ]); + + $searchQuery = $request->string('q'); + $user = $request->user(); + $lguSuffix = ''; + + if ($user && $user->tenant_id) { + $tenant = $user->tenant ?: Tenant::find($user->tenant_id); + if ($tenant && $tenant->cityMunicipality) { + $lguSuffix = ', ' . $tenant->cityMunicipality->name; + } + } + + $fullQuery = $searchQuery . $lguSuffix . ', Philippines'; + + // Cache lookup results to protect Nominatim API rate limits + $cacheKey = 'geo_boundary_' . md5(strtolower($fullQuery)); + + $results = Cache::remember($cacheKey, now()->addDays(7), function () use ($fullQuery) { + try { + $response = Http::withHeaders([ + 'User-Agent' => 'Verde Waste Management App (admin@verde.local)', + ])->get('https://nominatim.openstreetmap.org/search', [ + 'q' => $fullQuery, + 'format' => 'json', + 'polygon_geojson' => 1, + 'limit' => 5, + ]); + + if ($response->successful()) { + $data = $response->json() ?? []; + return collect($data) + ->filter(fn ($item) => isset($item['geojson'])) + ->map(fn ($item) => [ + 'display_name' => $item['display_name'] ?? 'Unknown Location', + 'geojson' => $item['geojson'], + ]) + ->values() + ->all(); + } + } catch (\Exception $e) { + Log::error('Nominatim boundary fetch failed: ' . $e->getMessage()); + } + + return []; + }); + + return $this->ok($results); + } +} diff --git a/app/Http/Controllers/Api/V1/Geo/ServiceAreaController.php b/app/Http/Controllers/Api/V1/Geo/ServiceAreaController.php index 177d191..92df27f 100644 --- a/app/Http/Controllers/Api/V1/Geo/ServiceAreaController.php +++ b/app/Http/Controllers/Api/V1/Geo/ServiceAreaController.php @@ -12,6 +12,9 @@ use App\Models\ServiceArea; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use MatanYadaev\EloquentSpatial\Objects\LineString; +use MatanYadaev\EloquentSpatial\Objects\Point; +use MatanYadaev\EloquentSpatial\Objects\Polygon; class ServiceAreaController extends ApiController { @@ -23,6 +26,9 @@ class ServiceAreaController extends ApiController ]); $areas = ServiceArea::query() + ->when($request->filled('tenant_id'), function ($q) use ($request) { + $q->withoutGlobalScopes()->where('tenant_id', $request->integer('tenant_id')); + }) ->withCount('barangays') ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('q'), fn ($q) => $q->where('name', 'like', '%'.$request->string('q').'%')) @@ -38,6 +44,10 @@ class ServiceAreaController extends ApiController $barangayIds = $data['barangay_ids'] ?? []; unset($data['barangay_ids']); + if (! empty($data['boundary'])) { + $data['boundary'] = $this->buildPolygon($data['boundary']); + } + $area = DB::transaction(function () use ($data, $barangayIds) { $area = ServiceArea::create($data); if (! empty($barangayIds)) { @@ -67,6 +77,10 @@ class ServiceAreaController extends ApiController $barangayIds = $data['barangay_ids'] ?? null; unset($data['barangay_ids']); + if (array_key_exists('boundary', $data)) { + $data['boundary'] = ! empty($data['boundary']) ? $this->buildPolygon($data['boundary']) : null; + } + DB::transaction(function () use ($serviceArea, $data, $barangayIds) { $serviceArea->update($data); if ($barangayIds !== null) { @@ -80,6 +94,22 @@ class ServiceAreaController extends ApiController ); } + private function buildPolygon(array $points): Polygon + { + $ring = array_map( + fn ($p) => new Point((float) $p['lat'], (float) $p['lng'], 4326), + $points, + ); + + $first = $ring[0]; + $last = $ring[count($ring) - 1]; + if ($first->latitude !== $last->latitude || $first->longitude !== $last->longitude) { + $ring[] = new Point($first->latitude, $first->longitude, 4326); + } + + return new Polygon([new LineString($ring)], 4326); + } + public function destroy(ServiceArea $serviceArea): JsonResponse { $serviceArea->delete(); diff --git a/app/Http/Controllers/Api/V1/Me/MyHouseholdController.php b/app/Http/Controllers/Api/V1/Me/MyHouseholdController.php index 12050d3..bc90a90 100644 --- a/app/Http/Controllers/Api/V1/Me/MyHouseholdController.php +++ b/app/Http/Controllers/Api/V1/Me/MyHouseholdController.php @@ -23,7 +23,7 @@ class MyHouseholdController extends ApiController $tenantId = Tenancy::current()?->id; Log::info("DEBUG HOUSEHOLD show for User $userId, Tenant: $tenantId"); $household = Household::with([ - 'head', 'barangay.cityMunicipality.province.region', + 'head', 'barangay.cityMunicipality.province.region', 'barangay.serviceAreas', 'members.user', 'assignedDropOffPoint', ]) diff --git a/app/Http/Controllers/Api/V1/Payment/PaymentController.php b/app/Http/Controllers/Api/V1/Payment/PaymentController.php index ec0c768..a2eac94 100644 --- a/app/Http/Controllers/Api/V1/Payment/PaymentController.php +++ b/app/Http/Controllers/Api/V1/Payment/PaymentController.php @@ -191,7 +191,7 @@ class PaymentController extends ApiController } try { - $stores->sellToHousehold($store, $household, $qty, $price); + $stores->sell($store, $household, null, $qty, $price); } catch (\DomainException $e) { $payment->forceFill(['status' => Payment::STATUS_FAILED])->save(); \Log::warning('Fulfillment failed', ['payment' => $payment->uuid, 'error' => $e->getMessage()]); diff --git a/app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php b/app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php index e9e58f0..0305aac 100644 --- a/app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php +++ b/app/Http/Controllers/Api/V1/Qr/MyQrCodeController.php @@ -49,6 +49,8 @@ class MyQrCodeController extends ApiController $household = Household::where('head_user_id', $request->user()->id)->first(); if (! $household) { return $this->ok([ + 'balance' => 0, + 'household_name' => 'No Household', 'active' => 0, 'allocated' => 0, 'used' => 0, 'expired' => 0, 'low_balance' => false, ]); diff --git a/app/Http/Controllers/Api/V1/Resident/QrPurchaseController.php b/app/Http/Controllers/Api/V1/Resident/QrPurchaseController.php new file mode 100644 index 0000000..b59ae32 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Resident/QrPurchaseController.php @@ -0,0 +1,45 @@ +where('resident_id', $request->user()->id) + ->orderByDesc('created_at') + ->get(); + + return $this->ok($orders); + } + + public function store(Request $request): JsonResponse + { + $request->validate([ + 'store_id' => 'required|exists:partner_stores,uuid', + ]); + + $partnerStore = PartnerStore::where('uuid', $request->store_id)->firstOrFail(); + + // Enforce LGU constraint: Resident and Store must belong to the same tenant + if ($partnerStore->tenant_id !== $request->user()->tenant_id) { + return $this->fail('The selected store does not belong to your LGU.', null, 403); + } + + $order = QrPurchaseOrder::create([ + 'resident_id' => $request->user()->id, + 'store_id' => $partnerStore->owner_user_id, + 'tenant_id' => $request->user()->tenant_id, + 'amount' => config('qr.default_retail_price_per_code_centavos', 1000) / 100.0, + ]); + + return $this->created($order->load('store.storePartnerProfile'), 'QR Reservation created successfully'); + } +} diff --git a/app/Http/Controllers/Api/V1/Store/StorePortalController.php b/app/Http/Controllers/Api/V1/Store/StorePortalController.php index fe1444d..ecabe4b 100644 --- a/app/Http/Controllers/Api/V1/Store/StorePortalController.php +++ b/app/Http/Controllers/Api/V1/Store/StorePortalController.php @@ -412,12 +412,89 @@ class StorePortalController extends ApiController public function getSettings(): JsonResponse { $store = $this->getStore(); + $store->loadMissing('inventory'); $tenant = $store->tenant; return $this->ok([ 'retail_price_centavos' => (int) ($tenant->qr_retail_price_centavos ?? 1000), 'retail_price_pesos' => ($tenant->qr_retail_price_centavos ?? 1000) / 100, 'commission_rate_percent' => (int) $store->commission_rate_percent, + 'inventory_balance' => (int) ($store->inventory?->current_code_balance ?? 0), ]); } + + /** + * Get store profile details. + */ + public function profile(): JsonResponse + { + $store = $this->getStore(); + $store->load(['owner', 'barangay', 'tenant']); + + $barangayBoundary = null; + if ($store->barangay && $store->barangay->boundary) { + $rings = $store->barangay->boundary->getGeometries(); + $ring = $rings->first(); + if ($ring) { + $barangayBoundary = $ring->getGeometries() + ->map(fn ($p) => ['lat' => $p->latitude, 'lng' => $p->longitude]) + ->all(); + } + } + + return $this->ok([ + 'id' => $store->uuid, + 'business_name' => $store->business_name, + 'business_permit_number' => $store->business_permit_number, + 'address_line' => $store->address_line, + 'barangay_name' => $store->barangay?->name ?? 'N/A', + 'lgu_name' => $store->tenant?->name ?? 'N/A', + 'barangay_boundary' => $barangayBoundary, + 'commission_rate_percent' => (int) $store->commission_rate_percent, + 'status' => $store->status, + 'operating_hours' => $store->operating_hours, + 'owner' => [ + 'name' => $store->owner?->full_name, + 'email' => $store->owner?->email, + 'phone' => $store->owner?->phone, + ], + 'coordinates' => $store->coordinates ? [ + 'lat' => $store->coordinates->latitude, + 'lng' => $store->coordinates->longitude, + ] : null, + ]); + } + + /** + * Update store profile details. + */ + public function updateProfile(Request $request): JsonResponse + { + $store = $this->getStore(); + + $data = $request->validate([ + 'business_name' => 'required|string|max:255', + 'business_permit_number' => 'nullable|string|max:255', + 'address_line' => 'required|string|max:255', + 'operating_hours' => 'nullable|array', + 'operating_hours.open' => 'nullable|string', + 'operating_hours.close' => 'nullable|string', + 'owner_phone' => 'nullable|string|max:255', + ]); + + $store->update([ + 'business_name' => $data['business_name'], + 'business_permit_number' => $data['business_permit_number'] ?? null, + 'address_line' => $data['address_line'], + 'operating_hours' => $data['operating_hours'] ?? null, + ]); + + if ($store->owner && isset($data['owner_phone'])) { + $store->owner->update([ + 'phone' => $data['owner_phone'], + ]); + } + + return $this->ok(null, 'Profile updated successfully.'); + } } diff --git a/app/Http/Controllers/Api/V1/Store/StoreQrPurchaseController.php b/app/Http/Controllers/Api/V1/Store/StoreQrPurchaseController.php new file mode 100644 index 0000000..e4d7e68 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Store/StoreQrPurchaseController.php @@ -0,0 +1,63 @@ +where('store_id', $request->user()->id) + ->where('status', 'pending_payment') + ->orderBy('created_at') + ->get(); + + return $this->ok($orders); + } + + public function complete(Request $request, string $uuid): JsonResponse + { + $request->validate([ + 'scanned_qr_data' => 'required|string', + ]); + + $order = QrPurchaseOrder::where('uuid', $uuid) + ->where('store_id', $request->user()->id) + ->where('status', 'pending_payment') + ->firstOrFail(); + + // Find the scanned QR code + $qrCode = QrCode::where('serial', $request->scanned_qr_data)->first(); + + if (!$qrCode) { + return $this->fail('Invalid QR Code. Not found in the system.', null, 404); + } + + if (! $qrCode->status->equals(\App\States\QrCode\Allocated::class) && ! $qrCode->status->equals(\App\States\QrCode\Unassigned::class)) { + return $this->fail('This QR Code cannot be assigned (already used or active).', null, 422); + } + + DB::transaction(function () use ($order, $qrCode) { + $order->update([ + 'status' => 'completed', + 'completed_at' => now(), + 'qr_code_id' => $qrCode->id, + ]); + + $qrCode->status->transitionTo(\App\States\QrCode\Active::class); + $qrCode->update([ + 'assigned_to_user_id' => $order->resident_id, + 'activated_at' => now(), + ]); + }); + + return $this->ok($order->fresh(), 'QR Purchase completed and assigned successfully.'); + } +} diff --git a/app/Http/Controllers/Store/StoreDashboardController.php b/app/Http/Controllers/Store/StoreDashboardController.php index 3740f1f..d8f10c8 100644 --- a/app/Http/Controllers/Store/StoreDashboardController.php +++ b/app/Http/Controllers/Store/StoreDashboardController.php @@ -25,4 +25,14 @@ class StoreDashboardController extends Controller { return view('store.financials'); } + + public function qrPurchases() + { + return view('store.qr-purchases'); + } + + public function profile() + { + return view('store.profile'); + } } diff --git a/app/Http/Requests/Geo/StoreServiceAreaRequest.php b/app/Http/Requests/Geo/StoreServiceAreaRequest.php index ecade45..5ff39a5 100644 --- a/app/Http/Requests/Geo/StoreServiceAreaRequest.php +++ b/app/Http/Requests/Geo/StoreServiceAreaRequest.php @@ -22,6 +22,9 @@ class StoreServiceAreaRequest extends FormRequest 'description' => ['nullable', 'string', 'max:1000'], 'barangay_ids' => ['nullable', 'array'], 'barangay_ids.*' => ['integer', 'exists:barangays,id'], + 'boundary' => ['nullable', 'array'], + 'boundary.*.lat' => ['required', 'numeric'], + 'boundary.*.lng' => ['required', 'numeric'], ]; } } diff --git a/app/Http/Requests/Geo/UpdateServiceAreaRequest.php b/app/Http/Requests/Geo/UpdateServiceAreaRequest.php index 2993b0a..43eb346 100644 --- a/app/Http/Requests/Geo/UpdateServiceAreaRequest.php +++ b/app/Http/Requests/Geo/UpdateServiceAreaRequest.php @@ -30,6 +30,9 @@ class UpdateServiceAreaRequest extends FormRequest 'description' => ['sometimes', 'nullable', 'string', 'max:1000'], 'barangay_ids' => ['nullable', 'array'], 'barangay_ids.*' => ['integer', 'exists:barangays,id'], + 'boundary' => ['nullable', 'array'], + 'boundary.*.lat' => ['required', 'numeric'], + 'boundary.*.lng' => ['required', 'numeric'], ]; } } diff --git a/app/Http/Resources/HouseholdResource.php b/app/Http/Resources/HouseholdResource.php index 71fb2c5..03c12e7 100644 --- a/app/Http/Resources/HouseholdResource.php +++ b/app/Http/Resources/HouseholdResource.php @@ -20,6 +20,7 @@ class HouseholdResource extends JsonResource 'lng' => $this->coordinates->longitude, ] : null, 'barangay' => BarangayResource::make($this->whenLoaded('barangay')), + 'service_area' => $this->barangay?->serviceAreas->first()?->name ?? 'None', 'head' => UserResource::make($this->whenLoaded('head')), 'verification_status' => $this->verification_status, 'verified_at' => $this->verified_at?->toIso8601String(), diff --git a/app/Http/Resources/ServiceAreaResource.php b/app/Http/Resources/ServiceAreaResource.php index fc8657d..53e336c 100644 --- a/app/Http/Resources/ServiceAreaResource.php +++ b/app/Http/Resources/ServiceAreaResource.php @@ -9,6 +9,17 @@ class ServiceAreaResource extends JsonResource { public function toArray(Request $request): array { + $boundaryPoints = null; + if ($this->boundary) { + $rings = $this->boundary->getGeometries(); + $ring = $rings->first(); + if ($ring) { + $boundaryPoints = $ring->getGeometries() + ->map(fn ($p) => ['lat' => $p->latitude, 'lng' => $p->longitude]) + ->all(); + } + } + return [ 'id' => $this->uuid, 'db_id' => $this->id, @@ -16,6 +27,7 @@ class ServiceAreaResource extends JsonResource 'code' => $this->code, 'status' => $this->status, 'description' => $this->description, + 'boundary' => $boundaryPoints, 'barangay_count' => $this->whenCounted('barangays'), 'barangays' => BarangayResource::collection($this->whenLoaded('barangays')), 'created_at' => $this->created_at?->toIso8601String(), diff --git a/app/Models/QrPurchaseOrder.php b/app/Models/QrPurchaseOrder.php new file mode 100644 index 0000000..3d003f5 --- /dev/null +++ b/app/Models/QrPurchaseOrder.php @@ -0,0 +1,68 @@ + 'decimal:2', + 'completed_at' => 'datetime', + ]; + } + + protected static function booted(): void + { + static::creating(function (self $order): void { + if (empty($order->uuid)) { + $order->uuid = (string) Str::uuid(); + } + if (empty($order->reservation_code)) { + // Generate a 6-character alphanumeric code + $order->reservation_code = strtoupper(Str::random(6)); + } + }); + } + + public function resident(): BelongsTo + { + return $this->belongsTo(User::class, 'resident_id'); + } + + public function store(): BelongsTo + { + return $this->belongsTo(User::class, 'store_id'); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + // Optional: relation to QrCode if the QrCode model exists + // public function qrCode(): BelongsTo + // { + // return $this->belongsTo(QrCode::class); + // } +} diff --git a/app/Models/ServiceArea.php b/app/Models/ServiceArea.php index 11b5513..0f55f09 100644 --- a/app/Models/ServiceArea.php +++ b/app/Models/ServiceArea.php @@ -9,6 +9,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; +use MatanYadaev\EloquentSpatial\Objects\Polygon; + class ServiceArea extends Model { use HasFactory, HasTenant, SoftDeletes; @@ -24,8 +26,16 @@ class ServiceArea extends Model 'code', 'status', 'description', + 'boundary', ]; + protected function casts(): array + { + return [ + 'boundary' => Polygon::class, + ]; + } + public function getRouteKeyName(): string { return 'uuid'; diff --git a/config/qr.php b/config/qr.php index ea5a2ad..01c40cd 100644 --- a/config/qr.php +++ b/config/qr.php @@ -10,7 +10,7 @@ return [ | When a household's active code count drops to or below this number, | a QrBalanceLow event fires (notification listeners hook off it). */ - 'low_balance_threshold' => (int) env('QR_LOW_BALANCE_THRESHOLD', 5), + 'low_balance_threshold' => (int) env('QR_LOW_BALANCE_THRESHOLD', 2), /* | Default expiry for a generated batch (months from creation). Null diff --git a/database/migrations/2026_07_03_143000_create_qr_purchase_orders_table.php b/database/migrations/2026_07_03_143000_create_qr_purchase_orders_table.php new file mode 100644 index 0000000..b982615 --- /dev/null +++ b/database/migrations/2026_07_03_143000_create_qr_purchase_orders_table.php @@ -0,0 +1,37 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('resident_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('store_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('tenant_id')->constrained()->onDelete('cascade'); + $table->string('reservation_code')->unique(); + $table->enum('status', ['pending_payment', 'completed', 'cancelled'])->default('pending_payment'); + $table->decimal('amount', 8, 2)->default(50.00); + $table->timestamp('completed_at')->nullable(); + $table->foreignId('qr_code_id')->nullable()->constrained('qr_codes')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('qr_purchase_orders'); + } +}; diff --git a/database/migrations/2026_07_04_040000_add_boundary_to_service_areas_table.php b/database/migrations/2026_07_04_040000_add_boundary_to_service_areas_table.php new file mode 100644 index 0000000..004b572 --- /dev/null +++ b/database/migrations/2026_07_04_040000_add_boundary_to_service_areas_table.php @@ -0,0 +1,22 @@ +geometry('boundary', subtype: 'polygon', srid: 4326)->nullable()->after('description'); + }); + } + + public function down(): void + { + Schema::table('service_areas', function (Blueprint $table) { + $table->dropColumn('boundary'); + }); + } +}; diff --git a/geofence-fetch.md b/geofence-fetch.md new file mode 100644 index 0000000..06fe760 --- /dev/null +++ b/geofence-fetch.md @@ -0,0 +1,48 @@ +# Plan - Geofence Fetching and Editing + +## Goal +Implement a dynamic geofence fetching API (via OpenStreetMap Nominatim proxy) and integrate Leaflet-Geoman in the Admin views to allow creating, editing, and saving geofence boundaries for Tenants (LGUs), Service Areas, and Barangays. + +--- + +## Proposed Changes + +### 1. Database Layer +- **Migration**: Create a migration to add a `boundary` polygon column (spatial `Polygon` or `MultiPolygon`) to the `service_areas` table: + - File: `[NEW] database/migrations/[timestamp]_add_boundary_to_service_areas_table.php` +- **Model**: Add the spatial cast to `App\Models\ServiceArea`: + - Property: `$casts = ['boundary' => PolygonCast::class]` + +### 2. Backend API Layer +- **Geo Controller**: Add `/api/v1/geo/fetch-boundary` endpoint. + - Suffixes queries with the active tenant's City/Municipality name (e.g., `", Quezon City, Philippines"`). + - Fetches from Nominatim, cleans the GeoJSON geometry, and returns standard geo json format. + - Caches the responses using Laravel Cache. + - Controller: `[NEW] app/Http/Controllers/Api/V1/Geo/FetchBoundaryController.php` + - Routes: Add route under `routes/api.php` +- **Service Area API**: Update `ServiceAreaController` (and request validation) to support storing/updating `boundary`. +- **Tenant API**: Update Tenant controller/requests to allow updating LGU `boundary_polygon`. +- **Barangay API**: Ensure admin can update a Barangay's boundary. + +### 3. Frontend UI Integration +- **Leaflet-Geoman Library**: Include the library (JS and CSS) via UNPKG CDN in the admin layout: + - File: `resources/views/admin/layouts/app.blade.php` +- **Service Areas Dashboard**: + - Integrate a search/fetch text input and button next to the map. + - Enable Geoman toolbar controls on the map (`pm:draw`, `pm:edit`, `pm:drag`, `pm:delete`). + - Wire up form submission to send the edited polygon coordinates as GeoJSON array to the backend. +- **Other Geofenced Pages**: Integrate Geoman editing capabilities for LGU boundary management and Barangay maps. + +--- + +## Verification Plan + +### Automated Tests +- Create `tests/Feature/Api/V1/Geo/FetchBoundaryTest.php` to verify: + - Fetch boundary successfully calls Nominatim proxy. + - Query parameters automatically include LGU suffix context. + - GeoJSON response formats correctly. + +### Manual Verification +- Navigate to **Service Areas** page, click "Edit", fetch a boundary (e.g., "Baesa"), reshape the geofence, and save. +- Verify the saved geofence is retrieved and displayed on reload. diff --git a/qr-purchase-flow.md b/qr-purchase-flow.md new file mode 100644 index 0000000..f476bd7 --- /dev/null +++ b/qr-purchase-flow.md @@ -0,0 +1,70 @@ +# QR Purchase Flow + +This plan outlines the implementation for the "Reservation Ticket" (Option A) approach for QR Code purchasing, constrained to the user's Local Government Unit (LGU). + +## Goal +Residents can reserve a physical QR code at a local store via the mobile app. The store confirms cash payment via the store web portal and hands over/links the physical QR sticker to the resident's account. + +## Constraint +Residents can only choose stores that belong to their LGU (`tenant_id`). + +--- + +## Task Breakdown + +### 1. Database Schema +- **Create `QrPurchaseOrder` Model & Migration** + - `id`, `uuid` + - `resident_id` (foreign key to users) + - `store_id` (foreign key to users where role=store_partner) + - `tenant_id` (foreign key to tenants) + - `reservation_code` (unique string, e.g., "QR-123456") + - `status` (enum: 'pending_payment', 'completed', 'cancelled') + - `amount` (decimal) + - `completed_at` (timestamp, nullable) + - `qr_code_id` (foreign key to the generated/assigned QR code, nullable) + - `timestamps` + +### 2. Backend API (Resident App) +- **GET `/api/v1/resident/stores` (Update)** + - Filter stores by the resident's `tenant_id`. + - Include store address, business name, and stock availability (if tracked). +- **POST `/api/v1/resident/qr-purchases`** + - Creates a `QrPurchaseOrder` with status `pending_payment`. + - Generates a `reservation_code`. +- **GET `/api/v1/resident/qr-purchases`** + - List the resident's purchase orders (to show their pending reservation code). + +### 3. Backend API (Store Portal) +- **GET `/api/v1/store/qr-purchases/pending`** + - List all `pending_payment` orders for the logged-in store. +- **POST `/api/v1/store/qr-purchases/{uuid}/complete`** + - Accepts a `scanned_qr_data` payload. + - Verifies the QR is valid and unassigned. + - Marks the order as `completed`. + - Assigns the QR code to the resident's household/profile. + +### 4. Resident App Integration +- **Stores Screen:** Show map/list of LGU stores. +- **Purchase Flow:** "Buy QR" button -> creates reservation -> shows "Reservation Ticket" screen with large code. + +### 5. Store Web Portal Integration +- **Pending Orders Tab:** Show a queue of people coming to buy QRs. +- **Completion Flow:** Button to "Confirm Payment & Scan QR". Opens camera or input box to scan the physical sticker and link it. + +## Verification Checklist +- [ ] Resident can only see stores in their LGU. +- [ ] Resident can create a reservation and see the code. +- [ ] Store sees the reservation on their dashboard. +- [ ] Store can mark it paid and link a physical QR. +- [ ] Physical QR is successfully linked to the Resident. + +--- + +## Agent Assignments +- **Backend Specialist:** Create the migrations, models, and API endpoints. +- **Frontend Specialist:** Update the Store Web Portal UI. +- **Mobile Developer:** Update the Resident App UI (Flutter/Dart). + +> [!IMPORTANT] +> User Review Required: Does the store charge a fixed price, or is it determined dynamically? I will assume a fixed price (e.g., 50 PHP) for now, but please confirm if the price varies. diff --git a/resources/views/admin/barangays.blade.php b/resources/views/admin/barangays.blade.php index 20563f1..3bb2c8b 100644 --- a/resources/views/admin/barangays.blade.php +++ b/resources/views/admin/barangays.blade.php @@ -116,10 +116,16 @@ {{-- Leaflet Map (7 cols) --}}
- Select a City/Municipality to load its boundaries. Use the drawing toolbar to draw the Barangay polygon boundary inside it. + Select a City/Municipality to load its boundaries. Use the search box to fetch the geofence or use the drawing toolbar.
Verify resident households and view assigned drop-offs.
-Total QR codes sold across partner stores
+Daily collection, route performance, store sales, compliance exports.
+Daily collection, route performance, store sales, compliance exports, and team analytics.
| Trip # | Date | Status | +Scans | Load (kg) | Duration | +
|---|---|---|---|---|---|
| Loading… | |||||
| Time | Trip | Event | Notes |
|---|---|---|---|
| Loading… | |||
Loading pending orders...
+When residents reserve a QR code at your store, it will appear here.
+Available stock: —
Total Amount
@@ -143,11 +144,18 @@ }; // Fetch Settings + let stockBalance = Infinity; + const fetchSettings = async () => { const res = await window.Verde.apiFetch('/api/v1/store/settings'); const data = res.body?.data; if (data) { retailPrice = data.retail_price_centavos; + stockBalance = data.inventory_balance ?? Infinity; + const input = document.getElementById('sale-qty'); + input.max = stockBalance; + document.getElementById('stock-badge').textContent = stockBalance; + updateButtons(); updateTotal(); } }; @@ -160,13 +168,41 @@ }).format(total); }; - window.adjustQty = (delta) => { - const input = document.getElementById('sale-qty'); - input.value = Math.max(1, parseInt(input.value) + delta); - updateTotal(); + const updateButtons = () => { + const qty = parseInt(document.getElementById('sale-qty').value) || 1; + const btnPlus = document.getElementById('btn-plus'); + const btnMinus = document.getElementById('btn-minus'); + const atMax = qty >= stockBalance; + const atMin = qty <= 1; + btnPlus.disabled = atMax; + btnPlus.classList.toggle('opacity-30', atMax); + btnPlus.classList.toggle('cursor-not-allowed', atMax); + btnMinus.disabled = atMin; + btnMinus.classList.toggle('opacity-30', atMin); + btnMinus.classList.toggle('cursor-not-allowed', atMin); + // Update stock badge color + const badge = document.getElementById('stock-badge'); + badge.classList.toggle('text-red-500', atMax); + badge.classList.toggle('text-neutral-600', !atMax); }; - document.getElementById('sale-qty').addEventListener('input', updateTotal); + window.adjustQty = (delta) => { + const input = document.getElementById('sale-qty'); + const newVal = Math.min(stockBalance, Math.max(1, parseInt(input.value) + delta)); + input.value = newVal; + updateTotal(); + updateButtons(); + }; + + document.getElementById('sale-qty').addEventListener('input', () => { + const input = document.getElementById('sale-qty'); + if (stockBalance !== Infinity && parseInt(input.value) > stockBalance) { + input.value = stockBalance; + } + if (parseInt(input.value) < 1 || !input.value) input.value = 1; + updateTotal(); + updateButtons(); + }); let searchMode = 'household'; let tomSelectInstance = null; diff --git a/routes/api.php b/routes/api.php index a647e98..7d7840d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\Admin\AdminPartnerStoreController; use App\Http\Controllers\Api\V1\Admin\AdminQrBatchController; use App\Http\Controllers\Api\V1\Admin\AdminQrCodeController; use App\Http\Controllers\Api\V1\Admin\AdminReportController; +use App\Http\Controllers\Api\V1\Admin\AdminTeamReportController; use App\Http\Controllers\Api\V1\Admin\AdminRouteController; use App\Http\Controllers\Api\V1\Admin\AdminTeamController; use App\Http\Controllers\Api\V1\Admin\AdminTripController; @@ -33,6 +34,7 @@ use App\Http\Controllers\Api\V1\Driver\DriverTripController; use App\Http\Controllers\Api\V1\Driver\TripDetourController; use App\Http\Controllers\Api\V1\DropOff\DropOffPointController; use App\Http\Controllers\Api\V1\Geo\BarangayController; +use App\Http\Controllers\Api\V1\Geo\FetchBoundaryController; use App\Http\Controllers\Api\V1\Geo\CityMunicipalityController; use App\Http\Controllers\Api\V1\Geo\ProvinceController; use App\Http\Controllers\Api\V1\Geo\RegionController; @@ -107,6 +109,10 @@ Route::middleware('auth:sanctum')->prefix('me')->name('api.v1.me.')->group(funct Route::get('/household', [MyHouseholdController::class, 'show'])->name('household.show'); + // QR Purchases (Resident) + Route::get('/qr-purchases', [\App\Http\Controllers\Api\V1\Resident\QrPurchaseController::class, 'index'])->name('qr-purchases.index'); + Route::post('/qr-purchases', [\App\Http\Controllers\Api\V1\Resident\QrPurchaseController::class, 'store'])->name('qr-purchases.store'); + Route::get('/collections', [MyCollectionsController::class, 'index'])->name('collections'); Route::get('/upcoming-pickups', [UpcomingPickupsController::class, 'index'])->name('upcoming-pickups'); @@ -147,6 +153,10 @@ Route::middleware(['auth:sanctum', 'role:driver'])->prefix('driver')->name('api. // Store Portal Route::middleware(['auth:sanctum', 'role:store_partner'])->prefix('store')->name('api.v1.store.')->group(function () { Route::get('/dashboard', [StorePortalController::class, 'dashboard'])->name('dashboard'); + + // QR Purchases (Fulfillment) + Route::get('/qr-purchases/pending', [\App\Http\Controllers\Api\V1\Store\StoreQrPurchaseController::class, 'pending'])->name('qr-purchases.pending'); + Route::post('/qr-purchases/{uuid}/complete', [\App\Http\Controllers\Api\V1\Store\StoreQrPurchaseController::class, 'complete'])->name('qr-purchases.complete'); Route::get('/analytics', [StorePortalController::class, 'analytics'])->name('analytics'); Route::post('/sales', [StorePortalController::class, 'recordSale'])->name('sales.store'); Route::get('/sales', [StorePortalController::class, 'salesHistory'])->name('sales.index'); @@ -154,6 +164,8 @@ Route::middleware(['auth:sanctum', 'role:store_partner'])->prefix('store')->name Route::get('/households', [StorePortalController::class, 'searchHouseholds'])->name('households.search'); Route::get('/users', [StorePortalController::class, 'searchResidents'])->name('users.search'); Route::get('/settings', [StorePortalController::class, 'getSettings'])->name('settings'); + Route::get('/profile', [StorePortalController::class, 'profile'])->name('profile'); + Route::put('/profile', [StorePortalController::class, 'updateProfile'])->name('profile.update'); }); // Store Portal signed routes @@ -326,6 +338,7 @@ Route::prefix('admin/partner-stores') ->middleware(['auth:sanctum', 'role:admin']) ->group(function () { Route::get('/', [AdminPartnerStoreController::class, 'index'])->name('index'); + Route::get('/overall-chart', [AdminPartnerStoreController::class, 'overallDistributionChart'])->name('overall-chart'); Route::post('/', [AdminPartnerStoreController::class, 'store'])->name('store'); Route::patch('/{store}', [AdminPartnerStoreController::class, 'update'])->name('update'); Route::post('/{store}/issue-inventory', [AdminPartnerStoreController::class, 'issueInventory'])->name('issue-inventory'); @@ -334,6 +347,7 @@ Route::prefix('admin/partner-stores') Route::get('/{store}/sales-history', [AdminPartnerStoreController::class, 'salesHistory'])->name('sales-history'); Route::get('/{store}/inventory-history', [AdminPartnerStoreController::class, 'inventoryLog'])->name('inventory-history'); Route::get('/{store}/analytics', [AdminPartnerStoreController::class, 'analytics'])->name('analytics'); + Route::get('/{store}/distribution-chart', [AdminPartnerStoreController::class, 'distributionChart'])->name('distribution-chart'); Route::post('/{store}/report-issue', [AdminPartnerStoreController::class, 'reportIssue'])->name('report-issue'); Route::post('/{store}/adjust-stock', [AdminPartnerStoreController::class, 'adjustInventory'])->name('adjust-stock'); Route::post('/{store}/settle', [AdminPartnerStoreController::class, 'recordPayment'])->name('settle'); @@ -351,6 +365,9 @@ Route::prefix('admin/reports') Route::get('/store-sales', [AdminReportController::class, 'storeSales'])->name('store-sales'); Route::get('/compliance.csv', [AdminReportController::class, 'complianceCsv'])->name('compliance-csv'); Route::post('/rebuild', [AdminReportController::class, 'rebuild'])->name('rebuild'); + // Team analytics — leaderboard MUST be before {team:uuid} to avoid route conflict + Route::get('/teams/leaderboard', [AdminTeamReportController::class, 'leaderboard'])->name('teams.leaderboard'); + Route::get('/teams/{team:uuid}/profile', [AdminTeamReportController::class, 'teamProfile'])->name('teams.profile'); }); Route::prefix('admin/trips') @@ -418,6 +435,7 @@ Route::prefix('geo')->name('api.v1.geo.')->group(function () { Route::get('/cities', CityMunicipalityController::class)->name('cities.index'); Route::get('/barangays', BarangayController::class)->name('barangays.index'); Route::post('/resolve', ResolveLocationController::class)->name('resolve'); + Route::get('/fetch-boundary', FetchBoundaryController::class)->name('fetch-boundary')->middleware('auth:sanctum'); }); Route::prefix('service-areas') diff --git a/routes/web.php b/routes/web.php index 5c7b563..f990393 100644 --- a/routes/web.php +++ b/routes/web.php @@ -53,5 +53,7 @@ Route::prefix('store')->name('store.')->group(function () { Route::get('/dashboard', [StoreDashboardController::class, 'index'])->name('dashboard'); Route::get('/sales', [StoreDashboardController::class, 'sales'])->name('sales'); Route::get('/inventory', [StoreDashboardController::class, 'inventory'])->name('inventory'); + Route::get('/qr-purchases', [StoreDashboardController::class, 'qrPurchases'])->name('qr-purchases'); Route::get('/financials', [StoreDashboardController::class, 'financials'])->name('financials'); + Route::get('/profile', [StoreDashboardController::class, 'profile'])->name('profile'); }); diff --git a/team-reports.md b/team-reports.md new file mode 100644 index 0000000..c19214d --- /dev/null +++ b/team-reports.md @@ -0,0 +1,428 @@ +# Team Reports Analytics — Implementation Plan + +> **Feature:** Add a "Teams" tab to `/admin/reports` with a Leaderboard + Team Profile drill-down, charts, incident logs, scan trends, and LGU filtering (Option D). +> **Project Type:** WEB +> **Plan File:** `team-reports.md` +> **Created:** 2026-07-03 + +--- + +## Overview + +Verde currently has four report tabs: Daily Collection, Trip Performance, Store Sales, and Compliance Export. +This plan adds a fifth tab — **Teams** — that gives admins a ranked overview of all collection teams and lets them drill into any team's full analytics history. + +**Architecture:** +``` +Teams Tab (date-filtered, LGU-aware) + └── Leaderboard Table (all teams ranked by KPIs) + └── [View Report] drawer/slide-in panel + ├── KPI Hero Cards + ├── Daily Scan Line/Bar Chart (last 30 days) + ├── Weekly Scan Bar Chart (last 12 weeks) + ├── Trip History Table (paginated) + └── Incident / Event Log (ALL 14 event types) +``` + +**No new migrations needed** — all data lives in `trips`, `collection_logs`, and `trip_timeline_events`. + +--- + +## Success Criteria + +- [ ] "Teams" tab appears in the Reports page tab bar +- [ ] Leaderboard loads all teams for the current LGU (or all LGUs for super-admin) +- [ ] Super-admin can filter by specific LGU OR view all LGUs combined +- [ ] Each team row shows: Rank, Name, Driver, Total Trips, Completion %, Total Scans, Avg Load, Incidents +- [ ] Clicking "View Report" opens a slide-in drawer +- [ ] Drawer shows KPI cards, two Chart.js charts, trip history table, full event log +- [ ] Event log includes ALL TripTimelineEvent types (all 14) +- [ ] All data is date-range filtered (default: last 30 days) +- [ ] API accepts `tenant_id` param (super-admin) or uses current tenant context + +--- + +## Tech Stack + +| Layer | Technology | Rationale | +|-------|-----------|-----------| +| Backend | Laravel PHP | Matches existing codebase | +| API | New `AdminTeamReportController` | Consistent with existing pattern | +| Frontend | Blade + vanilla JS | Consistent with existing reports page | +| Charts | Chart.js 4.4.0 (CDN) | Lightweight, no build step needed | +| Data Sources | `trips`, `collection_logs`, `trip_timeline_events` | Already exists, no new migrations | + +--- + +## File Structure + +``` +app/Http/Controllers/Api/V1/Admin/ + AdminTeamReportController.php [NEW] +routes/ + api.php [MODIFY — add 2 routes] +resources/views/admin/ + reports.blade.php [MODIFY — add Teams tab + panel + chart JS] +``` + +**Total: 1 new file, 2 modified files.** + +--- + +## Task Breakdown + +--- + +### PHASE 1 — Backend API + +#### Task 1.1 — `leaderboard` endpoint +**Agent:** `backend-specialist` | **Skill:** `api-patterns` | **Priority:** P0 + +**File:** `[NEW] app/Http/Controllers/Api/V1/Admin/AdminTeamReportController.php` + +**Endpoint:** `GET /api/v1/admin/reports/teams/leaderboard` + +**Query params:** + +| Param | Type | Default | Description | +|-------|------|---------|-------------| +| `from` | date | -30 days | Range start | +| `to` | date | today | Range end | +| `tenant_id` | int | null | Super-admin: specific LGU, omit for all | +| `per_page` | int | 50 | Pagination | + +**Response shape (per team row):** +```json +{ + "team_uuid": "...", + "team_name": "Team Alpha", + "driver_name": "Juan Dela Cruz", + "status": "active", + "total_trips": 24, + "completed_trips": 22, + "cancelled_trips": 1, + "completion_rate_percent": 91.7, + "total_scans": 1204, + "total_weight_kg": 4320, + "avg_load_per_trip_kg": 180, + "incident_count": 2, + "last_trip_date": "2026-07-02" +} +``` + +**Logic (raw SQL aggregates per team):** +- `total_trips` = `trips.count() whereBetween scheduled_date` +- `completed_trips` = status IN `[completed, handed_off]` +- `total_scans` = `collection_logs.count() whereBetween scanned_at` +- `total_weight_kg` = `trips.sum(total_load_kg)` +- `incident_count` = `trip_timeline_events.count()` for all event types in team's trips +- Sorted by `total_scans DESC` + +For super-admin with `tenant_id = null`: query ALL tenants (bypass `HasTenant` scope). + +**INPUT:** Date range + optional tenant_id +**OUTPUT:** Paginated leaderboard array +**VERIFY:** `GET /api/v1/admin/reports/teams/leaderboard?from=2026-06-01&to=2026-07-03` returns 200 with team rows + +--- + +#### Task 1.2 — `teamProfile` endpoint +**Agent:** `backend-specialist` | **Skill:** `api-patterns` | **Priority:** P0 + +**Endpoint:** `GET /api/v1/admin/reports/teams/{team:uuid}/profile` + +**Query params:** `from`, `to`, `tenant_id` + +**Response shape:** +```json +{ + "team": { + "uuid": "...", "name": "Team Alpha", + "driver": { "name": "Juan" }, + "scanner": { "name": "Maria" }, + "truck": { "plate": "ABC-123" }, + "status": "active" + }, + "kpis": { + "total_trips": 24, + "completed_trips": 22, + "cancelled_trips": 1, + "completion_rate_percent": 91.7, + "total_scans": 1204, + "total_weight_kg": 4320, + "avg_load_per_trip_kg": 180, + "on_time_trips": 20, + "on_time_rate_percent": 83.3, + "incident_count": 5, + "stops_skipped_count": 3, + "detours_count": 2, + "breakdowns_count": 1 + }, + "daily_scans": [ + { "date": "2026-06-01", "scans": 48 } + ], + "weekly_scans": [ + { "week_start": "2026-06-01", "scans": 312 } + ], + "trips": { + "data": [ + { + "trip_number": "TRIP-20260601-001", + "scheduled_date": "2026-06-01", + "status": "completed", + "scans_count": 52, + "total_load_kg": 185, + "duration_minutes": 143 + } + ], + "meta": { "page": 1, "per_page": 10, "total": 24, "last_page": 3 } + }, + "events": [ + { + "event_type": "incident_reported", + "event_at": "2026-06-15T10:30:00", + "trip_number": "TRIP-20260615-001", + "notes": "Truck breakdown near Barangay 5", + "metadata": {} + } + ] +} +``` + +**On-time logic:** Trip is on-time if `actual_start_time <= scheduled_start_time + 30 min` + +**Events:** Include ALL 14 `TripTimelineEvent` types: +`trip_started`, `arrived_at_stop`, `collection_started`, `qr_scanned`, `collection_completed`, +`departed_stop`, `stop_skipped`, `truck_full_warning`, `arrived_at_dumpsite`, `load_released`, +`departed_dumpsite`, `trip_completed`, `incident_reported`, `breakdown`, +`detour_to_dumpsite`, `resumed_from_detour`, `continuation_created` + +Events: limit to last 200 per range, ordered by `event_at DESC`. + +**daily_scans:** GROUP BY DATE(scanned_at) from collection_logs for this team's trips. +**weekly_scans:** GROUP BY YEARWEEK(scanned_at) for last 12 weeks. + +**INPUT:** Team UUID + date range +**OUTPUT:** Full analytics profile +**VERIFY:** Profile endpoint returns all 6 top-level keys: `team`, `kpis`, `daily_scans`, `weekly_scans`, `trips`, `events` + +--- + +#### Task 1.3 — Register routes +**Agent:** `backend-specialist` | **Priority:** P0 (depends on T1.1, T1.2) + +**File:** `[MODIFY] routes/api.php` + +Add inside the existing admin middleware group (near existing reports routes): +```php +Route::get('reports/teams/leaderboard', [AdminTeamReportController::class, 'leaderboard']); +Route::get('reports/teams/{team:uuid}/profile', [AdminTeamReportController::class, 'teamProfile']); +``` + +**INPUT:** T1.1 + T1.2 controllers complete +**OUTPUT:** Routes registered +**VERIFY:** `php artisan route:list | findstr team` shows both routes + +--- + +### PHASE 2 — Frontend UI + +#### Task 2.1 — Add "Teams" tab + section skeleton +**Agent:** `frontend-specialist` | **Skill:** `frontend-design` | **Priority:** P1 + +**File:** `[MODIFY] resources/views/admin/reports.blade.php` + +Changes: +1. Add `['key' => 'teams', 'label' => 'Teams']` to the tab foreach array +2. Add empty `