feat(dispatch): skill PRIORITY ordering per tech (specialists first)

Skill order (not just level) now drives auto-dispatch: index 0 in a
tech's skills = their primary function. A job goes to the specialists
of that skill first, sparing less-specialized techs for other work
(e.g. Louis-Paul does repairs but his primary is sales → repair jobs
prefer the repair/install specialists).

- buildSuggestion: skillRank = t.skills.indexOf(reqSkill) (0 = primary);
  score += skillRank * W.rank, tuned per strategy (enough/smart weight
  specialty highest). Skill capability stays a hard filter.
- TagEditor (shared component): chips are now drag-reorderable
  (vuedraggable, touch-friendly) via `sortable` prop; the 1st chip is
  marked ★ primary. Reorder emits the reordered array → persists as the
  ordered skills CSV (order round-trips; stored in custom `skills`
  field, not Frappe _user_tags, so no alphabetical re-sort).
- Skill editor: enable sortable chips + explicit priority numbers
  (1,2,3…) on the per-skill list, #1 highlighted, plus a hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-02 10:50:51 -04:00
parent f6e5128859
commit 4591ef6169
2 changed files with 45 additions and 18 deletions

View File

@ -12,6 +12,7 @@
*/
import { ref, computed, nextTick, watch, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import draggable from 'vuedraggable' // réordonnancement des chips (priorité de compétence) glisser-déposer, marche au doigt
const $q = useQuasar()
const isDark = computed(() => $q.dark.isActive)
@ -29,6 +30,7 @@ const props = defineProps({
showRequired:{ type: Boolean, default: false }, // show required pin per tag (jobs)
compact: { type: Boolean, default: false }, // smaller chips
autofocus: { type: Boolean, default: false }, // focus l'input au montage (ouvre la liste direct)
sortable: { type: Boolean, default: false }, // glisser-déposer pour réordonner les chips (ORDRE = priorité) ; 1er = principal
})
const emit = defineEmits(['update:modelValue', 'create', 'update-tag', 'rename-tag', 'delete-tag'])
@ -36,6 +38,9 @@ const emit = defineEmits(['update:modelValue', 'create', 'update-tag', 'rename-t
function _labels () {
return props.modelValue.map(v => typeof v === 'string' ? v : v.tag)
}
function _lbl (v) { return typeof v === 'string' ? v : (v && v.tag) } // libellé d'un item (string ou {tag})
function _itemKey (v) { return _lbl(v) }
function onReorder (v) { emit('update:modelValue', v) } // glisser-déposer nouvel ordre = nouvelle priorité (émis tel quel, préserve level/required)
function _getLevel (label) {
const item = props.modelValue.find(v => typeof v !== 'string' && v.tag === label)
return item?.level ?? 0
@ -187,17 +192,25 @@ onMounted(() => { if (props.autofocus) nextTick(() => { inputEl.value?.focus();
<template>
<div class="te-wrap" :class="{ 'te-focused': focused, 'te-compact': compact, 'te-dark': isDark }">
<!-- Existing tags as chips -->
<span v-for="label in _labels()" :key="label" class="te-chip"
:class="{ 'te-chip-required': showRequired && _isRequired(label) }"
:style="'background:' + getColor(label)"
@click.exact="canEdit ? openEdit(label, $event) : null"
@contextmenu="openEdit(label, $event)">
<span v-if="showRequired && _isRequired(label)" class="te-pin" title="Requis">&#x1F4CC;</span>
<span class="te-chip-label">{{ label }}</span>
<span v-if="showLevel && _getLevel(label)" class="te-chip-level" :class="'te-lvl-' + Math.min(_getLevel(label), 5)">{{ _getLevel(label) }}</span>
<button class="te-chip-rm" @click.stop="removeTag(label)">×</button>
</span>
<!-- Existing tags as chips (glisser-déposer pour réordonner si sortable ; 1er chip = compétence PRINCIPALE) -->
<draggable :model-value="modelValue" @update:model-value="onReorder" :item-key="_itemKey"
:disabled="!sortable" :animation="150" tag="span" class="te-chips"
filter=".te-chip-rm" :prevent-on-filter="false" ghost-class="te-chip-ghost">
<template #item="{ element, index }">
<span class="te-chip"
:class="{ 'te-chip-required': showRequired && _isRequired(_lbl(element)), 'te-chip-sortable': sortable, 'te-chip-primary': sortable && index === 0 }"
:style="'background:' + getColor(_lbl(element))"
:title="sortable ? (index === 0 ? 'Compétence PRINCIPALE — glisser pour changer la priorité' : 'Glisser pour changer la priorité') : ''"
@click.exact="canEdit ? openEdit(_lbl(element), $event) : null"
@contextmenu="openEdit(_lbl(element), $event)">
<span v-if="sortable && index === 0" class="te-chip-star" title="Compétence principale">&#x2605;</span>
<span v-if="showRequired && _isRequired(_lbl(element))" class="te-pin" title="Requis">&#x1F4CC;</span>
<span class="te-chip-label">{{ _lbl(element) }}</span>
<span v-if="showLevel && _getLevel(_lbl(element))" class="te-chip-level" :class="'te-lvl-' + Math.min(_getLevel(_lbl(element)), 5)">{{ _getLevel(_lbl(element)) }}</span>
<button class="te-chip-rm" @click.stop="removeTag(_lbl(element))">×</button>
</span>
</template>
</draggable>
<!-- Input -->
<input ref="inputEl" class="te-input" type="text"
v-model="query" :placeholder="modelValue.length ? '' : placeholder"
@ -327,6 +340,13 @@ onMounted(() => { if (props.autofocus) nextTick(() => { inputEl.value?.focus();
font-size:0.7rem; padding:0 1px; margin-left:1px; line-height:1;
}
.te-chip-rm:hover { color:#fff; }
/* Réordonnancement (priorité) */
.te-chips { display:flex; flex-wrap:wrap; gap:3px; align-items:center; }
.te-chip-sortable { cursor:grab; }
.te-chip-sortable:active { cursor:grabbing; }
.te-chip-primary { box-shadow:0 0 0 1.5px rgba(250,204,21,0.95); }
.te-chip-star { font-size:0.6rem; line-height:1; color:#fde047; }
.te-chip-ghost { opacity:0.35; }
/* Input */
.te-input {

View File

@ -722,17 +722,19 @@
<q-menu v-model="skillMenuShown" :target="skillMenuTarget" anchor="bottom left" self="top left" no-focus max-height="80vh">
<div v-if="skillDialog" class="q-pa-sm" style="width:368px;min-height:300px" @click.stop @mousedown.stop>
<div class="row items-center q-mb-xs"><div class="text-subtitle2 text-weight-bold col ellipsis">🏷 {{ skillDialog.name }}</div><q-btn flat dense round size="sm" icon="close" v-close-popup /></div>
<TagEditor :model-value="skillDialog.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" autofocus placeholder="Chercher ou créer une compétence…"
<TagEditor :model-value="skillDialog.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" sortable autofocus placeholder="Chercher ou créer une compétence…"
@update:model-value="items => onTagsChange(skillDialog, items)"
@create="onCreateRosterTag" />
<div v-if="(skillDialog.skills || []).length > 1" class="text-caption text-grey-6 q-mt-xs" style="line-height:1.3"> <b>Glisse les chips</b> pour l'ordre de priorité la <b>1<sup>re</sup> ()</b> = fonction principale : le dispatch auto envoie ces jobs à ce tech <b>en premier</b> (épargne les moins spécialisés).</div>
<div v-if="(skillDialog.skills || []).length" class="q-mt-md">
<div class="row items-center text-caption text-grey-6 q-pb-xs">
<div class="col">Compétence</div>
<div style="width:90px" class="text-center">Score</div>
<div style="width:88px" class="text-center">Efficacité</div>
</div>
<div v-for="sk in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
<div v-for="(sk, si) in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
<div class="col row items-center no-wrap">
<span class="skill-rank" :class="{ 'skill-rank-1': si === 0 }" :title="si === 0 ? 'Priorité 1 — compétence principale' : 'Priorité ' + (si + 1)">{{ si + 1 }}</span>
<q-btn flat dense round size="xs" icon="circle" :style="{ color: getTagColor(sk) }"><q-tooltip>Couleur</q-tooltip>
<q-menu><div class="q-pa-xs" style="width:208px">
<div class="row">
@ -3905,10 +3907,11 @@ function buildSuggestion () {
const cell = (tid, iso) => { const k = tid + '|' + iso; if (!proj[k]) { const load = techLoad(techDayOcc(tid, iso), hasShiftDay(tid, iso)); proj[k] = { h: load.h || 0, cap: load.cap || 8, pts: [], cities: new Set() } } return proj[k] }
// Poids selon la STRATÉGIE : smart · best (meilleurs d'abord, cascade) · balance (round-robin) · enough (juste ce qu'il faut réserve les experts)
const strat = suggestDlg.strategy || 'smart'
const W = strat === 'best' ? { dist: 0.4, skill: 15, eff: 65, cost: 20, load: -0.25, overqual: 0 }
: strat === 'balance' ? { dist: 0.6, skill: 6, eff: 30, cost: 8, load: 24, overqual: 0 }
: strat === 'enough' ? { dist: 1.4, skill: 0, eff: 25, cost: 10, load: 2, overqual: 22 } // « juste ce qu'il faut » : capable mais le moins sur-qualifié possible
: { dist: 1.6, skill: 6, eff: 30, cost: 7, load: 0.6, overqual: 0 }
// rank = ORDRE de la compétence chez le tech (0 = compétence PRINCIPALE) un job va d'abord aux spécialistes de CETTE compétence, épargnant les polyvalents.
const W = strat === 'best' ? { dist: 0.4, skill: 15, eff: 65, cost: 20, load: -0.25, overqual: 0, rank: 10 }
: strat === 'balance' ? { dist: 0.6, skill: 6, eff: 30, cost: 8, load: 24, overqual: 0, rank: 8 }
: strat === 'enough' ? { dist: 1.4, skill: 0, eff: 25, cost: 10, load: 2, overqual: 22, rank: 16 } // « juste ce qu'il faut » : capable mais le moins sur-qualifié possible
: { dist: 1.6, skill: 6, eff: 30, cost: 7, load: 0.6, overqual: 0, rank: 14 }
const costs = techs.map(t => Number(t.cost_h) || 0); const cMin = Math.min(...costs); const cRange = (Math.max(...costs) - cMin) || 1 // normalisation coût 0..1
const plan = []
const holdByDay = {} // jobs SANS tech de quart regroupés par jour puis découpés en tournées PLACEHOLDER (pass 2)
@ -3930,6 +3933,7 @@ function buildSuggestion () {
// SKILL = filtre DUR : un tech sans la compétence requise n'est JAMAIS candidat (ex. Josée-Anne ne fait ni réparation ni installation).
if (reqSkill && !(t.skills || []).includes(reqSkill)) continue
const capable = true
const skillRank = reqSkill ? Math.max(0, (t.skills || []).indexOf(reqSkill)) : 0 // ORDRE de la compétence chez le tech : 0 = principale (spécialiste) ; 1,2 = secondaire (polyvalent)
const lvl = reqSkill ? (skillLevelOf(t, reqSkill) || 0) : 3 // maîtrise 0-5 (3 = neutre sans compétence requise)
const eff = reqSkill ? skillEffOf(t, reqSkill) : (Number(t.efficiency) || 1) // vitesse : <1 = plus rapide pour cette compétence
const effDur = dur * (eff > 0 ? eff : 1) // durée EFFECTIVE = un tech rapide prend moins de capacité pour le même job
@ -3951,10 +3955,11 @@ function buildSuggestion () {
score += prox * W.dist // proximité (km réels OU regroupement par ville)
score += c.h * W.load // load>0 = étale (round-robin) · load<0 = remplit les 1ers (cascade)
score += (5 - lvl) * W.skill // (best/smart) maîtrise faible pénalisée avantage aux experts
score += skillRank * (W.rank || 0) // ORDRE de la compétence : le spécialiste (rang 0) passe avant le polyvalent épargne les moins spécialisés
score += Math.max(0, lvl - reqLevel) * (W.overqual || 0) // (« juste ce qu'il faut ») sur-qualification pénalisée garde les experts pour les jobs exigeants
score += (eff - 1) * W.eff // rapide (eff<1) récompensé · lent (eff>1) pénalisé
score += costN * W.cost // moins cher préféré
if (!best || score < best.score) best = { t, iso, score, noShift, capable, over, lvl, eff, effDur }
if (!best || score < best.score) best = { t, iso, score, noShift, capable, over, lvl, eff, effDur, skillRank }
}
}
// Commit sur un VRAI tech seulement s'il a un quart ce jour-là. Sinon réservé pour une file PLACEHOLDER (pass 2), à assigner ensuite à un vrai tech.
@ -5036,6 +5041,8 @@ th.clk, td.clk { cursor: pointer; }
.tech-skills { display: flex; align-items: center; gap: 2px; overflow: hidden; flex: 1 1 auto; min-width: 0; } /* chips inline, débordement clippé */
.skill-edit-btn { flex-shrink: 0; }
.skill-chip { font-size: 10px; line-height: 15px; height: 15px; padding: 0 5px; border-radius: 8px; color: #fff; font-weight: 600; white-space: nowrap; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
.skill-rank { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #e2e8f0; color: #64748b; font-size: 9px; font-weight: 800; margin-right: 5px; flex-shrink: 0; }
.skill-rank-1 { background: #fef3c7; color: #b45309; box-shadow: 0 0 0 1.5px rgba(250,204,21,0.7); }
.chip-lvl { display: inline-flex; align-items: center; justify-content: center; min-width: 13px; height: 13px; padding: 0 1px; border-radius: 50%; background: rgba(0,0,0,.32); font-size: 8px; font-weight: 800; box-shadow: 0 0 0 1.5px #fff; margin-left: 1px; } /* niveau 15 · contour blanc pour détacher la couleur (vitesse) */
.add-skill-hint { font-size: 10px; color: #9e9e9e; font-style: italic; } /* invite quand aucune compétence */
/* Compétence compacte = cercle de la couleur du skill + chiffre du niveau ; survol → nom complet + niveau + vitesse */