From c8afa97004aeee1c51183bbd965585a9e4abf951 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sat, 4 Jul 2026 10:06:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(planif):=20Tourn=C3=A9es=20=E2=80=94=20chi?= =?UTF-8?q?ps=20secteur=20+=20jobs=20non=20assign=C3=A9s=20sur=20la=20cart?= =?UTF-8?q?e=20(gouttes),=20filtre=20liste+carte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batch D — regrouper/situer les non-assignés du jour : - RouteMap : nouvelle prop `pins` (+ renderPins/clearPins/watch, gouttes oranges couleur=priorité, clic → @pin-click) — miroir de `live`, inclus dans le fit bounds - Planif Tournées : chips SECTEUR (par ville, comptés, triés) qui filtrent SIMULTANÉMENT la liste « à répartir » ET les gouttes sur la carte ; routeUnassignedPins (coords robustes latitude/lat), clic goutte → détail ; « Suggérer » agit sur le secteur si filtré Vérifié préview : jour 06/07 → 10 secteurs + 21 gouttes ; clic « STE CLOTILDE » → 4 chips + 4 gouttes (« 4 à répartir / 21 »). Co-Authored-By: Claude Opus 4.8 --- apps/ops/src/components/shared/RouteMap.vue | 34 ++++++++++++--- apps/ops/src/pages/PlanificationPage.vue | 48 +++++++++++++++------ 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/apps/ops/src/components/shared/RouteMap.vue b/apps/ops/src/components/shared/RouteMap.vue index 921d94b..aa6db71 100644 --- a/apps/ops/src/components/shared/RouteMap.vue +++ b/apps/ops/src/components/shared/RouteMap.vue @@ -16,12 +16,13 @@ import * as roster from 'src/api/roster' const props = defineProps({ routes: { type: Array, default: () => [] }, live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }] + pins: { type: Array, default: () => [] }, // jobs NON assignés à situer : [{ lat, lon, subject, name, color(priorité), city }] height: { type: String, default: '440px' }, }) -const emit = defineEmits(['metrics', 'stop-click']) +const emit = defineEmits(['metrics', 'stop-click', 'pin-click']) let el = null; let map = null; let ro = null; let ready = false; let drawSeq = 0 -let stopMk = []; let homeMk = []; let liveMk = [] // marqueurs HTML (arrêts + domiciles + positions live) +let stopMk = []; let homeMk = []; let liveMk = []; let pinMk = [] // marqueurs HTML (arrêts + domiciles + positions live + jobs non assignés) const _rmCache = RouteMapCache() // cache module (partagé entre instances) : signature → { geometry, km, mins } function RouteMapCache () { const g = globalThis; if (!g.__opsRouteMapCache) g.__opsRouteMapCache = new Map(); return g.__opsRouteMapCache } function setEl (node) { el = node } @@ -73,6 +74,7 @@ function clearMarkers () { for (const m of stopMk) m.mk.remove(); for (const m o // ── Positions GPS LIVE des techs (marqueur distinct des arrêts : pastille ronde à initiales + halo pulsé) ── function initials (n) { return String(n || '?').trim().split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() } function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] } +function clearPins () { for (const m of pinMk) m.mk.remove(); pinMk = [] } function renderLive (list) { if (!map || !ready) return clearLive() @@ -92,6 +94,22 @@ function renderLive (list) { liveMk.push({ mk, el: wrap }) } } +// Jobs NON assignés du jour : gouttes oranges (à situer/répartir) ; clic → la page ouvre le détail. +function renderPins (list) { + if (!map || !ready) return + clearPins() + const mapboxgl = window.mapboxgl + for (const p of (list || [])) { + if (!okLL(p.lon, p.lat)) continue + const color = p.color || '#f97316' + const wrap = document.createElement('div'); wrap.className = 'rm-pin' + wrap.innerHTML = '' + wrap.title = (p.subject || 'Job') + (p.city ? ' · ' + p.city : '') + ' — non assigné' + wrap.addEventListener('click', () => emit('pin-click', p)) + const mk = new mapboxgl.Marker({ element: wrap, anchor: 'bottom' }).setLngLat([+p.lon, +p.lat]).addTo(map) + pinMk.push({ mk, el: wrap }) + } +} function renderMarkers (routes) { clearMarkers() const mapboxgl = window.mapboxgl @@ -174,8 +192,9 @@ async function draw () { ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) }) renderMarkers(routes) renderLive(props.live) - const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|') - if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} } + renderPins(props.pins) + const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|') + '#' + (props.pins || []).length + if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); for (const p of (props.pins || [])) if (okLL(p.lon, p.lat)) b.extend([+p.lon, +p.lat]); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} } // Routes RÉELLES en asynchrone : remplace les segments droits + émet km/min réels. Ignoré si un draw plus récent est parti. const results = await Promise.all(routes.map(async r => ({ r, info: await realLine(r) }))) if (seq !== drawSeq || !map || !map.getSource('rm-line')) return @@ -224,9 +243,10 @@ onMounted(async () => { draw() }) }) -onBeforeUnmount(() => { clearMarkers(); clearLive(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false }) +onBeforeUnmount(() => { clearMarkers(); clearLive(); clearPins(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false }) watch(() => props.routes, () => { draw() }, { deep: true }) watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live sans redessiner les tournées +watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // jobs non assignés (secteur) sans redessiner les tournées