diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue
index c6dfbd7..648c055 100644
--- a/apps/ops/src/pages/PlanificationPage.vue
+++ b/apps/ops/src/pages/PlanificationPage.vue
@@ -485,7 +485,7 @@
-
👥 Assistant (renfort) · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h{{ b.subject || b.skill || 'Job' }}
{{ b.legacy ? (b.legacy_dept || b.dept || 'Ticket') : (b.skill || 'Job') }} · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h
⚠Dispatché OPS — pas publié dans F
clic = détails du jour (carte + trajet) · clic droit = renvoyer au poolRenvoyer au pool
+
👥 Assistant (renfort) · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h{{ b.subject || b.skill || 'Job' }}
{{ b.legacy ? (b.legacy_dept || b.dept || 'Ticket') : (b.skill || 'Job') }} · ≈{{ Math.round((b.e - b.s) * 10) / 10 }}h
⚠Dispatché OPS — pas publié dans F
clic = détails du jour (carte + trajet) · clic droit = renvoyer au poolRenvoyer au pool
@@ -1394,13 +1394,21 @@
{{ jobDetail.subject }}
{{ jobDetail.customer }} · Ticket #{{ jobDetail.lid }} · {{ jobDetail.dept }}
-
-
-
{{ initials(jobDetail.techName) }}{{ jobDetail.techName }}
-
Non assigné
-
v && jdAssignTech(v)" />
- { if (p && p.status) jobDetail.status = p.status; reloadOccupancy(); reloadPool && reloadPool() }" />
+
+
+
+ {{ initials(jobDetail.techName) }}À · {{ jobDetail.techName }}
+ Non assigné
+
+ Cc · {{ jdAssistantName(a) }}Assistant (renfort) — son créneau est réservé et grisé (anti double-booking)
+
+
+
+ { if (p && p.status) jobDetail.status = p.status; reloadOccupancy(); reloadPool && reloadPool() }" />
+
+
v && jdAssignOrAssist(v)" />
+ {{ jobDetail.techName ? 'Ajout = assistant (Cc / renfort) : son créneau est réservé et grisé. Retirer « À » = désassigner.' : 'Choisir un tech = l’assigner (À). Les ajouts suivants deviennent des assistants (Cc).' }}
@@ -1452,17 +1460,7 @@
-
-
Équipe / renfort
-
- {{ jdAssistantName(a) }}
-
-
Aucun assistant. Ajoutez un renfort → un bloc hachuré sera réservé dans son horaire (anti double‑booking).
-
-
-
-
-
+
Commentaires / fil du billet· plus récent en haut
@@ -2470,6 +2468,18 @@ async function jdRemoveAssistant (techId) {
if (!jobDetail.name) return
try { await roster.removeAssistant(jobDetail.name, techId); const r = await roster.getJobTeam(jobDetail.name); jobDetail.team = r.assistants || []; await reloadOccupancy(); $q.notify({ type: 'info', message: 'Assistant retiré', timeout: 1600 }) } catch (e) { err(e) }
}
+// Champ unique d'assignation façon courriel To/Cc : 1er tech choisi = assigné (À) ; ajouts suivants (job natif) = assistants (Cc / renfort).
+function jdAssignOrAssist (techId) {
+ if (!techId) return
+ if (jobDetail.techName && jobDetail.canTeam) { jobDetail.teamAdd = techId; jdAddAssistant(); jobDetail.teamAdd = null } // déjà un assigné + job natif → assistant
+ else jdAssignTech(techId) // sinon → assigner / réassigner (vaut aussi pour les tickets legacy, sans assistant)
+ jobDetail.assignTech = null // vider le champ après le choix
+}
+// Retirer le chip « À » (assigné) → renvoyer le job au pool (non assigné).
+async function jdUnassign () {
+ if (!jobDetail.name) return
+ try { await roster.unassignJobRoster(jobDetail.name); jobDetail.techId = ''; jobDetail.techName = ''; await reloadOccupancy(); if (reloadPool) await reloadPool(); $q.notify({ type: 'info', message: 'Renvoyé au pool (non assigné)', timeout: 1800 }) } catch (e) { err(e) }
+}
let _kbClickT = null
function kbBlockClick (t) { clearTimeout(_kbClickT); _kbClickT = setTimeout(() => { if (kanbanDay.value) openDayFromCell(t, kanbanDay.value) }, 230) } // simple clic (différé) = éditeur de jour
function kbBlockDbl (b, t) { clearTimeout(_kbClickT); openJobDetail(b, t) } // double-clic = volet détails
@@ -4658,8 +4668,21 @@ const PICKUP_RE = /ramm?ass|r[ée]cup[ée]ration/i
const PICKUP_MIN = 5
function isPickup (j) { return !!j && PICKUP_RE.test(String(j.subject || j.service_type || j.job_type || '')) }
function jobDur (j) { if (isPickup(j)) return PICKUP_MIN / 60; return (j && (j.est_min ? j.est_min / 60 : Number(j.duration_h || 1))) || 1 }
-// Cap la durée AFFICHÉE des ramassages à 5 min (le temps affiché suit alors jobDur partout : pool, carte, occupation).
-function capPickups (jobs) { return (jobs || []).map(j => isPickup(j) ? { ...j, est_min: PICKUP_MIN } : j) }
+// Normalise les jobs du pool à l'entrée (SOURCE UNIQUE, appelé aux 3 points de chargement) :
+// (1) décode les entités HTML des champs texte (adresses legacy « l'église » → « l'église ») via deEnt,
+// (2) cap la durée AFFICHÉE des ramassages à 5 min (le temps suit jobDur partout : pool, carte, occupation).
+function capPickups (jobs) {
+ return (jobs || []).map(j => {
+ if (!j) return j
+ const d = { ...j }
+ if (d.subject) d.subject = deEnt(d.subject)
+ if (d.customer_name) d.customer_name = deEnt(d.customer_name)
+ if (d.address) d.address = deEnt(d.address)
+ if (d.service_location) d.service_location = deEnt(d.service_location)
+ if (d.legacy_detail) d.legacy_detail = deEnt(d.legacy_detail)
+ return isPickup(d) ? { ...d, est_min: PICKUP_MIN } : d
+ })
+}
// Construit un plan job→tech×jour SANS rien écrire (proposition). Chaque job garde sa date si elle est dans la fenêtre.
function buildSuggestion () {
const useSel = selectedNames.value.length > 0
@@ -6363,7 +6386,8 @@ tr.res-hidden .hide-eye { opacity: 1; }
.kbb-leg-t { font-size: 9px; font-weight: 700; color: #5b6b80; white-space: nowrap; }
.kbb-blk { position: absolute; top: 7px; bottom: 7px; border-radius: 5px; background: #fff; color: #1c2533; border: 1.5px solid #cfd6e0; border-left-width: 4px; display: flex; flex-direction: column; justify-content: center; gap: 1px; padding: 2px 7px; cursor: grab; box-shadow: 0 1px 3px rgba(0,0,0,.12); overflow: hidden; } /* carte BLANCHE, contour + icône colorés (skill), texte noir — style Gaiia */
.kbb-blk:hover { box-shadow: 0 2px 8px rgba(0,0,0,.22); z-index: 2; }
-.kbb-blk.assist { border-style: dashed; background: #faf8ff; cursor: default; }
+.kbb-blk.assist { border-style: dashed !important; background: #eceff1 !important; color: #90a4ae; opacity: .6; cursor: default; } /* assistant (renfort) = créneau GRISÉ (réservé, secondaire) */
+.tl-blk.tl-blk-assist { opacity: .5; filter: grayscale(0.55); border: 1px dashed rgba(120,144,176,.7) !important; } /* idem dans la grille semaine : bloc assistant grisé */
.kbb-blk-l1 { display: flex; align-items: center; gap: 3px; font-size: 11px; font-weight: 700; line-height: 1.2; }
.kbb-blk-l2 { font-size: 9.5px; font-weight: 500; color: #6a7686; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.kbb-blk-t { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }