// ── Dispatch store ─────────────────────────────────────────────────────────── // Shared state for both MobilePage and DispatchPage. // All ERPNext calls go through api/dispatch.js — not here. // ───────────────────────────────────────────────────────────────────────────── import { defineStore } from 'pinia' import { ref } from 'vue' import { fetchTechnicians, fetchJobs, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch' import { fetchDevices, fetchPositions, createTraccarSession } from 'src/api/traccar' import { TECH_COLORS } from 'src/config/erpnext' import { serializeAssistants } from 'src/composables/useHelpers' // Module-level GPS guards — survive store re-creation and component remount let __gpsStarted = false let __gpsInterval = null let __gpsPolling = false export const useDispatchStore = defineStore('dispatch', () => { const technicians = ref([]) const jobs = ref([]) const allTags = ref([]) // { name, label, color, category } const loading = ref(false) const erpStatus = ref('pending') // 'pending' | 'ok' | 'error' | 'session_expired' // ── Data transformers ──────────────────────────────────────────────────── function _mapJob (j) { return { id: j.ticket_id || j.name, name: j.name, // ERPNext docname (used for PUT calls) subject: j.subject || 'Job sans titre', address: j.address || 'Adresse inconnue', coords: [j.longitude || 0, j.latitude || 0], priority: j.priority || 'low', duration: j.duration_h || 1, status: j.status || 'open', assignedTech: j.assigned_tech || null, routeOrder: j.route_order || 0, legDist: j.leg_distance || null, legDur: j.leg_duration || null, scheduledDate: j.scheduled_date || null, endDate: j.end_date || null, startTime: j.start_time || null, assistants: (j.assistants || []).map(a => ({ techId: a.tech_id, techName: a.tech_name, duration: a.duration_h || 0, note: a.note || '', pinned: !!a.pinned })), tags: (j.tags || []).map(t => t.tag), } } function _mapTech (t, idx) { return { id: t.technician_id || t.name, name: t.name, // ERPNext docname fullName: t.full_name || t.name, status: t.status || '', 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, phone: t.phone || '', email: t.email || '', queue: [], // filled in loadAll() tags: (t.tags || []).map(tg => tg.tag), } } // ── Loaders ────────────────────────────────────────────────────────────── async function loadAll () { loading.value = true erpStatus.value = 'pending' try { const [rawTechs, rawJobs, rawTags] = await Promise.all([ fetchTechnicians(), fetchJobs(), fetchTags(), ]) allTags.value = rawTags technicians.value = rawTechs.map(_mapTech) jobs.value = rawJobs.map(_mapJob) // Build each tech's ordered queue (primary + assistant jobs) technicians.value.forEach(tech => { tech.queue = jobs.value .filter(j => j.assignedTech === tech.id) .sort((a, b) => a.routeOrder - b.routeOrder) tech.assistJobs = jobs.value .filter(j => j.assistants.some(a => a.techId === tech.id)) }) erpStatus.value = 'ok' } catch (e) { erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error' console.error('loadAll error:', e) } finally { loading.value = false } } // Load jobs assigned to one tech — used by MobilePage async function loadJobsForTech (techId) { loading.value = true try { const raw = await fetchJobs([['assigned_tech', '=', techId]]) jobs.value = raw.map(_mapJob) } finally { loading.value = false } } // ── Mutations (also syncs to ERPNext) ──────────────────────────────────── async function setJobStatus (jobId, status) { const job = jobs.value.find(j => j.id === jobId) if (!job) return job.status = status await updateJob(job.id, { status }) } async function assignJobToTech (jobId, techId, routeOrder, scheduledDate) { const job = jobs.value.find(j => j.id === jobId) if (!job) return // Remove from old tech queue technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) }) // Add to new tech queue const tech = technicians.value.find(t => t.id === techId) if (tech) { job.assignedTech = techId job.routeOrder = routeOrder job.status = 'assigned' if (scheduledDate !== undefined) job.scheduledDate = scheduledDate tech.queue.splice(routeOrder, 0, job) // Re-number route_order tech.queue.forEach((q, i) => { q.routeOrder = i }) } const payload = { assigned_tech: techId, route_order: routeOrder, status: 'assigned', } if (scheduledDate !== undefined) payload.scheduled_date = scheduledDate || '' await updateJob(job.id, payload) } async function unassignJob (jobId) { const job = jobs.value.find(j => j.id === jobId) if (!job) return technicians.value.forEach(t => { t.queue = t.queue.filter(q => q.id !== jobId) }) job.assignedTech = null job.status = 'open' try { await updateJob(job.name || job.id, { assigned_tech: null, status: 'open' }) } catch (_) {} } async function createJob (fields) { // fields: { subject, address, duration_h, priority, assigned_tech?, scheduled_date?, start_time? } const localId = 'WO-' + Date.now().toString(36).toUpperCase() const job = _mapJob({ ticket_id: localId, name: localId, subject: fields.subject || 'Nouveau travail', address: fields.address || '', longitude: fields.longitude || 0, latitude: fields.latitude || 0, duration_h: parseFloat(fields.duration_h) || 1, priority: fields.priority || 'low', status: fields.assigned_tech ? 'assigned' : 'open', assigned_tech: fields.assigned_tech || null, scheduled_date: fields.scheduled_date || null, start_time: fields.start_time || null, route_order: 0, }) jobs.value.push(job) if (fields.assigned_tech) { const tech = technicians.value.find(t => t.id === fields.assigned_tech) if (tech) { job.routeOrder = tech.queue.length; tech.queue.push(job) } } try { const created = await apiCreateJob({ subject: job.subject, address: job.address, longitude: job.coords?.[0] || '', latitude: job.coords?.[1] || '', duration_h: job.duration, priority: job.priority, status: job.status, assigned_tech: job.assignedTech || '', scheduled_date: job.scheduledDate || '', start_time: job.startTime || '', }) if (created?.name) { job.id = created.name; job.name = created.name } } catch (_) {} return job } async function setJobSchedule (jobId, scheduledDate, startTime) { const job = jobs.value.find(j => j.id === jobId) if (!job) return job.scheduledDate = scheduledDate || null job.startTime = startTime !== undefined ? startTime : job.startTime const payload = { scheduled_date: job.scheduledDate || '' } if (startTime !== undefined) payload.start_time = startTime || '' try { await updateJob(job.name || job.id, payload) } catch (_) {} } async function updateJobCoords (jobId, lng, lat) { const job = jobs.value.find(j => j.id === jobId) if (!job) return job.coords = [lng, lat] try { await updateJob(job.name || job.id, { longitude: lng, latitude: lat }) } catch (_) {} } async function addAssistant (jobId, techId) { const job = jobs.value.find(j => j.id === jobId) if (!job) return if (job.assignedTech === techId) return // already lead if (job.assistants.some(a => a.techId === techId)) return // already assistant const tech = technicians.value.find(t => t.id === techId) const entry = { techId, techName: tech?.fullName || techId, duration: job.duration, note: '', pinned: false } job.assistants = [...job.assistants, entry] if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id)) try { await updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants), }) } catch (_) {} } async function removeAssistant (jobId, techId) { const job = jobs.value.find(j => j.id === jobId) if (!job) return job.assistants = job.assistants.filter(a => a.techId !== techId) const tech = technicians.value.find(t => t.id === techId) if (tech) tech.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === tech.id)) try { await updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants), }) } catch (_) {} } async function reorderTechQueue (techId, fromIdx, toIdx) { const tech = technicians.value.find(t => t.id === techId) if (!tech) return const [moved] = tech.queue.splice(fromIdx, 1) tech.queue.splice(toIdx, 0, moved) tech.queue.forEach((q, i) => { q.routeOrder = i }) // Sync all reordered jobs await Promise.all( tech.queue.map((q, i) => updateJob(q.id, { route_order: i })), ) } // ── Smart assign (removes circular assistant deps) ────────────────────── function smartAssign (jobId, newTechId, dateStr) { const job = jobs.value.find(j => j.id === jobId) if (!job) return if (job.assistants.some(a => a.techId === newTechId)) { job.assistants = job.assistants.filter(a => a.techId !== newTechId) updateJob(job.name || job.id, { assistants: serializeAssistants(job.assistants) }).catch(() => {}) } assignJobToTech(jobId, newTechId, technicians.value.find(t => t.id === newTechId)?.queue.length || 0, dateStr) _rebuildAssistJobs() } // ── Full unassign (clears assistants + unassigns) ────────────────────── function fullUnassign (jobId) { const job = jobs.value.find(j => j.id === jobId) if (!job) return if (job.assistants.length) { job.assistants = []; updateJob(job.name || job.id, { assistants: [] }).catch(() => {}) } unassignJob(jobId) _rebuildAssistJobs() } // Rebuild all tech.assistJobs references function _rebuildAssistJobs () { technicians.value.forEach(t => { t.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === t.id)) }) } // ── Traccar GPS — Hybrid: REST initial + WebSocket real-time ───────────────── const traccarDevices = ref([]) const _techsByDevice = {} // deviceId (number) → tech object function _buildTechDeviceMap () { Object.keys(_techsByDevice).forEach(k => delete _techsByDevice[k]) technicians.value.forEach(t => { if (!t.traccarDeviceId) return const dev = traccarDevices.value.find(d => d.id === parseInt(t.traccarDeviceId) || d.uniqueId === t.traccarDeviceId) if (dev) _techsByDevice[dev.id] = t }) } function _applyPositions (positions) { positions.forEach(p => { const tech = _techsByDevice[p.deviceId] if (!tech || !p.latitude || !p.longitude) return const cur = tech.gpsCoords if (!cur || Math.abs(cur[0] - p.longitude) > 0.00001 || Math.abs(cur[1] - p.latitude) > 0.00001) { tech.gpsCoords = [p.longitude, p.latitude] } tech.gpsSpeed = p.speed || 0 tech.gpsTime = p.fixTime tech.gpsOnline = true }) } // One-shot REST fetch (manual refresh button + initial load) async function pollGps () { if (__gpsPolling) return __gpsPolling = true try { if (!traccarDevices.value.length) traccarDevices.value = await fetchDevices() _buildTechDeviceMap() const deviceIds = Object.keys(_techsByDevice).map(Number) if (!deviceIds.length) return const positions = await fetchPositions(deviceIds) _applyPositions(positions) 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) } finally { __gpsPolling = false } } // WebSocket connection with auto-reconnect let __ws = null let __wsBackoff = 1000 function _connectWs () { if (__ws) return const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const url = proto + '//' + window.location.host + '/traccar/api/socket' try { __ws = new WebSocket(url) } catch (e) { console.warn('[GPS] WS error:', e); return } __ws.onopen = () => { __wsBackoff = 1000 // WS connected — stop fallback polling if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null } console.log('[GPS] WebSocket connected — real-time updates active') } __ws.onmessage = (e) => { try { const data = JSON.parse(e.data) if (data.positions?.length) { _buildTechDeviceMap() // refresh map in case techs changed _applyPositions(data.positions) } } catch {} } __ws.onerror = () => {} __ws.onclose = () => { __ws = null if (!__gpsStarted) return // Start fallback polling while WS is down if (!__gpsInterval) { __gpsInterval = setInterval(pollGps, 30000) console.log('[GPS] WS closed — fallback to 30s polling') } setTimeout(_connectWs, __wsBackoff) __wsBackoff = Math.min(__wsBackoff * 2, 60000) } } async function startGpsTracking () { if (__gpsStarted) return __gpsStarted = true // 1. Load devices + initial REST fetch (all last-known positions) await pollGps() console.log('[GPS] Initial positions loaded via REST') // 2. Create session cookie for WebSocket auth, then connect const sessionOk = await createTraccarSession() if (sessionOk) { _connectWs() } else { // Session failed — fall back to polling __gpsInterval = setInterval(pollGps, 30000) console.log('[GPS] Session failed — fallback to 30s polling') } } function stopGpsTracking () { if (__gpsInterval) { clearInterval(__gpsInterval); __gpsInterval = null } if (__ws) { const ws = __ws; __ws = null; ws.onclose = null; ws.close() } } const startGpsPolling = startGpsTracking const stopGpsPolling = stopGpsTracking // ── Create / Delete technician ───────────────────────────────────────────── async function createTechnician (fields) { // Auto-generate technician_id: TECH-N+1 const maxNum = technicians.value.reduce((max, t) => { const m = (t.id || '').match(/TECH-(\d+)/) return m ? Math.max(max, parseInt(m[1])) : max }, 0) fields.technician_id = 'TECH-' + (maxNum + 1) const doc = await apiCreateTech(fields) const tech = _mapTech(doc, technicians.value.length) technicians.value.push(tech) return tech } async function deleteTechnician (techId) { const tech = technicians.value.find(t => t.id === techId) if (!tech) return await apiDeleteTech(tech.name) technicians.value = technicians.value.filter(t => t.id !== techId) } return { technicians, jobs, allTags, loading, erpStatus, traccarDevices, loadAll, loadJobsForTech, setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant, smartAssign, fullUnassign, pollGps, startGpsTracking, stopGpsTracking, startGpsPolling, stopGpsPolling, createTechnician, deleteTechnician, } })