Files
Verde-Web/resources/views/admin/live-tracking.blade.php
admin c2115b767e feat(web): replace remaining "Soon" stubs with real pages
- 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>
2026-05-01 14:07:59 +08:00

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: '&copy; 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