Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" />
{{ 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 })
diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js
index 82ddf61..b787405 100644
--- a/services/targo-hub/lib/roster.js
+++ b/services/targo-hub/lib/roster.js
@@ -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$/)