Files
Verde-Web/app/Http/Controllers/Api/V1/Admin/AdminLiveTrackingController.php
admin 968ced302c feat(live): god's-eye view — heading, route, breadcrumb, click-to-detail, push
Live Tracking page upgrades:

- **Heading rotation** on each truck marker via CSS transform driven by
  the heading_degrees broadcast field.
- **Click-to-detail** opens a slide-over showing trip number, status,
  route, started-at, current load, an ordered stop list color-coded by
  status, and the last 20 timeline events.
- **Route overlay** draws planned-route polyline (stops in order →
  dumpsite, dashed), stop markers (pending/arrived/completed/skipped
  colors), a diamond marker for the dumpsite, and a translucent
  geofence polygon when the dumpsite has one configured.
- **Breadcrumb trail** of the last 60 minutes of GPS pings rendered
  as a polyline; new pings tack on incrementally while the panel is
  open.
- **Real-time push via Reverb** with polling fallback. Subscribes to
  private-admin.live, listens for `.truck.location`, and updates
  marker position + the trucks-online list immediately. When Reverb
  isn't configured or fails, falls back to 10s polling. Status pill
  shows live (push) / polling / offline.

Backend additions:
- GET /admin/live/trucks/{uuid}/trail — recent location pings
- GET /admin/live/trucks/{uuid}/active-trip — full trip + stops +
  dumpsite + boundary + recent timeline
- Broadcasting auth route now accepts Sanctum bearer tokens (admin
  web posts them via Echo's authorizer callback)

Frontend wiring:
- npm: laravel-echo + pusher-js
- window.Verde.getEcho() lazily inits Echo with bearer-token authorizer
- Layout exposes window.VERDE_BROADCAST with reverb config

196 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:44:13 +08:00

132 lines
4.6 KiB
PHP

<?php
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Api\V1\ApiController;
use App\Models\Trip;
use App\Models\Truck;
use App\Models\TruckLocationHistory;
use App\Services\LiveTracking\TruckTracker;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AdminLiveTrackingController extends ApiController
{
public function __construct(private readonly TruckTracker $tracker) {}
public function trucks(): JsonResponse
{
return $this->ok([
'trucks' => $this->tracker->activeTruckPositions(),
'as_of' => now()->toIso8601String(),
]);
}
/**
* Recent breadcrumb trail for a truck — last N pings within the
* given window (default 30 minutes, max 4 hours). Used by the live
* map to draw the actual path the truck has taken so far.
*/
public function trail(Request $request, Truck $truck): JsonResponse
{
$data = $request->validate([
'minutes' => ['nullable', 'integer', 'min:1', 'max:240'],
'limit' => ['nullable', 'integer', 'min:1', 'max:1000'],
]);
$minutes = (int) ($data['minutes'] ?? 30);
$limit = (int) ($data['limit'] ?? 500);
$rows = TruckLocationHistory::query()
->where('truck_id', $truck->id)
->where('recorded_at', '>=', now()->subMinutes($minutes))
->orderBy('recorded_at')
->limit($limit)
->get(['recorded_at', 'coordinates', 'heading_degrees', 'speed_kmh', 'trip_id']);
return $this->ok([
'truck_id' => $truck->uuid,
'window_minutes' => $minutes,
'points' => $rows->map(fn ($r) => [
'lat' => $r->coordinates->latitude,
'lng' => $r->coordinates->longitude,
'heading_degrees' => $r->heading_degrees,
'speed_kmh' => $r->speed_kmh,
'recorded_at' => $r->recorded_at->toIso8601String(),
])->all(),
]);
}
/**
* Find the truck's currently-running trip with full route + stops +
* dumpsite + recent timeline. Powers the click-to-detail panel.
*/
public function activeTrip(Truck $truck): JsonResponse
{
$trip = Trip::query()
->whereHas('team', fn ($q) => $q->where('truck_id', $truck->id))
->whereIn('status', [Trip::STATUS_IN_PROGRESS, Trip::STATUS_AT_DUMPSITE])
->orderByDesc('actual_start_time')
->with([
'route', 'team',
'dumpsite',
'stops.dropOffPoint',
'timelineEvents' => fn ($q) => $q->latest('event_at')->limit(20),
'timelineEvents.recordedBy',
])
->first();
if (! $trip) {
return $this->ok(['active_trip' => null]);
}
$stops = $trip->stops->map(fn ($s) => [
'id' => $s->id,
'sequence' => $s->sequence,
'status' => $s->status,
'name' => $s->dropOffPoint?->name,
'lat' => $s->dropOffPoint?->coordinates?->latitude,
'lng' => $s->dropOffPoint?->coordinates?->longitude,
'actual_arrival' => $s->actual_arrival?->toIso8601String(),
]);
$dumpsite = $trip->dumpsite ? [
'name' => $trip->dumpsite->name,
'lat' => $trip->dumpsite->coordinates?->latitude,
'lng' => $trip->dumpsite->coordinates?->longitude,
'boundary' => $this->dumpsiteBoundary($trip->dumpsite),
] : null;
return $this->ok([
'active_trip' => [
'id' => $trip->uuid,
'trip_number' => $trip->trip_number,
'status' => $trip->status,
'started_at' => $trip->actual_start_time?->toIso8601String(),
'route_name' => $trip->route?->name,
'team_name' => $trip->team?->name,
'total_load_kg' => $trip->total_load_kg,
'stops' => $stops,
'dumpsite' => $dumpsite,
'timeline' => $trip->timelineEvents->map(fn ($e) => [
'event_type' => $e->event_type,
'event_at' => $e->event_at->toIso8601String(),
'notes' => $e->notes,
])->values(),
],
]);
}
private function dumpsiteBoundary($dumpsite): ?array
{
if (! $dumpsite->boundary_polygon) return null;
$rings = $dumpsite->boundary_polygon->getGeometries();
$ring = $rings->first();
if (! $ring) return null;
return $ring->getGeometries()
->map(fn ($p) => ['lat' => $p->latitude, 'lng' => $p->longitude])
->all();
}
}