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, } })