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:
parent
f6e5128859
commit
4591ef6169
|
|
@ -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">📌</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">★</span>
|
||||
<span v-if="showRequired && _isRequired(_lbl(element))" class="te-pin" title="Requis">📌</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 {
|
||||
|
|
|
|||
|
|
@ -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 1–5 · 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 */
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user