Four fixes around the dispatch header following dispatcher feedback:
1. **⋯ overflow menu was invisible**: .sb-header had `overflow:hidden`,
which clipped the absolutely-positioned dropdown right at the
header's bottom edge. Switched the header to `overflow:visible`
(children all have flex-shrink:0 + a flex:1 center, so the layout
doesn't actually overflow horizontally). Bumped z-index to 5000
for safety on top of map/calendar layers.
2. **NLP/Assistant IA bar hidden by default**: was eagerly rendering
on every page load, with the long French placeholder polluting
the header below the toolbar. The user just wanted the icon. Now
`nlpVisible` defaults to false, persisted in localStorage so power
users who flip it on keep it open across sessions. Toggle still
lives in the ⋯ menu.
3. **Click a tech in the resource list now flies the map to them**:
selectTechOnBoard previously only opened the map panel. Now it
also `map.flyTo({ center })` using `tech.gpsCoords ?? tech.coords`
— live Traccar position wins when the tech is online; falls back
to the saved home base. Animated, deferred a tick so map.resize()
happens first, otherwise flyTo can land on garbage coords during
the panel's open transition.
4. **Board view tabs collapsed into a "Vue principale ▾" dropdown**:
was [Vue principale][Par région][+] inline. Now a single button
showing the active view; click reveals the others + the future
"+ Nouvelle vue" entry. Same dropdown component as the ⋯ menu
(shared CSS, click-outside + ESC close).
476 lines
25 KiB
JavaScript
476 lines
25 KiB
JavaScript
// ── Map composable: Mapbox GL map, markers, routes, geo-fix, map-drag ────────
|
|
import { ref, watch, nextTick } from 'vue'
|
|
import { localDateStr, jobSpansDate, jobSvcCode, SVC_COLORS } from './useHelpers'
|
|
|
|
export function useMap (deps) {
|
|
const {
|
|
store, MAPBOX_TOKEN, TECH_COLORS,
|
|
currentView, periodStart, filteredResources, mapVisible,
|
|
routeLegs, routeGeometry,
|
|
getJobDate, jobColor, pushUndo, smartAssign, invalidateRoutes,
|
|
dragJob, dragIsAssist, rightPanel, openCtxMenu, openTechCtx,
|
|
saveTechHome,
|
|
} = deps
|
|
|
|
let map = null
|
|
let mapResizeObs = null
|
|
const mapContainer = ref(null)
|
|
const selectedTechId = ref(null)
|
|
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
|
|
|
|
// ── Geo-fix ──────────────────────────────────────────────────────────────────
|
|
function startGeoFix (job) {
|
|
geoFixJob.value = job
|
|
if (!mapVisible.value) mapVisible.value = true
|
|
if (map) map.getCanvas().style.cursor = 'crosshair'
|
|
}
|
|
function cancelGeoFix () {
|
|
geoFixJob.value = null
|
|
if (map) map.getCanvas().style.cursor = ''
|
|
}
|
|
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()
|
|
const startX = e.clientX, startW = mapPanelW.value
|
|
function onMove (ev) {
|
|
mapPanelW.value = Math.max(220, Math.min(window.innerWidth * 0.65, startW - (ev.clientX - startX)))
|
|
}
|
|
function onUp () {
|
|
document.removeEventListener('mousemove', onMove)
|
|
document.removeEventListener('mouseup', onUp)
|
|
localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
|
|
if (map) map.resize()
|
|
}
|
|
document.addEventListener('mousemove', onMove)
|
|
document.addEventListener('mouseup', onUp)
|
|
}
|
|
|
|
// ── Init ─────────────────────────────────────────────────────────────────────
|
|
async function initMap () {
|
|
if (!mapContainer.value || map) return
|
|
if (!window.mapboxgl) {
|
|
if (!document.getElementById('mapbox-js')) {
|
|
await new Promise(resolve => {
|
|
const s = document.createElement('script'); s.id = 'mapbox-js'
|
|
s.src = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.js'
|
|
s.onload = resolve; document.head.appendChild(s)
|
|
})
|
|
} else { await new Promise(r => setTimeout(r, 200)) }
|
|
}
|
|
const mapboxgl = window.mapboxgl
|
|
mapboxgl.accessToken = MAPBOX_TOKEN
|
|
map = new mapboxgl.Map({
|
|
container: mapContainer.value,
|
|
style: 'mapbox://styles/mapbox/dark-v11',
|
|
// 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() })
|
|
mapResizeObs.observe(mapContainer.value)
|
|
|
|
map.on('load', () => {
|
|
map.resize()
|
|
// Route layers
|
|
map.addSource('sb-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
|
map.addLayer({ id: 'sb-route-halo', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 12, 'line-opacity': 0.18 } })
|
|
map.addLayer({ id: 'sb-route-line', type: 'line', source: 'sb-route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#6366f1', 'line-width': 3.5, 'line-opacity': 0.85 } })
|
|
// Job layers
|
|
map.addSource('sb-jobs', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
|
map.addLayer({ id: 'sb-jobs-halo', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 22, 'circle-color': ['get', 'color'], 'circle-opacity': ['*', ['get', 'opacity'], 0.18], 'circle-blur': 0.7 } })
|
|
map.addLayer({ id: 'sb-jobs-circle', type: 'circle', source: 'sb-jobs', paint: { 'circle-radius': 15, 'circle-color': ['get', 'color'], 'circle-opacity': ['get', 'opacity'], 'circle-stroke-width': 2, 'circle-stroke-color': ['case', ['get', 'unassigned'], 'rgba(255,255,255,0.4)', 'rgba(255,255,255,0.85)'], 'circle-stroke-opacity': ['get', 'opacity'] } })
|
|
map.addLayer({ id: 'sb-jobs-label', type: 'symbol', source: 'sb-jobs', layout: { 'text-field': ['get', 'label'], 'text-font': ['DIN Offc Pro Bold', 'Arial Unicode MS Bold'], 'text-size': 9, 'text-allow-overlap': true, 'text-ignore-placement': true }, paint: { 'text-color': '#ffffff', 'text-opacity': ['get', 'opacity'] } })
|
|
|
|
// Event handlers
|
|
map.on('mouseenter', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = 'grab' })
|
|
map.on('mouseleave', 'sb-jobs-circle', () => { if (!mapDragJob.value && !geoFixJob.value) map.getCanvas().style.cursor = '' })
|
|
map.on('mousedown', 'sb-jobs-circle', e => {
|
|
if (geoFixJob.value) return
|
|
e.preventDefault()
|
|
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
|
|
if (job) startMapDrag(e.originalEvent, job)
|
|
})
|
|
map.on('click', 'sb-jobs-circle', e => {
|
|
if (geoFixJob.value) return
|
|
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
|
|
if (job) {
|
|
const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
|
|
rightPanel.value = { mode: 'details', data: { job, tech } }
|
|
}
|
|
})
|
|
map.on('contextmenu', 'sb-jobs-circle', e => {
|
|
const job = store.jobs.find(j => j.id === e.features[0].properties.id)
|
|
if (job) {
|
|
const tech = job.assignedTech ? store.technicians.find(t => t.id === job.assignedTech) : null
|
|
openCtxMenu(e.originalEvent, job, tech?.id || null)
|
|
}
|
|
})
|
|
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 — 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') || '{}')
|
|
saved[job.id] = [e.lngLat.lng, e.lngLat.lat]
|
|
localStorage.setItem('dispatch-job-coords', JSON.stringify(saved))
|
|
store.updateJobCoords(job.id, e.lngLat.lng, e.lngLat.lat)
|
|
routeLegs.value = {}; routeGeometry.value = {}
|
|
geoFixJob.value = null
|
|
map.getCanvas().style.cursor = ''
|
|
nextTick(() => {
|
|
drawMapMarkers()
|
|
const dayStr = localDateStr(periodStart.value)
|
|
filteredResources.value.forEach(tech => computeDayRoute(tech, dayStr))
|
|
drawSelectedRoute()
|
|
})
|
|
})
|
|
|
|
drawMapMarkers()
|
|
drawSelectedRoute()
|
|
})
|
|
}
|
|
|
|
// ── Draw markers ─────────────────────────────────────────────────────────────
|
|
function drawMapMarkers () {
|
|
if (!map || !window.mapboxgl) return
|
|
const dayStr = localDateStr(periodStart.value)
|
|
const mbgl = window.mapboxgl
|
|
|
|
const jobFeatures = store.jobs
|
|
.filter(j => j.coords && !(j.coords[0] === 0 && j.coords[1] === 0))
|
|
.filter(j => {
|
|
if (!j.assignedTech) return (j.scheduledDate || null) === dayStr
|
|
const tech = store.technicians.find(t => t.id === j.assignedTech)
|
|
return jobSpansDate(j, dayStr, tech)
|
|
})
|
|
.map(job => {
|
|
const isUnassigned = !job.assignedTech
|
|
const isCompleted = (job.status || '').toLowerCase() === 'completed'
|
|
const isSelected = selectedTechId.value && job.assignedTech === selectedTechId.value
|
|
const opacity = isCompleted ? 0.4 : (isSelected || isUnassigned || !selectedTechId.value ? 0.92 : 0.4)
|
|
let label = jobSvcCode(job)
|
|
if (!isUnassigned) {
|
|
const tech = store.technicians.find(t => t.id === job.assignedTech)
|
|
if (tech) { const idx = tech.queue.filter(j2 => getJobDate(j2.id) === dayStr).indexOf(job); if (idx >= 0) label = String(idx + 1) }
|
|
}
|
|
return { type: 'Feature', geometry: { type: 'Point', coordinates: job.coords }, properties: { id: job.id, color: jobColor(job), label, title: job.subject, opacity, unassigned: isUnassigned, completed: isCompleted } }
|
|
})
|
|
if (map.getSource('sb-jobs')) map.getSource('sb-jobs').setData({ type: 'FeatureCollection', features: jobFeatures })
|
|
|
|
// Tech avatar markers
|
|
mapMarkers.value.forEach(m => m.remove())
|
|
mapMarkers.value = []
|
|
|
|
// Pre-compute: which techs are assistants on which lead tech's jobs today
|
|
const groupCounts = {} // leadTechId → total crew size (1 + assistants)
|
|
store.technicians.forEach(tech => {
|
|
const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr, tech))
|
|
const assistIds = new Set()
|
|
todayJobs.forEach(j => (j.assistants || []).forEach(a => assistIds.add(a.techId)))
|
|
if (assistIds.size > 0) groupCounts[tech.id] = 1 + assistIds.size
|
|
})
|
|
|
|
filteredResources.value.forEach(tech => {
|
|
const pos = tech.gpsCoords || tech.coords
|
|
if (!pos || (pos[0] === 0 && pos[1] === 0)) return
|
|
const initials = tech.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
|
|
const color = TECH_COLORS[tech.colorIdx]
|
|
|
|
// Calculate daily workload + completion
|
|
const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr, tech))
|
|
const todayAssist = (tech.assistJobs || []).filter(j => jobSpansDate(j, dayStr, tech))
|
|
const allToday = [...todayJobs, ...todayAssist]
|
|
const totalHours = allToday.reduce((s, j) => s + (j.duration || 1), 0)
|
|
const doneHours = allToday.filter(j => (j.status || '').toLowerCase() === 'completed')
|
|
.reduce((s, j) => s + (j.duration || 1), 0)
|
|
const loadPct = Math.min(totalHours / 8, 1)
|
|
const donePct = totalHours > 0 ? Math.min(doneHours / 8, 1) : 0
|
|
const loadColor = loadPct < 0.5 ? '#10b981' : loadPct < 0.75 ? '#f59e0b' : loadPct < 0.9 ? '#f97316' : '#ef4444'
|
|
|
|
// Ring + avatar in a fixed-size container so Mapbox anchor stays consistent
|
|
const PIN = 36, STROKE = 3.5, SIZE = PIN + STROKE * 2 + 2 // ~45px
|
|
const R = (SIZE - STROKE) / 2, CIRC = 2 * Math.PI * R
|
|
const completedJobs = allToday.filter(j => (j.status || '').toLowerCase() === 'completed').length
|
|
const totalJobs = allToday.length
|
|
const completionPct = totalJobs > 0 ? completedJobs / totalJobs : 0
|
|
|
|
// Fixed-size outer wrapper — Mapbox anchors to this. DO NOT set
|
|
// `position:relative` inline here: Mapbox GL's `.mapboxgl-marker`
|
|
// class applies `position:absolute` and is what gets the
|
|
// `transform: translate(...)` at every zoom/pan frame. An inline
|
|
// `relative` overrides that and the pin appears to drift away from
|
|
// its lat/lng on zoom. The svg + avatar children are positioned
|
|
// absolutely; that still works against an absolutely-positioned
|
|
// parent (any positioned element is a containing block).
|
|
const outer = document.createElement('div')
|
|
outer.style.cssText = `cursor:pointer;width:${SIZE}px;height:${SIZE}px;`
|
|
outer.dataset.techId = tech.id
|
|
|
|
// SVG ring (load arc + completion arc) — fills entire container
|
|
if (totalHours > 0) {
|
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
|
|
svg.setAttribute('width', SIZE); svg.setAttribute('height', SIZE)
|
|
svg.style.cssText = 'position:absolute;top:0;left:0;transform:rotate(-90deg);pointer-events:none;'
|
|
const loadArc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
|
loadArc.setAttribute('cx', SIZE/2); loadArc.setAttribute('cy', SIZE/2); loadArc.setAttribute('r', R)
|
|
loadArc.setAttribute('fill', 'none'); loadArc.setAttribute('stroke', loadColor)
|
|
loadArc.setAttribute('stroke-width', STROKE); loadArc.setAttribute('opacity', '0.3')
|
|
loadArc.setAttribute('stroke-dasharray', `${CIRC * loadPct} ${CIRC}`)
|
|
loadArc.setAttribute('stroke-linecap', 'round')
|
|
svg.appendChild(loadArc)
|
|
if (completionPct > 0) {
|
|
const doneArc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
|
|
doneArc.setAttribute('cx', SIZE/2); doneArc.setAttribute('cy', SIZE/2); doneArc.setAttribute('r', R)
|
|
doneArc.setAttribute('fill', 'none'); doneArc.setAttribute('stroke', '#10b981')
|
|
doneArc.setAttribute('stroke-width', STROKE); doneArc.setAttribute('opacity', '1')
|
|
doneArc.setAttribute('stroke-dasharray', `${CIRC * completionPct * loadPct} ${CIRC}`)
|
|
doneArc.setAttribute('stroke-linecap', 'round')
|
|
svg.appendChild(doneArc)
|
|
}
|
|
outer.appendChild(svg)
|
|
}
|
|
|
|
// Avatar circle — absolutely centered in container
|
|
const el = document.createElement('div')
|
|
el.className = 'sb-map-tech-pin'
|
|
const offset = (SIZE - PIN) / 2
|
|
el.style.cssText = `background:${color};border-color:${color};position:absolute;top:${offset}px;left:${offset}px;width:${PIN}px;height:${PIN}px;`
|
|
el.textContent = initials
|
|
el.title = `${tech.fullName} — ${completedJobs}/${totalJobs} jobs (${doneHours.toFixed(1)}h / ${totalHours.toFixed(1)}h)`
|
|
outer.appendChild(el)
|
|
|
|
// Group badge (crew size)
|
|
const crew = groupCounts[tech.id]
|
|
if (crew && crew > 1) {
|
|
const badge = document.createElement('div')
|
|
badge.className = 'sb-map-crew-badge'
|
|
badge.textContent = String(crew)
|
|
badge.title = `Équipe de ${crew}`
|
|
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 = '' })
|
|
outer.addEventListener('drop', e => {
|
|
e.preventDefault(); el.style.transform = ''
|
|
const job = dragJob.value
|
|
if (job) {
|
|
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
|
|
smartAssign(job, tech.id, dayStr)
|
|
dragJob.value = null
|
|
invalidateRoutes()
|
|
}
|
|
})
|
|
outer.addEventListener('mouseenter', () => { if (mapDragJob.value) el.style.transform = 'scale(1.3)' })
|
|
outer.addEventListener('mouseleave', () => { el.style.transform = '' })
|
|
|
|
if (tech.gpsCoords) {
|
|
el.classList.add('sb-map-gps-active')
|
|
el.title += ' (GPS)'
|
|
}
|
|
const m = new mbgl.Marker({ element: outer, anchor: 'center' }).setLngLat(pos).addTo(map)
|
|
mapMarkers.value.push(m)
|
|
})
|
|
}
|
|
|
|
// ── Map drag (job pin → tech) ────────────────────────────────────────────────
|
|
function startMapDrag (e, job) {
|
|
e.preventDefault()
|
|
mapDragJob.value = job
|
|
if (map) map.dragPan.disable()
|
|
_mapGhost = document.createElement('div')
|
|
_mapGhost.className = 'sb-map-drag-ghost'
|
|
_mapGhost.textContent = job.subject
|
|
_mapGhost.style.cssText = `position:fixed;pointer-events:none;z-index:9999;left:${e.clientX + 14}px;top:${e.clientY + 14}px`
|
|
document.body.appendChild(_mapGhost)
|
|
document.addEventListener('mousemove', _onMapDragMove)
|
|
document.addEventListener('mouseup', _onMapDragEnd)
|
|
}
|
|
function _onMapDragMove (e) { if (_mapGhost) { _mapGhost.style.left = (e.clientX + 14) + 'px'; _mapGhost.style.top = (e.clientY + 14) + 'px' } }
|
|
function _onMapDragEnd (e) {
|
|
document.removeEventListener('mousemove', _onMapDragMove)
|
|
document.removeEventListener('mouseup', _onMapDragEnd)
|
|
if (_mapGhost) { _mapGhost.remove(); _mapGhost = null }
|
|
if (map) { map.getCanvas().style.cursor = ''; map.dragPan.enable() }
|
|
const job = mapDragJob.value; mapDragJob.value = null
|
|
if (!job) return
|
|
const els = document.elementsFromPoint(e.clientX, e.clientY)
|
|
const dateStr = localDateStr(periodStart.value)
|
|
function assignFromMap (tech) {
|
|
if (dragIsAssist.value) { dragIsAssist.value = false; return }
|
|
pushUndo({ type: 'unassignJob', jobId: job.id, techId: job.assignedTech, routeOrder: job.routeOrder, scheduledDate: job.scheduledDate, assistants: [...(job.assistants || [])] })
|
|
smartAssign(job, tech.id, dateStr)
|
|
invalidateRoutes()
|
|
}
|
|
const domTarget = els.find(el => el.dataset?.techId)
|
|
if (domTarget) { const tech = store.technicians.find(t => t.id === domTarget.dataset.techId); if (tech) assignFromMap(tech); return }
|
|
if (map && selectedTechId.value) {
|
|
const canvas = map.getCanvas(), rect = canvas.getBoundingClientRect()
|
|
if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) {
|
|
const tech = store.technicians.find(t => t.id === selectedTechId.value)
|
|
if (tech) assignFromMap(tech)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Route computation ────────────────────────────────────────────────────────
|
|
async function computeDayRoute (tech, dateStr) {
|
|
const key = `${tech.id}||${dateStr}`
|
|
if (routeLegs.value[key] !== undefined) return
|
|
const points = []
|
|
if (tech.coords?.[0] && tech.coords?.[1]) points.push(`${tech.coords[0]},${tech.coords[1]}`)
|
|
const allJobs = [...tech.queue.filter(j => jobSpansDate(j, dateStr, tech)), ...(tech.assistJobs || []).filter(j => jobSpansDate(j, dateStr, tech))]
|
|
allJobs.forEach(j => { if (j.coords && (j.coords[0] !== 0 || j.coords[1] !== 0)) points.push(`${j.coords[0]},${j.coords[1]}`) })
|
|
function setCache (legs, geom) {
|
|
routeLegs.value = { ...routeLegs.value, [key]: legs }
|
|
routeGeometry.value = { ...routeGeometry.value, [key]: geom }
|
|
}
|
|
if (points.length < 2) { setCache([], null); return }
|
|
try {
|
|
const url = `https://api.mapbox.com/directions/v5/mapbox/driving-traffic/${points.join(';')}?overview=full&geometries=geojson&access_token=${MAPBOX_TOKEN}`
|
|
const r = await fetch(url)
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
|
const data = await r.json()
|
|
if (data.routes?.[0]) setCache(data.routes[0].legs.map(l => Math.round(l.duration / 60)), data.routes[0].geometry.coordinates)
|
|
else setCache([], null)
|
|
} catch (e) { console.warn('[route] fetch error', e); setCache([], null) }
|
|
}
|
|
|
|
// ── Draw route ───────────────────────────────────────────────────────────────
|
|
function drawSelectedRoute () {
|
|
if (!map || !mapVisible.value) return
|
|
const src = map.getSource('sb-route'); if (!src) return
|
|
const empty = { type: 'FeatureCollection', features: [] }
|
|
if (currentView.value !== 'day') { src.setData(empty); return }
|
|
const dayStr = localDateStr(periodStart.value)
|
|
const features = []
|
|
const techs = selectedTechId.value ? filteredResources.value.filter(t => t.id === selectedTechId.value) : filteredResources.value
|
|
techs.forEach(tech => {
|
|
const coords = routeGeometry.value[`${tech.id}||${dayStr}`]
|
|
if (coords?.length) features.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: TECH_COLORS[tech.colorIdx] } })
|
|
})
|
|
src.setData({ type: 'FeatureCollection', features })
|
|
map.setPaintProperty('sb-route-halo', 'line-color', ['get', 'color'])
|
|
map.setPaintProperty('sb-route-line', 'line-color', ['get', 'color'])
|
|
}
|
|
|
|
// ── Select tech on board ─────────────────────────────────────────────────────
|
|
function selectTechOnBoard (tech) {
|
|
const wasSelected = selectedTechId.value === tech.id
|
|
selectedTechId.value = wasSelected ? null : tech.id
|
|
if (!wasSelected && currentView.value === 'day') {
|
|
if (!mapVisible.value) {
|
|
mapPanelW.value = Math.round(window.innerWidth * 0.5)
|
|
localStorage.setItem('sbv2-mapW', String(mapPanelW.value))
|
|
mapVisible.value = true
|
|
}
|
|
// Live GPS wins over the ERPNext-saved home base, so the rep
|
|
// sees where the tech actually IS right now if Traccar reports
|
|
// them online. Fall back to home coords when offline. We
|
|
// flyTo (animated pan+zoom) so the dispatcher gets a clear
|
|
// visual cue, instead of a hard jump.
|
|
const pos = tech.gpsCoords || tech.coords
|
|
if (pos && map && Number.isFinite(pos[0]) && Number.isFinite(pos[1])) {
|
|
// Defer one tick so the map panel has time to be visible
|
|
// and `map.resize()` has run before the camera animation.
|
|
nextTick(() => {
|
|
try { map.flyTo({ center: pos, zoom: Math.max(map.getZoom(), 12), speed: 1.2, essential: true }) } catch (_e) {}
|
|
})
|
|
}
|
|
}
|
|
if (map) { drawMapMarkers(); drawSelectedRoute() }
|
|
}
|
|
|
|
// ── Watchers ─────────────────────────────────────────────────────────────────
|
|
watch([selectedTechId, () => periodStart.value?.getTime(), currentView, routeGeometry], () => { if (map) { drawMapMarkers(); drawSelectedRoute() } })
|
|
watch(mapVisible, async v => {
|
|
if (v) {
|
|
if (map) { try { map.remove() } catch (_) {} map = null }
|
|
await nextTick(); await initMap()
|
|
if (map) {
|
|
const r = () => { if (!map) return; map.resize(); drawMapMarkers(); drawSelectedRoute() }
|
|
await nextTick(); r(); setTimeout(r, 100); setTimeout(r, 300); setTimeout(r, 600)
|
|
}
|
|
} else {
|
|
if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
|
|
if (map) { try { map.remove() } catch (_) {} map = null }
|
|
}
|
|
})
|
|
watch([() => periodStart.value?.getTime(), filteredResources], () => {
|
|
if (currentView.value === 'day' && mapVisible.value && map) { drawMapMarkers(); drawSelectedRoute() }
|
|
})
|
|
watch(
|
|
() => store.technicians.map(t => t.gpsCoords),
|
|
() => { if (map) drawMapMarkers() },
|
|
{ deep: true }
|
|
)
|
|
|
|
// ── Lifecycle helpers ────────────────────────────────────────────────────────
|
|
function destroyMap () {
|
|
if (map) { map.remove(); map = null }
|
|
if (mapResizeObs) { mapResizeObs.disconnect(); mapResizeObs = null }
|
|
}
|
|
function loadMapboxCss () {
|
|
if (!document.getElementById('mapbox-css')) {
|
|
const l = document.createElement('link'); l.id = 'mapbox-css'; l.rel = 'stylesheet'
|
|
l.href = 'https://api.mapbox.com/mapbox-gl-js/v2.15.0/mapbox-gl.css'; document.head.appendChild(l)
|
|
}
|
|
}
|
|
function getMap () { return map }
|
|
|
|
return {
|
|
mapContainer, selectedTechId, mapMarkers, mapPanelW, geoFixJob, geoFixTech, mapDragJob,
|
|
startGeoFix, cancelGeoFix, startTechGeoFix, cancelTechGeoFix,
|
|
startMapResize, initMap,
|
|
drawMapMarkers, drawSelectedRoute, computeDayRoute,
|
|
selectTechOnBoard, destroyMap, loadMapboxCss, getMap,
|
|
}
|
|
}
|