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:
parent
d5c6240c6e
commit
e92c220396
|
|
@ -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(`<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
|
||||
|
|
|
|||
|
|
@ -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 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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user