From 72e84d6b8d7a827ae0540ce5ac1c16a2051d5b60 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 2 Jul 2026 09:38:54 -0400 Subject: [PATCH] =?UTF-8?q?refactor(dispatch):=20la=20JOB=20impose=20sa=20?= =?UTF-8?q?comp=C3=A9tence=20=E2=80=94=20retrait=20du=20niveau=20global=20?= =?UTF-8?q?par=20comp=C3=A9tence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logique métier : c'est le job précis qui impose la compétence/niveau, pas une caractéristique globale. Retrait du bouton + éditeur « Niveau requis par compétence » (level_by_skill/level_by_type globaux) et du repli global dans le moteur → le niveau vient UNIQUEMENT du job (required_level persistant). La gestion compétences+niveaux se fait PAR TECHNICIEN dans le Suggérer via l'icône ✏️ = TagEditor (chips + ★), le MÊME composant que le tableau timeline (Dispatch). Sous-compétences (ex. émondage) = simples compétences/tags créables et assignables au tech. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/pages/PlanificationPage.vue | 48 ++---------------------- 1 file changed, 3 insertions(+), 45 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 49c7e81..1916c3c 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -975,8 +975,6 @@ Stratégie : {{ ({ smart: 'proximité + compétence + charge', best: 'concentre le travail sur les meilleurs (cascade)', balance: 'répartit également entre tous (round-robin)', enough: 'qualifié mais le moins sur-qualifié → réserve les experts (distance + temps pris en compte)' })[suggestDlg.strategy] }} - - Définir le niveau min par défaut de chaque compétence (partagé — s'applique au dispatch auto)
Répartit sur : {{ suggestWindowLabel }} — défaut aujourd'hui + demain ; filtre par date (chips de la liste) pour cibler d'autres jours. Les jobs datés au-delà sont laissés pour plus tard.
@@ -1064,32 +1062,6 @@ - - - - - -
Niveau requis par compétence
Niveau min par défaut pour chaque type de travail → un job hérite de ce niveau (surchargeable par job avec « niv » dans la liste).
- - -
- -
Aucune compétence définie sur les techniciens.
-
- {{ sk }} -
- Niveau requis {{ n }} - niv {{ levelBySkill[sk] || 1 }} -
-
-
- - - - -
-
- @@ -2152,9 +2124,7 @@ const groupOptions = computed(() => { const s = new Set(); for (const t of techs const allSkills = computed(() => [...new Set(techs.value.flatMap(t => t.skills || []))].sort()) // Édition des compétences dans la cellule du nom (popover) + suggestions ALIGNÉES sur les catégories de job. const jobTypes = ref([]) // service_types distincts (catégories de job) → suggérés comme compétences -const skillByType = ref({}) // type de job → compétence requise (politique booking, éditable) -const levelByType = ref({}) // type de job → niveau requis 1-5 (politique booking ; service_type souvent vide) -const levelBySkill = ref({}) // COMPÉTENCE → niveau requis 1-5 par défaut (dimension réellement peuplée sur les jobs) +const skillByType = ref({}) // type de job → compétence requise déduite (politique booking) — sert au repli reqSkill const skillDialog = ref(null) // tech dont on édite les compétences const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée) const skillMenuShown = ref(false) @@ -3911,7 +3881,7 @@ function buildSuggestion () { const plan = [] for (const j of sorted) { const reqSkill = j.required_skill || skillByType.value[j.service_type] || '' // compétence explicite, sinon déduite du TYPE de job - const reqLevel = Number(j._reqLevel || j.required_level || levelBySkill.value[reqSkill] || levelByType.value[j.service_type]) || 1 // niveau : job explicite → défaut par COMPÉTENCE → par type → 1 + const reqLevel = Number(j.required_level) || 1 // niveau imposé PAR LE JOB (persistant) — plus de défaut global par compétence const jlat = +j.lat, jlon = +j.lon; const hasLL = isFinite(jlat) && isFinite(jlon) && (jlat || jlon) const dur = jobDur(j) const sched = (j.scheduled_date && j.scheduled_date !== 'Sans date' && /^\d{4}-\d{2}-\d{2}$/.test(j.scheduled_date)) ? j.scheduled_date : null @@ -4011,18 +3981,6 @@ function techQuality (t) { return (5 - avgLvl) * 2 + (eff - 1) * 5 + ((Number(t.cost_h) || 0) / 100) // plus BAS = meilleur } const suggestTechsRanked = computed(() => [...(visibleTechs.value || [])].sort((a, b) => techQuality(a) - techQuality(b))) -// Éditeur « Exigences par type de job » : compétence + niveau requis par service_type (politique booking, partagée à toute l'équipe). -const jobTypeReqDlg = reactive({ open: false, saving: false }) -async function saveJobTypeReq () { - jobTypeReqDlg.saving = true - try { - const lbs = {}; for (const k in levelBySkill.value) if (Number(levelBySkill.value[k]) > 1) lbs[k] = Number(levelBySkill.value[k]) - await roster.savePolicy({ booking: { level_by_skill: lbs } }) - levelBySkill.value = { ...lbs } - $q.notify({ type: 'positive', message: 'Exigences par type enregistrées — appliquées au dispatch auto', timeout: 3000 }) - jobTypeReqDlg.open = false - } catch (e) { err(e) } finally { jobTypeReqDlg.saving = false } -} function runSuggestion () { if (!suggestSelCount.value) { $q.notify({ type: 'warning', message: 'Choisis au moins un technicien disponible.', timeout: 2500 }); return } suggestDlg.building = true; buildSuggestion(); suggestDlg.building = false @@ -4815,7 +4773,7 @@ function onLegacyUpdate (data) { } const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } }) -onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {}; levelByType.value = m.level_by_type || {}; levelBySkill.value = m.level_by_skill || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ }) +onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ }) onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); if (_kbRO) _kbRO.disconnect() }) onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })