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:
parent
490b9ce457
commit
060cc034a8
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: `
|
||||
<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) {
|
||||
scheduleModalTech.value = tech
|
||||
scheduleForm.value = {}
|
||||
|
|
@ -1721,6 +1803,7 @@ onUnmounted(() => {
|
|||
<span v-else class="sb-gps-badge sb-gps-none">—</span>
|
||||
</td>
|
||||
<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-else class="sb-gps-react-btn" @click="endAbsence(tech)" title="Réactiver">▶</button>
|
||||
</td>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user