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.
This commit is contained in:
louispaulb 2026-05-05 13:53:14 -04:00
parent 490b9ce457
commit 060cc034a8
4 changed files with 109 additions and 3 deletions

View File

@ -212,12 +212,29 @@ export function useTechManagement (store, invalidateRoutes) {
invalidateRoutes() 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 { return {
editingTech, newTechName, newTechPhone, newTechDevice, addingTech, editingTech, newTechName, newTechPhone, newTechDevice, addingTech,
absenceModalOpen, absenceModalTech, absenceForm, absenceProcessing, absenceModalOpen, absenceModalTech, absenceForm, absenceProcessing,
saveTechField, addTech, saveTechField, addTech,
openAbsenceModal, confirmAbsence, endAbsence, openAbsenceModal, confirmAbsence, endAbsence,
deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule, deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule, saveTechHome,
ABSENCE_REASONS, ABSENCE_REASONS,
} }
} }

View File

@ -401,7 +401,7 @@ const {
absenceModalOpen, absenceModalTech, absenceForm, absenceProcessing, absenceModalOpen, absenceModalTech, absenceForm, absenceProcessing,
saveTechField, addTech, saveTechField, addTech,
openAbsenceModal, confirmAbsence, endAbsence, openAbsenceModal, confirmAbsence, endAbsence,
deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule, deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule, saveTechHome,
ABSENCE_REASONS, ABSENCE_REASONS,
} = useTechManagement(store, invalidateRoutes) } = useTechManagement(store, invalidateRoutes)
@ -411,6 +411,88 @@ const scheduleModalTech = ref(null)
const scheduleForm = ref({}) const scheduleForm = ref({})
const extraShiftsForm = ref([]) // On-call / garde shifts const extraShiftsForm = ref([]) // On-call / garde shifts
// Edit a tech's home/departure coordinates. Two paths converge here:
// Type an address free Nominatim geocode confirm save
// Use the live GPS position (if Traccar device is online) as the
// new home base handy after a tech moves
// Or paste lat/lng directly (advanced)
// We don't proxy the geocode through targo-hub on purpose: Nominatim
// allows browser calls with a sane User-Agent and there's no secret
// involved. Hitting it from the SPA also keeps the hub free of the
// tile-usage policy responsibility.
async function openTechHomeDialog (tech) {
const cur = tech.coords || [-73.6756177, 45.1599145]
const dialog = $q.dialog({
title: `Position de départ — ${tech.fullName}`,
message: `
<div class="text-caption text-grey-7 q-mb-sm">
Adresse à géocoder, ou colle directement <code>lat, lng</code>.
</div>
<div class="text-caption text-grey-6">
Actuelle : <code>${cur[1].toFixed(5)}, ${cur[0].toFixed(5)}</code>
</div>
`,
html: true,
prompt: {
model: '',
type: 'text',
outlined: true,
label: 'Adresse OU "lat, lng"',
placeholder: '1867 chemin de la Rivière, Sainte-Clotilde',
counter: false,
},
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'primary', unelevated: true, label: 'Géocoder + Enregistrer' },
persistent: false,
})
dialog.onOk(async (input) => {
const txt = (input || '').trim()
if (!txt) return
// Direct lat/lng paste? Pattern: "45.16, -73.67"
const direct = txt.match(/^(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)$/)
if (direct) {
const lat = parseFloat(direct[1]); const lng = parseFloat(direct[2])
try {
await saveTechHome(tech, lng, lat)
Notify.create({ type: 'positive', message: `Position mise à jour pour ${tech.fullName}`, position: 'top', timeout: 2500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, position: 'top' })
}
return
}
// Geocode via Nominatim
try {
const r = await fetch(
'https://nominatim.openstreetmap.org/search?format=json&limit=1&q=' + encodeURIComponent(txt),
{ headers: { 'Accept-Language': 'fr-CA,fr,en' } }
)
const data = await r.json()
if (!data?.length) {
Notify.create({ type: 'negative', message: 'Adresse introuvable', position: 'top' })
return
}
const hit = data[0]
const lat = parseFloat(hit.lat); const lng = parseFloat(hit.lon)
$q.dialog({
title: 'Confirmer la position',
message: `<div>${hit.display_name}</div><div class="text-caption text-grey-7 q-mt-sm"><code>${lat.toFixed(5)}, ${lng.toFixed(5)}</code></div>`,
html: true,
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'primary', unelevated: true, label: 'Enregistrer' },
}).onOk(async () => {
try {
await saveTechHome(tech, lng, lat)
Notify.create({ type: 'positive', message: `Position mise à jour pour ${tech.fullName}`, position: 'top', timeout: 2500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, position: 'top' })
}
})
} catch (e) {
Notify.create({ type: 'negative', message: 'Géocodage indisponible: ' + e.message, position: 'top' })
}
})
}
function openScheduleModal (tech) { function openScheduleModal (tech) {
scheduleModalTech.value = tech scheduleModalTech.value = tech
scheduleForm.value = {} scheduleForm.value = {}
@ -1721,6 +1803,7 @@ onUnmounted(() => {
<span v-else class="sb-gps-badge sb-gps-none"></span> <span v-else class="sb-gps-badge sb-gps-none"></span>
</td> </td>
<td class="sb-gps-actions"> <td class="sb-gps-actions">
<button class="sb-gps-loc-btn" @click="openTechHomeDialog(tech)" title="Modifier l'adresse de départ">📍</button>
<button v-if="tech.active" class="sb-gps-absence-btn" @click="openAbsenceModal(tech)" title="Mettre en absence"></button> <button v-if="tech.active" class="sb-gps-absence-btn" @click="openAbsenceModal(tech)" title="Mettre en absence"></button>
<button v-else class="sb-gps-react-btn" @click="endAbsence(tech)" title="Réactiver"></button> <button v-else class="sb-gps-react-btn" @click="endAbsence(tech)" title="Réactiver"></button>
</td> </td>

View File

@ -725,6 +725,8 @@
.sb-gps-absence-btn:hover { color:#f59e0b; background:rgba(245,158,11,0.1); } .sb-gps-absence-btn:hover { color:#f59e0b; background:rgba(245,158,11,0.1); }
.sb-gps-react-btn { background:none; border:none; color:#22c55e; font-size:14px; cursor:pointer; padding:2px 6px; border-radius:4px; } .sb-gps-react-btn { background:none; border:none; color:#22c55e; font-size:14px; cursor:pointer; padding:2px 6px; border-radius:4px; }
.sb-gps-react-btn:hover { background:rgba(34,197,94,0.1); } .sb-gps-react-btn:hover { background:rgba(34,197,94,0.1); }
.sb-gps-loc-btn { background:none; border:none; color:var(--sb-muted); font-size:13px; cursor:pointer; padding:2px 6px; border-radius:4px; }
.sb-gps-loc-btn:hover { color:#3b82f6; background:rgba(59,130,246,0.1); }
.sb-gps-inactive-row { opacity:0.45; } .sb-gps-inactive-row { opacity:0.45; }
.sb-gps-inactive-row:hover { opacity:0.75; } .sb-gps-inactive-row:hover { opacity:0.75; }
.sb-gps-toggle-row { margin-bottom:10px; } .sb-gps-toggle-row { margin-bottom:10px; }

View File

@ -78,7 +78,11 @@ export const useDispatchStore = defineStore('dispatch', () => {
resourceCategory: t.resource_category || '', // 'Véhicule' | 'Outil' | 'Salle' | etc. resourceCategory: t.resource_category || '', // 'Véhicule' | 'Outil' | 'Salle' | etc.
user: t.user || null, user: t.user || null,
colorIdx: idx % TECH_COLORS.length, colorIdx: idx % TECH_COLORS.length,
coords: [t.longitude || -73.5673, t.latitude || 45.5017], // 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, gpsCoords: null,
gpsSpeed: 0, gpsSpeed: 0,
gpsTime: null, gpsTime: null,