- Live Tracking: Leaflet map (CDN) showing active truck positions,
auto-refresh every 10s, sidebar list with speed + last update.
- Finance / Payments: filterable list of payments with mark-paid
action that runs admin fulfillment.
- Settings: notification-preferences form bound to /me/notification-
preferences.
Backend:
- New admin payments index endpoint
GET /admin/payments + POST /admin/payments/{uuid}/mark-paid (moved
out of /admin/live/* for clarity).
Sidebar groups now reflect what's live: Operations (Trips, Live
Tracking), Planning (Routes, Teams, Trucks, DOPs, Dumpsites), People
(Users, Households, Partner Stores), QR, Areas, Finance, Reports,
Settings. Trip Calendar + Incidents remain "Soon" — backends ready,
UI work pending. Dashboard backend-status copy updated to 13/13
modules / 171 tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
127 lines
5.1 KiB
PHP
127 lines
5.1 KiB
PHP
@extends('admin.layouts.app', ['pageTitle' => 'Live Tracking'])
|
|
|
|
@section('page')
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
|
|
|
|
<div class="mx-auto max-w-7xl">
|
|
<header class="mb-6 flex items-end justify-between">
|
|
<div>
|
|
<h2 class="text-2xl font-semibold tracking-tight text-neutral-900">Live Tracking</h2>
|
|
<p class="mt-1 text-sm text-neutral-500">Active truck positions, refreshed every 10 seconds.</p>
|
|
</div>
|
|
<div class="flex items-center gap-2 text-xs text-neutral-500">
|
|
<span class="inline-flex h-2 w-2 animate-pulse rounded-full bg-verde-500"></span>
|
|
<span id="last-update">Loading…</span>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
|
<div class="lg:col-span-2">
|
|
<div id="map" class="card h-[600px]"></div>
|
|
</div>
|
|
|
|
<div class="card-padded">
|
|
<h3 class="mb-3 text-sm font-semibold text-neutral-900">Trucks online</h3>
|
|
<div id="truck-list" class="space-y-2">
|
|
<div class="text-sm text-neutral-400">Loading…</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
|
<script type="module">
|
|
// Wait for Leaflet to be available (loaded via non-module script tag)
|
|
function awaitLeaflet() {
|
|
return new Promise(resolve => {
|
|
const tick = () => window.L ? resolve(window.L) : setTimeout(tick, 50);
|
|
tick();
|
|
});
|
|
}
|
|
|
|
const L = await awaitLeaflet();
|
|
const map = L.map('map').setView([14.65, 121.05], 12);
|
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap',
|
|
maxZoom: 19,
|
|
}).addTo(map);
|
|
|
|
const markers = new Map();
|
|
const truckList = document.getElementById('truck-list');
|
|
const lastUpdate = document.getElementById('last-update');
|
|
|
|
function truckIcon(plate) {
|
|
return L.divIcon({
|
|
html: `<div class="flex h-7 w-auto items-center gap-1 rounded-md border border-verde-700 bg-verde-600 px-2 text-[10px] font-semibold text-white shadow"><span>🚛</span><span>${plate}</span></div>`,
|
|
className: '',
|
|
iconSize: null,
|
|
iconAnchor: [0, 14],
|
|
});
|
|
}
|
|
|
|
async function refresh() {
|
|
const res = await window.Verde.apiFetch('/api/v1/admin/live/trucks');
|
|
if (!res.ok) {
|
|
lastUpdate.textContent = 'Error fetching positions';
|
|
return;
|
|
}
|
|
const trucks = res.body.data?.trucks ?? [];
|
|
lastUpdate.textContent = `Last update ${window.Verde.formatDate(res.body.data.as_of, { hour: '2-digit', minute: '2-digit', second: '2-digit' })} · ${trucks.length} active`;
|
|
|
|
// Update markers
|
|
const seen = new Set();
|
|
const bounds = [];
|
|
trucks.forEach(t => {
|
|
seen.add(t.truck_id);
|
|
bounds.push([t.lat, t.lng]);
|
|
const existing = markers.get(t.truck_id);
|
|
if (existing) {
|
|
existing.setLatLng([t.lat, t.lng]);
|
|
} else {
|
|
const m = L.marker([t.lat, t.lng], { icon: truckIcon(t.plate_number) }).addTo(map);
|
|
m.bindPopup(`
|
|
<div class="text-sm">
|
|
<strong>${t.plate_number}</strong><br>
|
|
Team: ${t.team ?? '—'}<br>
|
|
Speed: ${t.speed_kmh ?? '—'} km/h<br>
|
|
${window.Verde.formatDate(t.recorded_at)}
|
|
</div>
|
|
`);
|
|
markers.set(t.truck_id, m);
|
|
}
|
|
});
|
|
// Remove stale markers
|
|
for (const [id, marker] of markers) {
|
|
if (!seen.has(id)) {
|
|
map.removeLayer(marker);
|
|
markers.delete(id);
|
|
}
|
|
}
|
|
if (bounds.length > 0) {
|
|
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 });
|
|
}
|
|
|
|
// Update sidebar list
|
|
if (trucks.length === 0) {
|
|
truckList.innerHTML = `<div class="rounded-md border border-dashed border-neutral-200 p-4 text-center text-sm text-neutral-400">No trucks active in the last hour.</div>`;
|
|
return;
|
|
}
|
|
truckList.innerHTML = trucks.map(t => `
|
|
<div class="flex items-center justify-between rounded-md border border-neutral-200 bg-white p-3">
|
|
<div>
|
|
<div class="font-mono text-sm font-medium text-neutral-900">${window.Verde.escapeHtml(t.plate_number)}</div>
|
|
<div class="text-xs text-neutral-500">${window.Verde.escapeHtml(t.team ?? 'Unassigned')}</div>
|
|
</div>
|
|
<div class="text-right text-xs text-neutral-500">
|
|
${t.speed_kmh != null ? `${Math.round(t.speed_kmh)} km/h` : '—'}<br>
|
|
<span class="text-[10px]">${window.Verde.formatDate(t.recorded_at, { hour: '2-digit', minute: '2-digit' })}</span>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
refresh();
|
|
setInterval(refresh, 10000);
|
|
</script>
|
|
@endsection
|