diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 3ab67ca..ff2aa72 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -923,6 +923,8 @@ Même adresse de service (groupe {{ groupLabel(j)[0] }}) — simple repère de proximité{{ groupLabel(j) }} {{ j.step_order }} {{ j.subject || j.service_type || j.name }}{{ j.legacy_detail }} + + {{ jobSlaBadge(j).short }}{{ jobSlaBadge(j).label }} Assigner à un technicien (classés par pertinence : compétence · dispo · distance · charge) @@ -1115,6 +1117,7 @@ {{ e.skill }} ★{{ e.lvl }}/{{ e.reqLevel }} Tech rapide sur ce type ({{ e.eff }}) + {{ entrySlaBadge(e).short }}{{ entrySlaBadge(e).label }} Coordonné : même adresse que « {{ e.coordWith }} » déjà assigné à {{ e.techName }} — placé sur SON arrêt @@ -1755,6 +1758,7 @@ import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par ty 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, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations +import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) — réutilisé sur les jobs (échéance = création + résolution) 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' @@ -4113,6 +4117,20 @@ async function groupSameAddr (e, g, d) { // 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é) +// ── SLA sur les jobs (RÉUTILISE useSla des tickets) : échéance de RÉSOLUTION = création + politique[priorité]. Compute-on-read, aucune migration ERPNext. ── +const poolByName = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) m[j.name] = j; return m }) +const { slaFor: _slaForTicket } = useSla() +const PRIO_CAP = { high: 'High', 'élevée': 'High', elevee: 'High', urgent: 'Urgent', medium: 'Medium', moyenne: 'Medium', normal: 'Medium', low: 'Low', basse: 'Low' } +function jobSlaBadge (job) { // { state, color, text, icon, label } pour at_risk/breached SEULEMENT (états actionnables) ; null sinon + if (!job || !job.creation) return null + const prio = PRIO_CAP[String(job.priority || jobPriorityOf(job.name) || 'medium').toLowerCase()] || 'Medium' + // job → ticket synthétique : first_responded_on renseigné → on SAUTE la bande « réponse » et on prend la bande RÉSOLUTION depuis la création. + const s = _slaForTicket({ issue_type: job.job_type || undefined, priority: prio, creation: job.creation, first_responded_on: job.creation, status: (String(job.status || '').toLowerCase() === 'completed' ? 'Closed' : job.status) }) + if (!s || (s.state !== 'at_risk' && s.state !== 'breached')) return null + const b = SLA_BADGE[s.state] + return { state: s.state, color: b.color, text: b.text, icon: b.icon, label: s.label, short: s.state === 'breached' ? 'SLA dépassé' : 'SLA proche' } +} +function entrySlaBadge (e) { return jobSlaBadge(poolByName.value[e.jobName]) } // revue : retrouve le job du pool pour l'échéance function setEntryPriority (name, lvl) { // écrit la priorité ERPNext du job (low/medium/high) — réutilise patchJob (optimiste + updateJob) const j = (assignPanel.jobs || []).find(x => x.name === name); if (!j) return patchJob(j, { priority: lvl || 'medium' }, 'Priorité : ' + priorityMeta(lvl).label) @@ -5401,6 +5419,9 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return .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; } .suggest-leg-return { color: #64748b; font-style: italic; padding-top: 2px; border-top: 1px dashed #e2e8f0; margin-top: 1px; } +.assign-sla { display: inline-flex; align-items: center; gap: 2px; font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 8px; white-space: nowrap; margin-left: 4px; } +.assign-sla.sla-breached { background: #fee2e2; color: #b91c1c; } +.assign-sla.sla-at_risk { background: #ffedd5; color: #c2410c; } .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; }