feat(routemap): full ticket detail on pin click + routes return to origin

- Click a pin → the FULL job-detail dialog (address, description, ticket
  thread, team) instead of a mini popup. RouteMap emits 'stop-click'
  (stop payload + route id); stops now carry their job identity
  (suggestRoutes → entry/name, dayRoutes → job/name). Parent wires
  onReviewStopClick (→ openEntryDetail) and onRoutesStopClick (→
  openJobDetail). Verified on both maps (#253081 Tournées, #253286
  review).
- Routes now RETURN to the starting point: line + OSRM geometry + km/min
  close the loop home→stops→home in RouteMap; the review leg list gets a
  "🔄 retour 🏢 bureau TARGO : X min" row (fetchTechRoute appends origin;
  loadLegTimes captures returnTo). The solver already optimized round
  trips; now the displayed distance/time reflect it. Verified: 11 return
  legs shown.
- Fix (0,0) coord blowing out map bounds: suggestRoutes now filters
  |lat|>0.01 (dayRoutes already did) and RouteMap.boundsOf guards okLL —
  review map was zooming to the Gulf of Guinea. Verified: bounds back on
  Montérégie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 20:19:57 -04:00
parent d5c6240c6e
commit e92c220396
2 changed files with 39 additions and 19 deletions

View File

@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 domicilearrêtsdomicile
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(`<div style="font-size:12px"><b>${esc(rec.tech)}</b> · arrêt ${esc(rec.seq)}<br>${esc(rec.subject)}</div>`).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

View File

@ -488,7 +488,7 @@
<q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }}</q-tooltip>
</span>
</div>
<RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" />
<RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" />
<div class="suggest-legend q-mt-xs">
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }}
@ -1047,7 +1047,7 @@
</template>
</div>
<div v-if="suggestDlg.view === 'map'">
<RouteMap :routes="suggestRoutes.map(r => ({ id: r.techId, name: r.techName, color: r.color, home: r.home, stops: r.stops }))" height="440px" @metrics="onRouteMetrics" />
<RouteMap :routes="suggestRoutes.map(r => ({ id: r.techId, name: r.techName, color: r.color, home: r.home, stops: r.stops }))" height="440px" @metrics="onRouteMetrics" @stop-click="onReviewStopClick" />
<div class="suggest-legend">
<span v-for="r in suggestRoutes" :key="r.techId" class="suggest-leg"><span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.techName }} · {{ r.stops.length }} arrêt(s)<span v-if="realRoutes[r.techId]"> · 🚗 {{ realRoutes[r.techId].km }} km / {{ realRoutes[r.techId].mins }} min</span><span v-else-if="r.km"> · 🚗 {{ r.km }} km</span></span>
<span v-if="!suggestRoutes.length" class="text-grey-6" style="font-size:12px">Aucun job géolocalisé ce jour (utilise « Localiser » sur le pool pour les placer sur la carte).</span>
@ -1162,6 +1162,8 @@
</div>
<div v-if="legInfo(g, d, ei)" class="suggest-leg-time" :title="legInfo(g, d, ei).real ? 'Temps de route réel (OSRM)' : 'Estimation à vol d\'oiseau'">🚗 {{ legInfo(g, d, ei).mins }} min{{ legInfo(g, d, ei).real ? '' : ' ' }}</div>
</template>
<!-- Retour au point de départ (le tech revient chez lui / au bureau) inclus dans la distance/temps de la tournée -->
<div v-if="legReturn(g, d) != null" class="suggest-leg-time suggest-leg-return">🔄 retour {{ (legsByKey[g.techId + '|' + d.iso] || {}).kind === 'depot' ? '🏢 bureau TARGO' : '🏠 domicile' }} : {{ legReturn(g, d) }} min</div>
</div>
</div>
</div>
@ -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 domicilearrêtsdomicile
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; }