gigafibre-fsm/apps/ops/src/composables/useTechManagement.js
louispaulb ac52406e30 feat(ops/dispatch): editable tech home base + new default at Gigafibre HQ
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.
2026-05-05 13:53:14 -04:00

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