From 060cc034a888d2157588e6d1feb00519f18d33d4 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Tue, 5 May 2026 13:53:14 -0400 Subject: [PATCH] feat(ops/dispatch): editable tech home base + new default at Gigafibre HQ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/ops/src/composables/useTechManagement.js | 19 ++++- apps/ops/src/pages/DispatchPage.vue | 85 ++++++++++++++++++- apps/ops/src/pages/dispatch-styles.scss | 2 + apps/ops/src/stores/dispatch.js | 6 +- 4 files changed, 109 insertions(+), 3 deletions(-) diff --git a/apps/ops/src/composables/useTechManagement.js b/apps/ops/src/composables/useTechManagement.js index 2d68767..a704f13 100644 --- a/apps/ops/src/composables/useTechManagement.js +++ b/apps/ops/src/composables/useTechManagement.js @@ -212,12 +212,29 @@ export function useTechManagement (store, 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 { editingTech, newTechName, newTechPhone, newTechDevice, addingTech, absenceModalOpen, absenceModalTech, absenceForm, absenceProcessing, saveTechField, addTech, openAbsenceModal, confirmAbsence, endAbsence, - deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule, + deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule, saveTechHome, ABSENCE_REASONS, } } diff --git a/apps/ops/src/pages/DispatchPage.vue b/apps/ops/src/pages/DispatchPage.vue index ef93f2f..e756995 100644 --- a/apps/ops/src/pages/DispatchPage.vue +++ b/apps/ops/src/pages/DispatchPage.vue @@ -401,7 +401,7 @@ const { absenceModalOpen, absenceModalTech, absenceForm, absenceProcessing, saveTechField, addTech, openAbsenceModal, confirmAbsence, endAbsence, - deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule, + deactivateTech, reactivateTech, removeTech, saveTraccarLink, saveWeeklySchedule, saveTechHome, ABSENCE_REASONS, } = useTechManagement(store, invalidateRoutes) @@ -411,6 +411,88 @@ const scheduleModalTech = ref(null) const scheduleForm = ref({}) 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: ` +
+ Adresse à géocoder, ou colle directement lat, lng. +
+
+ Actuelle : ${cur[1].toFixed(5)}, ${cur[0].toFixed(5)} +
+ `, + 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: `
${hit.display_name}
${lat.toFixed(5)}, ${lng.toFixed(5)}
`, + 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) { scheduleModalTech.value = tech scheduleForm.value = {} @@ -1721,6 +1803,7 @@ onUnmounted(() => { + diff --git a/apps/ops/src/pages/dispatch-styles.scss b/apps/ops/src/pages/dispatch-styles.scss index 1586950..1cec366 100644 --- a/apps/ops/src/pages/dispatch-styles.scss +++ b/apps/ops/src/pages/dispatch-styles.scss @@ -725,6 +725,8 @@ .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: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:hover { opacity:0.75; } .sb-gps-toggle-row { margin-bottom:10px; } diff --git a/apps/ops/src/stores/dispatch.js b/apps/ops/src/stores/dispatch.js index beeace3..217da6c 100644 --- a/apps/ops/src/stores/dispatch.js +++ b/apps/ops/src/stores/dispatch.js @@ -78,7 +78,11 @@ export const useDispatchStore = defineStore('dispatch', () => { resourceCategory: t.resource_category || '', // 'Véhicule' | 'Outil' | 'Salle' | etc. user: t.user || null, 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, gpsSpeed: 0, gpsTime: null,