Integrates the Dispatch PWA (Vue/Quasar) into the gigafibre-fsm monorepo. Full git history accessible via `git log -- apps/dispatch/`. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
418 lines
16 KiB
JavaScript
418 lines
16 KiB
JavaScript
// ── 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,
|
|
}
|
|
})
|