feat(ops/dispatch): right-click tech pin + click-on-map home picker + center map on HQ

Three connected UX changes:

1. **Map centered on Gigafibre HQ on first load** —
   Sainte-Clotilde (lng=-73.6756, lat=45.1599), zoom 10 — covers the
   service area (Sainte-Clotilde + Châteauguay + Napierville +
   Hemmingford). Was downtown Montréal.

2. **Right-click on a tech pin** opens the existing techCtx menu
   (already used from the calendar via @ctx-tech). New entries:
     • 📍 Adresse de départ…  → openTechHomeDialog
     • 🎯 Choisir sur la carte → startTechGeoFix (mirrors the existing
                                  geoFixJob flow used for jobs)

3. **The 📍 button in the GPS sidebar** now offers a 2-option chooser
   first: "Saisir une adresse" or "Cliquer sur la carte". Picking the
   map option drops the user into geoFixTech mode.

Implementation:

  • useMap.js: new geoFixTech ref + startTechGeoFix/cancelTechGeoFix
    + a contextmenu listener on each tech outer wrapper that calls
    openTechCtx(e, tech). The map's main click handler now branches:
    if geoFixTech is set, persist the lng/lat via saveTechHome (passed
    in via deps as a forward-bound arrow because saveTechHome is
    destructured below the useMap call in DispatchPage).
  • DispatchPage.vue: new banner shown while in pick mode (animated
    indigo bar at top, "Cliquez sur la carte pour {tech}", with a
    cancel button); ESC also cancels.
  • dispatch-styles.scss: .sb-geofix-banner styles + reusing the
    existing pulse keyframe.
This commit is contained in:
louispaulb 2026-05-05 14:02:26 -04:00
parent 060cc034a8
commit c96092e9e8
3 changed files with 114 additions and 24 deletions

View File

@ -8,7 +8,8 @@ export function useMap (deps) {
currentView, periodStart, filteredResources, mapVisible, currentView, periodStart, filteredResources, mapVisible,
routeLegs, routeGeometry, routeLegs, routeGeometry,
getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes, getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
dragJob, dragIsAssist, rightPanel, openCtxMenu, dragJob, dragIsAssist, rightPanel, openCtxMenu, openTechCtx,
saveTechHome,
} = deps } = deps
let map = null let map = null
@ -18,6 +19,7 @@ export function useMap (deps) {
const mapMarkers = ref([]) const mapMarkers = ref([])
const mapPanelW = ref(parseInt(localStorage.getItem('sbv2-mapW')) || 340) const mapPanelW = ref(parseInt(localStorage.getItem('sbv2-mapW')) || 340)
const geoFixJob = ref(null) const geoFixJob = ref(null)
const geoFixTech = ref(null) // ← analog of geoFixJob, but for tech home base
const mapDragJob = ref(null) const mapDragJob = ref(null)
let _mapGhost = null let _mapGhost = null
@ -33,6 +35,20 @@ export function useMap (deps) {
} }
watch(geoFixJob, v => { if (map) map.getCanvas().style.cursor = v ? 'crosshair' : '' }) watch(geoFixJob, v => { if (map) map.getCanvas().style.cursor = v ? 'crosshair' : '' })
// Tech home-base "click on the map" picker. Same pattern as geoFixJob:
// user enters the mode, cursor turns to crosshair, next click on the
// map captures the lng/lat and persists it to ERPNext.
function startTechGeoFix (tech) {
geoFixTech.value = tech
if (!mapVisible.value) mapVisible.value = true
if (map) map.getCanvas().style.cursor = 'crosshair'
}
function cancelTechGeoFix () {
geoFixTech.value = null
if (map) map.getCanvas().style.cursor = ''
}
watch(geoFixTech, v => { if (map) map.getCanvas().style.cursor = v ? 'crosshair' : '' })
// ── Panel resize ───────────────────────────────────────────────────────────── // ── Panel resize ─────────────────────────────────────────────────────────────
function startMapResize (e) { function startMapResize (e) {
e.preventDefault() e.preventDefault()
@ -67,7 +83,10 @@ export function useMap (deps) {
map = new mapboxgl.Map({ map = new mapboxgl.Map({
container: mapContainer.value, container: mapContainer.value,
style: 'mapbox://styles/mapbox/dark-v11', style: 'mapbox://styles/mapbox/dark-v11',
center: [-73.567, 45.502], zoom: 10, // Default centered on Gigafibre HQ (1867 chemin de la Rivière,
// Sainte-Clotilde, QC). Zoom 10 shows Sainte-Clotilde + the
// surrounding service area (Châteauguay, Napierville, Hemmingford…).
center: [-73.6756177, 45.1599145], zoom: 10,
}) })
if (mapResizeObs) mapResizeObs.disconnect() if (mapResizeObs) mapResizeObs.disconnect()
mapResizeObs = new ResizeObserver(() => { if (map) map.resize() }) mapResizeObs = new ResizeObserver(() => { if (map) map.resize() })
@ -112,8 +131,20 @@ export function useMap (deps) {
map.on('mouseenter', 'sb-route-line', () => { if (mapDragJob.value) map.getCanvas().style.cursor = 'copy' }) map.on('mouseenter', 'sb-route-line', () => { if (mapDragJob.value) map.getCanvas().style.cursor = 'copy' })
map.on('mouseleave', 'sb-route-line', () => { if (!mapDragJob.value) map.getCanvas().style.cursor = '' }) map.on('mouseleave', 'sb-route-line', () => { if (!mapDragJob.value) map.getCanvas().style.cursor = '' })
// Geo-fix click // Geo-fix click — handles BOTH job geofix and tech home-base pick.
map.on('click', e => { // Tech mode wins if both are somehow active simultaneously (defensive;
// shouldn't happen in practice).
map.on('click', async e => {
if (geoFixTech.value) {
const tech = geoFixTech.value
geoFixTech.value = null
map.getCanvas().style.cursor = ''
if (typeof saveTechHome === 'function') {
try { await saveTechHome(tech, e.lngLat.lng, e.lngLat.lat) } catch (_e) {}
}
nextTick(() => drawMapMarkers())
return
}
if (!geoFixJob.value) return if (!geoFixJob.value) return
const job = geoFixJob.value const job = geoFixJob.value
const saved = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}') const saved = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
@ -255,6 +286,15 @@ export function useMap (deps) {
el.appendChild(badge) el.appendChild(badge)
} }
// Right-click → open tech context menu (DispatchPage handles the
// q-menu). We pre-position by passing the original click event;
// useContextMenus reads e.clientX/Y to anchor the menu.
outer.addEventListener('contextmenu', e => {
e.preventDefault()
e.stopPropagation()
if (typeof openTechCtx === 'function') openTechCtx(e, tech)
})
// Drag & drop handlers // Drag & drop handlers
outer.addEventListener('dragover', e => { e.preventDefault(); el.style.transform = 'scale(1.25)' }) outer.addEventListener('dragover', e => { e.preventDefault(); el.style.transform = 'scale(1.25)' })
outer.addEventListener('dragleave', () => { el.style.transform = '' }) outer.addEventListener('dragleave', () => { el.style.transform = '' })
@ -413,8 +453,9 @@ export function useMap (deps) {
function getMap () { return map } function getMap () { return map }
return { return {
mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, mapDragJob, mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, geoFixTech, mapDragJob,
startGeoFix, cancelGeoFix, startMapResize, initMap, startGeoFix, cancelGeoFix, startTechGeoFix, cancelTechGeoFix,
startMapResize, initMap,
drawMapMarkers, drawSelectedRoute, computeDayRoute, drawMapMarkers, drawSelectedRoute, computeDayRoute,
selectTechOnBoard, destroyMap, loadMapboxCss, getMap, selectTechOnBoard, destroyMap, loadMapboxCss, getMap,
} }

View File

@ -318,10 +318,16 @@ const _map = useMap({
currentView, periodStart, filteredResources, mapVisible, currentView, periodStart, filteredResources, mapVisible,
routeLegs, routeGeometry, routeLegs, routeGeometry,
getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes, getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
dragJob, dragIsAssist, rightPanel, openCtxMenu, dragJob, dragIsAssist, rightPanel, openCtxMenu, openTechCtx,
// Forward-binding through an arrow: `saveTechHome` is destructured
// from useTechManagement BELOW this call, so we can't pass the
// function value directly here (TDZ). The arrow defers the lookup
// to invocation time by which point the const is defined.
saveTechHome: (tech, lng, lat) => saveTechHome(tech, lng, lat),
}) })
const { mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, mapDragJob, const { mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, geoFixTech, mapDragJob,
startGeoFix, cancelGeoFix, startMapResize, initMap, selectTechOnBoard, destroyMap, loadMapboxCss } = _map startGeoFix, cancelGeoFix, startTechGeoFix, cancelTechGeoFix,
startMapResize, initMap, selectTechOnBoard, destroyMap, loadMapboxCss } = _map
computeDayRoute = _map.computeDayRoute computeDayRoute = _map.computeDayRoute
drawMapMarkers = _map.drawMapMarkers drawMapMarkers = _map.drawMapMarkers
drawSelectedRoute = _map.drawSelectedRoute drawSelectedRoute = _map.drawSelectedRoute
@ -411,27 +417,48 @@ 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: // Edit a tech's home/departure coordinates. Three paths now converge:
// Type an address free Nominatim geocode confirm save // Type an address free Nominatim geocode confirm save
// Use the live GPS position (if Traccar device is online) as the // Type "lat, lng" directly (advanced)
// new home base handy after a tech moves // Pick the location by clicking on the map (geoFixTech mode)
// Or paste lat/lng directly (advanced)
// We don't proxy the geocode through targo-hub on purpose: Nominatim // 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 // 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 // involved.
// tile-usage policy responsibility.
async function openTechHomeDialog (tech) { async function openTechHomeDialog (tech) {
const cur = tech.coords || [-73.6756177, 45.1599145] const cur = tech.coords || [-73.6756177, 45.1599145]
const dialog = $q.dialog({ // Step 1: ask the user how they want to set it.
$q.dialog({
title: `Position de départ — ${tech.fullName}`, title: `Position de départ — ${tech.fullName}`,
message: ` message: `<div class="text-caption text-grey-7">
<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> Actuelle: <code>${cur[1].toFixed(5)}, ${cur[0].toFixed(5)}</code>
</div> </div>
`, <div class="text-caption q-mt-sm">Comment veux-tu la définir ?</div>`,
html: true,
options: {
type: 'radio',
model: 'address',
items: [
{ label: '📝 Saisir une adresse (ou "lat, lng")', value: 'address' },
{ label: '🎯 Cliquer sur la carte', value: 'map' },
],
},
cancel: { flat: true, label: 'Annuler' },
ok: { color: 'primary', unelevated: true, label: 'Continuer' },
}).onOk(method => {
if (method === 'map') {
startTechGeoFix(tech)
Notify.create({ type: 'info', message: `Cliquez sur la carte pour ${tech.fullName}`, position: 'top', timeout: 3000 })
return
}
// method === 'address' ask for the address string
promptTechHomeAddress(tech)
})
}
async function promptTechHomeAddress (tech) {
const dialog = $q.dialog({
title: `Adresse de départ — ${tech.fullName}`,
message: `<div class="text-caption text-grey-7">Adresse à géocoder, ou colle directement <code>lat, lng</code>.</div>`,
html: true, html: true,
prompt: { prompt: {
model: '', model: '',
@ -932,6 +959,7 @@ function onKeyDown (e) {
dispatchCriteriaModal.value = false; bookingOverlay.value = null dispatchCriteriaModal.value = false; bookingOverlay.value = null
filterPanelOpen.value = false; projectsPanelOpen.value = false filterPanelOpen.value = false; projectsPanelOpen.value = false
selectedJob.value = null; multiSelect.value = [] selectedJob.value = null; multiSelect.value = []
if (geoFixTech.value) cancelTechGeoFix()
} }
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) { if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) return if (['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) return
@ -1555,9 +1583,19 @@ onUnmounted(() => {
<button class="sb-ctx-item" @click="techTagModal=techCtx.tech; techCtx=null">🏷 Skills / Tags</button> <button class="sb-ctx-item" @click="techTagModal=techCtx.tech; techCtx=null">🏷 Skills / Tags</button>
<button class="sb-ctx-item" @click="copyIcalUrl(techCtx.tech); techCtx=null">📅 Copier le lien iCal</button> <button class="sb-ctx-item" @click="copyIcalUrl(techCtx.tech); techCtx=null">📅 Copier le lien iCal</button>
<div class="sb-ctx-sep"></div> <div class="sb-ctx-sep"></div>
<button class="sb-ctx-item" @click="openTechHomeDialog(techCtx.tech); techCtx=null">📍 Adresse de départ</button>
<button class="sb-ctx-item" @click="startTechGeoFix(techCtx.tech); techCtx=null">🎯 Choisir sur la carte</button>
<div class="sb-ctx-sep"></div>
<button class="sb-ctx-item" @click="window.open('/desk/Dispatch Technician/'+techCtx.tech.name,'_blank'); techCtx=null"> Ouvrir dans ERPNext</button> <button class="sb-ctx-item" @click="window.open('/desk/Dispatch Technician/'+techCtx.tech.name,'_blank'); techCtx=null"> Ouvrir dans ERPNext</button>
</SbContextMenu> </SbContextMenu>
<!-- Banner shown while in tech-home pick mode. ESC cancels. -->
<div v-if="geoFixTech" class="sb-geofix-banner">
🎯 Cliquez sur la carte pour définir le point de départ de
<b>{{ geoFixTech.fullName }}</b>
<button class="sb-geofix-cancel" @click="cancelTechGeoFix()">Annuler</button>
</div>
<SbContextMenu :pos="assistCtx"> <SbContextMenu :pos="assistCtx">
<button class="sb-ctx-item" @click="assistCtxTogglePin()"> <button class="sb-ctx-item" @click="assistCtxTogglePin()">
{{ assistCtx?.job?.assistants?.find(a=>a.techId===assistCtx?.techId)?.pinned ? '↕ Rendre flottant' : '📌 Prioriser dans le timeline' }} {{ assistCtx?.job?.assistants?.find(a=>a.techId===assistCtx?.techId)?.pinned ? '↕ Rendre flottant' : '📌 Prioriser dans le timeline' }}

View File

@ -549,6 +549,17 @@
.sb-geofix-hint { flex:1; font-size:0.68rem; color:var(--sb-text); animation:sb-geofix-pulse 1.4s ease-in-out infinite; } .sb-geofix-hint { flex:1; font-size:0.68rem; color:var(--sb-text); animation:sb-geofix-pulse 1.4s ease-in-out infinite; }
.sb-geofix-cancel { background:none; border:1px solid var(--sb-border-acc); border-radius:5px; color:var(--sb-muted); font-size:0.65rem; padding:0.18rem 0.45rem; cursor:pointer; } .sb-geofix-cancel { background:none; border:1px solid var(--sb-border-acc); border-radius:5px; color:var(--sb-muted); font-size:0.65rem; padding:0.18rem 0.45rem; cursor:pointer; }
.sb-geofix-cancel:hover { color:var(--sb-red); border-color:rgba(239,68,68,0.4); } .sb-geofix-cancel:hover { color:var(--sb-red); border-color:rgba(239,68,68,0.4); }
/* Tech-home pick mode banner — overlays the page top, dismiss via ESC or button. */
.sb-geofix-banner {
position: fixed; top: 8px; left: 50%; transform: translateX(-50%);
z-index: 1500; padding: 8px 14px; border-radius: 8px;
background: rgba(99,102,241,0.95); color: #fff; font-size: 0.85rem;
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
display: flex; align-items: center; gap: 14px;
animation: sb-geofix-pulse 1.4s ease-in-out infinite;
}
.sb-geofix-banner .sb-geofix-cancel { color: #fff; border-color: rgba(255,255,255,0.5); font-size: 0.75rem; }
.sb-geofix-banner .sb-geofix-cancel:hover { background: rgba(255,255,255,0.15); border-color: #fff; }
@keyframes sb-geofix-pulse { 0%,100%{ opacity:1 } 50%{ opacity:0.55 } } @keyframes sb-geofix-pulse { 0%,100%{ opacity:1 } 50%{ opacity:0.55 } }
.sb-map-close { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.9rem; margin-left:auto; } .sb-map-close { background:none; border:none; color:var(--sb-muted); cursor:pointer; font-size:0.9rem; margin-left:auto; }
.sb-map-legend { display:flex; flex-wrap:wrap; gap:0.3rem 0.6rem; padding:0.3rem 0.65rem; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); flex-shrink:0; } .sb-map-legend { display:flex; flex-wrap:wrap; gap:0.3rem 0.6rem; padding:0.3rem 0.65rem; background:var(--sb-sidebar); border-bottom:1px solid var(--sb-border); flex-shrink:0; }