feat(filters): sort number plates A→Z + add assigned-city filter

- Number-plate dropdown now sorted alphabetically (natural/numeric, placeholder
  pinned), re-sorted as live-discovered plates are added.
- New 'Assigned city' filter: populated from the API + live feed, filters the
  live map instantly (with cost centre + plate), auto-fills from a picked
  vehicle, and is passed to the trips query (assigned_city).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kianiadee 2026-06-05 23:23:57 +03:00
parent 135253d37d
commit b930582dc8

View file

@ -299,6 +299,10 @@
<label for="f-cc">Cost centre</label>
<select id="f-cc"><option value="">All cost centres</option></select>
</div>
<div class="field">
<label for="f-city">Assigned city</label>
<select id="f-city"><option value="">All cities</option></select>
</div>
<div class="field">
<label for="f-period">Time</label>
<select id="f-period">
@ -683,6 +687,7 @@ async function loadFilters() {
const data = await resp.json();
fillVehicleSelect(data.vehicles || []);
fillSelect('f-cc', data.cost_centres || []);
fillSelect('f-city', data.cities || []);
} catch (e) { console.error('loadFilters failed:', e); }
}
function fillVehicleSelect(vehicles) {
@ -695,34 +700,53 @@ function fillVehicleSelect(vehicles) {
opt.value = plate; opt.textContent = v.drivers ? `${plate} — ${v.drivers}` : plate;
sel.appendChild(opt);
});
sortSelect('f-vehicle');
sel.addEventListener('change', applyVehicleAutoFilter);
}
function fillSelect(id, values) {
const sel = document.getElementById(id);
const have = new Set(Array.from(sel.options).map(o => o.value));
values.forEach(v => { if (have.has(v)) return; const o = document.createElement('option'); o.value = v; o.textContent = v; sel.appendChild(o); });
let added = false;
values.forEach(v => { if (have.has(v)) return; const o = document.createElement('option'); o.value = v; o.textContent = v; sel.appendChild(o); added = true; });
if (added) sortSelect(id);
}
// Add any plate/cc seen in the live feed that the filter-options endpoint missed
// Reorder a select's options A→Z by value (natural/numeric), keeping any
// blank-value placeholder ("All …") pinned at the top and preserving selection.
function sortSelect(id) {
const sel = document.getElementById(id);
const selected = new Set(Array.from(sel.selectedOptions).map(o => o.value));
const opts = Array.from(sel.options);
const head = opts.filter(o => o.value === '');
const body = opts.filter(o => o.value !== '')
.sort((a, b) => a.value.localeCompare(b.value, undefined, { numeric: true, sensitivity: 'base' }));
sel.replaceChildren(...head, ...body);
Array.from(sel.options).forEach(o => { o.selected = selected.has(o.value); });
}
// Add any plate/cc/city seen in the live feed that the filter-options endpoint missed
function populateFiltersFromLive(features) {
const ccs = new Set(), vehSel = document.getElementById('f-vehicle');
const ccs = new Set(), cities = new Set(), vehSel = document.getElementById('f-vehicle');
const havePlates = new Set(Array.from(vehSel.options).map(o => o.value));
let addedPlate = false;
features.forEach(f => {
const p = f.properties;
if (p.cost_centre) ccs.add(p.cost_centre);
if (p.assigned_city) cities.add(p.assigned_city);
if (p.vehicle_number && !havePlates.has(p.vehicle_number)) {
havePlates.add(p.vehicle_number);
VEHICLE_META.set(p.vehicle_number, { cost_centre: p.cost_centre, assigned_city: p.assigned_city });
const o = document.createElement('option'); o.value = p.vehicle_number; o.textContent = p.vehicle_number; vehSel.appendChild(o);
const o = document.createElement('option'); o.value = p.vehicle_number; o.textContent = p.vehicle_number; vehSel.appendChild(o); addedPlate = true;
}
});
if (addedPlate) sortSelect('f-vehicle');
fillSelect('f-cc', Array.from(ccs).sort());
fillSelect('f-city', Array.from(cities).sort());
}
function applyVehicleAutoFilter() {
const sel = document.getElementById('f-vehicle');
const selected = Array.from(sel.selectedOptions).map(o => o.value);
const metas = selected.map(p => VEHICLE_META.get(p)).filter(Boolean);
const sharedCC = collapse(metas.map(m => m.cost_centre));
setSelectValue('f-cc', sharedCC ?? '');
setSelectValue('f-cc', collapse(metas.map(m => m.cost_centre)) ?? '');
setSelectValue('f-city', collapse(metas.map(m => m.assigned_city)) ?? '');
applyLiveFilters(); // reflect the plate selection on the live map immediately
}
function collapse(values) { if (!values.length) return null; const f = values[0]; return values.every(v => v === f) ? f : null; }
@ -742,10 +766,11 @@ function applyLiveFilters() {
if (mode !== 'live') return;
const plates = new Set(Array.from(document.getElementById('f-vehicle').selectedOptions).map(o => o.value).filter(Boolean));
const cc = document.getElementById('f-cc').value;
const city = document.getElementById('f-city').value;
let total = 0, moving = 0, parked = 0, offline = 0; const speeds = [];
(lastLivePayload?.geojson?.features || []).forEach(f => {
const p = f.properties; const m = liveMarkers.get(p.imei); if (!m) return;
const pass = (plates.size === 0 || plates.has(p.vehicle_number)) && (!cc || p.cost_centre === cc);
const pass = (plates.size === 0 || plates.has(p.vehicle_number)) && (!cc || p.cost_centre === cc) && (!city || p.assigned_city === city);
m.getElement().style.display = pass ? '' : 'none';
if (!pass) return;
total++;
@ -761,8 +786,9 @@ function applyLiveFilters() {
document.getElementById('f-period').addEventListener('change', e => {
document.getElementById('custom').classList.toggle('show', e.target.value === 'custom');
});
// Selecting a cost centre filters the live map immediately.
// Selecting a cost centre or assigned city filters the live map immediately.
document.getElementById('f-cc').addEventListener('change', applyLiveFilters);
document.getElementById('f-city').addEventListener('change', applyLiveFilters);
function periodToRange(period, cs, ce) {
const today = new Date(); const fmt = d => d.toISOString().slice(0, 10);
const minus = n => { const x = new Date(today); x.setDate(x.getDate() - n); return x; };
@ -780,19 +806,20 @@ function currentFilterSelection() {
const period = document.getElementById('f-period').value;
return {
vehicles, cost_centre: document.getElementById('f-cc').value || '',
assigned_city: document.getElementById('f-city').value || '',
period, start: document.getElementById('f-start').value, end: document.getElementById('f-end').value,
};
}
// "Show trips" button → fleet-wide / multi-vehicle trips from the filter card
document.getElementById('show-trips').addEventListener('click', () => {
const sel = currentFilterSelection();
enterTrips({ vehicle_numbers: sel.vehicles, cost_centre: sel.cost_centre, period: sel.period, start: sel.start, end: sel.end });
enterTrips({ vehicle_numbers: sel.vehicles, cost_centre: sel.cost_centre, assigned_city: sel.assigned_city, period: sel.period, start: sel.start, end: sel.end });
});
// Map-dot path → single vehicle, current period in the card
function enterTripsForVehicle(plate) {
if (!plate) return;
const sel = currentFilterSelection();
enterTrips({ vehicle_numbers: [plate], cost_centre: '', period: sel.period, start: sel.start, end: sel.end });
enterTrips({ vehicle_numbers: [plate], cost_centre: '', assigned_city: '', period: sel.period, start: sel.start, end: sel.end });
}
// ============================================================================
@ -812,6 +839,7 @@ async function enterTrips(sel) {
const params = new URLSearchParams({
vehicle_numbers: (sel.vehicle_numbers || []).join(','),
cost_centre: sel.cost_centre || '',
assigned_city: sel.assigned_city || '',
period: sel.period, start_date: range.start_date, end_date: range.end_date,
});
try {