feat(dispatch): P1-A SLA on Planification jobs — reuse existing SLA system

SLA was already built (missed by Gemini's plan): hub /sla/policies store,
useSla() composable (slaFor → ok/at_risk/breached + SLA_BADGE), editable
SlaSettings.vue — used on tickets. Surfaced it on Planification jobs by
reuse (NO ERPNext schema migration, NO new cron):

- jobSlaBadge(job): maps a Dispatch Job to a synthetic ticket
  (priority→capitalized, creation, first_responded_on set so it uses the
  RESOLUTION band = creation + policy[priority]) and returns only the
  actionable states. entrySlaBadge for review entries via poolByName.
- Red "SLA dépassé" / orange "SLA proche" chip on pool jobs (triage) and
  review entries; ok/none show nothing (no clutter).

Compute-on-read, so the matrix stays editable in Settings with zero
migration risk. Verified: 57 breached + 5 at-risk on the current pool
backlog; chip renders next to the job subject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-03 07:39:33 -04:00
parent 3599ec995f
commit 28dff24227

View File

@ -923,6 +923,8 @@
<span v-if="groupLabel(j)" class="assign-grp-badge q-mr-xs"><q-tooltip class="bg-grey-9">Même adresse de service (groupe {{ groupLabel(j)[0] }}) simple repère de proximité</q-tooltip>{{ groupLabel(j) }}</span>
<q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
<span class="ellipsis text-weight-medium" :style="assignPanel.showMap ? 'cursor:pointer' : ''" @click="assignPanel.showMap && focusAssignJob(j)">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
<!-- SLA : pastille rouge (dépassé) / orange (proche) réutilise les politiques SLA des tickets -->
<span v-if="jobSlaBadge(j)" class="assign-sla" :class="'sla-' + jobSlaBadge(j).state"><q-icon :name="jobSlaBadge(j).icon" size="11px" />{{ jobSlaBadge(j).short }}<q-tooltip class="bg-grey-9">{{ jobSlaBadge(j).label }}</q-tooltip></span>
<q-space />
<q-btn v-if="j.status !== 'On Hold'" flat dense round size="sm" icon="person_add" color="primary" class="q-ml-xs" @click.stop @mousedown.stop>
<q-tooltip>Assigner à un technicien (classés par pertinence : compétence · dispo · distance · charge)</q-tooltip>
@ -1115,6 +1117,7 @@
<span v-if="e.skill" class="assign-skill" :style="{ background: getTagColor(e.skill) }">{{ e.skill }}</span>
<span v-if="e.lvl" class="suggest-mlvl" :title="'Maîtrise du tech : ' + e.lvl + (e.reqLevel > 1 ? ' / niveau requis ' + e.reqLevel : '')">★{{ e.lvl }}<span v-if="e.reqLevel > 1" style="opacity:.6">/{{ e.reqLevel }}</span></span>
<q-icon v-if="e.eff && e.eff < 0.95" name="bolt" size="12px" color="green-6" class="q-mr-xs"><q-tooltip>Tech rapide sur ce type ({{ e.eff }})</q-tooltip></q-icon>
<span v-if="entrySlaBadge(e)" class="assign-sla q-mr-xs" :class="'sla-' + entrySlaBadge(e).state"><q-icon :name="entrySlaBadge(e).icon" size="11px" />{{ entrySlaBadge(e).short }}<q-tooltip class="bg-grey-9">{{ entrySlaBadge(e).label }}</q-tooltip></span>
<!-- Coordination d'adresse : 🔗 teal = épinglé sur le tech qui a DÉJÀ un job à cette adresse · 🔗 indigo = N jobs du pool fusionnés en 1 arrêt -->
<q-icon v-if="e.coordWith" name="link" size="14px" color="teal-7" class="q-mr-xs"><q-tooltip>Coordonné : même adresse que « {{ e.coordWith }} » déjà assigné à {{ e.techName }} placé sur SON arrêt</q-tooltip></q-icon>
<q-btn v-else-if="e.sameAddrN > 1" flat dense round size="xs" :icon="e.grouped ? 'inventory_2' : 'link'" :color="e.grouped ? 'indigo-7' : 'indigo-4'" class="q-mr-xs">
@ -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; }