Files
Verde-Web/team-reports.md

16 KiB
Raw Blame History

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):

{
  "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:

{
  "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:

  1. Add ['key' => 'teams', 'label' => 'Teams'] to the tab foreach array
  2. 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 from lguData)
  • 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-uuid and data-team-name attributes
  • 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-fulltranslate-x-0):

Sections inside scrollable drawer body:

  1. Header: Team name, driver/scanner/truck info, date range badge, Close button
  2. KPI cards grid (2×4): Total Trips, Completed, On-Time %, Avg Load, Total Scans, Total Weight, Incidents, Detours
  3. 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)
  4. Trip History table: Trip #, Date, Status, Scans, Load, Duration (paginated, 10/page)
  5. 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 sections
  • closeTeamDrawer() — hides drawer + backdrop, destroys charts
  • renderDailyChart(data) / renderWeeklyChart(data) — Chart.js bar charts
  • renderTripsPage(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: ~35 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 team shows 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