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.
This commit is contained in:
Developer
2026-07-06 12:39:13 +08:00
parent 514c42ba88
commit 6f5327bbcd
10 changed files with 584 additions and 13 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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.

View File

@@ -153,9 +153,25 @@
</div>
</div>
<div id="daily-digest-modal" class="hidden">
<div class="slide-over-mask" data-close></div>
<div class="slide-over-panel translate-x-0 p-6 overflow-y-auto" style="max-width: 500px;">
<div class="mb-4 flex items-center justify-between border-b border-neutral-100 pb-3">
<h3 id="digest-modal-title" class="text-base font-semibold text-neutral-900">Daily Summary</h3>
<button data-close aria-label="Close" class="text-neutral-400 hover:text-neutral-600">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div id="digest-body" class="space-y-3 mt-4"></div>
</div>
</div>
<script type="module">
const modal = document.getElementById('form-modal');
const detail = document.getElementById('detail-modal');
const digestModal = document.getElementById('daily-digest-modal');
const digestBody = document.getElementById('digest-body');
const digestTitle = document.getElementById('digest-modal-title');
const createForm = document.getElementById('create-form');
const deleteContainer = document.getElementById('delete-container');
const deleteBtn = document.getElementById('delete-btn');
@@ -313,9 +329,20 @@
}
},
select: function(info) {
if (info.jsEvent && info.jsEvent.shiftKey) {
calendar.unselect();
openDailyDigest(info.startStr);
return;
}
openCreateModal(info.startStr);
calendar.unselect();
},
dateClick: function(info) {
if (info.jsEvent.shiftKey) {
info.jsEvent.preventDefault();
openDailyDigest(info.dateStr);
}
},
eventClick: function(info) {
const trip = info.event.extendedProps;
if (trip.status === 'scheduled') {
@@ -574,7 +601,63 @@
}
});
[modal, detail].forEach(m => m.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', () => m.classList.add('hidden'))));
function openDailyDigest(dateStr) {
const formattedDate = new Date(dateStr).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
digestTitle.textContent = `Collection Runs — ${formattedDate}`;
const events = calendar.getEvents().filter(e => e.startStr === dateStr);
if (events.length === 0) {
digestBody.innerHTML = `
<div class="rounded-lg border border-dashed border-neutral-200 p-8 text-center text-sm text-neutral-400">
No collection runs scheduled for this day.
</div>
`;
} else {
digestBody.innerHTML = events.map(e => {
const t = e.extendedProps;
return `
<div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm hover:border-neutral-300 transition">
<div class="flex items-center justify-between mb-2">
<span class="font-mono text-xs font-semibold text-neutral-900">${window.Verde.escapeHtml(t.trip_number)}</span>
${badge(t.status)}
</div>
<div class="text-xs text-neutral-600 mb-3 grid grid-cols-2 gap-y-1">
<div class="text-neutral-400">Route</div><div class="font-medium text-neutral-800">${window.Verde.escapeHtml(t.route?.name ?? '—')}</div>
<div class="text-neutral-400">Team</div><div class="font-medium text-neutral-800">${window.Verde.escapeHtml(t.team?.name ?? '—')}</div>
<div class="text-neutral-400">Truck</div><div class="font-medium text-neutral-800">${window.Verde.escapeHtml(t.truck?.plate_number ?? '—')}</div>
</div>
<button type="button" class="btn-ghost w-full py-1 text-xs text-center border rounded-md hover:bg-neutral-50 transition"
onclick="window.viewTripFromDigest('${t.id}')">
View Details / Edit
</button>
</div>
`;
}).join('');
}
digestModal.classList.remove('hidden');
}
window.viewTripFromDigest = (uuid) => {
digestModal.classList.add('hidden');
const ev = calendar.getEventById(uuid);
if (ev) {
const trip = ev.extendedProps;
if (trip.status === 'scheduled') {
openEditModal(trip);
} else {
openDetailModal(trip);
}
}
};
[modal, detail, digestModal].forEach(m => m.querySelectorAll('[data-close]').forEach(el => el.addEventListener('click', () => m.classList.add('hidden'))));
document.getElementById('route-select').addEventListener('change', (e) => {
const opt = e.target.options[e.target.selectedIndex];

View File

@@ -5,6 +5,7 @@
<link rel="stylesheet" href="https://unpkg.com/@geoman-io/leaflet-geoman-free@2.14.2/dist/leaflet-geoman.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://unpkg.com/@geoman-io/leaflet-geoman-free@2.14.2/dist/leaflet-geoman.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@turf/turf@6/turf.min.js"></script>
<div class="mx-auto max-w-7xl">
<header class="mb-6 flex items-end justify-between">
@@ -60,6 +61,12 @@
<label class="form-label">Name</label>
<input name="name" required class="form-input">
</div>
<div>
<label class="form-label">Barangay (LGU)</label>
<select name="barangay_id" id="form-barangay" class="form-select">
<option value=""> Select Barangay </option>
</select>
</div>
<div>
<label class="form-label">Code</label>
<input name="code" id="form-code" required class="form-input" placeholder="DOP-...">
@@ -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 = '<option value="">— Select Barangay —</option>' +
allBarangays.map(b => `<option value="${b.id}">${window.Verde.escapeHtml(b.name)}</option>`).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;

View File

@@ -0,0 +1,258 @@
@extends('admin.layouts.app', ['pageTitle' => 'Incidents'])
@section('page')
<!-- Leaflet CSS and JS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<div class="mx-auto max-w-7xl">
<header class="mb-6">
<h2 class="text-2xl font-semibold tracking-tight text-neutral-900">Operations Incidents</h2>
<p class="mt-1 text-sm text-neutral-500">Track and review mechanical breakdowns and other incidents reported by drivers during collection runs.</p>
</header>
<div class="card mb-4 flex flex-wrap items-center gap-3 p-4 bg-white shadow rounded-lg border border-neutral-100">
<select id="filter-type" class="form-select w-48">
<option value="">All Incident Types</option>
<option value="incident_reported">General Incidents</option>
<option value="breakdown">Mechanical Breakdowns</option>
</select>
<input id="filter-date" type="date" class="form-input w-48">
<button id="filter-apply" class="btn-primary">Apply Filters</button>
<button id="filter-clear" class="btn-ghost text-xs">Clear</button>
</div>
<div class="table-wrap bg-white shadow rounded-lg border border-neutral-100 overflow-hidden">
<table class="table min-w-full">
<thead>
<tr class="bg-neutral-50 border-b border-neutral-200">
<th class="py-3 px-4 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wider">Reported At</th>
<th class="py-3 px-4 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wider">Trip</th>
<th class="py-3 px-4 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wider">Route</th>
<th class="py-3 px-4 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wider">Team / Driver</th>
<th class="py-3 px-4 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wider">Type</th>
<th class="py-3 px-4 text-left text-xs font-semibold text-neutral-500 uppercase tracking-wider">Driver Notes</th>
<th class="py-3 px-4 text-right text-xs font-semibold text-neutral-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="rows" class="divide-y divide-neutral-200">
<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div id="pagination-container" class="mt-4 flex items-center justify-between text-sm text-neutral-600"></div>
</div>
{{-- Detail modal --}}
<div id="detail-modal" class="hidden">
<div class="slide-over-mask" data-close></div>
<div class="slide-over-panel translate-x-0 p-6 overflow-y-auto" style="max-width: 600px;">
<div class="mb-4 flex items-center justify-between">
<h3 id="detail-title" class="text-base font-semibold text-neutral-900">Incident Details</h3>
<button data-close aria-label="Close" class="text-neutral-400 hover:text-neutral-600">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div id="detail-body" class="text-sm"></div>
</div>
</div>
<script type="module">
const rows = document.getElementById('rows');
const detailModal = document.getElementById('detail-modal');
const paginationContainer = document.getElementById('pagination-container');
let mapInstance = null;
let mapMarker = null;
let currentPage = 1;
function badge(type) {
if (type === 'breakdown') {
return `<span class="badge badge-rejected">Breakdown</span>`;
}
return `<span class="badge badge-pending">Incident</span>`;
}
async function load(page = 1) {
currentPage = page;
rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">Loading…</td></tr>`;
const params = new URLSearchParams();
const type = document.getElementById('filter-type').value;
const date = document.getElementById('filter-date').value;
if (type) params.set('event_type', type);
if (date) params.set('date', date);
params.set('page', page);
params.set('per_page', '15');
const res = await window.Verde.apiFetch(`/api/v1/admin/trips/incidents?${params}`);
if (!res.ok) {
rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-red-500">Failed to load incidents data.</td></tr>`;
return;
}
const data = res.body.data ?? [];
const meta = res.body.meta ?? {};
if (data.length === 0) {
rows.innerHTML = `<tr><td colspan="7" class="py-10 text-center text-sm text-neutral-400">No incident logs found.</td></tr>`;
paginationContainer.innerHTML = '';
return;
}
rows.innerHTML = data.map(event => {
const trip = event.trip ?? {};
const timeFormatted = window.Verde.formatDate(event.event_at, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
return `
<tr class="hover:bg-neutral-50 transition">
<td class="py-3.5 px-4 text-xs text-neutral-600 font-medium">${timeFormatted}</td>
<td class="py-3.5 px-4 font-mono text-xs font-semibold text-neutral-900">${window.Verde.escapeHtml(trip.trip_number ?? '—')}</td>
<td class="py-3.5 px-4 text-xs text-neutral-800">${window.Verde.escapeHtml(trip.route_name ?? '—')}</td>
<td class="py-3.5 px-4 text-xs text-neutral-600">
<div class="font-medium text-neutral-800">${window.Verde.escapeHtml(trip.team_name ?? '—')}</div>
<div class="text-[10px] text-neutral-400">${window.Verde.escapeHtml(trip.driver_name ?? '—')}</div>
</td>
<td class="py-3.5 px-4 text-xs">${badge(event.event_type)}</td>
<td class="py-3.5 px-4 text-xs text-neutral-500 max-w-xs truncate">${window.Verde.escapeHtml(event.notes ?? '—')}</td>
<td class="py-3.5 px-4 text-right text-xs">
<button data-id="${event.id}" data-action="view" class="btn-primary py-1 px-3 text-xs">View Map</button>
</td>
</tr>
`;
}).join('');
rows.querySelectorAll('button[data-action="view"]').forEach(btn => {
btn.addEventListener('click', () => {
const eventObj = data.find(e => e.id == btn.dataset.id);
if (eventObj) openDetails(eventObj);
});
});
renderPagination(meta);
}
function renderPagination(meta) {
if (!meta.last_page || meta.last_page <= 1) {
paginationContainer.innerHTML = '';
return;
}
let html = `<div>Showing page <strong>${meta.page}</strong> of <strong>${meta.last_page}</strong> (${meta.total} incidents)</div>`;
html += `<div class="flex gap-2">`;
if (meta.page > 1) {
html += `<button id="btn-prev" class="btn-ghost py-1 px-2.5 text-xs"><svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd"/></svg></button>`;
}
if (meta.page < meta.last_page) {
html += `<button id="btn-next" class="btn-ghost py-1 px-2.5 text-xs"><svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg></button>`;
}
html += `</div>`;
paginationContainer.innerHTML = html;
const prev = document.getElementById('btn-prev');
const next = document.getElementById('btn-next');
if (prev) prev.addEventListener('click', () => load(meta.page - 1));
if (next) next.addEventListener('click', () => load(meta.page + 1));
}
function openDetails(e) {
const trip = e.trip ?? {};
const isBreakdown = e.event_type === 'breakdown';
const bannerClass = isBreakdown ? 'bg-red-50 border-red-200 text-red-800' : 'bg-amber-50 border-amber-200 text-amber-800';
document.getElementById('detail-title').textContent = `${isBreakdown ? '⚠ Breakdown' : 'Alert'} for ${trip.trip_number ?? 'Trip'}`;
document.getElementById('detail-body').innerHTML = `
<div class="mb-4 rounded border p-3 text-xs ${bannerClass}">
<strong>Type:</strong> ${isBreakdown ? 'Mechanical Breakdown / Truck Issues' : 'General Operational Incident'}<br>
<strong>Reported:</strong> ${window.Verde.formatDate(e.event_at, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</div>
<div class="mb-4 border-b border-neutral-100 pb-4 text-xs grid grid-cols-2 gap-y-2.5">
<div class="text-neutral-500">Route</div>
<div class="font-medium text-neutral-800">${window.Verde.escapeHtml(trip.route_name ?? '—')}</div>
<div class="text-neutral-500">Collection Team</div>
<div class="font-medium text-neutral-800">${window.Verde.escapeHtml(trip.team_name ?? '—')}</div>
<div class="text-neutral-500">Driver</div>
<div class="font-medium text-neutral-800">${window.Verde.escapeHtml(trip.driver_name ?? '—')}</div>
<div class="text-neutral-500">Truck Plate</div>
<div class="font-medium text-neutral-800">${window.Verde.escapeHtml(trip.truck_plate ?? '—')}</div>
</div>
<div class="mb-4">
<h4 class="mb-1 text-xs font-semibold uppercase tracking-wider text-neutral-500">Driver Report Notes</h4>
<div class="rounded bg-neutral-50 border border-neutral-100 p-3 text-xs text-neutral-700 italic leading-relaxed">
"${window.Verde.escapeHtml(e.notes ?? 'No notes reported')}"
</div>
</div>
${e.coordinates ? `
<h4 class="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-500">Incident GPS Location</h4>
<div id="incident-map" class="rounded border" style="height: 250px; z-index: 0;"></div>
` : `
<div class="rounded border border-dashed p-4 text-center text-xs text-neutral-400">
No GPS coordinates were logged with this report.
</div>
`}
<div class="mt-6 flex justify-end gap-2">
<a href="/admin/trips?uuid=${trip.id}" class="btn-ghost text-xs py-2 px-4 border rounded hover:bg-neutral-50">View Entire Trip Log</a>
<button type="button" data-close class="btn-primary text-xs py-2 px-4">Close Details</button>
</div>
`;
detailModal.classList.remove('hidden');
// Bind close button
detailModal.querySelector('[data-close]').addEventListener('click', () => detailModal.classList.add('hidden'));
const closeBtn = detailModal.querySelector('button[data-close]');
if (closeBtn) closeBtn.addEventListener('click', () => detailModal.classList.add('hidden'));
if (e.coordinates) {
setTimeout(() => {
if (mapInstance) {
mapInstance.remove();
mapInstance = null;
}
mapInstance = L.map('incident-map').setView([e.coordinates.lat, e.coordinates.lng], 15);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
}).addTo(mapInstance);
const iconColor = isBreakdown ? 'red' : 'orange';
mapMarker = L.marker([e.coordinates.lat, e.coordinates.lng], {
icon: L.icon({
iconUrl: `https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-${iconColor}.png`,
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34]
})
}).addTo(mapInstance);
mapMarker.bindPopup(`<strong>${isBreakdown ? 'Breakdown Location' : 'Incident Location'}</strong>`).openPopup();
}, 100);
}
}
// Modal click closes mask
detailModal.querySelector('.slide-over-mask').addEventListener('click', () => detailModal.classList.add('hidden'));
// Event listeners
document.getElementById('filter-apply').addEventListener('click', () => load(1));
document.getElementById('filter-type').addEventListener('change', () => load(1));
document.getElementById('filter-date').addEventListener('change', () => load(1));
document.getElementById('filter-clear').addEventListener('click', () => {
document.getElementById('filter-type').value = '';
document.getElementById('filter-date').value = '';
load(1);
});
// Init page
load(1);
</script>
@endsection

View File

@@ -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'],

View File

@@ -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');

View File

@@ -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 () {

View File

@@ -0,0 +1,118 @@
<?php
namespace Tests\Feature\Api\V1\Trip;
use App\Models\CollectionTeam;
use App\Models\DropOffPoint;
use App\Models\Dumpsite;
use App\Models\Route;
use App\Models\RouteStop;
use App\Models\Trip;
use App\Models\TripTimelineEvent;
use App\Models\Truck;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Database\Seeders\SampleDropOffPointsSeeder;
use Database\Seeders\SampleDumpsitesSeeder;
use Database\Seeders\SamplePsgcSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class TripIncidentDashboardTest extends TestCase
{
use RefreshDatabase;
private User $admin;
private Trip $trip;
protected function setUp(): void
{
parent::setUp();
$this->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();
}
}