refactor(dispatch): la JOB impose sa compétence — retrait du niveau global par compétence
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) <noreply@anthropic.com>
This commit is contained in:
parent
aa6cc3e732
commit
72e84d6b8d
|
|
@ -975,8 +975,6 @@
|
|||
<span class="text-caption text-grey-7 q-mr-xs">Stratégie :</span>
|
||||
<q-btn-toggle v-model="suggestDlg.strategy" dense no-caps unelevated toggle-color="primary" color="grey-3" text-color="grey-8" :options="[{ label: 'Intelligent', value: 'smart' }, { label: 'Meilleurs d\'abord', value: 'best' }, { label: 'Équilibré', value: 'balance' }, { label: 'Juste ce qu\'il faut', value: 'enough' }]" />
|
||||
<span class="text-caption text-grey-6">{{ ({ 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] }}</span>
|
||||
<q-space />
|
||||
<q-btn dense flat size="sm" no-caps icon="tune" label="Niveau requis par compétence" color="primary" @click="jobTypeReqDlg.open = true"><q-tooltip>Définir le niveau min par défaut de chaque compétence (partagé — s'applique au dispatch auto)</q-tooltip></q-btn>
|
||||
</div>
|
||||
<div class="text-caption q-mb-sm" style="color:#475569;background:#eef2ff;border-radius:6px;padding:4px 8px"><q-icon name="date_range" size="14px" class="q-mr-xs" color="primary" />Répartit sur : <b>{{ suggestWindowLabel }}</b><span class="text-grey-6"> — 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.</span></div>
|
||||
<div class="row items-center q-mb-xs" style="gap:4px">
|
||||
|
|
@ -1064,32 +1062,6 @@
|
|||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Exigences par TYPE de job : compétence + niveau requis (partagé — pilote « Meilleurs d'abord » et « Juste ce qu'il faut ») -->
|
||||
<q-dialog v-model="jobTypeReqDlg.open">
|
||||
<q-card style="width:680px;max-width:96vw;max-height:88vh;display:flex;flex-direction:column">
|
||||
<q-card-section class="row items-center q-py-sm" style="border-bottom:1px solid #e2e8f0">
|
||||
<q-icon name="tune" color="primary" size="22px" class="q-mr-sm" />
|
||||
<div><div class="text-subtitle1 text-weight-bold">Niveau requis par compétence</div><div class="text-caption text-grey-7">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).</div></div>
|
||||
<q-space />
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section class="col scroll q-pa-sm" style="overflow:auto">
|
||||
<div v-if="!allSkills.length" class="text-grey-6 text-center q-pa-lg">Aucune compétence définie sur les techniciens.</div>
|
||||
<div v-for="sk in allSkills" :key="sk" class="jtr-row">
|
||||
<span class="jtr-type ellipsis"><span class="assign-skill" :style="{ background: getTagColor(sk) }">{{ sk }}</span></span>
|
||||
<div class="jtr-stars">
|
||||
<q-icon v-for="n in 5" :key="n" :name="(levelBySkill[sk] || 1) >= n ? 'star' : 'star_outline'" :color="(levelBySkill[sk] || 1) >= n ? 'indigo' : 'grey-4'" size="18px" class="cursor-pointer" @click="levelBySkill[sk] = ((levelBySkill[sk] || 1) === n ? 1 : n)"><q-tooltip>Niveau requis {{ n }}</q-tooltip></q-icon>
|
||||
<span class="text-grey-6 q-ml-xs" style="font-size:11px;min-width:34px">niv {{ levelBySkill[sk] || 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right" style="border-top:1px solid #e2e8f0">
|
||||
<q-btn flat no-caps label="Annuler" v-close-popup />
|
||||
<q-btn unelevated no-caps color="primary" icon="check" label="Enregistrer" :loading="jobTypeReqDlg.saving" @click="saveJobTypeReq" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Write-back legacy : aperçu des réassignations osTicket → confirmer avant d'écrire dans le legacy -->
|
||||
<q-dialog v-model="legacyPush.open">
|
||||
<q-card style="min-width:540px;max-width:660px">
|
||||
|
|
@ -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 })
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user