diff --git a/apps/ops/src/components/shared/RouteMap.vue b/apps/ops/src/components/shared/RouteMap.vue index 0717665..1f324e8 100644 --- a/apps/ops/src/components/shared/RouteMap.vue +++ b/apps/ops/src/components/shared/RouteMap.vue @@ -17,7 +17,7 @@ const props = defineProps({ routes: { type: Array, default: () => [] }, height: { type: String, default: '440px' }, }) -const emit = defineEmits(['metrics']) +const emit = defineEmits(['metrics', 'stop-click']) let el = null; let map = null; let ro = null; let ready = false; let drawSeq = 0 let stopMk = []; let homeMk = [] // marqueurs HTML (arrêts + domiciles) @@ -35,11 +35,10 @@ function ensureMapbox () { const iv = setInterval(() => { if (window.mapboxgl) { clearInterval(iv); resolve(window.mapboxgl) } }, 150) }) } -const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) - +const okLL = (lon, lat) => isFinite(+lon) && isFinite(+lat) && Math.abs(+lat) > 0.01 // écarte (0,0) / invalides qui feraient dézoomer la carte function boundsOf (routes) { const b = new window.mapboxgl.LngLatBounds() - for (const r of routes) { if (r.home) b.extend([+r.home.lon, +r.home.lat]); for (const s of (r.stops || [])) b.extend([+s.lon, +s.lat]) } + for (const r of routes) { if (r.home && okLL(r.home.lon, r.home.lat)) b.extend([+r.home.lon, +r.home.lat]); for (const s of (r.stops || [])) if (okLL(s.lon, s.lat)) b.extend([+s.lon, +s.lat]) } return b } function lineFeatures (routes) { @@ -48,13 +47,15 @@ function lineFeatures (routes) { const coords = [] if (r.home) coords.push([+r.home.lon, +r.home.lat]) for (const s of (r.stops || [])) coords.push([+s.lon, +s.lat]) + if (r.home && coords.length >= 2) coords.push([+r.home.lon, +r.home.lat]) // RETOUR au départ : boucle domicile→arrêts→domicile if (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color, id: r.id, rid: r.id } }) } return lines } -async function realLine (r) { // géométrie routière réelle d'une tournée (OSRM via hub), cache par signature +async function realLine (r) { // géométrie routière réelle d'une tournée (OSRM via hub), cache par signature — BOUCLE avec retour au départ const pts = []; if (r.home) pts.push([+r.home.lon, +r.home.lat]); for (const s of (r.stops || [])) pts.push([+s.lon, +s.lat]) - if (pts.length < 2 || pts.length > 50) return null + if (r.home) pts.push([+r.home.lon, +r.home.lat]) // le tech revient à son point de départ → compté dans km/min + if (pts.length < 2 || pts.length > 52) return null const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';') if (_rmCache.has(sig)) return _rmCache.get(sig) try { @@ -83,19 +84,15 @@ function renderMarkers (routes) { const disc = document.createElement('div'); disc.className = 'rm-disc'; disc.style.background = r.color; disc.textContent = String(s.seq) wrap.appendChild(disc) const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+s.lon, +s.lat]).addTo(map) - const rec = { mk, el: wrap, disc, rid: r.id, lngLat: [+s.lon, +s.lat], seq: s.seq, tech: r.name, subject: s.subject || '', fan: null, members: null } + const rec = { mk, el: wrap, disc, rid: r.id, lngLat: [+s.lon, +s.lat], seq: s.seq, tech: r.name, subject: s.subject || '', stop: s, fan: null, members: null } wrap.addEventListener('mouseenter', () => onEnter(rec)) wrap.addEventListener('mouseleave', () => onLeave(rec)) - wrap.addEventListener('click', () => openPopup(rec)) + wrap.addEventListener('click', () => emit('stop-click', rec.stop, rec.rid)) // → la page ouvre la fiche détail complète du ticket stopMk.push(rec) } } recluster() } -function openPopup (rec) { - new window.mapboxgl.Popup({ offset: 14 }).setLngLat(rec.lngLat) - .setHTML(`
${esc(rec.tech)} · arrêt ${esc(rec.seq)}
${esc(rec.subject)}
`).addTo(map) -} // Regroupe les pastilles qui se CHEVAUCHENT à l'écran (distance pixel) → calcule pour chacune un décalage en éventail. function recluster () { if (!map || !stopMk.length) return diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index caa002c..9067f54 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -488,7 +488,7 @@ {{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }} - +
{{ r.name }} @@ -1047,7 +1047,7 @@
- +
{{ r.techName }} · {{ r.stops.length }} arrêt(s) · 🚗 {{ realRoutes[r.techId].km }} km / {{ realRoutes[r.techId].mins }} min · 🚗 ≈{{ r.km }} km Aucun job géolocalisé ce jour (utilise « Localiser » sur le pool pour les placer sur la carte). @@ -1162,6 +1162,8 @@
🚗 {{ legInfo(g, d, ei).mins }} min{{ legInfo(g, d, ei).real ? '' : ' ≈' }}
+ +
🔄 retour {{ (legsByKey[g.techId + '|' + d.iso] || {}).kind === 'depot' ? '🏢 bureau TARGO' : '🏠 domicile' }} : {{ legReturn(g, d) }} min
@@ -2986,7 +2988,8 @@ const realRoutes = reactive({}) // techId → { km, mins } (la légende préfèr // Tournée routière RÉELLE d'un tech (OSRM auto-hébergé via hub, plus de Mapbox payant) : tracé + total + temps ENTRE arrêts. Cache par signature. async function fetchTechRoute (home, stops) { const pts = []; if (home) pts.push([+home.lon, +home.lat]); for (const s of stops) pts.push([+s.lon, +s.lat]) - if (pts.length < 2 || pts.length > 50) return null + if (home) pts.push([+home.lon, +home.lat]) // RETOUR au point de départ : la tournée est une boucle domicile→arrêts→domicile + if (pts.length < 2 || pts.length > 52) return null const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';') if (_routeCache.has(sig)) return _routeCache.get(sig) try { @@ -3947,6 +3950,23 @@ function openEntryDetail (e, g) { // clic sur un job de la revue → même fiche lat: e.lat, lon: e.lon, }, (g && !g.placeholder) ? { id: g.techId, name: g.techName } : null) } +// Clic sur une pastille de la carte → fiche détail COMPLÈTE du ticket (même dialog que la grille). rid = techId de la tournée. +function onReviewStopClick (stop, rid) { // carte de la REVUE : l'arrêt porte l'entrée du plan + if (!stop) return + const e = stop.entry || { jobName: stop.name, subject: stop.subject, skill: '', lat: stop.lat, lon: stop.lon, dur: 0 } + const g = suggestGroups.value.find(x => x.techId === rid) || { techId: rid, techName: (techById.value[rid] || {}).name || '', placeholder: false } + openEntryDetail(e, g) +} +function onRoutesStopClick (stop, rid) { // onglet TOURNÉES : l'arrêt porte le job d'occupation + if (!stop) return + const j = stop.job || {} + openJobDetail({ + name: stop.name || j.name, subject: stop.subject || j.subject, customer: j.customer || j.customer_name || '', + address: j.address || j.location_label || '', skill: j.skill || '', dur: j.dur, + detail: j.legacy_detail || '', legacy_id: j.legacy_id || j.legacy_ticket_id || null, dept: j.legacy_dept || j.dept || '', + lat: stop.lat, lon: stop.lon, + }, { id: rid, name: (techById.value[rid] || {}).name || '' }) +} function setEntryDur (e, v) { // temps révisé : plan (réactif) + mémoire de session + persistance ERPNext duration_h const h = Math.round(Math.max(0.25, Math.min(24, Number(v) || 0)) * 4) / 4 if (!h) return @@ -3964,10 +3984,11 @@ async function loadLegTimes () { if (geo.length < (h ? 1 : 2)) continue const info = await fetchTechRoute(h, geo.map(e => ({ lat: +e.lat, lon: +e.lon }))) if (!info || !info.legs || !info.legs.length) continue - const legs = info.legs; const out = { homeTo: null, after: {}, kind: h ? h.kind : null } + const legs = info.legs; const out = { homeTo: null, after: {}, returnTo: null, kind: h ? h.kind : null } let li = 0 if (h) { out.homeTo = legs[0] ? legs[0].mins : null; li = 1 } for (let k = 0; k < geo.length - 1; k++) { const L = legs[li + k]; if (L) out.after[geo[k].jobName] = L.mins } + if (h) { const rl = legs[li + geo.length - 1]; if (rl) out.returnTo = rl.mins } // dernier segment = dernier arrêt → point de départ legsByKey[g.techId + '|' + d.iso] = out } } @@ -3982,6 +4003,7 @@ function legInfo (g, d, ei) { return null } function legHome (g, d) { const v = (legsByKey[g.techId + '|' + d.iso] || {}).homeTo; return v != null ? v : null } +function legReturn (g, d) { const v = (legsByKey[g.techId + '|' + d.iso] || {}).returnTo; return v != null ? v : null } // temps de retour au départ // Contraintes horaires par job (⚡ Optimiser) : AM/PM (fenêtre dure) + « tôt » (urgence → début de journée). Session (clé = jobName, survit aux ré-optimisations). const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge) function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} } @@ -4383,10 +4405,10 @@ const suggestRoutes = computed(() => { // MÊME ADRESSE = 1 ARRÊT sur la carte : les entrées aux mêmes coordonnées (~10 m) partagent un seul pin numéroté. const stops = []; const seen = new Map() for (const e of d.entries) { - if (e.lat == null || e.lon == null) continue + if (e.lat == null || e.lon == null || !isFinite(+e.lat) || !isFinite(+e.lon) || Math.abs(+e.lat) < 0.01) continue // 0,0 / invalides exclus (sinon la carte dézoome jusqu'au golfe de Guinée) const k = (+e.lat).toFixed(4) + ',' + (+e.lon).toFixed(4) const ex = seen.get(k) - if (ex) { ex.n++; ex.subject = ex.n + ' jobs — ' + ex.first } else { const s = { lat: +e.lat, lon: +e.lon, seq: stops.length + 1, subject: e.subject, first: e.subject, n: 1 }; seen.set(k, s); stops.push(s) } + if (ex) { ex.n++; ex.subject = ex.n + ' jobs — ' + ex.first } else { const s = { lat: +e.lat, lon: +e.lon, seq: stops.length + 1, subject: e.subject, first: e.subject, n: 1, name: e.jobName, entry: e }; seen.set(k, s); stops.push(s) } } if (!stops.length) continue const home = techOrigin(g.techId) // domicile, sinon bureau TARGO @@ -4412,7 +4434,7 @@ const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs if (!jobs.length) continue jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel const h = techOrigin(t.id) // domicile, sinon bureau TARGO - out.push({ id: t.id, name: t.name, color: routeColor(ci++), home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject })) }) + out.push({ id: t.id, name: t.name, color: routeColor(ci++), home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject, name: j.name, job: j })) }) } return out }) @@ -5300,6 +5322,7 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return .suggest-dur { font-size: 11px; white-space: nowrap; color: #475569; border-bottom: 1px dashed #cbd5e1; cursor: pointer; } .suggest-dur:hover { color: #4f46e5; border-bottom-color: #4f46e5; } .suggest-leg-time { font-size: 10px; color: #94a3b8; padding: 0 6px 2px 30px; line-height: 1.2; } +.suggest-leg-return { color: #64748b; font-style: italic; padding-top: 2px; border-top: 1px dashed #e2e8f0; margin-top: 1px; } .rt-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12px; font-weight: 600; color: #334155; cursor: pointer; user-select: none; background: #fff; } .rt-chip.off { opacity: 0.55; border-style: dashed; } .rt-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }