feat(dispatch): P2 RouteMap.vue shared component + P3 permanent "Tournées" tab

P2 — extract RouteMap.vue (reusable route map): colored per-tech routes
(instant straight lines → real OSRM geometry via hub, module-level
cache), numbered stops with popups, home markers, auto-fit, 'metrics'
emit (real km/min for legends), exposed fitTo(id)/fitAll(). The suggest
review map now uses it — deleted initSuggestMap/refreshSuggestMap/
destroySuggestMap/loadRealRoutes (~90 lines) and the TDZ-prone map
watches; the component is fully reactive on suggestRoutes.

P3 — new "Tournées" board view (grid | day | routes toggle, persisted):
the selected day's REAL routes (assigned jobs from occupancy, ordered by
route_order/start), one color per tech, real OSRM traces + km/min in the
legend, click tech = zoom (fitTo), click stop = detail popup. Day chips
over the visible week. Answers "tournée de la journée sélectionnée, par
tech avec chacun une couleur et trajets optimisés dynamiquement".

Verified: Tournées tab (16 routes, real road-following geometry,
metrics 140.4km/148min etc.); dialog map still renders via RouteMap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 18:54:46 -04:00
parent 462fcef9d5
commit ff43b54a76
2 changed files with 173 additions and 63 deletions

View File

@ -0,0 +1,128 @@
<script setup>
/**
* RouteMap carte de TOURNÉES réutilisable (revue du dispatch auto, onglet « Tournées », etc.).
*
* props.routes = [{ id, name, color, home:{lat,lon}|null, stops:[{lat,lon,seq,subject}] }]
* - trace INSTANTANÉMENT des segments droits, puis remplace par la géométrie ROUTIÈRE réelle
* (OSRM auto-hébergé via hub /roster/osrm-route, cache module partagé entre instances)
* - émet 'metrics' { id: { km, mins } } quand les routes réelles arrivent (pour les légendes)
* - expose fitTo(id) (zoom sur une tournée) et fitAll()
*/
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { MAPBOX_TOKEN } from 'src/config/erpnext'
import * as roster from 'src/api/roster'
const props = defineProps({
routes: { type: Array, default: () => [] },
height: { type: String, default: '440px' },
})
const emit = defineEmits(['metrics'])
const el = ref(null)
let map = null; let ro = null; let ready = false; let drawSeq = 0
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 ensureMapbox () {
return new Promise((resolve) => {
if (!document.getElementById('mapbox-css')) { const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'; l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l) }
if (window.mapboxgl) return resolve(window.mapboxgl)
let s = document.getElementById('mapbox-js')
if (!s) { s = document.createElement('script'); s.id = 'mapbox-js'; s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'; document.head.appendChild(s) }
s.addEventListener('load', () => resolve(window.mapboxgl))
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]))
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]) }
return b
}
function straightData (routes) {
const lines = []; const pts = []
for (const r of routes) {
const coords = []
if (r.home) { coords.push([+r.home.lon, +r.home.lat]); pts.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [+r.home.lon, +r.home.lat] }, properties: { kind: 'home', color: r.color } }) }
for (const s of (r.stops || [])) { coords.push([+s.lon, +s.lat]); pts.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [+s.lon, +s.lat] }, properties: { kind: 'stop', color: r.color, seq: String(s.seq), tech: r.name, subject: s.subject || '' } }) }
if (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color, id: r.id } })
}
return { lines, pts }
}
async function realLine (r) { // géométrie routière réelle d'une tournée (OSRM via hub), cache par signature
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
const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';')
if (_rmCache.has(sig)) return _rmCache.get(sig)
try {
const d = await roster.osrmRoute(pts)
if (!d || !Array.isArray(d.geometry) || !d.geometry.length) return null
const info = { geometry: { type: 'LineString', coordinates: d.geometry }, km: d.km, mins: d.mins }
_rmCache.set(sig, info); return info
} catch (e) { return null }
}
let _lastSig = ''
async function draw () {
if (!map || !ready) return
const seq = ++drawSeq
const routes = props.routes || []
const { lines, pts } = straightData(routes)
const ls = map.getSource('rm-line'); const ps = map.getSource('rm-pt')
if (!ls || !ps) return
ls.setData({ type: 'FeatureCollection', features: lines })
ps.setData({ type: 'FeatureCollection', features: pts })
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: 50, 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
const metrics = {}
const finalLines = results.map(({ r, info }) => {
if (info) { metrics[r.id] = { km: info.km, mins: info.mins }; return { type: 'Feature', geometry: info.geometry, properties: { color: r.color, id: r.id } } }
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])
return coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color, id: r.id } } : null
}).filter(Boolean)
map.getSource('rm-line').setData({ type: 'FeatureCollection', features: finalLines })
if (Object.keys(metrics).length) emit('metrics', metrics)
}
function fitTo (id) {
const r = (props.routes || []).find(x => x.id === id); if (!r || !map) return
try { const b = boundsOf([r]); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {}
}
function fitAll () { try { const b = boundsOf(props.routes || []); if (!b.isEmpty()) map.fitBounds(b, { padding: 50, maxZoom: 13, duration: 400 }) } catch (e) {} }
defineExpose({ fitTo, fitAll })
onMounted(async () => {
if (!MAPBOX_TOKEN || !el.value) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !el.value) return
mapboxgl.accessToken = MAPBOX_TOKEN
map = new mapboxgl.Map({ container: el.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
ro = new ResizeObserver(() => { if (map) map.resize() }); ro.observe(el.value)
map.on('load', () => {
map.resize()
map.addSource('rm-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({ id: 'rm-line', type: 'line', source: 'rm-line', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.75 } })
map.addSource('rm-pt', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
map.addLayer({ id: 'rm-home', type: 'circle', source: 'rm-pt', filter: ['==', ['get', 'kind'], 'home'], paint: { 'circle-radius': 7, 'circle-color': '#fff', 'circle-stroke-color': ['get', 'color'], 'circle-stroke-width': 3 } })
map.addLayer({ id: 'rm-stop', type: 'circle', source: 'rm-pt', filter: ['==', ['get', 'kind'], 'stop'], paint: { 'circle-radius': 10, 'circle-color': ['get', 'color'], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 } })
map.addLayer({ id: 'rm-seq', type: 'symbol', source: 'rm-pt', filter: ['==', ['get', 'kind'], 'stop'], layout: { 'text-field': ['get', 'seq'], 'text-size': 11, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
map.on('click', 'rm-stop', (e) => { const p = e.features[0].properties; new window.mapboxgl.Popup({ offset: 12 }).setLngLat(e.features[0].geometry.coordinates).setHTML(`<div style="font-size:12px"><b>${esc(p.tech)}</b> · arrêt ${esc(p.seq)}<br>${esc(p.subject)}</div>`).addTo(map) })
map.on('mouseenter', 'rm-stop', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'rm-stop', () => { map.getCanvas().style.cursor = '' })
ready = true
draw()
})
})
onBeforeUnmount(() => { 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 })
</script>
<template>
<div ref="el" class="route-map" :style="{ height }" />
</template>
<style scoped>
.route-map { border-radius: 8px; overflow: hidden; border: 1px solid #cfd8dc; }
</style>

View File

@ -43,7 +43,7 @@
<q-input dense outlined type="date" v-model="boardDate" style="width:150px"><q-tooltip>{{ boardView === 'kanban' ? 'Jour affiché (mode Jour)' : 'Début de la fenêtre (mode Semaine)' }}</q-tooltip></q-input> <q-input dense outlined type="date" v-model="boardDate" style="width:150px"><q-tooltip>{{ boardView === 'kanban' ? 'Jour affiché (mode Jour)' : 'Début de la fenêtre (mode Semaine)' }}</q-tooltip></q-input>
<q-select v-show="boardView === 'grid'" dense outlined v-model="days" :options="[{ label: 'Semaine', value: 7 }, { label: '2 sem.', value: 14 }]" style="width:108px" emit-value map-options @update:model-value="onDaysChange"><q-tooltip>Étendue de la grille</q-tooltip></q-select> <q-select v-show="boardView === 'grid'" dense outlined v-model="days" :options="[{ label: 'Semaine', value: 7 }, { label: '2 sem.', value: 14 }]" style="width:108px" emit-value map-options @update:model-value="onDaysChange"><q-tooltip>Étendue de la grille</q-tooltip></q-select>
<span class="text-caption text-grey-7" style="margin-right:-2px">Horaire :</span> <span class="text-caption text-grey-7" style="margin-right:-2px">Horaire :</span>
<q-btn-toggle v-model="boardView" dense unelevated no-caps :options="[{ value: 'grid', icon: 'calendar_view_week', label: 'Semaine' }, { value: 'kanban', icon: 'today', label: 'Jour' }]" toggle-color="primary" color="grey-3" text-color="grey-8"><q-tooltip>Semaine = grille (quarts + occupation, vue d'ensemble) · Jour = disposition d'une journée (glisser les jobs entre techs)</q-tooltip></q-btn-toggle> <q-btn-toggle v-model="boardView" dense unelevated no-caps :options="[{ value: 'grid', icon: 'calendar_view_week', label: 'Semaine' }, { value: 'kanban', icon: 'today', label: 'Jour' }, { value: 'routes', icon: 'route', label: 'Tournées' }]" toggle-color="primary" color="grey-3" text-color="grey-8"><q-tooltip>Semaine = grille (vue d'ensemble) · Jour = disposition d'une journée · Tournées = trajets réels de la journée, 1 couleur par tech (OSRM)</q-tooltip></q-btn-toggle>
<q-btn dense flat round icon="undo" :disable="!history.length" @click="undo"><q-tooltip>Annuler (Ctrl+Z)</q-tooltip></q-btn> <q-btn dense flat round icon="undo" :disable="!history.length" @click="undo"><q-tooltip>Annuler (Ctrl+Z)</q-tooltip></q-btn>
<q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn> <q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn>
<q-separator vertical class="q-mx-xs" /> <q-separator vertical class="q-mx-xs" />
@ -393,7 +393,7 @@
</q-dialog> </q-dialog>
</div> </div>
<div class="grid-wrap gt-sm" v-show="boardView !== 'kanban'"> <div class="grid-wrap gt-sm" v-show="boardView === 'grid'">
<table class="roster-grid" :class="{ 'day-mode': dayMode }"> <table class="roster-grid" :class="{ 'day-mode': dayMode }">
<thead> <thead>
<tr> <tr>
@ -472,6 +472,22 @@
<!-- VUE BOARD (Kanban horizontal, façon Dispatch/Gaiia) : pool « À assigner » VERTICAL (recherche + tri) à gauche ; <!-- VUE BOARD (Kanban horizontal, façon Dispatch/Gaiia) : pool « À assigner » VERTICAL (recherche + tri) à gauche ;
techs en LANES horizontales à ÉCHELLE D'HEURES (réutilise cellBands + pos + axisTicks). Glisser = assigner (hub). --> techs en LANES horizontales à ÉCHELLE D'HEURES (réutilise cellBands + pos + axisTicks). Glisser = assigner (hub). -->
<!-- P3 Onglet « Tournées » : trajets RÉELS (jobs assignés) de la journée sélectionnée, 1 couleur/tech (composant RouteMap partagé, OSRM) -->
<div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm">
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
<span class="text-caption text-grey-7">Journée :</span>
<q-btn-toggle v-model="routesDay" dense no-caps unelevated toggle-color="indigo" color="grey-3" text-color="grey-8" :options="dayList.slice(0, 7).map(d => ({ label: d.dow + ' ' + d.dnum, value: d.iso }))" />
<q-space />
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
</div>
<RouteMap ref="routesMapRef" :routes="dayRoutes" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" />
<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)">
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }} · {{ r.stops.length }} arrêt(s)<span v-if="dayRouteMetrics[r.id]"> · 🚗 {{ dayRouteMetrics[r.id].km }} km / {{ dayRouteMetrics[r.id].mins }} min</span>
</span>
<span v-if="!dayRoutes.length" class="text-grey-6" style="font-size:12px">Aucune tournée géolocalisée ce jour les jobs assignés n'ont pas de coordonnées, ou la journée est vide.</span>
</div>
</div>
<div v-if="boardView === 'kanban'" class="kbb-wrap"> <div v-if="boardView === 'kanban'" class="kbb-wrap">
<!-- Pool « À assigner » : liste verticale + recherche + tri (priorité/ville/distance/compétence/durée) --> <!-- Pool « À assigner » : liste verticale + recherche + tri (priorité/ville/distance/compétence/durée) -->
<div class="kbb-pool" :class="{ 'drop-hover': dropCell === '__pool__' }" @dragover.prevent="dropCell = '__pool__'" @dragleave="dropCell = null" @drop="onKanbanUnassign"> <div class="kbb-pool" :class="{ 'drop-hover': dropCell === '__pool__' }" @dragover.prevent="dropCell = '__pool__'" @dragleave="dropCell = null" @drop="onKanbanUnassign">
@ -1020,8 +1036,8 @@
<q-btn-toggle v-model="suggestMapDay" dense no-caps unelevated toggle-color="indigo" color="grey-3" text-color="grey-8" :options="suggestWindow.map(iso => ({ label: windowDayLabel(iso), value: iso }))" /> <q-btn-toggle v-model="suggestMapDay" dense no-caps unelevated toggle-color="indigo" color="grey-3" text-color="grey-8" :options="suggestWindow.map(iso => ({ label: windowDayLabel(iso), value: iso }))" />
</template> </template>
</div> </div>
<div v-show="suggestDlg.view === 'map'"> <div v-if="suggestDlg.view === 'map'">
<div ref="suggestMapEl" class="suggest-map"></div> <RouteMap :routes="suggestRoutes.map(r => ({ id: r.techId, name: r.techName, color: r.color, home: r.home, stops: r.stops }))" height="440px" @metrics="onRouteMetrics" />
<div class="suggest-legend"> <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-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> <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>
@ -1705,6 +1721,7 @@ import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-
import TechSelect from 'src/components/shared/TechSelect.vue' import TechSelect from 'src/components/shared/TechSelect.vue'
import SkillSelect from 'src/components/shared/SkillSelect.vue' import SkillSelect from 'src/components/shared/SkillSelect.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
const $q = useQuasar() const $q = useQuasar()
const router = useRouter() const router = useRouter()
@ -1853,7 +1870,7 @@ watch(dayMode, (on) => { if (on) showDayJobs.value = true }) // en mode Jour, af
// VUE KANBAN (mode de vue, sur le socle hub) : colonne « À assigner » + 1 colonne/tech, cartes glissables entre lanes. // VUE KANBAN (mode de vue, sur le socle hub) : colonne « À assigner » + 1 colonne/tech, cartes glissables entre lanes.
// Réutilise tout : occupation (avec équipes via job.assist), pool unassignedJobs, onCellDrop (= roster.assignJob hub). // Réutilise tout : occupation (avec équipes via job.assist), pool unassignedJobs, onCellDrop (= roster.assignJob hub).
const { prefs: planifPrefs, save: savePlanif } = useUserPrefs('planif', { view: 'grid' }) // préférence d'affichage SERVEUR (suit l'usager entre appareils) const { prefs: planifPrefs, save: savePlanif } = useUserPrefs('planif', { view: 'grid' }) // préférence d'affichage SERVEUR (suit l'usager entre appareils)
const boardView = ref(planifPrefs.value.view === 'kanban' ? 'kanban' : 'grid') // 'grid' | 'kanban' const boardView = ref(['kanban', 'routes'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | 'routes'
watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = thisMonday(); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } }) watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = thisMonday(); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } })
watch(() => planifPrefs.value.view, (v) => { if (v && v !== boardView.value) boardView.value = v }) // sync depuis le serveur (autre appareil) une fois chargé watch(() => planifPrefs.value.view, (v) => { if (v && v !== boardView.value) boardView.value = v }) // sync depuis le serveur (autre appareil) une fois chargé
const kbSelIso = ref('') // mode Jour : jour explicitement choisi ('' = auto aujourd'hui) const kbSelIso = ref('') // mode Jour : jour explicitement choisi ('' = auto aujourd'hui)
@ -2940,42 +2957,8 @@ function refreshAssignMap () {
} }
function destroyAssignMap () { assignLasso.value = false; if (_assignMapRO) { try { _assignMapRO.disconnect() } catch (e) {} _assignMapRO = null } if (_assignMap) { try { _assignMap.remove() } catch (e) {} _assignMap = null } } function destroyAssignMap () { assignLasso.value = false; if (_assignMapRO) { try { _assignMapRO.disconnect() } catch (e) {} _assignMapRO = null } if (_assignMap) { try { _assignMap.remove() } catch (e) {} _assignMap = null } }
// Grande carte des TOURNÉES proposées (revue Suggérer) : ligne domicilearrêts par tech, 1 couleur/tech, 1 jour // Grande carte des TOURNÉES proposées (revue Suggérer) : ligne domicilearrêts par tech, 1 couleur/tech, 1 jour
const suggestMapEl = ref(null); let _suggestMap = null, _suggestMapRO = null // Carte de la revue = composant partagé <RouteMap> (P2). Les métriques réelles (OSRM) remontent ici pour la légende.
async function initSuggestMap () { function onRouteMetrics (m) { for (const k in realRoutes) delete realRoutes[k]; Object.assign(realRoutes, m) }
if (!MAPBOX_TOKEN || !suggestMapEl.value || _suggestMap) return
const mapboxgl = await ensureMapbox(); if (!mapboxgl || !suggestMapEl.value) return
mapboxgl.accessToken = MAPBOX_TOKEN
_suggestMap = new mapboxgl.Map({ container: suggestMapEl.value, style: 'mapbox://styles/mapbox/streets-v12', center: [-73.55, 45.2], zoom: 8 })
_suggestMap.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right')
_suggestMapRO = new ResizeObserver(() => { if (_suggestMap) _suggestMap.resize() }); _suggestMapRO.observe(suggestMapEl.value)
_suggestMap.on('load', () => {
_suggestMap.resize()
_suggestMap.addSource('sr-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_suggestMap.addLayer({ id: 'sr-line', type: 'line', source: 'sr-line', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.75 } })
_suggestMap.addSource('sr-pt', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
_suggestMap.addLayer({ id: 'sr-home', type: 'circle', source: 'sr-pt', filter: ['==', ['get', 'kind'], 'home'], paint: { 'circle-radius': 7, 'circle-color': '#fff', 'circle-stroke-color': ['get', 'color'], 'circle-stroke-width': 3 } })
_suggestMap.addLayer({ id: 'sr-stop', type: 'circle', source: 'sr-pt', filter: ['==', ['get', 'kind'], 'stop'], paint: { 'circle-radius': 10, 'circle-color': ['get', 'color'], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2 } })
_suggestMap.addLayer({ id: 'sr-seq', type: 'symbol', source: 'sr-pt', filter: ['==', ['get', 'kind'], 'stop'], layout: { 'text-field': ['get', 'seq'], 'text-size': 11, 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-allow-overlap': true }, paint: { 'text-color': '#fff' } })
_suggestMap.on('click', 'sr-stop', (e) => { const p = e.features[0].properties; new window.mapboxgl.Popup({ offset: 12 }).setLngLat(e.features[0].geometry.coordinates).setHTML(`<div style="font-size:12px"><b>${_esc(p.tech)}</b> · arrêt ${p.seq}<br>${_esc(p.subject || '')}</div>`).addTo(_suggestMap) })
refreshSuggestMap()
})
}
function refreshSuggestMap () {
if (!_suggestMap || !_suggestMap.isStyleLoaded()) { setTimeout(refreshSuggestMap, 200); return }
const routes = suggestRoutes.value
for (const k in realRoutes) delete realRoutes[k] // repart des estimations ; les routes RÉELLES arrivent en async
const lines = []; const pts = []; const b = new window.mapboxgl.LngLatBounds()
for (const r of routes) {
const coords = []
if (r.home) { coords.push([r.home.lon, r.home.lat]); pts.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [r.home.lon, r.home.lat] }, properties: { kind: 'home', color: r.color } }); b.extend([r.home.lon, r.home.lat]) }
for (const s of r.stops) { coords.push([s.lon, s.lat]); pts.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [s.lon, s.lat] }, properties: { kind: 'stop', color: r.color, seq: String(s.seq), tech: r.techName, subject: s.subject } }); b.extend([s.lon, s.lat]) }
if (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color } })
}
const ls = _suggestMap.getSource('sr-line'); if (ls) ls.setData({ type: 'FeatureCollection', features: lines })
const ps = _suggestMap.getSource('sr-pt'); if (ps) ps.setData({ type: 'FeatureCollection', features: pts })
try { if (pts.length && !b.isEmpty()) _suggestMap.fitBounds(b, { padding: 50, maxZoom: 13, duration: 400 }) } catch (e) {}
loadRealRoutes(routes) // D v2 : remplace les segments droits par les routes ROUTIÈRES réelles (Mapbox Directions) + km/temps réels
}
// D v2 routes RÉELLES : Mapbox Directions par tech (domicilearrêts) géométrie routière + distance/durée réelles. Cache par signature. // D v2 routes RÉELLES : Mapbox Directions par tech (domicilearrêts) géométrie routière + distance/durée réelles. Cache par signature.
const _routeCache = new Map() const _routeCache = new Map()
const realRoutes = reactive({}) // techId { km, mins } (la légende préfère ces valeurs réelles quand présentes) const realRoutes = reactive({}) // techId { km, mins } (la légende préfère ces valeurs réelles quand présentes)
@ -2992,24 +2975,6 @@ async function fetchTechRoute (home, stops) {
_routeCache.set(sig, info); return info _routeCache.set(sig, info); return info
} catch (e) { return null } } catch (e) { return null }
} }
async function loadRealRoutes (routes) {
if (!routes.length || !_suggestMap) return
const results = await Promise.all(routes.map(async r => {
const info = await fetchTechRoute(r.home, r.stops)
return info ? { techId: r.techId, ...info } : null
}))
if (!_suggestMap || !_suggestMap.getSource('sr-line')) return
const byTech = {}; for (const x of results) { if (x) { byTech[x.techId] = x; realRoutes[x.techId] = { km: x.km, mins: x.mins } } }
const lines = []
for (const r of routes) {
const real = byTech[r.techId]
if (real && real.geometry) { lines.push({ type: 'Feature', geometry: real.geometry, properties: { color: r.color } }); continue }
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 (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color } })
}
_suggestMap.getSource('sr-line').setData({ type: 'FeatureCollection', features: lines })
}
function destroySuggestMap () { if (_suggestMapRO) { try { _suggestMapRO.disconnect() } catch (e) {} _suggestMapRO = null } if (_suggestMap) { try { _suggestMap.remove() } catch (e) {} _suggestMap = null } }
watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() }) watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() })
watch(assignJobsFiltered, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) }) // carte suit le filtre (date/compétence) + les jobs watch(assignJobsFiltered, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) }) // carte suit le filtre (date/compétence) + les jobs
watch(selectedJobs, () => { if (assignPanel.showMap && _assignMap) refreshAssignMap() }) // surlignage doré des pins/amas suit la sélection watch(selectedJobs, () => { if (assignPanel.showMap && _assignMap) refreshAssignMap() }) // surlignage doré des pins/amas suit la sélection
@ -4337,10 +4302,27 @@ const suggestRoutes = computed(() => {
} }
return out return out
}) })
// Watches de la carte des tournées DÉFINIS ICI (après suggestDlg/suggestMapDay/suggestGroups) pour éviter le piège TDZ (watch-avant-const). // Carte de la revue = <RouteMap> (réactif sur suggestRoutes) il ne reste qu'à défauter le jour affiché à l'ouverture de la vue carte.
watch(() => suggestDlg.view, (v) => { if (v === 'map') { if (!suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || ''; nextTick(() => { _suggestMap ? refreshSuggestMap() : initSuggestMap() }) } }) watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || '' })
watch([suggestMapDay, suggestGroups], () => { if (suggestDlg.view === 'map' && _suggestMap) refreshSuggestMap() }) // P3 Onglet « Tournées » : tournées RÉELLES (jobs assignés, occupation) de la journée choisie, 1 couleur/tech, tracés OSRM (RouteMap).
watch(() => suggestDlg.open, (o) => { if (!o) destroySuggestMap() }) const routesDay = ref(null)
const routesMapRef = ref(null)
const dayRouteMetrics = reactive({})
const dayRoutes = computed(() => {
const iso = routesDay.value; if (!iso) return []
const out = []; let ci = 0
for (const t of (visibleTechs.value || [])) {
const occ = techDayOcc(t.id, iso)
const jobs = ((occ && occ.jobs) || []).filter(j => !j.cancelled && j.lat != null && j.lon != null && isFinite(+j.lat) && isFinite(+j.lon) && Math.abs(+j.lat) > 0.01)
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 = techHomes.value[t.id]
out.push({ id: t.id, name: t.name, color: ROUTE_COLORS[ci++ % ROUTE_COLORS.length], home: (h && isFinite(+h.lat) && isFinite(+h.lon)) ? { lat: +h.lat, lon: +h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject })) })
}
return out
})
watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k] })
watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true })
// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées). // Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées).
const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s)) const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s))
// Existe-t-il AU MOINS un tech capable ? Sinon (données de compétences incomplètes) on ne bloque personne (évite de piéger une file inassignable). // Existe-t-il AU MOINS un tech capable ? Sinon (données de compétences incomplètes) on ne bloque personne (évite de piéger une file inassignable).