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 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-03-26 20:17:13 -04:00
parent 859f043bb2
commit f1badea201
3 changed files with 150 additions and 2 deletions

81
src/api/traccar.js Normal file
View File

@ -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 }

View File

@ -191,7 +191,13 @@ export function useMap (deps) {
}) })
outer.addEventListener('mouseenter', () => { if (mapDragJob.value) el.style.transform = 'scale(1.3)' }) outer.addEventListener('mouseenter', () => { if (mapDragJob.value) el.style.transform = 'scale(1.3)' })
outer.addEventListener('mouseleave', () => { el.style.transform = '' }) 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) mapMarkers.value.push(m)
}) })
} }
@ -309,6 +315,11 @@ export function useMap (deps) {
watch([() => periodStart.value?.getTime(), filteredResources], () => { watch([() => periodStart.value?.getTime(), filteredResources], () => {
if (currentView.value === 'day' && mapVisible.value && map) { drawMapMarkers(); drawSelectedRoute() } if (currentView.value === 'day' && mapVisible.value && map) { drawMapMarkers(); drawSelectedRoute() }
}) })
watch(
() => store.technicians.map(t => t.gpsCoords),
() => { if (map) drawMapMarkers() },
{ deep: true }
)
// ── Lifecycle helpers ──────────────────────────────────────────────────────── // ── Lifecycle helpers ────────────────────────────────────────────────────────
function destroyMap () { function destroyMap () {

View File

@ -5,6 +5,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags } from 'src/api/dispatch' 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 { TECH_COLORS } from 'src/config/erpnext'
import { serializeAssistants } from 'src/composables/useHelpers' import { serializeAssistants } from 'src/composables/useHelpers'
@ -48,6 +49,11 @@ export const useDispatchStore = defineStore('dispatch', () => {
user: t.user || null, user: t.user || null,
colorIdx: idx % TECH_COLORS.length, colorIdx: idx % TECH_COLORS.length,
coords: [t.longitude || -73.5673, t.latitude || 45.5017], 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() queue: [], // filled in loadAll()
tags: (t.tags || []).map(tg => tg.tag), 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)) }) 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 { return {
technicians, jobs, allTags, loading, erpStatus, technicians, jobs, allTags, loading, erpStatus, traccarDevices,
loadAll, loadJobsForTech, loadAll, loadJobsForTech,
setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant, setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant,
smartAssign, fullUnassign, smartAssign, fullUnassign,
pollGps, startGpsPolling, stopGpsPolling,
} }
}) })