From f1badea201c038dac17ea401b9ad3d03196f893c Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 26 Mar 2026 20:17:13 -0400 Subject: [PATCH] fix: add watcher for GPS position updates on map markers Technician GPS positions from Traccar were being fetched and stored correctly every 30s but drawMapMarkers() was never triggered on gpsCoords change, so markers stayed at their initial position. Added a deep watcher on store.technicians[].gpsCoords in useMap.js that calls drawMapMarkers() whenever any technician's GPS position is updated by pollGps(). Also includes traccar.js API module and dispatch store GPS polling (pollGps / startGpsPolling / stopGpsPolling). Co-Authored-By: Claude Sonnet 4.6 --- src/api/traccar.js | 81 +++++++++++++++++++++++++++++++++++++++ src/composables/useMap.js | 13 ++++++- src/stores/dispatch.js | 58 +++++++++++++++++++++++++++- 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 src/api/traccar.js diff --git a/src/api/traccar.js b/src/api/traccar.js new file mode 100644 index 0000000..c992eea --- /dev/null +++ b/src/api/traccar.js @@ -0,0 +1,81 @@ +// ── Traccar GPS API ────────────────────────────────────────────────────────── +// Polls Traccar for real-time device positions. +// Auth: session cookie via POST /api/session +// ───────────────────────────────────────────────────────────────────────────── + +// Use proxy on same origin to avoid mixed content (HTTPS → HTTP) +const TRACCAR_URL = window.location.hostname === 'localhost' + ? 'http://tracker.targointernet.com:8082' + : window.location.origin + '/traccar' +const TRACCAR_USER = 'louis@targo.ca' +const TRACCAR_PASS = 'targo2026' + +let _devices = [] + +// Use Basic auth — works through proxy without cookies +function authOpts () { + return { + headers: { + Authorization: 'Basic ' + btoa(TRACCAR_USER + ':' + TRACCAR_PASS), + Accept: 'application/json', + } + } +} + +// ── Devices ────────────────────────────────────────────────────────────────── +export async function fetchDevices () { + try { + const res = await fetch(TRACCAR_URL + '/api/devices?all=true', authOpts()) + if (res.ok) { + _devices = await res.json() + return _devices + } + } catch {} + return _devices +} + +// ── Positions ──────────────────────────────────────────────────────────────── +export async function fetchPositions (deviceIds = null) { + let url = TRACCAR_URL + '/api/positions' + if (deviceIds && deviceIds.length) { + url += '?' + deviceIds.map(id => 'deviceId=' + id).join('&') + } + try { + const res = await fetch(url, authOpts()) + if (res.ok) return await res.json() + } catch {} + return [] +} + +// ── Get position for a specific device ─────────────────────────────────────── +export async function fetchDevicePosition (deviceId) { + const positions = await fetchPositions([deviceId]) + return positions[0] || null +} + +// ── Get all positions mapped by deviceId ───────────────────────────────────── +export async function fetchAllPositions () { + // Get devices we care about (online + offline with recent position) + if (!_devices.length) await fetchDevices() + const deviceIds = _devices.filter(d => d.positionId).map(d => d.id) + if (!deviceIds.length) return {} + + const positions = await fetchPositions(deviceIds) + const map = {} + positions.forEach(p => { map[p.deviceId] = p }) + return map +} + +// ── Utility: match device to tech by uniqueId or name ──────────────────────── +export function matchDeviceToTech (devices, techs) { + const matched = [] + for (const tech of techs) { + const traccarId = tech.traccarDeviceId + if (!traccarId) continue + const device = devices.find(d => d.id === parseInt(traccarId) || d.uniqueId === traccarId) + if (device) matched.push({ tech, device }) + } + return matched +} + +export { TRACCAR_URL, _devices as cachedDevices } diff --git a/src/composables/useMap.js b/src/composables/useMap.js index 065cc11..992794e 100644 --- a/src/composables/useMap.js +++ b/src/composables/useMap.js @@ -191,7 +191,13 @@ export function useMap (deps) { }) outer.addEventListener('mouseenter', () => { if (mapDragJob.value) el.style.transform = 'scale(1.3)' }) outer.addEventListener('mouseleave', () => { el.style.transform = '' }) - const m = new mbgl.Marker({ element: outer, anchor: 'bottom' }).setLngLat(tech.coords).addTo(map) + // Use GPS position if available, else static coords + const pos = tech.gpsCoords || tech.coords + if (tech.gpsCoords) { + el.classList.add('sb-map-gps-active') + el.title = tech.fullName + ' (GPS)' + } + const m = new mbgl.Marker({ element: outer, anchor: 'bottom' }).setLngLat(pos).addTo(map) mapMarkers.value.push(m) }) } @@ -309,6 +315,11 @@ export function useMap (deps) { watch([() => periodStart.value?.getTime(), filteredResources], () => { if (currentView.value === 'day' && mapVisible.value && map) { drawMapMarkers(); drawSelectedRoute() } }) + watch( + () => store.technicians.map(t => t.gpsCoords), + () => { if (map) drawMapMarkers() }, + { deep: true } + ) // ── Lifecycle helpers ──────────────────────────────────────────────────────── function destroyMap () { diff --git a/src/stores/dispatch.js b/src/stores/dispatch.js index 4a38cdc..2b327b9 100644 --- a/src/stores/dispatch.js +++ b/src/stores/dispatch.js @@ -5,6 +5,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags } from 'src/api/dispatch' +import { fetchDevices, fetchPositions } from 'src/api/traccar' import { TECH_COLORS } from 'src/config/erpnext' import { serializeAssistants } from 'src/composables/useHelpers' @@ -48,6 +49,11 @@ export const useDispatchStore = defineStore('dispatch', () => { user: t.user || null, colorIdx: idx % TECH_COLORS.length, coords: [t.longitude || -73.5673, t.latitude || 45.5017], + gpsCoords: null, // live GPS from Traccar (updated by polling) + gpsSpeed: 0, + gpsTime: null, + gpsOnline: false, + traccarDeviceId: t.traccar_device_id || null, queue: [], // filled in loadAll() tags: (t.tags || []).map(tg => tg.tag), } @@ -264,10 +270,60 @@ export const useDispatchStore = defineStore('dispatch', () => { technicians.value.forEach(t => { t.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === t.id)) }) } + // ── Traccar GPS polling ────────────────────────────────────────────────── + const traccarDevices = ref([]) + let _gpsInterval = null + + async function pollGps () { + try { + if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices() + console.log('[GPS] Devices loaded:', traccarDevices.value.length) + // Build map of traccarDeviceId → tech + const techsByDevice = {} + technicians.value.forEach(t => { + if (t.traccarDeviceId) { + const dev = traccarDevices.value.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId) + if (dev) { techsByDevice[dev.id] = t; console.log('[GPS] Matched', t.fullName, '→ device', dev.id, dev.name) } + else console.log('[GPS] No device match for', t.fullName, 'traccarId:', t.traccarDeviceId) + } + }) + const deviceIds = Object.keys(techsByDevice).map(Number) + if (!deviceIds.length) { console.log('[GPS] No devices to poll'); return } + console.log('[GPS] Fetching positions for devices:', deviceIds) + const positions = await fetchPositions(deviceIds) + console.log('[GPS] Got', positions.length, 'positions') + positions.forEach(p => { + const tech = techsByDevice[p.deviceId] + if (tech && p.latitude && p.longitude) { + tech.gpsCoords = [p.longitude, p.latitude] + tech.gpsSpeed = p.speed || 0 + tech.gpsTime = p.fixTime + tech.gpsOnline = true + console.log('[GPS]', tech.fullName, 'updated:', p.latitude.toFixed(4), p.longitude.toFixed(4)) + } + }) + // Mark techs with no position as offline + Object.values(techsByDevice).forEach(t => { + if (!positions.find(p => techsByDevice[p.deviceId] === t)) t.gpsOnline = false + }) + } catch (e) { console.warn('[GPS] Poll error:', e.message) } + } + + function startGpsPolling (intervalMs = 30000) { + if (_gpsInterval) return + pollGps() // immediate first poll + _gpsInterval = setInterval(pollGps, intervalMs) + } + + function stopGpsPolling () { + if (_gpsInterval) { clearInterval(_gpsInterval); _gpsInterval = null } + } + return { - technicians, jobs, allTags, loading, erpStatus, + technicians, jobs, allTags, loading, erpStatus, traccarDevices, loadAll, loadJobsForTech, setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant, smartAssign, fullUnassign, + pollGps, startGpsPolling, stopGpsPolling, } })