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,
|
||||
routeLegs, routeGeometry,
|
||||
getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
|
||||
dragJob, dragIsAssist, rightPanel, openCtxMenu,
|
||||
dragJob, dragIsAssist, rightPanel, openCtxMenu, openTechCtx,
|
||||
saveTechHome,
|
||||
} = deps
|
||||
|
||||
let map = null
|
||||
|
|
@ -18,6 +19,7 @@ export function useMap (deps) {
|
|||
const mapMarkers = ref([])
|
||||
const mapPanelW = ref(parseInt(localStorage.getItem('sbv2-mapW')) || 340)
|
||||
const geoFixJob = ref(null)
|
||||
const geoFixTech = ref(null) // ← analog of geoFixJob, but for tech home base
|
||||
const mapDragJob = ref(null)
|
||||
let _mapGhost = null
|
||||
|
||||
|
|
@ -33,6 +35,20 @@ export function useMap (deps) {
|
|||
}
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
function startMapResize (e) {
|
||||
e.preventDefault()
|
||||
|
|
@ -67,7 +83,10 @@ export function useMap (deps) {
|
|||
map = new mapboxgl.Map({
|
||||
container: mapContainer.value,
|
||||
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()
|
||||
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('mouseleave', 'sb-route-line', () => { if (!mapDragJob.value) map.getCanvas().style.cursor = '' })
|
||||
|
||||
// Geo-fix click
|
||||
map.on('click', e => {
|
||||
// Geo-fix click — handles BOTH job geofix and tech home-base pick.
|
||||
// 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
|
||||
const job = geoFixJob.value
|
||||
const saved = JSON.parse(localStorage.getItem('dispatch-job-coords') || '{}')
|
||||
|
|
@ -255,6 +286,15 @@ export function useMap (deps) {
|
|||
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
|
||||
outer.addEventListener('dragover', e => { e.preventDefault(); el.style.transform = 'scale(1.25)' })
|
||||
outer.addEventListener('dragleave', () => { el.style.transform = '' })
|
||||
|
|
@ -413,8 +453,9 @@ export function useMap (deps) {
|
|||
function getMap () { return map }
|
||||
|
||||
return {
|
||||
mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, mapDragJob,
|
||||
startGeoFix, cancelGeoFix, startMapResize, initMap,
|
||||
mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, geoFixTech, mapDragJob,
|
||||
startGeoFix, cancelGeoFix, startTechGeoFix, cancelTechGeoFix,
|
||||
startMapResize, initMap,
|
||||
drawMapMarkers, drawSelectedRoute, computeDayRoute,
|
||||
selectTechOnBoard, destroyMap, loadMapboxCss, getMap,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,10 +318,16 @@ const _map = useMap({
|
|||
currentView, periodStart, filteredResources, mapVisible,
|
||||
routeLegs, routeGeometry,
|
||||
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,
|
||||
startGeoFix, cancelGeoFix, startMapResize, initMap, selectTechOnBoard, destroyMap, loadMapboxCss } = _map
|
||||
const { mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, geoFixTech, mapDragJob,
|
||||
startGeoFix, cancelGeoFix, startTechGeoFix, cancelTechGeoFix,
|
||||
startMapResize, initMap, selectTechOnBoard, destroyMap, loadMapboxCss } = _map
|
||||
computeDayRoute = _map.computeDayRoute
|
||||
drawMapMarkers = _map.drawMapMarkers
|
||||
drawSelectedRoute = _map.drawSelectedRoute
|
||||
|
|
@ -411,27 +417,48 @@ 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:
|
||||
// Edit a tech's home/departure coordinates. Three paths now converge:
|
||||
// • 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)
|
||||
// • Type "lat, lng" directly (advanced)
|
||||
// • Pick the location by clicking on the map (geoFixTech mode)
|
||||
// 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.
|
||||
// involved.
|
||||
async function openTechHomeDialog (tech) {
|
||||
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}`,
|
||||
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>
|
||||
`,
|
||||
message: `<div class="text-caption text-grey-7">
|
||||
Actuelle: <code>${cur[1].toFixed(5)}, ${cur[0].toFixed(5)}</code>
|
||||
</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,
|
||||
prompt: {
|
||||
model: '',
|
||||
|
|
@ -932,6 +959,7 @@ function onKeyDown (e) {
|
|||
dispatchCriteriaModal.value = false; bookingOverlay.value = null
|
||||
filterPanelOpen.value = false; projectsPanelOpen.value = false
|
||||
selectedJob.value = null; multiSelect.value = []
|
||||
if (geoFixTech.value) cancelTechGeoFix()
|
||||
}
|
||||
if (e.key === 'z' && (e.metaKey || e.ctrlKey) && !e.shiftKey) {
|
||||
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="copyIcalUrl(techCtx.tech); techCtx=null">📅 Copier le lien iCal</button>
|
||||
<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>
|
||||
</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">
|
||||
<button class="sb-ctx-item" @click="assistCtxTogglePin()">
|
||||
{{ 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-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); }
|
||||
/* 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 } }
|
||||
.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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user