fix(ops): job overdue = due date, not created; day-list from carousel

Retours terrain :
1. « SLA dépassé » / en-retard se basait sur la CRÉATION du ticket
   (jobSlaBadge → creation), ce qui marque en retard des jobs planifiés
   plus tard (le client peut demander une visite quelques jours après
   l'incident). Désormais l'échéance = la DATE DE VISITE PRÉVUE
   (scheduled_date) : badge « En retard » seulement si scheduled_date <
   aujourd'hui ; futur/aujourd'hui = à l'heure. Repli SLA-création
   uniquement pour les jobs SANS date planifiée (backlog). (Le dispatch
   utilisait déjà scheduled_date pour le périmètre — inchangé.)
2. Le carrousel « N à répartir » (Tournées) est plus long à scanner que la
   liste par jour du legacy. La pastille « N à répartir » est maintenant
   cliquable → ouvre la liste VERTICALE des jobs de ce jour (panneau
   « Jobs à assigner » via openDayInPanel, filtré sur le jour) avec
   recherche + chips type/compétence + tri par ville. Meilleure lecture,
   surtout sur mobile.

Vérifié live (Tournées, 06/07) : clic « à répartir » ouvre le panneau
filtré (25 jobs du jour) ; 0 nouvelle erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-05 07:13:38 -04:00
parent bae7303d66
commit 4c8242eccb

View File

@ -498,7 +498,7 @@
<!-- Jobs DUS ce jour, NON assignés (mis en évidence) chips secteur filtrent liste+carte · clic = détail · « Suggérer » = dispatch auto --> <!-- Jobs DUS ce jour, NON assignés (mis en évidence) chips secteur filtrent liste+carte · clic = détail · « Suggérer » = dispatch auto -->
<div v-if="routeDayUnassigned.length" class="rt-unassigned"> <div v-if="routeDayUnassigned.length" class="rt-unassigned">
<div class="rt-ua-top"> <div class="rt-ua-top">
<span class="rt-ua-lbl"><q-icon name="inbox" size="15px" color="deep-orange-7" /> <b>{{ routeDayUnassignedView.length }}</b> à répartir<span v-if="routeSectorSel.size" class="text-grey-6"> / {{ routeDayUnassigned.length }}</span> :</span> <button type="button" class="rt-ua-lbl rt-ua-open" @click="openDayInPanel(routesDay)"><q-icon name="inbox" size="15px" color="deep-orange-7" /> <b>{{ routeDayUnassignedView.length }}</b> à répartir<span v-if="routeSectorSel.size" class="text-grey-6"> / {{ routeDayUnassigned.length }}</span><q-icon name="open_in_full" size="12px" class="q-ml-xs" /><q-tooltip>Voir la liste des jobs de ce jour (recherche · ville · compétence)</q-tooltip></button>
<template v-if="routeSectors.length > 1"> <template v-if="routeSectors.length > 1">
<button v-for="s in routeSectors" :key="s.sector" type="button" class="rt-sect" :class="{ on: routeSectorSel.has(s.sector) }" @click="toggleRouteSector(s.sector)"><q-icon name="place" size="11px" />{{ s.sector }}<span class="rt-sect-n">{{ s.n }}</span></button> <button v-for="s in routeSectors" :key="s.sector" type="button" class="rt-sect" :class="{ on: routeSectorSel.has(s.sector) }" @click="toggleRouteSector(s.sector)"><q-icon name="place" size="11px" />{{ s.sector }}<span class="rt-sect-n">{{ s.n }}</span></button>
<button v-if="routeSectorSel.size" type="button" class="rt-sect rt-sect-x" @click="routeSectorSel = new Set()"> tout</button> <button v-if="routeSectorSel.size" type="button" class="rt-sect rt-sect-x" @click="routeSectorSel = new Set()"> tout</button>
@ -4288,7 +4288,17 @@ const poolByName = computed(() => { const m = {}; for (const j of (assignPanel.j
const { slaFor: _slaForTicket } = useSla() 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' } 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 function jobSlaBadge (job) { // { state, color, text, icon, label } pour at_risk/breached SEULEMENT (états actionnables) ; null sinon
if (!job || !job.creation) return null if (!job) return null
// Échéance = DATE DE VISITE PRÉVUE (scheduled_date), PAS la création : un job planifié dans le futur n'est PAS en retard
// même si l'incident est ancien (le client peut avoir demandé une visite plus tard). La création ne sert que faute de date.
const sched = job.scheduled_date
if (sched && sched !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(sched)) {
// Badge « en retard » UNIQUEMENT si la date de visite est PASSÉE (aujourd'hui/futur = à l'heure, pas de badge).
if (sched < todayISO()) { const b = SLA_BADGE.breached; return { state: 'breached', color: b.color, text: b.text, icon: b.icon, label: 'Visite prévue en retard — ' + fmtDueLabel(sched), short: 'En retard' } }
return null
}
// Aucune date de visite planifiée : SLA classique (vieillissement du backlog depuis la création).
if (!job.creation) return null
const prio = PRIO_CAP[String(job.priority || jobPriorityOf(job.name) || 'medium').toLowerCase()] || 'Medium' 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. // 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) }) 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) })
@ -5731,6 +5741,8 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.rt-ua-jobs::-webkit-scrollbar { height: 6px; } .rt-ua-jobs::-webkit-scrollbar { height: 6px; }
.rt-ua-jobs::-webkit-scrollbar-thumb { background: #fdba74; border-radius: 3px; } .rt-ua-jobs::-webkit-scrollbar-thumb { background: #fdba74; border-radius: 3px; }
.rt-ua-lbl { font-size: 12px; color: #9a3412; display: inline-flex; align-items: center; gap: 3px; } .rt-ua-lbl { font-size: 12px; color: #9a3412; display: inline-flex; align-items: center; gap: 3px; }
.rt-ua-open { background: #fff; border: 1px solid #fed7aa; border-radius: 12px; padding: 2px 8px; cursor: pointer; font-weight: 600; }
.rt-ua-open:hover { border-color: #fb923c; background: #fff7ed; }
.rt-sect { display: inline-flex; align-items: center; gap: 2px; padding: 2px 8px; border: 1px solid #cbd5e1; border-radius: 12px; background: #fff; font-size: 11.5px; font-weight: 600; color: #475569; cursor: pointer; user-select: none; } .rt-sect { display: inline-flex; align-items: center; gap: 2px; padding: 2px 8px; border: 1px solid #cbd5e1; border-radius: 12px; background: #fff; font-size: 11.5px; font-weight: 600; color: #475569; cursor: pointer; user-select: none; }
.rt-sect.on { background: #6366f1; border-color: #6366f1; color: #fff; } .rt-sect.on { background: #6366f1; border-color: #6366f1; color: #fff; }
.rt-sect-n { font-weight: 800; margin-left: 2px; opacity: .85; } .rt-sect-n { font-weight: 800; margin-left: 2px; opacity: .85; }