From c3f1c77164193d79ad204c212c0adf81981c5fd9 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 2 Jul 2026 19:25:39 -0400 Subject: [PATCH] feat(dispatch): tech chips on map, distinct colors, remote (no-travel) jobs, group-as-project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tournées tab: removable/addable TECH CHIPS above the map — each chip in the tech's color with a job-count badge; click toggles the route on/off (colors stay stable). Route colors switched from a 12-color fixed palette to golden-angle HSL (137.5°) — consecutive techs always get highly distinguishable hues, unlimited count. - 🖥 SANS DÉPLACEMENT: per-job persistent flag (hub store data/ job-remote.json + POST /roster/job-remote; buildUnassigned sets j.remote). optimizePlan EXCLUDES remote jobs from routing and address pinning (e.g. "Configuration de boîtier tel" doable via ACS — nobody travels) → returned in a remote[] bucket, shown as a "🖥 Sans déplacement" group in the review, assignable to an agent. Toggle button (computer icon) on every review entry. Verified: "Boitier ouvert | Lac des pins" flagged → excluded from routes, then reverted. - 📦 GROUPER EN PROJET: same-address entries (🔗) now open a menu → "Grouper en projet" sets ERPNext parent_job on the shorter tasks (parent = longest), POST /roster/job-group. Plugs into the existing parent-child pool sort ("Groupe (parent-enfant)"). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/api/roster.js | 2 + apps/ops/src/pages/PlanificationPage.vue | 62 +++++++++++++++++++++--- services/targo-hub/lib/roster.js | 34 +++++++++++-- 3 files changed, 86 insertions(+), 12 deletions(-) diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 87b4137..b41fb5e 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -53,6 +53,8 @@ export const askAssistant = (message, history) => jpost('/roster/assistant', { m 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 const setJobRemote = (name, remote) => jpost('/roster/job-remote', { name, remote }) // 🖥 sans déplacement (à distance) — exclu des tournées +export const groupJobs = (parent, children) => jpost('/roster/job-group', { parent, children }) // 📦 grouper en « projet » (parent_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) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 512692c..65bf6e2 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -480,6 +480,14 @@ {{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail + +
+ + {{ r.name }} + {{ r.stops.length }} + {{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }} + +
@@ -1109,7 +1117,12 @@ Tech rapide sur ce type ({{ e.eff }}) Coordonné : même adresse que « {{ e.coordWith }} » déjà assigné à {{ e.techName }} — placé sur SON arrêt - {{ e.sameAddrN }} jobs à la MÊME adresse — regroupés en 1 seul arrêt + + {{ e.grouped ? 'Groupé en projet 📦' : e.sameAddrN + ' jobs à la MÊME adresse — 1 seul arrêt · clic pour grouper en projet' }} + + 📦 Grouper en « projet » ({{ e.sameAddrN }} tâches même adresse — parent = la plus longue) + + {{ e.subject }} · {{ e.customer }} {{ Math.round(e.dur * 10) / 10 }}h @@ -1144,6 +1157,7 @@ + {{ isJobRemote(e.jobName) ? '🖥 SANS déplacement (à distance) — Ré-optimiser pour l\'exclure des tournées' : 'Marquer « sans déplacement » (configurable à distance, ex. boîtier tel via ACS)' }} Retirer (le job reste au pool)
🚗 {{ legInfo(g, d, ei).mins }} min{{ legInfo(g, d, ei).real ? '' : ' ≈' }}
@@ -1868,7 +1882,7 @@ const dayList = computed(() => { function dowOf (iso) { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() } const isWeekendIso = iso => { const d = dowOf(iso); return d === 0 || d === 6 } // samedi/dimanche → PAS de quart auto (techs de fin de semaine dédiés) // File PLACEHOLDER (dispatch auto) : pas un vrai tech → « à assigner ». `__wknd__` (legacy) + `__hold__||` (tournées sans quart, tout jour). -const isHoldId = id => id === '__wknd__' || id === '__unassigned__' || (typeof id === 'string' && id.startsWith('__hold__')) +const isHoldId = id => id === '__wknd__' || id === '__unassigned__' || id === '__remote__' || (typeof id === 'string' && id.startsWith('__hold__')) // ── MODE JOUR (style Gaiia) : 1 seule colonne pleine largeur → timelines hautes + règle d'heures, idéal pour disposer // les jobs (glisser depuis le panneau « jobs du jour » vers un tech, réordonner via l'éditeur de jour). ───────────── const dayMode = computed(() => dayList.value.length === 1) @@ -3972,6 +3986,30 @@ function legHome (g, d) { const v = (legsByKey[g.techId + '|' + d.iso] || {}).ho const AMPM_WIN = { am: [480, 720], pm: [720, 960] } // 8-12 / 12-16 (heure d'horloge) function jobTimeOf (name) { return suggestDlg.jobTime[name] || {} } function toggleJobAmpm (name, v) { const c = suggestDlg.jobTime[name] || {}; suggestDlg.jobTime[name] = { ...c, ampm: c.ampm === v ? null : v } } +// 🖥 « SANS DÉPLACEMENT » (à distance, ex. config boîtier tel via ACS) : persistant (store hub), exclu des tournées par l'optimiseur. +const poolRemote = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = !!j.remote; return m }) +function isJobRemote (name) { return !!poolRemote.value[name] } +async function toggleJobRemote (e) { + const j = (assignPanel.jobs || []).find(x => x.name === e.jobName); const next = !(j ? j.remote : e.remote) + if (j) j.remote = next; e.remote = next // optimiste + try { await roster.setJobRemote(e.jobName, next) } catch (er) { if (j) j.remote = !next; e.remote = !next; err(er); return } + $q.notify({ type: 'info', icon: 'computer', message: next ? '🖥 Sans déplacement — Ré-optimiser pour l\'exclure des tournées' : 'Déplacement requis rétabli — Ré-optimiser', timeout: 2600 }) +} +// 📦 Grouper les tâches MÊME ADRESSE en « projet » (parent_job ERPNext) — parent = la plus longue, les autres deviennent sous-tâches. +async function groupSameAddr (e, g, d) { + const key = x => (x.lat != null && x.lon != null) ? (+x.lat).toFixed(4) + ',' + (+x.lon).toFixed(4) : null + const k = key(e); if (!k) return + const grp = d.entries.filter(x => key(x) === k) + if (grp.length < 2) { $q.notify({ type: 'info', message: 'Une seule tâche à cette adresse dans ce plan.' }); return } + const parent = grp.slice().sort((a, b) => (b.dur || 0) - (a.dur || 0))[0] + const children = grp.filter(x => x !== parent).map(x => x.jobName) + try { + const r = await roster.groupJobs(parent.jobName, children) + if (!r || !r.ok) throw new Error((r && r.error) || 'échec du groupement') + grp.forEach(x => { x.grouped = true }) + $q.notify({ type: 'positive', icon: 'inventory_2', message: `📦 Projet créé : « ${(parent.subject || '').slice(0, 42)} » + ${children.length} sous-tâche(s)`, timeout: 3500 }) + } catch (er) { err(er) } +} // PRIORITÉ du job — MÊME drapeau que les conversations (priorityMeta) + champ ERPNext STANDARD (low/medium/high) via patchJob, comme la fiche du pool. Une seule source. const poolPriority = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = String(j.priority || '').toLowerCase(); return m }) function jobPriorityOf (name) { return poolPriority.value[name] || '' } // medium → priorityMeta = contour gris (non classé) @@ -4202,6 +4240,7 @@ async function optimizeSuggestion () { }) } for (const e of (r.unassigned || [])) out.push({ ...e, techId: '__unassigned__', techName: '⚠️ Non assignés — ajoute des techs ou assigne à la main', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 }) + for (const e of (r.remote || [])) out.push({ ...e, techId: '__remote__', techName: '🖥 Sans déplacement — à faire à distance (assigner à un agent)', placeholder: true, noShift: false, capable: true, lvl: 0, eff: 1 }) suggestDlg.plan = out return } catch (e) { @@ -4331,7 +4370,9 @@ const suggestGroups = computed(() => { }).sort((a, b) => b.jobs - a.jobs) }) // Carte des tournées (grande carte dans la revue) : 1 couleur par tech, domicile + arrêts ordonnés, POUR UN SEUL JOUR. -const ROUTE_COLORS = ['#e11d48', '#2563eb', '#16a34a', '#d97706', '#7c3aed', '#0891b2', '#db2777', '#65a30d', '#0d9488', '#4f46e5', '#dc2626', '#ca8a04'] +// Couleur de tournée par index : ANGLE D'OR (137,5°) sur la roue HSL → couleurs consécutives toujours très éloignées, +// nombre illimité de techs sans collision visuelle (remplace la palette fixe de 12 qui se ressemblait). +const routeColor = (i) => `hsl(${Math.round((i * 137.508) % 360)}, 70%, 40%)` const suggestMapDay = ref('') const suggestRoutes = computed(() => { const day = suggestMapDay.value || (suggestWindow.value[0] || '') @@ -4349,7 +4390,7 @@ const suggestRoutes = computed(() => { } if (!stops.length) continue const home = techOrigin(g.techId) // domicile, sinon bureau TARGO - out.push({ techId: g.techId, techName: g.techName, color: ROUTE_COLORS[ci % ROUTE_COLORS.length], km: d.km, mins: d.mins, home: home ? { lat: home.lat, lon: home.lon } : null, stops }) + out.push({ techId: g.techId, techName: g.techName, color: routeColor(ci), km: d.km, mins: d.mins, home: home ? { lat: home.lat, lon: home.lon } : null, stops }) ci++ } return out @@ -4360,7 +4401,9 @@ watch(() => suggestDlg.view, (v) => { if (v === 'map' && !suggestMapDay.value) s const routesDay = ref(null) const routesMapRef = ref(null) const dayRouteMetrics = reactive({}) -const dayRoutes = computed(() => { +const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic) +function toggleRouteTech (id) { const s = new Set(hiddenRouteTechs.value); if (s.has(id)) s.delete(id); else s.add(id); hiddenRouteTechs.value = s } +const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs STABLES par index, même quand on masque) const iso = routesDay.value; if (!iso) return [] const out = []; let ci = 0 for (const t of (visibleTechs.value || [])) { @@ -4369,11 +4412,12 @@ const dayRoutes = computed(() => { if (!jobs.length) continue jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel const h = techOrigin(t.id) // domicile, sinon bureau TARGO - out.push({ id: t.id, name: t.name, color: ROUTE_COLORS[ci++ % ROUTE_COLORS.length], home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject })) }) + out.push({ id: t.id, name: t.name, color: routeColor(ci++), home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject })) }) } return out }) -watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k] }) +const dayRoutes = computed(() => allDayRoutes.value.filter(r => !hiddenRouteTechs.value.has(r.id))) // ce que la carte affiche +watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() }) watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true }) // Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées). const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s)) @@ -5256,6 +5300,10 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return .suggest-dur { font-size: 11px; white-space: nowrap; color: #475569; border-bottom: 1px dashed #cbd5e1; cursor: pointer; } .suggest-dur:hover { color: #4f46e5; border-bottom-color: #4f46e5; } .suggest-leg-time { font-size: 10px; color: #94a3b8; padding: 0 6px 2px 30px; line-height: 1.2; } +.rt-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12px; font-weight: 600; color: #334155; cursor: pointer; user-select: none; background: #fff; } +.rt-chip.off { opacity: 0.55; border-style: dashed; } +.rt-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.rt-n { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; border-radius: 9px; padding: 0 4px; color: #fff; font-size: 10px; font-weight: 800; } .jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; } .jt-chip:hover { border-color: #6366f1; } .jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; } diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 31c320b..a0aea9f 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -46,6 +46,15 @@ function setJobLevel (name, level) { fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m } // Priorité de job = champ ERPNext standard (low/medium/high), édité via /roster/job/update (comme le pool). Plus de store parallèle. +// 🖥 Job « SANS DÉPLACEMENT » (configurable à distance, ex. boîtier tel via ACS) : exclu des tournées (aucun arrêt), à faire du bureau. +const JOB_REMOTE_FILE = path.join(__dirname, '..', 'data', 'job-remote.json') +function getJobRemote () { try { return JSON.parse(fs.readFileSync(JOB_REMOTE_FILE, 'utf8')) || {} } catch { return {} } } +function setJobRemote (name, remote) { + const m = getJobRemote() + if (remote) m[String(name)] = 1; else delete m[String(name)] + try { fs.mkdirSync(path.dirname(JOB_REMOTE_FILE), { recursive: true }) } catch (e) {} + fs.writeFileSync(JOB_REMOTE_FILE, JSON.stringify(m)); return m +} const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090' const PAUSE_STATUS = 'En pause' @@ -180,15 +189,17 @@ async function optimizePlan (body) { jobName: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || j.location_label || '', iso, dur: Math.round(dur * 100) / 100, skill: j.required_skill || '', reqLevel: Number(j.required_level) || 1, lat: (j.latitude != null && isFinite(+j.latitude)) ? +j.latitude : null, lon: (j.longitude != null && isFinite(+j.longitude)) ? +j.longitude : null, - priority: String(j.priority || '').toLowerCase(), + priority: String(j.priority || '').toLowerCase(), remote: !!j.remote, parent: j.parent_job || null, }) // Bucket par jour : daté dans la fenêtre → son jour ; daté APRÈS → laissé pour plus tard ; en retard/sans date → 1er jour (déborde vers le suivant si non casé). - const dset = new Set(dates); const byDay = {}; const skipped = [] + // 🖥 Les jobs SANS DÉPLACEMENT (remote) sont EXCLUS des tournées : aucun arrêt, aucun épinglage d'adresse — à faire du bureau. + const dset = new Set(dates); const byDay = {}; const skipped = []; const remote = [] for (const j of pool) { const sched = (j.scheduled_date && /^\d{4}-\d{2}-\d{2}$/.test(String(j.scheduled_date))) ? j.scheduled_date : null if (sched && sched > hi) { skipped.push(j.name); continue } const iso = (sched && dset.has(sched)) ? sched : dates[0] + if (j.remote) { remote.push(entryOf(j, iso, durOf(j))); continue } ;(byDay[iso] = byDay[iso] || []).push(j) } @@ -268,7 +279,7 @@ async function optimizePlan (body) { } } for (const j of spill) unassigned.push(entryOf(j, dates[dates.length - 1], durOf(j))) // sécurité (spill après le dernier jour) - return { ok: true, dates, plan, unassigned, assumed_shift: assumedShift, skipped_later: skipped.length } + return { ok: true, dates, plan, unassigned, remote, assumed_shift: assumedShift, skipped_later: skipped.length } } const sleep = (ms) => new Promise(r => setTimeout(r, ms)) @@ -603,8 +614,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 - 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 } + const jlv = getJobLevels(); const jrm = getJobRemote() + 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; j.remote = !!jrm[j.name]; 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 @@ -1386,6 +1397,19 @@ async function handle (req, res, method, path, url) { 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))) }) } + if (path === '/roster/job-remote' && method === 'POST') { // 🖥 « sans déplacement » (configurable à distance, ex. boîtier tel via ACS) — exclu des tournées + const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' }) + setJobRemote(b.name, !!b.remote); invalidatePool() // le pool porte j.remote → rafraîchir + return json(res, 200, { ok: true, name: b.name, remote: !!b.remote }) + } + if (path === '/roster/job-group' && method === 'POST') { // 📦 grouper des tâches (même adresse) en « projet » : parent_job ERPNext sur les enfants + const b = await parseBody(req); const kids = (Array.isArray(b.children) ? b.children : []).filter(c => c && c !== b.parent) + if (!b.parent || !kids.length) return json(res, 400, { error: 'parent + children[] requis' }) + const results = [] + for (const c of kids) { const r = await retryWrite(() => erp.update('Dispatch Job', c, { parent_job: b.parent })); results.push({ job: c, ok: !!r.ok }) } + invalidatePool() + return json(res, 200, { ok: results.every(x => x.ok), parent: b.parent, results }) + } // 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') {