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 @@
+
@@ -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')
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Reported At |
+ Trip |
+ Route |
+ Team / Driver |
+ Type |
+ Driver Notes |
+ Actions |
+
+
+
+ | Loading… |
+
+
+
+
+
+
+
+
+{{-- Detail modal --}}
+
+
+
+
+
Incident Details
+
+
+
+
+
+
+
+@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();
+ }
+}