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:
parent
060cc034a8
commit
c96092e9e8
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
Actuelle: <code>${cur[1].toFixed(5)}, ${cur[0].toFixed(5)}</code>
|
||||||
Adresse à géocoder, ou colle directement <code>lat, lng</code>.
|
</div>
|
||||||
</div>
|
<div class="text-caption q-mt-sm">Comment veux-tu la définir ?</div>`,
|
||||||
<div class="text-caption text-grey-6">
|
html: true,
|
||||||
Actuelle : <code>${cur[1].toFixed(5)}, ${cur[0].toFixed(5)}</code>
|
options: {
|
||||||
</div>
|
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' }}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user