diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 764d057..745b0a9 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -53,7 +53,7 @@ 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 setJobFlag = (name, urgent) => jpost('/roster/job-flag', { name, urgent }) // drapeau urgent persistant par job (SLA/commercial) +export const setJobFlag = (name, priority) => jpost('/roster/job-flag', { name, priority }) // priorité de dispatch persistante par job (SLA/commercial) 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 fbaf0b6..6919a3d 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -1078,7 +1078,20 @@ {{ e.techName }} n'a pas la compétence « {{ e.skill }} » — à réassigner Aucun quart publié ce jour (assignable, mais ⚠ hors quart) - Drapeau URGENT persistant (client SLA/commercial) — jamais laissé de côté + servi en début de journée. Ré-optimiser pour appliquer.🚩 + + + Priorité : {{ priorityMeta(jobPriorityOf(e.jobName)).label }} (persistante — SLA/commercial). Ré-optimiser pour appliquer. + + Priorité + + + {{ priorityMeta(lvl).label }} + + + + Aucune + + Avant-midi (8-12) — Ré-optimiser pour appliquerAM Après-midi (12-16) — Ré-optimiser pour appliquerPM @@ -1669,7 +1682,7 @@ import { MAPBOX_TOKEN } from 'src/config/erpnext' // routage routier réel (API import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch) import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur) import { relTime } from 'src/composables/useFormatters' // temps relatif COHÉRENT (même formateur que la Boîte) -import { messageIdentity } from 'src/composables/useConversationDisplay' // resolvers partagés nom/initiales/couleur (cohérence des fils) +import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client import TechSelect from 'src/components/shared/TechSelect.vue' import SkillSelect from 'src/components/shared/SkillSelect.vue' @@ -3917,14 +3930,14 @@ const suggestDlg = reactive({ open: false, mode: 'config', plan: [], building: f 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 } } -// Drapeau URGENT PERSISTANT par job (comme le flag urgent des conversations) — SLA/commercial. Écrit au hub + reflété localement. -const poolUrgent = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = !!j.urgent; return m }) -function isJobUrgent (name) { return !!poolUrgent.value[name] } -async function toggleJobUrgent (name) { - const j = (assignPanel.jobs || []).find(x => x.name === name); const next = !(j && j.urgent) - if (j) j.urgent = next // optimiste - try { await roster.setJobFlag(name, next) } catch (e) { if (j) j.urgent = !next; err(e); return } - $q.notify({ type: next ? 'warning' : 'info', icon: next ? 'flag' : 'outlined_flag', message: next ? 'Job marqué URGENT (prioritaire, servi tôt)' : 'Drapeau urgent retiré', timeout: 2000 }) +// PRIORITÉ de dispatch PERSISTANTE par job — MÊME composant que les conversations (priorityMeta : drapeau contour gris → coloré + liste de niveaux). SLA/commercial. +const poolPriority = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = String(j.priority || '').toLowerCase(); return m }) +function jobPriorityOf (name) { const p = poolPriority.value[name] || ''; return (p === 'medium' || p === 'moyenne') ? '' : p } // medium ERPNext = non classé → drapeau contour gris (invite à classer) +async function setEntryPriority (name, lvl) { // priorité de DISPATCH (store override, supporte urgent que le champ ERPNext low/medium/high ne fait pas) + const j = (assignPanel.jobs || []).find(x => x.name === name); const prev = j ? j.priority : undefined + if (j) j.priority = lvl || 'medium' // optimiste + try { await roster.setJobFlag(name, lvl) } catch (e) { if (j) j.priority = prev; err(e); return } + $q.notify({ type: 'info', icon: priorityMeta(lvl).icon, message: 'Priorité : ' + priorityMeta(lvl).label, timeout: 1600 }) } // Réglages de l'optimiseur VRP (curseur distance↔spécialité, vitesse repli, budget de calcul) — persistés localement, ajustables sans redéploiement. const solverOpts = reactive({ rankWeight: 2, speedKmh: 45, maxSeconds: 8 }) @@ -4145,10 +4158,8 @@ async function optimizeSuggestion () { const jobs = entries.map(e => { const jt = suggestDlg.jobTime[e.jobName] || {} const win = AMPM_WIN[jt.ampm] // AM/PM → fenêtre horaire dure - const urg = poolUrgent.value[e.jobName] // drapeau URGENT persistant (SLA/commercial) - const priority_boost = Math.max(prBoost(poolPr[e.jobName]), urg ? 400000 : 0) // urgent = jamais lâché - const urgent_weight = Math.max(prEarly(poolPr[e.jobName]), urg ? 10 : 0) // urgent = servi tôt (début de journée) - return { id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '', priority_boost, urgent_weight, ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) } + const p = poolPr[e.jobName] // priorité (surcharge persistante incluse via buildUnassigned) : urgent/high jamais lâché + servi tôt + return { id: e.jobName, lat: e.lat, lon: e.lon, service_min: Math.max(5, Math.round((e.dur || 1) * 60)), skill: e.skill || '', priority_boost: prBoost(p), urgent_weight: prEarly(p), ...(win ? { tw_start_min: win[0], tw_end_min: win[1] } : {}) } }) const vehicles = [] for (const t of selTechs) { @@ -5110,7 +5121,6 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return .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; } -.jt-urgent.on { background: #ef4444; border-color: #ef4444; color: #fff; } .suggest-day { padding: 2px 8px 6px; } .suggest-day-lbl { font-size: 11px; font-weight: 700; color: #475569; padding: 4px 2px 2px; } .suggest-entry { display: flex; align-items: center; gap: 2px; padding: 4px 6px; border-radius: 6px; font-size: 12.5px; } diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 21b693f..3e2084f 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -45,12 +45,14 @@ function setJobLevel (name, level) { try { fs.mkdirSync(path.dirname(JOB_LEVELS_FILE), { recursive: true }) } catch (e) {} fs.writeFileSync(JOB_LEVELS_FILE, JSON.stringify(m)); return m } -// Drapeau URGENT persistant par job (comme le flag urgent des conversations) — client commercial/SLA/contrainte. +// PRIORITÉ de dispatch persistante par job (même notion que la priorité des conversations : urgent|high|normal|low). +// Store durable = SURCHARGE de la priorité ERPNext pour le dispatch (client commercial/SLA/contrainte). const JOB_FLAGS_FILE = path.join(__dirname, '..', 'data', 'job-flags.json') function getJobFlags () { try { return JSON.parse(fs.readFileSync(JOB_FLAGS_FILE, 'utf8')) || {} } catch { return {} } } -function setJobFlag (name, urgent) { - const m = getJobFlags() - if (urgent) m[String(name)] = 1; else delete m[String(name)] +const PRIO_LEVELS = ['urgent', 'high', 'normal', 'low'] +function setJobFlag (name, priority) { + const m = getJobFlags(); const p = String(priority || '').toLowerCase() + if (PRIO_LEVELS.includes(p)) m[String(name)] = p; else delete m[String(name)] // niveau valide → surcharge ; sinon (vide/« aucune ») → retire try { fs.mkdirSync(path.dirname(JOB_FLAGS_FILE), { recursive: true }) } catch (e) {} fs.writeFileSync(JOB_FLAGS_FILE, JSON.stringify(m)); return m } @@ -455,7 +457,7 @@ async function buildUnassigned () { const jobs = (rows || []).filter(j => !j.assigned_tech) const chars = readJobChar().items const jlv = getJobLevels(); const jfl = getJobFlags() - 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.urgent = !!jfl[j.name]; const est = estimateForJob(j, chars); j.est_min = est.minutes; j.est_labels = est.labels } + 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; if (jfl[j.name]) j.priority = jfl[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 @@ -1212,10 +1214,11 @@ 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-flag' && method === 'POST') { // drapeau URGENT persistant par job (SLA/commercial) → store hub durable + if (path === '/roster/job-flag' && method === 'POST') { // PRIORITÉ de dispatch persistante par job (SLA/commercial) → store hub durable (surcharge) const b = await parseBody(req); if (!b.name) return json(res, 400, { error: 'name requis' }) - setJobFlag(b.name, !!b.urgent); invalidatePool() // le pool porte j.urgent → rafraîchir - return json(res, 200, { ok: true, name: b.name, urgent: !!b.urgent }) + const p = String(b.priority || '').toLowerCase() + setJobFlag(b.name, p); invalidatePool() // le pool porte j.priority → rafraîchir + return json(res, 200, { ok: true, name: b.name, priority: PRIO_LEVELS.includes(p) ? p : '' }) } // 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).