diff --git a/apps/ops/src/components/shared/RouteMap.vue b/apps/ops/src/components/shared/RouteMap.vue new file mode 100644 index 0000000..c58e0b5 --- /dev/null +++ b/apps/ops/src/components/shared/RouteMap.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 319a4a4..7a6eb5e 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -43,7 +43,7 @@ {{ boardView === 'kanban' ? 'Jour affiché (mode Jour)' : 'Début de la fenêtre (mode Semaine)' }} Étendue de la grille Horaire : - Semaine = grille (quarts + occupation, vue d'ensemble) · Jour = disposition d'une journée (glisser les jobs entre techs) + 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) Annuler (Ctrl+Z) Rétablir (Ctrl+Shift+Z) @@ -393,7 +393,7 @@ -
+
@@ -472,6 +472,22 @@ + +
+
+ Journée : + + + {{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail +
+ +
+ + {{ r.name }} · {{ r.stops.length }} arrêt(s) · 🚗 {{ dayRouteMetrics[r.id].km }} km / {{ dayRouteMetrics[r.id].mins }} min + + Aucune tournée géolocalisée ce jour — les jobs assignés n'ont pas de coordonnées, ou la journée est vide. +
+
@@ -1020,8 +1036,8 @@
-
-
+
+
{{ 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). @@ -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 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 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 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. // 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 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(() => 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) @@ -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 } } // ── Grande carte des TOURNÉES proposées (revue Suggérer) : ligne domicile→arrêts par tech, 1 couleur/tech, 1 jour ── -const suggestMapEl = ref(null); let _suggestMap = null, _suggestMapRO = null -async function initSuggestMap () { - 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(`
${_esc(p.tech)} · arrêt ${p.seq}
${_esc(p.subject || '')}
`).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 -} +// Carte de la revue = composant partagé (P2). Les métriques réelles (OSRM) remontent ici pour la légende. +function onRouteMetrics (m) { for (const k in realRoutes) delete realRoutes[k]; Object.assign(realRoutes, m) } // D v2 — routes RÉELLES : Mapbox Directions par tech (domicile→arrêts) → géométrie routière + distance/durée réelles. Cache par signature. const _routeCache = new Map() 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 } 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(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 @@ -4337,10 +4302,27 @@ const suggestRoutes = computed(() => { } 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). -watch(() => suggestDlg.view, (v) => { if (v === 'map') { if (!suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || ''; nextTick(() => { _suggestMap ? refreshSuggestMap() : initSuggestMap() }) } }) -watch([suggestMapDay, suggestGroups], () => { if (suggestDlg.view === 'map' && _suggestMap) refreshSuggestMap() }) -watch(() => suggestDlg.open, (o) => { if (!o) destroySuggestMap() }) +// Carte de la revue = (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' && !suggestMapDay.value) suggestMapDay.value = suggestWindow.value[0] || '' }) +// ── P3 — Onglet « Tournées » : tournées RÉELLES (jobs assignés, occupation) de la journée choisie, 1 couleur/tech, tracés OSRM (RouteMap). ── +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). 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).