Two changes around tech "departure point" coords (used for route
optimization when the tech has no live GPS yet):
1. New default fallback = 1867 chemin de la Rivière, Sainte-Clotilde
(Gigafibre HQ, lng=-73.6756, lat=45.1599). Was downtown Montréal,
which never made sense — every tech started the day with a 70 km
imaginary commute.
2. Per-tech editable home base via a 📍 button on each row of the
tech sidebar. Clicking it opens a dialog that accepts either:
• a free-text address — geocoded via OpenStreetMap Nominatim
(browser-side, sane User-Agent, no hub proxy needed)
• or a literal "lat, lng" pair pasted directly
On confirm: PUT to ERPNext (Dispatch Technician.latitude /
.longitude), patch the local store row, and trigger a route
recompute since the start point changed.
The geocode hits Nominatim public — fine for a low-volume
internal tool. If we ever exceed their fair-use limits, swap to
the existing /address-search hub route which already has the
AQ + RQA pipeline.
241 lines
8.2 KiB
JavaScript
241 lines
8.2 KiB
JavaScript
import { ref } from 'vue'
|
|
import { updateTech } from 'src/api/dispatch'
|
|
import { toErpStatus } from 'src/composables/useHelpers'
|
|
|
|
const ABSENCE_REASONS = [
|
|
{ value: 'vacation', label: 'Vacances', icon: '🏖️' },
|
|
{ value: 'sick', label: 'Maladie', icon: '🤒' },
|
|
{ value: 'personal', label: 'Personnel', icon: '🏠' },
|
|
{ value: 'training', label: 'Formation', icon: '📚' },
|
|
{ value: 'injury', label: 'Blessure', icon: '🩹' },
|
|
{ value: 'other', label: 'Autre', icon: '📋' },
|
|
]
|
|
|
|
export { ABSENCE_REASONS }
|
|
|
|
export function useTechManagement (store, invalidateRoutes) {
|
|
const editingTech = ref(null)
|
|
const newTechName = ref('')
|
|
const newTechPhone = ref('')
|
|
const newTechDevice = ref('')
|
|
const addingTech = ref(false)
|
|
|
|
// Absence modal state
|
|
const absenceModalOpen = ref(false)
|
|
const absenceModalTech = ref(null)
|
|
const absenceForm = ref({ reason: 'vacation', from: '', until: '', jobAction: 'unassign' })
|
|
const absenceProcessing = ref(false)
|
|
|
|
async function saveTechField (tech, field, value) {
|
|
const trimmed = typeof value === 'string' ? value.trim() : value
|
|
if (field === 'full_name') {
|
|
if (!trimmed || trimmed === tech.fullName) return
|
|
tech.fullName = trimmed
|
|
} else if (field === 'status') {
|
|
tech.status = trimmed
|
|
tech.active = trimmed !== 'inactive' && trimmed !== 'off'
|
|
} else if (field === 'phone') {
|
|
if (trimmed === tech.phone) return
|
|
tech.phone = trimmed
|
|
}
|
|
const saveValue = field === 'status' ? toErpStatus(trimmed) : trimmed
|
|
try { await updateTech(tech.name || tech.id, { [field]: saveValue }) }
|
|
catch (_e) { /* save failed */ }
|
|
}
|
|
|
|
async function addTech (extraFields = {}) {
|
|
const name = newTechName.value.trim()
|
|
if (!name || addingTech.value) return
|
|
addingTech.value = true
|
|
try {
|
|
const tech = await store.createTechnician({
|
|
full_name: name,
|
|
phone: newTechPhone.value.trim() || '',
|
|
traccar_device_id: newTechDevice.value || '',
|
|
...extraFields,
|
|
})
|
|
newTechName.value = ''
|
|
newTechPhone.value = ''
|
|
newTechDevice.value = ''
|
|
if (tech.traccarDeviceId) await store.pollGps()
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
alert('Erreur création technicien:\n' + msg.replace(/<[^>]+>/g, ''))
|
|
}
|
|
finally { addingTech.value = false }
|
|
}
|
|
|
|
// ── Absence flow ──
|
|
|
|
function openAbsenceModal (tech) {
|
|
absenceModalTech.value = tech
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
absenceForm.value = {
|
|
reason: tech.absenceReason || 'vacation',
|
|
from: tech.absenceFrom || today,
|
|
until: tech.absenceUntil || '',
|
|
jobAction: 'unassign', // 'unassign' | 'reassign'
|
|
}
|
|
absenceModalOpen.value = true
|
|
}
|
|
|
|
async function confirmAbsence () {
|
|
const tech = absenceModalTech.value
|
|
if (!tech) return
|
|
absenceProcessing.value = true
|
|
|
|
try {
|
|
const { reason, from, until, jobAction } = absenceForm.value
|
|
|
|
// 1. Handle assigned jobs
|
|
const linkedJobs = store.jobs.filter(j => j.assignedTech === tech.id)
|
|
if (linkedJobs.length > 0) {
|
|
if (jobAction === 'unassign') {
|
|
for (const job of linkedJobs) {
|
|
await store.unassignJob(job.id)
|
|
}
|
|
}
|
|
// 'reassign' is handled by the user manually after — jobs stay visible in unassigned pool
|
|
}
|
|
|
|
// 2. Update tech status + absence fields
|
|
tech.status = 'off'
|
|
tech.active = false
|
|
tech.absenceReason = reason
|
|
tech.absenceFrom = from || null
|
|
tech.absenceUntil = until || null
|
|
tech.absenceStartTime = null
|
|
tech.absenceEndTime = null
|
|
|
|
await updateTech(tech.name || tech.id, {
|
|
status: toErpStatus('off'),
|
|
absence_reason: reason,
|
|
absence_from: from || '',
|
|
absence_until: until || '',
|
|
absence_start_time: '',
|
|
absence_end_time: '',
|
|
})
|
|
|
|
absenceModalOpen.value = false
|
|
} catch (e) {
|
|
const msg = e?.message || String(e)
|
|
alert('Erreur: ' + msg.replace(/<[^>]+>/g, ''))
|
|
}
|
|
absenceProcessing.value = false
|
|
}
|
|
|
|
async function endAbsence (tech) {
|
|
tech.status = 'available'
|
|
tech.active = true
|
|
tech.absenceReason = ''
|
|
tech.absenceFrom = null
|
|
tech.absenceUntil = null
|
|
tech.absenceStartTime = null
|
|
tech.absenceEndTime = null
|
|
|
|
try {
|
|
await updateTech(tech.name || tech.id, {
|
|
status: toErpStatus('available'),
|
|
absence_reason: '',
|
|
absence_from: '',
|
|
absence_until: '',
|
|
absence_start_time: '',
|
|
absence_end_time: '',
|
|
})
|
|
} catch (_e) {
|
|
tech.status = 'off'
|
|
tech.active = false
|
|
}
|
|
}
|
|
|
|
// Permanent deactivation (non-dispatchable resource like support staff)
|
|
async function deactivateTech (tech) {
|
|
if (!confirm(`Désactiver ${tech.fullName} du dispatch ?\n\nLa ressource ne sera plus dispatchable mais reste dans le système.`)) return
|
|
try {
|
|
const linkedJobs = store.jobs.filter(j => j.assignedTech === tech.id)
|
|
for (const job of linkedJobs) await store.unassignJob(job.id)
|
|
tech.status = 'inactive'
|
|
tech.active = false
|
|
tech.absenceReason = ''
|
|
tech.absenceFrom = null
|
|
tech.absenceUntil = null
|
|
tech.absenceStartTime = null
|
|
tech.absenceEndTime = null
|
|
await updateTech(tech.name || tech.id, { status: toErpStatus('inactive'), absence_reason: '', absence_from: '', absence_until: '', absence_start_time: '', absence_end_time: '' })
|
|
} catch (e) {
|
|
alert('Erreur: ' + (e?.message || String(e)).replace(/<[^>]+>/g, ''))
|
|
}
|
|
}
|
|
|
|
async function reactivateTech (tech) {
|
|
tech.status = 'available'
|
|
tech.active = true
|
|
tech.absenceReason = ''
|
|
tech.absenceFrom = null
|
|
tech.absenceUntil = null
|
|
tech.absenceStartTime = null
|
|
tech.absenceEndTime = null
|
|
try {
|
|
await updateTech(tech.name || tech.id, { status: toErpStatus('available'), absence_reason: '', absence_from: '', absence_until: '', absence_start_time: '', absence_end_time: '' })
|
|
} catch (_e) {
|
|
tech.status = 'inactive'
|
|
tech.active = false
|
|
}
|
|
}
|
|
|
|
async function removeTech (tech) {
|
|
if (!confirm(`⚠ SUPPRIMER DÉFINITIVEMENT ${tech.fullName} ?\n\nCette action est irréversible.`)) return
|
|
try {
|
|
const linkedJobs = store.jobs.filter(j => j.assignedTech === tech.id)
|
|
for (const job of linkedJobs) await store.unassignJob(job.id)
|
|
await store.deleteTechnician(tech.id)
|
|
} catch (e) {
|
|
alert('Erreur: ' + (e?.message || String(e)).replace(/<[^>]+>/g, ''))
|
|
}
|
|
}
|
|
|
|
async function saveWeeklySchedule (tech, schedule) {
|
|
tech.weeklySchedule = { ...schedule }
|
|
try {
|
|
await updateTech(tech.name || tech.id, { weekly_schedule: JSON.stringify(schedule) })
|
|
} catch (e) {
|
|
alert('Erreur: ' + (e?.message || String(e)).replace(/<[^>]+>/g, ''))
|
|
}
|
|
}
|
|
|
|
async function saveTraccarLink (tech, deviceId) {
|
|
tech.traccarDeviceId = deviceId || null
|
|
tech.gpsCoords = null
|
|
tech.gpsOnline = false
|
|
await updateTech(tech.name || tech.id, { traccar_device_id: deviceId || '' })
|
|
await store.pollGps()
|
|
invalidateRoutes()
|
|
}
|
|
|
|
// Persist a tech's "home base" coordinates (where they start the day from
|
|
// when no live GPS is available). Coords are stored in ERPNext as
|
|
// separate Float fields `latitude` / `longitude`; the dispatch store
|
|
// reads them back as `tech.coords = [longitude, latitude]` (the
|
|
// [lng, lat] order is what Mapbox/MapLibre + GeoJSON expect).
|
|
// Route optimization is recomputed because changing the start point
|
|
// shifts every tech's optimal job sequence for the day.
|
|
async function saveTechHome (tech, longitude, latitude) {
|
|
const lng = Number(longitude); const lat = Number(latitude)
|
|
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
|
|
throw new Error('Coordonnées invalides')
|
|
}
|
|
tech.coords = [lng, lat]
|
|
await updateTech(tech.name || tech.id, { longitude: lng, latitude: lat })
|
|
invalidateRoutes()
|
|
}
|
|
|
|
return {
|
|
editingTech, newTechName, newTechPhone, newTechDevice, addingTech,
|
|
absenceModalOpen, absenceModalTech, absenceForm, absenceProcessing,
|
|
saveTechField, addTech,
|
|
openAbsenceModal, confirmAbsence, endAbsence,
|
|
deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule, saveTechHome,
|
|
ABSENCE_REASONS,
|
|
}
|
|
}
|