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:
parent
83338ca57b
commit
aa6cc3e732
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {{ 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 {{ 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)
|
||||
}
|
||||
const onUp = (e) => {
|
||||
poly.setAttribute('points', pts.map(q => q.x + ',' + q.y).join(' '))
|
||||
}
|
||||
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 (domicile→arrê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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user