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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
258
resources/views/admin/incidents.blade.php
Normal file
258
resources/views/admin/incidents.blade.php
Normal 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
|
||||
@@ -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'],
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
118
tests/Feature/Api/V1/Trip/TripIncidentDashboardTest.php
Normal file
118
tests/Feature/Api/V1/Trip/TripIncidentDashboardTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user