feat(dispatch): niveau requis persistant + lasso freeform + routes réelles Mapbox

- Niveau requis PAR JOB persistant : store hub durable (job-levels.json) + endpoint
  POST /roster/job-level + buildUnassigned enrichit required_level ; pastille « niv »
  écrit via l'API (survit reload/session). NB : l'API Custom Field ERPNext v16 échoue
  (IndexError) sur ce doctype custom → store hub (migratable en champ ERPNext plus tard).
- Lasso FREEFORM : tracé libre (souris + tactile) → polygone SVG + point-in-polygon sur
  les pins/amas projetés (remplace le rectangle) + expansion des amas (getClusterLeaves).
- D v2 : carte des tournées = routes ROUTIÈRES réelles (Mapbox Directions par tech,
  domicile→arrêts) + distance/temps RÉELS dans la légende (cache par signature ;
  fallback segments droits + estimation haversine).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 09:18:33 -04:00
parent 83338ca57b
commit aa6cc3e732
3 changed files with 96 additions and 26 deletions

View File

@ -52,6 +52,7 @@ export const updateTemplate = (name, patch) => jput('/roster/template/' + encode
export const askAssistant = (message, history) => jpost('/roster/assistant', { message, history })
export const getPolicy = () => jget('/roster/policy')
export const savePolicy = (policy) => jpost('/roster/policy', policy)
export const setJobLevel = (name, level) => jpost('/roster/job-level', { name, level }) // niveau requis persistant par job
export async function deleteShiftTemplate (name) {
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)

View File

@ -878,7 +878,7 @@
<div v-show="assignPanel.showMap" class="assign-map-wrap" @mousedown.stop>
<div ref="assignMapEl" class="assign-map"></div>
<q-btn dense round size="sm" class="map-sat-btn" :color="satView ? 'primary' : 'grey-8'" :icon="satView ? 'map' : 'satellite_alt'" @click="toggleSat"><q-tooltip>{{ satView ? 'Vue carte' : 'Vue satellite' }}</q-tooltip></q-btn>
<q-btn dense round size="sm" class="map-lasso-btn" :color="assignLasso ? 'primary' : 'grey-8'" icon="highlight_alt" @click="toggleLasso"><q-tooltip>{{ assignLasso ? 'Lasso ACTIF — glisse un rectangle pour sélectionner un groupe (reclique pour quitter)' : 'Lasso : sélectionner un groupe de jobs en glissant un rectangle' }}</q-tooltip></q-btn>
<q-btn dense round size="sm" class="map-lasso-btn" :color="assignLasso ? 'primary' : 'grey-8'" icon="highlight_alt" @click="toggleLasso"><q-tooltip>{{ assignLasso ? 'Lasso ACTIF — trace une zone à main levée pour sélectionner les jobs dedans (reclique pour quitter)' : 'Lasso : encercler un groupe de jobs (tracé libre)' }}</q-tooltip></q-btn>
<div class="assign-map-cap">📍 Pins par <b>compétence</b> · amas = <b>total de jobs</b> (clic = zoom) · <b><q-icon name="highlight_alt" size="12px" /> lasso</b> = sélectionner une zone<span v-if="assignNoCoord" class="text-warning"> · {{ assignNoCoord }} sans coords</span></div>
</div>
<div class="assign-body">
@ -918,7 +918,7 @@
</div>
<div class="assign-sub">
<span v-if="j.required_skill" class="assign-skill" :style="{ background: getTagColor(j.required_skill) }">{{ j.required_skill }}</span>
<span v-if="j.required_skill" class="assign-lvl" :class="{ set: j._reqLevel > 1 }" @click.stop @mousedown.stop><q-icon name="military_tech" size="11px" />niv&nbsp;{{ j._reqLevel || 1 }}<q-tooltip class="bg-grey-9">Niveau min requis pour ce job (mode « juste ce qu'il faut » → réserve les experts)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:150px"><q-item v-for="n in [1,2,3,4,5]" :key="n" clickable v-close-popup @click="j._reqLevel = n" :active="(j._reqLevel||1)===n"><q-item-section>Niveau {{ n }}{{ n===1 ? ' — de base' : (n===5 ? ' — expert' : '') }}</q-item-section></q-item></q-list></q-menu></span>
<span v-if="j.required_skill" class="assign-lvl" :class="{ set: j.required_level > 1 }" @click.stop @mousedown.stop><q-icon name="military_tech" size="11px" />niv&nbsp;{{ j.required_level || 1 }}<q-tooltip class="bg-grey-9">Niveau min requis pour ce job (persistant · mode « juste ce qu'il faut » → réserve les experts)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:150px"><q-item v-for="n in [1,2,3,4,5]" :key="n" clickable v-close-popup @click="setJobReqLevel(j, n)" :active="(j.required_level||1)===n"><q-item-section>Niveau {{ n }}{{ n===1 ? ' — de base' : (n===5 ? ' — expert' : '') }}</q-item-section></q-item></q-list></q-menu></span>
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · <span class="text-positive text-weight-medium"> {{ j.est_min ? fmtMin(j.est_min) : (j.duration_h || 1) + 'h' }}<q-tooltip v-if="j.est_labels && j.est_labels.length" class="bg-grey-9" style="font-size:11px">Estimé (auto) : {{ j.est_labels.join(' + ') }}</q-tooltip></span><span v-if="j.scheduled_date"> · 📅 <span :class="isOverdue(j.scheduled_date) ? 'text-warning text-weight-bold' : 'text-grey-7'">{{ fmtDueLabel(j.scheduled_date) }}</span><q-icon name="edit_calendar" size="14px" :color="isOverdue(j.scheduled_date) ? 'warning' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop @mousedown.stop><q-tooltip>Replanifier (aujourd'hui / demain / date)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:160px"><q-item clickable v-close-popup @click="setJobDate(j, todayISO())"><q-item-section avatar style="min-width:28px"><q-icon name="today" size="18px" color="primary" /></q-item-section><q-item-section>Aujourd'hui</q-item-section></q-item><q-item clickable v-close-popup @click="setJobDate(j, tomorrowISO())"><q-item-section avatar style="min-width:28px"><q-icon name="event" size="18px" color="primary" /></q-item-section><q-item-section>Demain</q-item-section></q-item><q-separator /><q-date :model-value="j.scheduled_date" @update:model-value="v => setJobDate(j, v)" mask="YYYY-MM-DD" minimal today-btn /></q-list></q-menu></q-icon></span>
</div>
<div v-if="j._showThread" class="assign-thread" @mousedown.stop @click.stop>
@ -1015,7 +1015,7 @@
<div v-show="suggestDlg.view === 'map'">
<div ref="suggestMapEl" class="suggest-map"></div>
<div class="suggest-legend">
<span v-for="r in suggestRoutes" :key="r.techId" class="suggest-leg"><span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.techName }} · {{ r.stops.length }} arrêt(s)<span v-if="r.km"> · 🚗 {{ r.km }} km</span></span>
<span v-for="r in suggestRoutes" :key="r.techId" class="suggest-leg"><span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.techName }} · {{ r.stops.length }} arrêt(s)<span v-if="realRoutes[r.techId]"> · 🚗 {{ realRoutes[r.techId].km }} km / {{ realRoutes[r.techId].mins }} min</span><span v-else-if="r.km"> · 🚗 {{ r.km }} km</span></span>
<span v-if="!suggestRoutes.length" class="text-grey-6" style="font-size:12px">Aucun job géolocalisé ce jour (utilise « Localiser » sur le pool pour les placer sur la carte).</span>
</div>
</div>
@ -2761,37 +2761,59 @@ function toggleLasso () {
assignLasso.value = !assignLasso.value
if (_assignMap) { assignLasso.value ? _assignMap.dragPan.disable() : _assignMap.dragPan.enable(); _assignMap.getCanvas().style.cursor = assignLasso.value ? 'crosshair' : '' }
}
function setupBoxSelect (map) {
function setupBoxSelect (map) { // LASSO FREEFORM : tracé libre (souris ou doigt) polygone de sélection
const canvas = map.getCanvasContainer()
let startPt = null, boxEl = null
const pos = (e) => { const r = canvas.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top } }
let pts = null, svg = null, poly = null
const pos = (e) => { const r = canvas.getBoundingClientRect(); const t = (e.touches && e.touches[0]) || e; return { x: t.clientX - r.left, y: t.clientY - r.top } }
const onMove = (e) => {
const cur = pos(e)
if (!boxEl) { boxEl = document.createElement('div'); boxEl.className = 'lasso-box'; boxEl.style.cssText = 'position:absolute;top:0;left:0;background:rgba(99,102,241,.18);border:2px dashed #6366f1;border-radius:3px;pointer-events:none;z-index:10'; canvas.appendChild(boxEl) } // styles INLINE : le CSS scoped ne touche pas un élément créé en JS
boxEl.style.transform = `translate(${Math.min(startPt.x, cur.x)}px, ${Math.min(startPt.y, cur.y)}px)`
boxEl.style.width = Math.abs(cur.x - startPt.x) + 'px'; boxEl.style.height = Math.abs(cur.y - startPt.y) + 'px'
if (!pts) return; if (e.cancelable) e.preventDefault(); pts.push(pos(e))
if (!svg) {
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:10'
poly = document.createElementNS('http://www.w3.org/2000/svg', 'polyline')
poly.setAttribute('fill', 'rgba(99,102,241,.15)'); poly.setAttribute('stroke', '#6366f1'); poly.setAttribute('stroke-width', '2'); poly.setAttribute('stroke-dasharray', '5 3')
svg.appendChild(poly); canvas.appendChild(svg)
}
poly.setAttribute('points', pts.map(q => q.x + ',' + q.y).join(' '))
}
const onUp = (e) => {
const onUp = () => {
document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp)
const cur = pos(e); if (boxEl) { boxEl.remove(); boxEl = null }
const bbox = [[Math.min(startPt.x, cur.x), Math.min(startPt.y, cur.y)], [Math.max(startPt.x, cur.x), Math.max(startPt.y, cur.y)]]
startPt = null; selectInBox(map, bbox)
document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp)
if (svg) { svg.remove(); svg = null; poly = null }
const path = pts; pts = null
if (path && path.length >= 3) selectInLasso(map, path)
}
canvas.addEventListener('mousedown', (e) => {
if (!assignLasso.value || e.button !== 0) return
e.preventDefault(); e.stopPropagation(); startPt = pos(e)
const onDown = (e) => {
if (!assignLasso.value || (e.button != null && e.button !== 0)) return
e.preventDefault(); e.stopPropagation(); pts = [pos(e)]
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp)
}, true)
document.addEventListener('touchmove', onMove, { passive: false }); document.addEventListener('touchend', onUp)
}
canvas.addEventListener('mousedown', onDown, true)
canvas.addEventListener('touchstart', onDown, true)
}
async function selectInBox (map, bbox) {
const names = new Set()
const w = Math.abs(bbox[1][0] - bbox[0][0]), h = Math.abs(bbox[1][1] - bbox[0][1])
const region = (w < 5 && h < 5) ? bbox[0] : bbox // clic quasi immobile = point
// Point-in-polygon (ray casting) sur coordonnées écran.
function _inPoly (pt, poly) {
let inside = false
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y
if (((yi > pt.y) !== (yj > pt.y)) && (pt.x < (xj - xi) * (pt.y - yi) / ((yj - yi) || 1e-9) + xi)) inside = !inside
}
return inside
}
async function selectInLasso (map, path) { // sélectionne pins + amas dont le CENTRE est dans le tracé
const names = new Set(); const clusterIds = []
try {
(map.queryRenderedFeatures(region, { layers: ['aj-c'] }) || []).forEach(f => { try { JSON.parse(f.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} })
const clusters = map.queryRenderedFeatures(region, { layers: ['aj-cluster'] }) || []
const feats = map.queryRenderedFeatures({ layers: ['aj-c', 'aj-cluster'] }) || []
for (const f of feats) {
const c = f.geometry && f.geometry.coordinates; if (!c) continue
const scr = map.project(c)
if (!_inPoly({ x: scr.x, y: scr.y }, path)) continue
if (f.properties && f.properties.cluster_id != null) clusterIds.push(f.properties.cluster_id)
else { try { JSON.parse(f.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }
}
const src = map.getSource('aj')
await Promise.all(clusters.map(c => new Promise(res => { try { src.getClusterLeaves(c.properties.cluster_id, 1000, 0, (err, leaves) => { if (!err && leaves) leaves.forEach(l => { try { JSON.parse(l.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }); res() }) } catch (e) { res() } })))
await Promise.all(clusterIds.map(cid => new Promise(res => { try { src.getClusterLeaves(cid, 1000, 0, (err, leaves) => { if (!err && leaves) leaves.forEach(l => { try { JSON.parse(l.properties.names || '[]').forEach(n => names.add(n)) } catch (e) {} }); res() }) } catch (e) { res() } })))
} catch (e) {}
names.forEach(n => { selectedJobs[n] = true })
if (names.size) $q.notify({ type: 'positive', message: names.size + ' job(s) sélectionné(s) au lasso', timeout: 1800 })
@ -2883,6 +2905,7 @@ async function initSuggestMap () {
function refreshSuggestMap () {
if (!_suggestMap || !_suggestMap.isStyleLoaded()) { setTimeout(refreshSuggestMap, 200); return }
const routes = suggestRoutes.value
for (const k in realRoutes) delete realRoutes[k] // repart des estimations ; les routes RÉELLES arrivent en async
const lines = []; const pts = []; const b = new window.mapboxgl.LngLatBounds()
for (const r of routes) {
const coords = []
@ -2893,6 +2916,35 @@ function refreshSuggestMap () {
const ls = _suggestMap.getSource('sr-line'); if (ls) ls.setData({ type: 'FeatureCollection', features: lines })
const ps = _suggestMap.getSource('sr-pt'); if (ps) ps.setData({ type: 'FeatureCollection', features: pts })
try { if (pts.length && !b.isEmpty()) _suggestMap.fitBounds(b, { padding: 50, maxZoom: 13, duration: 400 }) } catch (e) {}
loadRealRoutes(routes) // D v2 : remplace les segments droits par les routes ROUTIÈRES réelles (Mapbox Directions) + km/temps réels
}
// D v2 routes RÉELLES : Mapbox Directions par tech (domicilearrêts) géométrie routière + distance/durée réelles. Cache par signature.
const _routeCache = new Map()
const realRoutes = reactive({}) // techId { km, mins } (la légende préfère ces valeurs réelles quand présentes)
async function loadRealRoutes (routes) {
if (!MAPBOX_TOKEN || !routes.length || !_suggestMap) return
const results = await Promise.all(routes.map(async r => {
const pts = []; if (r.home) pts.push([r.home.lon, r.home.lat]); for (const s of r.stops) pts.push([s.lon, s.lat])
if (pts.length < 2 || pts.length > 25) return null // Directions = 25 points max
const sig = pts.map(p => p[0].toFixed(5) + ',' + p[1].toFixed(5)).join(';')
if (_routeCache.has(sig)) return { techId: r.techId, ..._routeCache.get(sig) }
try {
const url = `https://api.mapbox.com/directions/v5/mapbox/driving/${pts.map(p => p[0].toFixed(6) + ',' + p[1].toFixed(6)).join(';')}?geometries=geojson&overview=full&access_token=${MAPBOX_TOKEN}`
const d = await fetch(url).then(x => x.json()); const rt = d && d.routes && d.routes[0]; if (!rt) return null
const info = { geometry: rt.geometry, km: Math.round(rt.distance / 100) / 10, mins: Math.round(rt.duration / 60) }
_routeCache.set(sig, info); return { techId: r.techId, ...info }
} catch (e) { return null }
}))
if (!_suggestMap || !_suggestMap.getSource('sr-line')) return
const byTech = {}; for (const x of results) { if (x) { byTech[x.techId] = x; realRoutes[x.techId] = { km: x.km, mins: x.mins } } }
const lines = []
for (const r of routes) {
const real = byTech[r.techId]
if (real && real.geometry) { lines.push({ type: 'Feature', geometry: real.geometry, properties: { color: r.color } }); continue }
const coords = []; if (r.home) coords.push([r.home.lon, r.home.lat]); for (const s of r.stops) coords.push([s.lon, s.lat])
if (coords.length >= 2) lines.push({ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: { color: r.color } })
}
_suggestMap.getSource('sr-line').setData({ type: 'FeatureCollection', features: lines })
}
function destroySuggestMap () { if (_suggestMapRO) { try { _suggestMapRO.disconnect() } catch (e) {} _suggestMapRO = null } if (_suggestMap) { try { _suggestMap.remove() } catch (e) {} _suggestMap = null } }
watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() })
@ -3761,6 +3813,8 @@ function setSelDate (iso) {
for (const nm of names) { const j = (assignPanel.jobs || []).find(x => x.name === nm); if (j && iso !== j.scheduled_date) { j.scheduled_date = iso; roster.updateJob(j.name, { scheduled_date: iso }).catch(e => err(e)); n++ } }
if (n) $q.notify({ type: 'info', icon: 'event', message: n + ' job(s) reportés au ' + fmtDueLabel(iso), timeout: 3000 })
}
// Niveau requis PERSISTANT d'un job (store hub survit aux rechargements/sessions) piloté par la pastille « niv ».
function setJobReqLevel (job, n) { const lv = Math.max(1, Math.min(5, Number(n) || 1)); job.required_level = lv; roster.setJobLevel(job.name, lv).catch(e => err(e)) }
function techsForJob (job) {
const iso = jobTargetDay(job)
const reqSkill = job && job.required_skill

View File

@ -36,6 +36,15 @@ const cfg = require('./config')
const fs = require('fs')
const path = require('path')
const POLICY_FILE = path.join(__dirname, '..', 'data', 'dispatch-policy.json')
// Niveau requis PAR JOB, persistant. (L'API Custom Field d'ERPNext v16 échoue sur ce doctype custom → store hub durable.)
const JOB_LEVELS_FILE = path.join(__dirname, '..', 'data', 'job-levels.json')
function getJobLevels () { try { return JSON.parse(fs.readFileSync(JOB_LEVELS_FILE, 'utf8')) || {} } catch { return {} } }
function setJobLevel (name, level) {
const m = getJobLevels(); const lv = Math.max(0, Math.min(5, Math.round(Number(level) || 0)))
if (lv > 0) m[String(name)] = lv; else delete m[String(name)]
try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {}
fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m
}
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
const PAUSE_STATUS = 'En pause'
@ -397,7 +406,8 @@ async function buildUnassigned () {
const rows = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'On Hold']]], fields: ['name', 'creation', 'subject', 'customer_name', 'service_location', 'service_type', 'job_type', 'assigned_group', 'legacy_dept', 'legacy_detail', 'legacy_ticket_id', 'legacy_activation_url', 'priority', 'duration_h', 'scheduled_date', 'status', 'depends_on', 'parent_job', 'step_order', 'assigned_tech', 'latitude', 'longitude', 'address'], orderBy: 'modified desc', limit: 400 })
const jobs = (rows || []).filter(j => !j.assigned_tech)
const chars = readJobChar().items
for (const j of jobs) { j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject); const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels }
const jlv = getJobLevels()
for (const j of jobs) { j.required_skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject); j.required_level = jlv[j.name] || 0; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels }
await attachLocations(jobs)
log(`pool: construit ${jobs.length} jobs en ${nowMs() - t0}ms`) // mesure réelle (requête + enrichissement)
return jobs
@ -1137,6 +1147,11 @@ async function handle (req, res, method, path, url) {
if (r.ok) invalidatePool() // le job quitte le pool → rafraîchir le cache
return json(res, r.ok ? 200 : 500, { ...r, job: b.job, tech: b.tech, start_time: placed, duration_h: dur })
}
if (path === '/roster/job-level' && method === 'POST') { // niveau requis PERSISTANT par job (store hub durable)
const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' })
setJobLevel(b.name, b.level); invalidatePool() // le pool porte required_level → rafraîchir
return json(res, 200, { ok: true, name: b.name, level: Math.max(0, Math.min(5, Math.round(Number(b.level) || 0))) })
}
// Persister UN quart immédiatement (ex. « Créer un quart et assigner » sur un tech sans quart) — sinon `addShift` reste LOCAL
// (status Proposé en mémoire) et le quart disparaît au rechargement. Idempotent par clé tech|date|template (pas de doublon vs Publier).
if (path === '/roster/shift' && method === 'POST') {