From 6f5327bbcd74c6da740415d6a61aa483cbb4e054 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 6 Jul 2026 12:39:13 +0800 Subject: [PATCH] feat: implement trip incident dashboard, drop-off geofence validation, and calendar updates - **Trip Incidents**: Created the `AdminTripController@incidents` API endpoint, built the `incidents.blade.php` view, enabled the incidents sidebar link, and added test coverage (`TripIncidentDashboardTest.php`). - **Drop-off Points (LGU Geofence)**: Added a Barangay (LGU) dropdown to the drop-off point form. Integrated `Turf.js` to render the LGU boundary polygon on Leaflet and restrict pin placement (clicks, drags, and address search) to within the selected boundary. - **Trip Calendar**: Changed the shortcut for opening the Daily Digest from Ctrl+Click to Shift+Click on calendar dates. - **API fixes**: Corrected the Barangay fetch endpoint to `/api/v1/geo/barangays` in the Drop-off points view. --- .../Api/V1/Admin/AdminTripController.php | 34 +++ .../Resources/TripTimelineEventResource.php | 8 + commit_message.txt | 12 +- resources/views/admin/calendar.blade.php | 85 +++++- .../views/admin/drop-off-points.blade.php | 74 +++++ resources/views/admin/incidents.blade.php | 258 ++++++++++++++++++ .../views/admin/partials/sidebar.blade.php | 2 +- routes/api.php | 1 + routes/web.php | 5 +- .../Api/V1/Trip/TripIncidentDashboardTest.php | 118 ++++++++ 10 files changed, 584 insertions(+), 13 deletions(-) create mode 100644 resources/views/admin/incidents.blade.php create mode 100644 tests/Feature/Api/V1/Trip/TripIncidentDashboardTest.php diff --git a/app/Http/Controllers/Api/V1/Admin/AdminTripController.php b/app/Http/Controllers/Api/V1/Admin/AdminTripController.php index 12930a1..2584c2e 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminTripController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminTripController.php @@ -5,11 +5,13 @@ namespace App\Http\Controllers\Api\V1\Admin; use App\Http\Controllers\Api\V1\ApiController; use App\Http\Requests\Trip\StoreTripRequest; use App\Http\Resources\TripResource; +use App\Http\Resources\TripTimelineEventResource; use App\Models\Route; use App\Models\Trip; use App\Models\TripStop; use App\Models\CollectionTeam; use App\Models\Truck; +use App\Models\TripTimelineEvent; use App\Services\Trip\TripExecutor; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -260,6 +262,38 @@ class AdminTripController extends ApiController ]); } + public function incidents(Request $request): JsonResponse + { + $request->validate([ + 'event_type' => ['nullable', 'string', 'in:incident_reported,breakdown'], + 'date' => ['nullable', 'date'], + 'per_page' => ['nullable', 'integer', 'min:1', 'max:500'], + ]); + $perPage = (int) $request->input('per_page', 25); + + $events = TripTimelineEvent::query() + ->with(['trip.route', 'trip.team.driver', 'trip.truck', 'recordedBy']) + ->whereIn('event_type', [ + TripTimelineEvent::TYPE_INCIDENT_REPORTED, + TripTimelineEvent::TYPE_BREAKDOWN, + ]) + ->when($request->filled('event_type'), fn ($q) => $q->where('event_type', $request->string('event_type'))) + ->when($request->filled('date'), fn ($q) => $q->whereDate('event_at', $request->string('date'))) + ->orderByDesc('event_at') + ->paginate($perPage); + + return $this->ok( + TripTimelineEventResource::collection($events), + null, + [ + 'page' => $events->currentPage(), + 'per_page' => $events->perPage(), + 'total' => $events->total(), + 'last_page' => $events->lastPage(), + ] + ); + } + /** * Returns human-readable conflict messages for the proposed trip. * Cancelled trips don't conflict; everything else on the same date diff --git a/app/Http/Resources/TripTimelineEventResource.php b/app/Http/Resources/TripTimelineEventResource.php index 6db3317..d030cc9 100644 --- a/app/Http/Resources/TripTimelineEventResource.php +++ b/app/Http/Resources/TripTimelineEventResource.php @@ -21,6 +21,14 @@ class TripTimelineEventResource extends JsonResource 'id' => $this->recordedBy?->uuid, 'name' => $this->recordedBy?->full_name, ]), + 'trip' => $this->whenLoaded('trip', fn () => [ + 'id' => $this->trip?->id, + 'trip_number' => $this->trip?->trip_number, + 'route_name' => $this->trip?->route?->name, + 'team_name' => $this->trip?->team?->name, + 'driver_name' => $this->trip?->team?->driver?->full_name, + 'truck_plate' => $this->trip?->truck?->plate_number, + ]), 'related_type' => $this->related_type, 'related_id' => $this->related_id, 'metadata' => $this->metadata, diff --git a/commit_message.txt b/commit_message.txt index 1b7272b..cf83fe0 100644 --- a/commit_message.txt +++ b/commit_message.txt @@ -1,8 +1,6 @@ -feat: expand Quezon City seeder with full organizational structure +feat: implement trip incident dashboard, drop-off geofence validation, and calendar updates -- Users: Added 2 Residents, 3 Drivers, 3 Scanners, and 4 Helpers with 'password1' credentials. -- Teams: Created 'QC Team Alpha' and 'QC Team Bravo' with assigned drivers, scanners, and helpers. -- Assets: Added 2 dedicated Trucks (QC-TRK-001/002) for collection teams. -- Business: Created 2 additional Partner Stores with owners and spatial coordinates. -- Fix: Ensured 'assigned_from' is populated for team members to meet DB constraints. -- Data: Maintained 3-month historical collection statistics for all 142 barangays. +- **Trip Incidents**: Created the `AdminTripController@incidents` API endpoint, built the `incidents.blade.php` view, enabled the incidents sidebar link, and added test coverage (`TripIncidentDashboardTest.php`). +- **Drop-off Points (LGU Geofence)**: Added a Barangay (LGU) dropdown to the drop-off point form. Integrated `Turf.js` to render the LGU boundary polygon on Leaflet and restrict pin placement (clicks, drags, and address search) to within the selected boundary. +- **Trip Calendar**: Changed the shortcut for opening the Daily Digest from Ctrl+Click to Shift+Click on calendar dates. +- **API fixes**: Corrected the Barangay fetch endpoint to `/api/v1/geo/barangays` in the Drop-off points view. diff --git a/resources/views/admin/calendar.blade.php b/resources/views/admin/calendar.blade.php index 624d152..485dcb2 100644 --- a/resources/views/admin/calendar.blade.php +++ b/resources/views/admin/calendar.blade.php @@ -153,9 +153,25 @@ + + +
@@ -60,6 +61,12 @@
+
+ + +
@@ -158,6 +165,8 @@ const d = res.body.data.drop_off_point || res.body.data; createForm.querySelector('[name="name"]').value = d.name; createForm.querySelector('[name="code"]').value = d.code; + createForm.querySelector('[name="barangay_id"]').value = d.barangay_id || ''; + barangaySelect.dispatchEvent(new Event('change')); createForm.querySelector('[name="address_line"]').value = d.address_line; createForm.querySelector('[name="capacity_kg"]').value = d.capacity_kg ?? ''; createForm.querySelector('[name="contact_person"]').value = d.contact_person ?? ''; @@ -226,9 +235,51 @@ let map = null; let marker = null; let geofenceLayer = null; + let currentLguBoundaryLayer = null; + let currentLguPolygon = null; + let allBarangays = []; const latInput = document.getElementById('dop-lat'); const lngInput = document.getElementById('dop-lng'); const geofenceInput = document.getElementById('geofence_wkt'); + const barangaySelect = document.getElementById('form-barangay'); + + async function loadBarangays() { + const res = await window.Verde.apiFetch('/api/v1/geo/barangays'); + if (res.ok) { + allBarangays = res.body.data || []; + barangaySelect.innerHTML = '' + + allBarangays.map(b => ``).join(''); + } + } + loadBarangays(); + + barangaySelect.addEventListener('change', () => { + const id = barangaySelect.value; + const b = allBarangays.find(x => x.id == id); + + if (currentLguBoundaryLayer && map) { + map.removeLayer(currentLguBoundaryLayer); + currentLguBoundaryLayer = null; + } + currentLguPolygon = null; + + if (b && b.boundary && b.boundary.length > 2 && map) { + const coords = b.boundary.map(p => [p.lat, p.lng]); + currentLguBoundaryLayer = L.polygon(coords, { color: '#3b82f6', fillOpacity: 0.1, weight: 2 }).addTo(map); + map.fitBounds(currentLguBoundaryLayer.getBounds()); + + // Close the loop for turf + const turfCoords = b.boundary.map(p => [p.lng, p.lat]); + turfCoords.push([b.boundary[0].lng, b.boundary[0].lat]); + currentLguPolygon = turf.polygon([turfCoords]); + } + }); + + function validatePoint(latlng) { + if (!currentLguPolygon) return true; + const pt = turf.point([latlng.lng, latlng.lat]); + return turf.booleanPointInPolygon(pt, currentLguPolygon); + } function updateGeofenceInput(layer) { if (!layer) { @@ -276,13 +327,26 @@ marker = L.marker([initialLat, initialLng], { draggable: true }).addTo(map); + marker.on('dragstart', function () { + marker._lastPos = marker.getLatLng(); + }); + marker.on('dragend', function () { const pos = marker.getLatLng(); + if (!validatePoint(pos)) { + marker.setLatLng(marker._lastPos); + window.Verde.toast('Drop-off point must be within the selected LGU boundary.', 'error'); + return; + } latInput.value = pos.lat.toFixed(6); lngInput.value = pos.lng.toFixed(6); }); map.on('click', function (e) { + if (!validatePoint(e.latlng)) { + window.Verde.toast('Drop-off point must be within the selected LGU boundary.', 'error'); + return; + } marker.setLatLng(e.latlng); latInput.value = e.latlng.lat.toFixed(6); lngInput.value = e.latlng.lng.toFixed(6); @@ -375,6 +439,12 @@ li.addEventListener('click', () => { const lat = parseFloat(li.dataset.lat); const lng = parseFloat(li.dataset.lng); + + if (!validatePoint({lat, lng})) { + window.Verde.toast('Searched location is outside the selected LGU boundary.', 'error'); + return; + } + latInput.value = lat.toFixed(6); lngInput.value = lng.toFixed(6); marker.setLatLng([lat, lng]); @@ -426,6 +496,8 @@ latInput.value = '14.653900'; lngInput.value = '121.068500'; geofenceInput.value = ''; + createForm.querySelector('[name="barangay_id"]').value = ''; + barangaySelect.dispatchEvent(new Event('change')); modal.classList.remove('hidden'); initMap(14.6539, 121.0685, null); }); @@ -440,6 +512,8 @@ const payload = Object.fromEntries(fd.entries()); payload.lat = parseFloat(payload.lat); payload.lng = parseFloat(payload.lng); + if (payload.barangay_id) payload.barangay_id = parseInt(payload.barangay_id, 10); + else delete payload.barangay_id; if (payload.capacity_kg) payload.capacity_kg = parseInt(payload.capacity_kg, 10); else delete payload.capacity_kg; if (!payload.contact_person) payload.contact_person = null; diff --git a/resources/views/admin/incidents.blade.php b/resources/views/admin/incidents.blade.php new file mode 100644 index 0000000..bf70008 --- /dev/null +++ b/resources/views/admin/incidents.blade.php @@ -0,0 +1,258 @@ +@extends('admin.layouts.app', ['pageTitle' => 'Incidents']) + +@section('page') + + + + +
+
+

Operations Incidents

+

Track and review mechanical breakdowns and other incidents reported by drivers during collection runs.

+
+ +
+ + + + +
+ +
+ + + + + + + + + + + + + + + +
Reported AtTripRouteTeam / DriverTypeDriver NotesActions
Loading…
+
+ + +
+
+ +{{-- Detail modal --}} + + + +@endsection diff --git a/resources/views/admin/partials/sidebar.blade.php b/resources/views/admin/partials/sidebar.blade.php index 0f045ad..328c447 100644 --- a/resources/views/admin/partials/sidebar.blade.php +++ b/resources/views/admin/partials/sidebar.blade.php @@ -6,7 +6,7 @@ ['href' => '/admin/trips', 'label' => 'Trips', 'icon' => 'truck'], ['href' => '/admin/operations/live', 'label' => 'Live Tracking', 'icon' => 'map'], ['href' => '/admin/operations/calendar', 'label' => 'Trip Calendar', 'icon' => 'calendar'], - ['href' => '/admin/operations/incidents', 'label' => 'Incidents', 'disabled' => true, 'icon' => 'alert'], + ['href' => '/admin/operations/incidents', 'label' => 'Incidents', 'icon' => 'alert'], ], 'Planning' => [ ['href' => '/admin/routes', 'label' => 'Routes', 'icon' => 'route'], diff --git a/routes/api.php b/routes/api.php index 4849e7b..c10d0bf 100644 --- a/routes/api.php +++ b/routes/api.php @@ -378,6 +378,7 @@ Route::prefix('admin/trips') Route::get('/', [AdminTripController::class, 'index'])->name('index'); Route::post('/', [AdminTripController::class, 'store'])->name('store'); Route::get('/suggest-assignment', [AdminTripController::class, 'suggestAssignment'])->name('suggest-assignment'); + Route::get('/incidents', [AdminTripController::class, 'incidents'])->name('incidents'); Route::get('/{trip}', [AdminTripController::class, 'show'])->name('show'); Route::patch('/{trip}', [AdminTripController::class, 'update'])->name('update'); Route::delete('/{trip}', [AdminTripController::class, 'destroy'])->name('destroy'); diff --git a/routes/web.php b/routes/web.php index 0fdf1cc..88ab8fd 100644 --- a/routes/web.php +++ b/routes/web.php @@ -38,10 +38,7 @@ Route::prefix('admin')->group(function () { Route::view('/barangays', 'admin.barangays')->name('admin.barangays'); Route::view('/operations/calendar', 'admin.calendar')->name('admin.calendar'); - Route::view('/operations/incidents', 'admin.coming-soon', [ - 'title' => 'Incidents', - 'description' => 'Filtered view of trip incident events — UI work pending. Visible inline in each Trip detail.', - ]); + Route::view('/operations/incidents', 'admin.incidents')->name('admin.incidents'); }); Route::prefix('store')->name('store.')->group(function () { diff --git a/tests/Feature/Api/V1/Trip/TripIncidentDashboardTest.php b/tests/Feature/Api/V1/Trip/TripIncidentDashboardTest.php new file mode 100644 index 0000000..ef29892 --- /dev/null +++ b/tests/Feature/Api/V1/Trip/TripIncidentDashboardTest.php @@ -0,0 +1,118 @@ +seed([RoleSeeder::class, SamplePsgcSeeder::class, SampleDropOffPointsSeeder::class, SampleDumpsitesSeeder::class]); + $this->admin = User::factory()->create(['role' => User::ROLE_ADMIN, 'status' => 'active']); + + $driver = User::factory()->create(['role' => User::ROLE_DRIVER, 'status' => 'active']); + $truck = Truck::create(['plate_number' => 'TRK-INC', 'status' => 'active']); + $team = CollectionTeam::create([ + 'name' => 'Inc-Team', 'driver_id' => $driver->id, 'truck_id' => $truck->id, 'status' => 'active', + ]); + + $dumpsite = Dumpsite::first(); + $route = Route::create([ + 'name' => 'Inc-Route', 'code' => 'RT-INC', 'default_dumpsite_id' => $dumpsite->id, 'status' => 'active', + ]); + RouteStop::create([ + 'route_id' => $route->id, + 'drop_off_point_id' => DropOffPoint::first()->id, + 'sequence' => 1, + ]); + + $this->trip = Trip::create([ + 'route_id' => $route->id, + 'team_id' => $team->id, + 'truck_id' => $truck->id, + 'scheduled_date' => now()->toDateString(), + 'status' => Trip::STATUS_IN_PROGRESS, + ]); + } + + public function test_admin_can_view_incidents_list(): void + { + Sanctum::actingAs($this->admin); + + // 1. Create a breakdown event + TripTimelineEvent::create([ + 'trip_id' => $this->trip->id, + 'event_type' => TripTimelineEvent::TYPE_BREAKDOWN, + 'event_at' => now(), + 'notes' => 'Engine overheated', + 'recorded_by_user_id' => $this->trip->team->driver_id, + ]); + + // 2. Create a generic incident event + TripTimelineEvent::create([ + 'trip_id' => $this->trip->id, + 'event_type' => TripTimelineEvent::TYPE_INCIDENT_REPORTED, + 'event_at' => now()->addMinutes(5), + 'notes' => 'Blocked road due to parade', + 'recorded_by_user_id' => $this->trip->team->driver_id, + ]); + + // 3. Create a non-incident event (should not return in list) + TripTimelineEvent::create([ + 'trip_id' => $this->trip->id, + 'event_type' => TripTimelineEvent::TYPE_TRIP_STARTED, + 'event_at' => now()->subMinutes(10), + 'notes' => 'Started trip', + 'recorded_by_user_id' => $this->trip->team->driver_id, + ]); + + // 4. Query endpoint + $response = $this->getJson('/api/v1/admin/trips/incidents'); + $response->assertOk(); + $this->assertCount(2, $response->json('data')); + + // Assert serialization of trip relationships is present + $this->assertEquals($this->trip->trip_number, $response->json('data.0.trip.trip_number')); + $this->assertEquals('Inc-Team', $response->json('data.0.trip.team_name')); + + // 5. Query with type filter: breakdown + $responseFiltered = $this->getJson('/api/v1/admin/trips/incidents?event_type=breakdown'); + $responseFiltered->assertOk(); + $this->assertCount(1, $responseFiltered->json('data')); + $this->assertEquals('Engine overheated', $responseFiltered->json('data.0.notes')); + } + + public function test_unauthorized_users_are_blocked(): void + { + // Unauthenticated + $this->getJson('/api/v1/admin/trips/incidents')->assertUnauthorized(); + + // Driver + $driver = User::factory()->create(['role' => User::ROLE_DRIVER]); + Sanctum::actingAs($driver); + $this->getJson('/api/v1/admin/trips/incidents')->assertForbidden(); + } +}