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:
parent
462fcef9d5
commit
ff43b54a76
128
apps/ops/src/components/shared/RouteMap.vue
Normal file
128
apps/ops/src/components/shared/RouteMap.vue
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||||
|
|
@ -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 domicile→arrêts par tech, 1 couleur/tech, 1 jour ──
|
// ── 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
|
// 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 (domicile→arrêts) → géométrie routière + distance/durée réelles. Cache par signature.
|
// 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 _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).
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user