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' }}
- Object.assign(dayRouteMetrics, m)" />
+ Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" />
{{ 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; }