16 KiB
Team Reports Analytics — Implementation Plan
Feature: Add a "Teams" tab to
/admin/reportswith a Leaderboard + Team Profile drill-down, charts, incident logs, scan trends, and LGU filtering (Option D). Project Type: WEB Plan File:team-reports.mdCreated: 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_idparam (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):
{
"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_datecompleted_trips= status IN[completed, handed_off]total_scans=collection_logs.count() whereBetween scanned_attotal_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:
{
"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):
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:
- Add
['key' => 'teams', 'label' => 'Teams']to the tab foreach array - Add empty
<section data-panel="teams" class="report-panel hidden">after the compliance section
INPUT: Existing tab nav foreach OUTPUT: "Teams" tab button appears in nav VERIFY: Clicking "Teams" hides other panels, shows teams panel (even if empty)
Task 2.2 — Leaderboard panel (filter bar + ranked table)
Agent: frontend-specialist | Skill: frontend-design | Priority: P1 (depends on T2.1)
UI inside data-panel="teams" section:
Filter bar:
- From/To date inputs (ids:
teams-from,teams-to) - Super-admin LGU selector with "All LGUs" option (id:
teams-lgu, populated fromlguData) - Load button (id:
teams-load)
Summary KPI cards row (4 cards):
- Total Teams, Total Trips, Total Scans, Total Incidents
Leaderboard table columns:
Rank | Team | Driver | Status | Trips | Completion % | Total Scans | Avg Load (kg) | Incidents | [View Report]
- Rank =
(page - 1) * perPage + index + 1 - "View Report" button has
data-team-uuidanddata-team-nameattributes - Status badge: active=green, inactive=gray, standby=amber
JS function: async function loadTeams(page = 1)
INPUT: Leaderboard API (T1.1) OUTPUT: Ranked paginated table VERIFY: Table loads, ranks increment, View Report button exists per row
Task 2.3 — Team Profile slide-in drawer
Agent: frontend-specialist | Skill: frontend-design | Priority: P1 (depends on T2.2)
Drawer structure (fixed right panel, max-w-3xl, slides in via translate-x-full → translate-x-0):
Sections inside scrollable drawer body:
- Header: Team name, driver/scanner/truck info, date range badge, Close button
- KPI cards grid (2×4): Total Trips, Completed, On-Time %, Avg Load, Total Scans, Total Weight, Incidents, Detours
- Charts row (2 side by side):
- Daily Scans — bar chart (last 30 days, x=dates)
- Weekly Scans — bar chart (last 12 weeks, x=week start dates)
- Trip History table: Trip #, Date, Status, Scans, Load, Duration (paginated, 10/page)
- Event Log table: Time, Trip #, Event (badge), Notes
Chart.js CDN added to blade head:
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
Verde green color scheme for charts: rgba(45,131,65,0.75) fill, #2d8341 border.
JS functions:
openTeamDrawer(uuid, name)— fetches profile, destroys old charts, renders all sectionscloseTeamDrawer()— hides drawer + backdrop, destroys chartsrenderDailyChart(data)/renderWeeklyChart(data)— Chart.js bar chartsrenderTripsPage(page)— paginated trip table within drawer- Backdrop click closes drawer
INPUT: Team profile API (T1.2) OUTPUT: Slide-in drawer with all 5 sections VERIFY: Open drawer → charts render → close → open different team → charts refresh without error
Task 2.4 — Event type labels + color badges
Agent: frontend-specialist | Priority: P2 (polish, depends on T2.3)
const EVENT_LABELS = {
trip_started: { label: 'Trip Started', css: 'bg-green-50 text-green-700' },
arrived_at_stop: { label: 'Arrived at Stop', css: 'bg-blue-50 text-blue-700' },
collection_started: { label: 'Collection Start', css: 'bg-blue-50 text-blue-600' },
qr_scanned: { label: 'QR Scanned', css: 'bg-neutral-100 text-neutral-600' },
collection_completed: { label: 'Collection Done', css: 'bg-blue-50 text-blue-700' },
departed_stop: { label: 'Departed Stop', css: 'bg-neutral-50 text-neutral-500' },
stop_skipped: { label: 'Stop Skipped', css: 'bg-amber-50 text-amber-700' },
truck_full_warning: { label: 'Truck Full', css: 'bg-orange-50 text-orange-700' },
arrived_at_dumpsite: { label: 'At Dumpsite', css: 'bg-teal-50 text-teal-700' },
load_released: { label: 'Load Released', css: 'bg-teal-50 text-teal-600' },
departed_dumpsite: { label: 'Left Dumpsite', css: 'bg-neutral-50 text-neutral-500' },
trip_completed: { label: '✓ Trip Completed', css: 'bg-green-100 text-green-800' },
incident_reported: { label: '⚠ Incident', css: 'bg-red-50 text-red-700' },
breakdown: { label: '🔧 Breakdown', css: 'bg-red-100 text-red-800' },
detour_to_dumpsite: { label: 'Detour', css: 'bg-amber-50 text-amber-700' },
resumed_from_detour: { label: 'Resumed', css: 'bg-green-50 text-green-600' },
continuation_created: { label: 'Continuation', css: 'bg-neutral-50 text-neutral-600' },
};
INPUT: Raw event type strings from API OUTPUT: Colored badge pill in event log table VERIFY: All 17 event types render a distinct readable badge
PHASE 3 — Integration & Polish
Task 3.1 — Wire LGU selector to teams tab
Agent: frontend-specialist | Priority: P2
Extend existing loadAll():
else if (activeTab === 'teams') loadTeams(1);
Teams-specific LGU selector (teams-lgu) is independent of main lgu-selector.
It offers "All LGUs" (blank tenant_id) so super-admins can see cross-tenant leaderboard.
Populated from lguData same as main selector.
VERIFY: Switch teams-lgu → leaderboard reloads; "All LGUs" → combined cross-tenant results
Task 3.2 — Chart destroy/recreate guard
Agent: frontend-specialist | Priority: P2
let dailyChart = null;
let weeklyChart = null;
function destroyCharts() {
if (dailyChart) { dailyChart.destroy(); dailyChart = null; }
if (weeklyChart) { weeklyChart.destroy(); weeklyChart = null; }
}
// Called at top of openTeamDrawer()
VERIFY: Open drawer for Team A, close, open Team B → no "Canvas already in use" console error
API Contract Summary
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/v1/admin/reports/teams/leaderboard |
GET | admin, super_admin | Ranked teams list |
/api/v1/admin/reports/teams/{uuid}/profile |
GET | admin, super_admin | Full team profile |
Both endpoints:
- Use
Tenancy::current()for regular admins (scoped to their LGU) - Accept
?tenant_id=for super-admin to switch LGU - When super-admin sends no
tenant_id→ query all tenants (unscoped)
Implementation Order
T1.1 ──┐
T1.2 ──┴──► T1.3 ──► T2.1 ──► T2.2 ──► T2.3 ──► T2.4 ──► T3.1 + T3.2
Estimated effort: ~3–5 hours (2h backend, 2h frontend, 1h polish/testing)
Risk Register
| Risk | Likelihood | Mitigation |
|---|---|---|
| Slow profile query on teams with many logs | Medium | Limit events to last 200; paginate trips (10/page) |
| Chart.js canvas reuse error | Low | Destroy chart instances before recreating (T3.2) |
| Cross-tenant query for "All LGUs" | Medium | Use withoutGlobalScopes() or explicit unscoped query |
| Route ordering conflict (leaderboard vs {team:uuid}) | Low | Register leaderboard route BEFORE {team:uuid} route |
Phase X: Verification Checklist
php artisan route:list | findstr teamshows both routes- Leaderboard API returns 200 with correct shape
- Profile API returns all 6 sections:
team,kpis,daily_scans,weekly_scans,trips,events - "Teams" tab visible and clickable in reports page
- Leaderboard table renders with rank numbers
- KPI summary cards above leaderboard update on load
- "View Report" button opens drawer with smooth slide-in
- Drawer KPI cards match API kpis values
- Daily scan bar chart renders with correct date labels
- Weekly scan bar chart renders with correct week labels
- Trip history table paginates correctly within drawer
- Event log shows all event types with color badges
- Super-admin can switch LGU in teams-lgu selector → data updates
- "All LGUs" option works → shows combined cross-tenant data
- Chart.js charts destroy/recreate cleanly on drawer reopen
- No console errors in browser
- Drawer is scrollable on small screens