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:
parent
859f043bb2
commit
f1badea201
81
src/api/traccar.js
Normal file
81
src/api/traccar.js
Normal 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 }
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user