feat(planif): positions GPS live des techs (Traccar) sur la carte des tournées
Onglet Tournées : marqueurs « live » de chaque tech (appareil Traccar associé),
rafraîchis ~25 s tant que l'onglet est ouvert. Marqueur = pastille ronde à initiales,
couleur = celle de la tournée du tech, halo pulsé si position fraîche (grisé sans halo
si > 10 min). Tooltip : nom · « il y a X min » · vitesse (km/h) ou « arrêté ».
Bouton « GPS live (N) » pour afficher/masquer. Respecte les chips techs masqués.
- hub roster.js : GET /roster/tech-positions → {positions:[{techId,techName,lat,lon,time,speed}]}
(batch getPositions par appareil). Vérifié prod : 14 positions.
- api/roster.js : techPositions().
- RouteMap.vue : prop `live` + marqueurs .rm-live (halo pulsé, staleness), watch dédié.
- PlanificationPage : polling 25 s (démarré sur l'onglet Tournées, arrêté sinon +
onUnmounted), couleur mappée depuis dayRoutes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c74462f97b
commit
d966e1b749
|
|
@ -182,6 +182,8 @@ export const jobServiceStatus = (job) => jget('/roster/job-service-status?job='
|
|||
export const onsiteCross = (days = 30) => jget('/traccar/onsite?days=' + days)
|
||||
// Position GPS LIVE (dernière connue) d'un tech → { ok, lat, lon, time, speed } ; pour fixer son point de départ (domicile).
|
||||
export const techLivePosition = (tech) => jget('/traccar/live?tech=' + encodeURIComponent(tech))
|
||||
// Positions GPS LIVE de TOUS les techs (appareil Traccar associé) → { positions:[{techId,techName,lat,lon,time,speed}] }.
|
||||
export const techPositions = () => jget('/roster/tech-positions')
|
||||
// Liste des appareils Traccar (pour associer un tech à son GPS) — { id, name, uniqueId, status, lastUpdate }.
|
||||
export const listTraccarDevices = () => jget('/traccar/devices')
|
||||
// Associer / changer (device_id) ou dissocier ('') l'appareil Traccar d'un tech — INLINE depuis Planif.
|
||||
|
|
|
|||
|
|
@ -15,12 +15,13 @@ import * as roster from 'src/api/roster'
|
|||
|
||||
const props = defineProps({
|
||||
routes: { type: Array, default: () => [] },
|
||||
live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }]
|
||||
height: { type: String, default: '440px' },
|
||||
})
|
||||
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)
|
||||
let stopMk = []; let homeMk = []; let liveMk = [] // marqueurs HTML (arrêts + domiciles + positions live)
|
||||
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 setEl (node) { el = node }
|
||||
|
|
@ -68,6 +69,29 @@ async function realLine (r) { // géométrie routière réelle d'une tournée (O
|
|||
|
||||
// ── Marqueurs HTML : le numéro est DANS la pastille (même élément, même niveau) ──
|
||||
function clearMarkers () { for (const m of stopMk) m.mk.remove(); for (const m of homeMk) m.mk.remove(); stopMk = []; homeMk = [] }
|
||||
|
||||
// ── Positions GPS LIVE des techs (marqueur distinct des arrêts : pastille ronde à initiales + halo pulsé) ──
|
||||
function initials (n) { return String(n || '?').trim().split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() }
|
||||
function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] }
|
||||
function renderLive (list) {
|
||||
if (!map || !ready) return
|
||||
clearLive()
|
||||
const mapboxgl = window.mapboxgl; const now = Date.now()
|
||||
for (const p of (list || [])) {
|
||||
if (!okLL(p.lon, p.lat)) continue
|
||||
const ageMin = p.time ? Math.round((now - Date.parse(p.time)) / 60000) : null
|
||||
const stale = ageMin != null && ageMin > 10 // > 10 min sans fix = position vieillie (pas de halo, grisée)
|
||||
const color = p.color || '#1e88e5'
|
||||
const wrap = document.createElement('div'); wrap.className = 'rm-live' + (stale ? ' stale' : '')
|
||||
wrap.innerHTML = '<span class="rm-live-pulse" style="background:' + color + '"></span>' +
|
||||
'<span class="rm-live-dot" style="background:' + color + '">' + initials(p.name || p.techName) + '</span>'
|
||||
const spd = (p.speed != null && p.speed > 1) ? Math.round(p.speed * 1.852) + ' km/h' : 'arrêté' // Traccar speed = nœuds → km/h
|
||||
const when = ageMin == null ? 'position' : (ageMin <= 1 ? "à l'instant" : 'il y a ' + ageMin + ' min')
|
||||
wrap.title = (p.name || p.techName || '') + ' — ' + when + ' · ' + spd
|
||||
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map)
|
||||
liveMk.push({ mk, el: wrap })
|
||||
}
|
||||
}
|
||||
function renderMarkers (routes) {
|
||||
clearMarkers()
|
||||
const mapboxgl = window.mapboxgl
|
||||
|
|
@ -149,6 +173,7 @@ async function draw () {
|
|||
const ls = map.getSource('rm-line'); if (!ls) return
|
||||
ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) })
|
||||
renderMarkers(routes)
|
||||
renderLive(props.live)
|
||||
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: 60, 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.
|
||||
|
|
@ -199,8 +224,9 @@ onMounted(async () => {
|
|||
draw()
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => { clearMarkers(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false })
|
||||
onBeforeUnmount(() => { clearMarkers(); clearLive(); 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 })
|
||||
watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live sans redessiner les tournées
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -228,4 +254,11 @@ watch(() => props.routes, () => { draw() }, { deep: true })
|
|||
.rm-mk.route-hot { z-index: 5; }
|
||||
.rm-mk.fanned { z-index: 6; }
|
||||
.rm-mk.fanned .rm-pill { box-shadow: 0 3px 10px rgba(0,0,0,.55); }
|
||||
/* Position GPS LIVE d'un tech : pastille ronde à initiales + halo pulsé (distincte des pastilles d'arrêt). */
|
||||
.rm-live { position: relative; width: 0; height: 0; z-index: 8; pointer-events: auto; }
|
||||
.rm-live .rm-live-dot { position: absolute; transform: translate(-50%, -50%); width: 28px; height: 28px; border-radius: 50%; border: 2.5px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,.5); color: #fff; font-size: 10.5px; font-weight: 800; display: flex; align-items: center; justify-content: center; z-index: 2; cursor: default; }
|
||||
.rm-live .rm-live-pulse { position: absolute; width: 28px; height: 28px; border-radius: 50%; opacity: .45; z-index: 1; animation: rm-live-pulse 1.8s ease-out infinite; }
|
||||
@keyframes rm-live-pulse { 0% { transform: translate(-50%,-50%) scale(1); opacity: .45; } 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } }
|
||||
.rm-live.stale .rm-live-pulse { display: none; }
|
||||
.rm-live.stale .rm-live-dot { opacity: .55; filter: grayscale(.5); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -485,6 +485,7 @@
|
|||
<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 />
|
||||
<q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn>
|
||||
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
|
||||
</div>
|
||||
<!-- Chips techs : enlever/ajouter une tournée de la carte d'un clic — pastille = nb de jobs, dans la couleur du tech -->
|
||||
|
|
@ -495,7 +496,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)" @stop-click="onRoutesStopClick" />
|
||||
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" 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 }}
|
||||
|
|
@ -4673,6 +4674,22 @@ const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs
|
|||
return out
|
||||
})
|
||||
const dayRoutes = computed(() => allDayRoutes.value.filter(r => !hiddenRouteTechs.value.has(r.id))) // ce que la carte affiche
|
||||
|
||||
// ── Positions GPS LIVE des techs (onglet Tournées) : polling doux (25 s) tant que l'onglet est ouvert ; couleur = celle de la tournée du tech ──
|
||||
const livePosRaw = ref([]) // [{ techId, techName, lat, lon, time, speed }]
|
||||
const showLivePos = ref(true) // afficher/masquer les positions GPS live sur la carte des tournées
|
||||
let _livePosTimer = null
|
||||
async function refreshLivePositions () { try { const r = await roster.techPositions(); livePosRaw.value = (r && r.positions) || [] } catch (e) { /* non bloquant */ } }
|
||||
function startLivePositions () { if (_livePosTimer) return; refreshLivePositions(); _livePosTimer = setInterval(refreshLivePositions, 25000) }
|
||||
function stopLivePositions () { if (_livePosTimer) { clearInterval(_livePosTimer); _livePosTimer = null } }
|
||||
// Positions pour la carte : masque les techs cachés (chips), colore selon leur tournée du jour (sinon gris neutre).
|
||||
const dayLivePositions = computed(() => {
|
||||
const colorByTech = {}; for (const r of (allDayRoutes.value || [])) colorByTech[r.id] = r.color
|
||||
return (livePosRaw.value || [])
|
||||
.filter(p => !hiddenRouteTechs.value.has(p.techId))
|
||||
.map(p => ({ techId: p.techId, name: p.techName, lat: p.lat, lon: p.lon, time: p.time, speed: p.speed, color: colorByTech[p.techId] || '#455a64' }))
|
||||
})
|
||||
watch(boardView, (v) => { if (v === 'routes') startLivePositions(); else stopLivePositions() }, { immediate: true })
|
||||
watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() })
|
||||
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).
|
||||
|
|
@ -5444,7 +5461,7 @@ function onLegacyUpdate (data) {
|
|||
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
|
||||
|
||||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
|
||||
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); if (_kbRO) _kbRO.disconnect() })
|
||||
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); stopLivePositions(); if (_kbRO) _kbRO.disconnect() })
|
||||
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1811,6 +1811,23 @@ async function handle (req, res, method, path, url) {
|
|||
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch))
|
||||
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status })
|
||||
}
|
||||
// Positions GPS LIVE de TOUS les techs ayant un appareil Traccar → marqueurs « live » sur la carte des tournées
|
||||
// (rafraîchi périodiquement côté client). Léger : une requête position par appareil (getPositions parallèle).
|
||||
if (path === '/roster/tech-positions' && method === 'GET') {
|
||||
const techs = (await fetchTechnicians()).filter(t => t.traccar_device_id)
|
||||
if (!techs.length) return json(res, 200, { positions: [] })
|
||||
const { getPositions } = require('./traccar')
|
||||
const ids = [...new Set(techs.map(t => parseInt(t.traccar_device_id)).filter(Boolean))]
|
||||
let positions = []
|
||||
try { positions = await getPositions(ids) } catch (e) { return json(res, 200, { positions: [], error: e.message }) }
|
||||
const byDev = {}; for (const p of positions) if (p && p.deviceId != null) byDev[p.deviceId] = p
|
||||
const out = []
|
||||
for (const t of techs) {
|
||||
const p = byDev[parseInt(t.traccar_device_id)]
|
||||
if (p && p.latitude != null) out.push({ techId: t.id, techName: t.name, lat: p.latitude, lon: p.longitude, time: p.fixTime || p.deviceTime || p.serverTime || null, speed: p.speed || 0 })
|
||||
}
|
||||
return json(res, 200, { positions: out })
|
||||
}
|
||||
// Associer / changer (ou retirer) l'appareil Traccar d'un tech (GPS live + tracé) — éditable INLINE depuis Planif.
|
||||
// device_id = id numérique Traccar (ou '' pour dissocier).
|
||||
const mTrk = path.match(/^\/roster\/technician\/(.+)\/traccar-device$/)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user