Two related issues, one PR:
1. **Bad coords** on customer C-LPB4's "Wifi buggy" job (DJ-MNP8WIKT).
Address on file is `691 rue des Hirondelles, Saint-Michel J0L2J0`,
but the saved lat/lng (-73.677086, 45.159206) reverse-geocodes to
`2336 rue René-Vinet, Sainte-Clotilde J0L1W0` — ~9 km away. The
delta matches the Gigafibre HQ default fallback (-73.6756, 45.1599)
pretty closely, suggesting the geocoder either failed silently at
Service Location creation time or got pinned to the HQ centroid.
Fixed live in DB (UPDATE on tabService Location LOC-0000000004 +
tabDispatch Job DJ-MNP8WIKT to lng=-73.5792377, lat=45.2408452,
verified via Nominatim against the typed address). The job pin
should now show on the correct house.
2. **No way to jump from a job to the client** — the dispatcher had
to memorize/type the customer ID. Now both the RightPanel and the
job context-menu surface clickable shortcuts:
• Client → `#/clients/<id>` (opens ClientDetailPage in-app)
• Lieu → `/desk/Service Location/<id>` (opens ERPNext in a new
tab; the ops SPA doesn't have a dedicated SL detail page)
Required wiring `customer` + `serviceLocation` into the job map in
`stores/dispatch.js` — the API (`fetchJobsFast` uses `["*"]`) was
already returning the fields, the store just wasn't surfacing them.
Note on the deeper bug: the SL lat/lng is the source of truth and the
job currently *copies* it at creation time (rather than reading from
the SL link dynamically). If a Service Location's coords are corrected
after a job exists, the job retains stale coords. A follow-up could
either (a) re-read on render, or (b) trigger a backfill when SL coords
change. Out of scope for this fix — for now, the dispatcher who fixes
an SL must also update any open jobs at that location.
392 lines
16 KiB
JavaScript
392 lines
16 KiB
JavaScript
import { defineStore } from 'pinia'
|
|
import { ref } from 'vue'
|
|
import { fetchTechnicians, loadTechTags, fetchJobsFast, fetchJobFull, updateJob, createJob as apiCreateJob, fetchTags, createTech as apiCreateTech, deleteTech as apiDeleteTech } from 'src/api/dispatch'
|
|
import { TECH_COLORS } from 'src/config/erpnext'
|
|
import { serializeAssistants, normalizeStatus, parseWeeklySchedule } from 'src/composables/useHelpers'
|
|
import { useGpsTracking } from 'src/composables/useGpsTracking'
|
|
|
|
function nextWeekday () {
|
|
const d = new Date()
|
|
const day = d.getDay()
|
|
if (day === 6) d.setDate(d.getDate() + 2)
|
|
else if (day === 0) d.setDate(d.getDate() + 1)
|
|
return d.toISOString().slice(0, 10)
|
|
}
|
|
|
|
export const useDispatchStore = defineStore('dispatch', () => {
|
|
const technicians = ref([])
|
|
const jobs = ref([])
|
|
const allTags = ref([])
|
|
const loading = ref(false)
|
|
const erpStatus = ref('pending')
|
|
const jobVersion = ref(0) // Incremented on any job/queue mutation to bust caches
|
|
|
|
const { traccarDevices, pollGps, startGpsTracking, stopGpsTracking } = useGpsTracking(technicians)
|
|
|
|
function _mapJob (j) {
|
|
return {
|
|
id: j.ticket_id || j.name,
|
|
name: j.name,
|
|
subject: j.subject || 'Job sans titre',
|
|
address: j.address || 'Adresse inconnue',
|
|
coords: [j.longitude || 0, j.latitude || 0],
|
|
// Persisted ERPNext links — surfaced as clickable shortcuts in
|
|
// the RightPanel so the dispatcher can jump to the client's
|
|
// full record (or the Service Location in ERPNext) without
|
|
// leaving the dispatch board. The Mapbox marker still uses
|
|
// the cached coords above; for source-of-truth verification
|
|
// (geocode mismatch) the rep clicks through to /clients/:id.
|
|
customer: j.customer || null,
|
|
serviceLocation: j.service_location || null,
|
|
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),
|
|
tagsWithLevel: (j.tags || []).map(t => ({ tag: t.tag, level: t.level || 0, required: t.required || 0 })),
|
|
customer: j.customer || null,
|
|
serviceLocation: j.service_location || null,
|
|
sourceIssue: j.source_issue || null,
|
|
dependsOn: j.depends_on || null,
|
|
jobType: j.job_type || null,
|
|
parentJob: j.parent_job || null,
|
|
stepOrder: j.step_order || 0,
|
|
onOpenWebhook: j.on_open_webhook || null,
|
|
onCloseWebhook: j.on_close_webhook || null,
|
|
salesOrder: j.sales_order || null,
|
|
orderSource: j.order_source || 'Manual',
|
|
published: j.published === undefined ? true : !!j.published,
|
|
continuous: !!j.continuous, // Emergency: span weekends/off-days
|
|
// Recurrence fields
|
|
isRecurring: !!j.is_recurring,
|
|
recurrenceRule: j.recurrence_rule || null, // RRULE string
|
|
recurrenceEnd: j.recurrence_end || null, // YYYY-MM-DD or null = indefinite
|
|
pausePeriods: j.pause_periods ? (typeof j.pause_periods === 'string' ? JSON.parse(j.pause_periods) : j.pause_periods) : [],
|
|
templateId: j.template_id || null, // For materialized instances → parent template
|
|
}
|
|
}
|
|
|
|
function _mapTech (t, idx) {
|
|
const status = normalizeStatus(t.status)
|
|
return {
|
|
id: t.technician_id || t.name,
|
|
name: t.name,
|
|
fullName: t.full_name || t.name,
|
|
status,
|
|
active: status !== 'inactive' && status !== 'off',
|
|
group: t.tech_group || '',
|
|
resourceType: t.resource_type || 'human', // 'human' | 'material'
|
|
resourceCategory: t.resource_category || '', // 'Véhicule' | 'Outil' | 'Salle' | etc.
|
|
user: t.user || null,
|
|
colorIdx: idx % TECH_COLORS.length,
|
|
// Default departure point = Gigafibre HQ (1867 chemin de la Rivière,
|
|
// Sainte-Clotilde, QC). Used when a tech has no explicit longitude
|
|
// /latitude saved in ERPNext, so route optimization has a sensible
|
|
// starting point even before the dispatcher sets it.
|
|
coords: [t.longitude || -73.6756177, t.latitude || 45.1599145],
|
|
gpsCoords: null,
|
|
gpsSpeed: 0,
|
|
gpsTime: null,
|
|
gpsOnline: false,
|
|
traccarDeviceId: t.traccar_device_id || null,
|
|
phone: t.phone || '',
|
|
email: t.email || '',
|
|
absenceReason: t.absence_reason || '',
|
|
absenceFrom: t.absence_from || null,
|
|
absenceUntil: t.absence_until || null,
|
|
absenceStartTime: t.absence_start_time || null,
|
|
absenceEndTime: t.absence_end_time || null,
|
|
weeklySchedule: parseWeeklySchedule(t.weekly_schedule),
|
|
queue: [],
|
|
tags: (t.tags || []).map(tg => tg.tag),
|
|
tagsWithLevel: (t.tags || []).map(tg => ({ tag: tg.tag, level: tg.level || 0 })),
|
|
extraShifts: t.extra_shifts ? (typeof t.extra_shifts === 'string' ? JSON.parse(t.extra_shifts) : t.extra_shifts) : [],
|
|
}
|
|
}
|
|
|
|
function _rebuildQueues () {
|
|
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))
|
|
})
|
|
}
|
|
|
|
async function loadAll (dateRange = null) {
|
|
loading.value = true
|
|
erpStatus.value = 'pending'
|
|
const t0 = performance.now()
|
|
try {
|
|
// All 3 fetches in parallel — techs, tags, jobs
|
|
const [rawTechs, rawTags, rawJobs] = await Promise.all([
|
|
fetchTechnicians(),
|
|
fetchTags(),
|
|
fetchJobsFast([['status', 'in', ['open', 'assigned', 'in_progress']]]),
|
|
])
|
|
allTags.value = rawTags
|
|
technicians.value = rawTechs.map(_mapTech)
|
|
jobs.value = rawJobs.map(_mapJob)
|
|
_rebuildQueues()
|
|
erpStatus.value = 'ok'
|
|
|
|
// Background: load tech tags (child tables) without blocking render
|
|
loadTechTags(rawTechs.map(t => t.name)).then(fullDocs => {
|
|
for (const doc of fullDocs) {
|
|
const tech = technicians.value.find(t => t.name === doc.name)
|
|
if (tech && doc.tags?.length) {
|
|
tech.tags = doc.tags.map(tg => tg.tag)
|
|
tech.tagsWithLevel = doc.tags.map(tg => ({ tag: tg.tag, level: tg.level || 0 }))
|
|
}
|
|
}
|
|
// Tech tags loaded in background
|
|
}).catch(() => {})
|
|
} catch (e) {
|
|
console.error('[dispatch] loadAll failed:', e)
|
|
erpStatus.value = e.message?.includes('session') ? 'session_expired' : 'error'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// Load full doc with child tables for a specific job (on demand)
|
|
async function loadJobDetails (jobId) {
|
|
const job = jobs.value.find(j => j.id === jobId || j.name === jobId)
|
|
if (!job) return
|
|
try {
|
|
const full = await fetchJobFull(job.name)
|
|
const mapped = _mapJob(full)
|
|
Object.assign(job, mapped)
|
|
_rebuildQueues()
|
|
} catch {}
|
|
}
|
|
|
|
async function loadJobsForTech (techId) {
|
|
loading.value = true
|
|
try {
|
|
const raw = await fetchJobsFast([['assigned_tech', '=', techId]])
|
|
jobs.value = raw.map(_mapJob)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function setJobStatus (jobId, status) {
|
|
const job = jobs.value.find(j => j.id === jobId)
|
|
if (!job) return
|
|
const prevStatus = job.status
|
|
job.status = status
|
|
await updateJob(job.id, { status })
|
|
// Fire n8n webhooks on status transitions
|
|
_fireWebhookIfNeeded(job, prevStatus, status)
|
|
}
|
|
|
|
function _fireWebhookIfNeeded (job, prevStatus, newStatus) {
|
|
const prev = (prevStatus || '').toLowerCase()
|
|
const next = (newStatus || '').toLowerCase()
|
|
let url = null
|
|
if (next === 'assigned' && prev === 'open' && job.onOpenWebhook) {
|
|
url = job.onOpenWebhook
|
|
} else if (next === 'completed' && job.onCloseWebhook) {
|
|
url = job.onCloseWebhook
|
|
}
|
|
if (url) {
|
|
fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
event: next === 'completed' ? 'job_closed' : 'job_opened',
|
|
job_name: job.name, job_subject: job.subject,
|
|
job_status: newStatus, job_type: job.jobType,
|
|
customer: job.customer, timestamp: new Date().toISOString(),
|
|
}),
|
|
}).catch(err => console.warn('[n8n webhook]', err))
|
|
}
|
|
}
|
|
|
|
async function assignJobToTech (jobId, techId, routeOrder, scheduledDate) {
|
|
jobVersion.value++
|
|
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) })
|
|
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)
|
|
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) {
|
|
jobVersion.value++
|
|
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) {
|
|
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 || (fields.assigned_tech ? nextWeekday() : null),
|
|
start_time: fields.start_time || null,
|
|
route_order: 0,
|
|
published: 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 || '',
|
|
customer: fields.customer || '', service_location: fields.service_location || '',
|
|
source_issue: fields.source_issue || '', depends_on: fields.depends_on || '',
|
|
job_type: fields.job_type || '',
|
|
parent_job: fields.parent_job || '', step_order: fields.step_order || 0,
|
|
on_open_webhook: fields.on_open_webhook || '', on_close_webhook: fields.on_close_webhook || '',
|
|
})
|
|
if (created?.name) { job.id = created.name; job.name = created.name }
|
|
} catch (_) {}
|
|
return job
|
|
}
|
|
|
|
function publishJobsLocal (jobNames) {
|
|
for (const name of jobNames) {
|
|
const job = jobs.value.find(j => j.id === name || j.name === name)
|
|
if (job) job.published = true
|
|
}
|
|
}
|
|
|
|
async function setJobSchedule (jobId, scheduledDate, startTime) {
|
|
jobVersion.value++
|
|
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
|
|
if (job.assistants.some(a => a.techId === techId)) return
|
|
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 })
|
|
await Promise.all(tech.queue.map((q, i) => updateJob(q.id, { route_order: i })))
|
|
}
|
|
|
|
function smartAssign (jobId, newTechId, dateStr) {
|
|
jobVersion.value++
|
|
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()
|
|
}
|
|
|
|
function fullUnassign (jobId) {
|
|
jobVersion.value++
|
|
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()
|
|
}
|
|
|
|
function _rebuildAssistJobs () {
|
|
technicians.value.forEach(t => { t.assistJobs = jobs.value.filter(j => j.assistants.some(a => a.techId === t.id)) })
|
|
}
|
|
|
|
async function createTechnician (fields) {
|
|
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)
|
|
}
|
|
|
|
const startGpsPolling = startGpsTracking
|
|
const stopGpsPolling = stopGpsTracking
|
|
|
|
return {
|
|
technicians, jobs, allTags, loading, erpStatus, jobVersion, traccarDevices,
|
|
loadAll, loadJobsForTech, loadJobDetails,
|
|
setJobStatus, assignJobToTech, unassignJob, createJob, reorderTechQueue, updateJobCoords, setJobSchedule, addAssistant, removeAssistant,
|
|
smartAssign, fullUnassign, publishJobsLocal,
|
|
pollGps, startGpsTracking, stopGpsTracking, startGpsPolling, stopGpsPolling,
|
|
createTechnician, deleteTechnician,
|
|
}
|
|
})
|