feat(ops): unify tech skill/cadence editor + merge team tools
Extract the per-tech skill editor (chips ordered by priority + per-skill Score ★ + Cadence %) into shared components/planif/SkillCadenceTable.vue, used by BOTH the click-a-tech popover AND the team tool — one template everywhere. Rebuild "Équipe — cadence & coût" into "Équipe — compétences, cadence & coût": each tech is an expandable row showing SkillCadenceTable (per-skill cadence) + global cadence (default 100%) + cost. Operates on live tech objects so edits persist via the existing handlers. Folds "Gérer les compétences" (tag catalog: rename/recolor/delete) in as a collapsible section, and collapses the two menu entries into one "Équipe — compétences · cadence · coût". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
04fb52426f
commit
9145624949
97
apps/ops/src/components/planif/SkillCadenceTable.vue
Normal file
97
apps/ops/src/components/planif/SkillCadenceTable.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<script setup>
|
||||
/**
|
||||
* SkillCadenceTable — éditeur RÉUTILISABLE des compétences d'un technicien :
|
||||
* chips (ordre = priorité, glisser-déposer) + Score (★ maîtrise) + Cadence % PAR compétence,
|
||||
* et (optionnel) la Cadence GLOBALE du tech (défaut 100 %).
|
||||
*
|
||||
* SOURCE UNIQUE : utilisé à la fois par le volet « clic sur un tech » (Planif) ET par l'outil
|
||||
* « Équipe — compétences, cadence & coût » → même gabarit partout. Opère sur l'objet tech LIVE
|
||||
* (skills[]/skill_levels{}/skill_eff{}/efficiency) ; la persistance reste au parent via les events.
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
import TagEditor from 'src/components/shared/TagEditor.vue'
|
||||
import HelpHint from 'src/components/shared/HelpHint.vue'
|
||||
|
||||
const props = defineProps({
|
||||
tech: { type: Object, required: true }, // objet LIVE : { id, name, skills[], skill_levels{}, skill_eff{}, efficiency }
|
||||
catalog: { type: Array, default: () => [] }, // tagCatalog
|
||||
getColor: { type: Function, default: () => '#6b7280' },
|
||||
palette: { type: Array, default: () => [] }, // TAG_PALETTE
|
||||
showGlobal: { type: Boolean, default: false }, // affiche la cadence GLOBALE (défaut 100 %) — utile dans l'outil équipe
|
||||
})
|
||||
const emit = defineEmits(['tags-change', 'set-level', 'set-cadence', 'set-global', 'create-tag', 'update-tag'])
|
||||
|
||||
// Conversions cadence % ↔ facteur (bas facteur = rapide). Mêmes formules que PlanificationPage (effPctOf/factorFromPct).
|
||||
const pctOf = (factor) => { const f = Number(factor) || 1; return Math.round(100 / (f > 0 ? f : 1)) }
|
||||
|
||||
const skills = () => (Array.isArray(props.tech.skills) ? props.tech.skills : [])
|
||||
const levelOf = (sk) => (props.tech.skill_levels && props.tech.skill_levels[sk]) || 0
|
||||
const cadenceOf = (sk) => { const e = props.tech.skill_eff && props.tech.skill_eff[sk]; return (e != null && e !== '') ? pctOf(e) : '' } // '' = hérite du global
|
||||
const globalPct = () => pctOf(props.tech.efficiency) // défaut efficiency=1 → 100 %
|
||||
|
||||
// Tampons de saisie (n'écrit qu'au blur/enter, comme l'original) — locaux au composant.
|
||||
const buf = reactive({}) // { sk: pct }
|
||||
const gBuf = reactive({ v: undefined })
|
||||
function commitSkill (sk) { if (!(sk in buf)) return; const v = buf[sk]; delete buf[sk]; emit('set-cadence', { sk, pct: v }) }
|
||||
function commitGlobal () { if (gBuf.v === undefined) return; const v = gBuf.v; gBuf.v = undefined; emit('set-global', v) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<TagEditor :model-value="skills()" :all-tags="catalog" :get-color="getColor" :can-edit="false" sortable
|
||||
placeholder="Cliquez pour ajouter une compétence…"
|
||||
@update:model-value="items => emit('tags-change', items)" @create="e => emit('create-tag', e)" />
|
||||
<div v-if="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="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">Cadence <HelpHint title="Cadence (vitesse)" text="Vitesse du technicien pour cette compétence. 100 % = cadence normale ; plus haut = plus rapide (fait plus de jobs, ex. 200 % = deux fois plus) ; sous 100 % = plus lent. Vide = hérite de la cadence globale du tech. Le dispatch auto en tient compte pour estimer les durées." /></div>
|
||||
</div>
|
||||
<div v-for="(sk, si) in 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: getColor(sk) }"><q-tooltip>Couleur</q-tooltip>
|
||||
<q-menu><div class="q-pa-xs" style="width:208px">
|
||||
<div class="row">
|
||||
<q-btn v-for="c in palette" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="emit('update-tag', { name: sk, color: c })" />
|
||||
</div>
|
||||
<div class="row items-center no-wrap q-mt-xs q-px-xs">
|
||||
<span class="text-caption text-grey-7 q-mr-sm">Perso</span>
|
||||
<input type="color" :value="getColor(sk)" @change="e => emit('update-tag', { name: sk, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" />
|
||||
<span class="text-caption text-grey-5 q-ml-sm">toute couleur</span>
|
||||
</div>
|
||||
</div></q-menu>
|
||||
</q-btn>
|
||||
<span class="skill-chip" :style="{ background: getColor(sk) }">{{ sk }}</span>
|
||||
</div>
|
||||
<div style="width:90px" class="text-center no-wrap">
|
||||
<q-icon v-for="n in 5" :key="n" :name="levelOf(sk) >= n ? 'star' : 'star_outline'" :color="levelOf(sk) >= n ? 'indigo' : 'grey-4'" size="16px" class="cursor-pointer" @click="emit('set-level', { sk, level: levelOf(sk) === n ? 0 : n })" />
|
||||
</div>
|
||||
<div style="width:88px">
|
||||
<q-input dense outlined type="number" step="25" min="20" max="1000" :model-value="(sk in buf) ? buf[sk] : cadenceOf(sk)" @update:model-value="v => { buf[sk] = v }" @blur="commitSkill(sk)" @keyup.enter="commitSkill(sk)" suffix="%" placeholder="glob." input-class="text-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> ★ = maîtrise · <b>Cadence</b> : <b>100 %</b> = normale · <b>200 %</b> = deux fois plus de jobs · < 100 % = plus lent · vide = hérite du global · × sur le chip = retirer.</div>
|
||||
</div>
|
||||
|
||||
<!-- Cadence GLOBALE du tech (défaut 100 %) — s'applique aux compétences sans cadence propre. -->
|
||||
<div v-if="showGlobal" class="row items-center q-mt-sm q-pt-sm" style="border-top:1px dashed #e2e8f0">
|
||||
<div class="col text-caption text-weight-medium text-grey-7">Cadence globale <span class="text-grey-5 text-weight-regular">(défaut 100 %)</span></div>
|
||||
<q-input dense outlined type="number" step="10" min="20" max="1000" style="width:96px" suffix="%"
|
||||
:model-value="(gBuf.v !== undefined) ? gBuf.v : globalPct()" @update:model-value="v => { gBuf.v = v }" @blur="commitGlobal" @keyup.enter="commitGlobal">
|
||||
<q-tooltip>100 % = cadence normale · PLUS HAUT = plus rapide (fait plus de jobs). Hérité par les compétences sans cadence propre.</q-tooltip>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.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); }
|
||||
</style>
|
||||
|
|
@ -76,8 +76,7 @@
|
|||
<q-item-section avatar><q-icon name="warehouse" color="blue-grey-7" /></q-item-section>
|
||||
<q-item-section>Point de départ (dépôt)<q-item-label caption class="ellipsis" style="max-width:210px">{{ depot && depot.address ? depot.address : 'non défini — cliquer pour situer' }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showTagManager = true"><q-item-section avatar><q-icon name="sell" color="teal" /></q-item-section><q-item-section>Gérer les compétences (tags)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="groups" color="teal" /></q-item-section><q-item-section>Équipe — compétences · cadence · coût<q-item-label caption>Compétences (score + cadence/skill) · coût · catalogue</q-item-label></q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
|
||||
<q-item clickable>
|
||||
<q-item-section avatar><q-icon name="bookmark" color="brown" /></q-item-section>
|
||||
|
|
@ -614,25 +613,73 @@
|
|||
<LeaveDialog v-model="showLeave" :techs="techs" :tech-options="techOptions" />
|
||||
|
||||
<q-dialog v-model="showTeamEditor">
|
||||
<q-card style="min-width:900px">
|
||||
<q-card-section class="row items-center q-pb-none"><div class="text-subtitle1 text-weight-bold">Équipe — cadence & coût</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
|
||||
<q-card style="min-width:640px;max-width:96vw">
|
||||
<q-card-section class="row items-center q-pb-none"><div class="text-subtitle1 text-weight-bold">Équipe — compétences, cadence & coût</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Cadence : 1.00 normal · 1.10 = +10 % (plus lent) · 0.90 = −10 % (plus rapide). Coût chargé/h = salaire × (1 + charges %) + autres (véhicule, outils, frais). Le solveur préfère les techs rapides et moins coûteux.</div>
|
||||
<div style="max-height:55vh;overflow:auto">
|
||||
<table class="demand-tbl" style="width:100%">
|
||||
<thead><tr><th>Technicien</th><th>Compétences</th><th>Efficacité %</th><th>Salaire/h</th><th>Charges %</th><th>Autres/h</th><th>Coût chargé/h</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="t in editTechs" :key="t.id">
|
||||
<td>{{ t.name }}<span v-if="t.group" class="grp">{{ t.group }}</span></td>
|
||||
<td><TagEditor :model-value="Array.isArray(t.skills) ? t.skills : []" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" compact placeholder="+ compétence" style="min-width:160px" @update:model-value="items => onTeamSkills(t, items)" @create="onCreateRosterTag" /></td>
|
||||
<td><q-input dense outlined type="number" step="10" min="20" max="1000" :model-value="(t.id in gEffBuf) ? gEffBuf[t.id] : effPctOf(t.efficiency)" @update:model-value="v => gEffBuf[t.id] = v" @blur="commitGEff(t)" @keyup.enter="commitGEff(t)" style="width:92px" suffix="%"><q-tooltip>100 % = cadence normale · PLUS HAUT = plus rapide (fait plus de jobs). Ex. 300 % = 3× plus rapide · 60 % = plus lent.</q-tooltip></q-input></td>
|
||||
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.salary" style="width:80px" @blur="saveCost(t)" /></td>
|
||||
<td><q-input dense outlined type="number" step="1" v-model.number="t.charges" style="width:80px" @blur="saveCost(t)" /></td>
|
||||
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.other" style="width:80px" @blur="saveCost(t)" /></td>
|
||||
<td class="text-weight-bold text-center">{{ loadedCost(t) }} $</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Gérer les compétences (catalogue global) — fusionné ici : renommer / recolorer / supprimer partout -->
|
||||
<q-expansion-item dense icon="sell" label="Gérer les compétences (catalogue)" caption="Renommer · recolorer · supprimer — partout" header-class="text-teal-8" class="q-mb-xs">
|
||||
<div class="q-pa-sm">
|
||||
<div v-if="!managedTags.length" class="text-grey-6 text-caption">Aucune compétence — ajoute-en via un technicien ci-dessous.</div>
|
||||
<q-list v-else dense separator>
|
||||
<q-item v-for="tg in managedTags" :key="tg.label">
|
||||
<q-item-section avatar>
|
||||
<q-btn flat dense round size="sm" icon="circle" :style="{ color: tg.color }"><q-tooltip>Couleur</q-tooltip>
|
||||
<q-menu><div class="q-pa-xs" style="width:208px">
|
||||
<div class="row"><q-btn v-for="c in TAG_PALETTE" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="onUpdateRosterTag({ name: tg.label, color: c })" /></div>
|
||||
<div class="row items-center no-wrap q-mt-xs q-px-xs"><span class="text-caption text-grey-7 q-mr-sm">Perso</span><input type="color" :value="tg.color" @change="e => onUpdateRosterTag({ name: tg.label, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" /></div>
|
||||
</div></q-menu>
|
||||
</q-btn>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="cursor-pointer">{{ tg.label }}
|
||||
<q-popup-edit :model-value="tg.label" auto-save v-slot="scope" @save="v => renameTagGlobal(tg.label, v)">
|
||||
<q-input dense autofocus :model-value="scope.value" @update:model-value="scope.value = $event" label="Renommer (partout)" @keyup.enter="scope.set" />
|
||||
</q-popup-edit>
|
||||
</q-item-label>
|
||||
<q-item-label caption>{{ tg.count }} technicien(s) · clic = renommer</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="deleteTagGlobal(tg)"><q-tooltip>Supprimer partout</q-tooltip></q-btn></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
<q-separator class="q-my-sm" />
|
||||
|
||||
<div class="text-caption text-grey-7 q-mb-xs">Clique un technicien pour régler ses <b>compétences</b> (ordre = priorité), son <b>score</b> ★ et sa <b>cadence par compétence</b> (défaut global 100 %), plus son <b>coût</b>. Enregistrement automatique.</div>
|
||||
<div style="max-height:58vh;overflow:auto">
|
||||
<q-list separator>
|
||||
<q-expansion-item v-for="t in editTechs" :key="t.id" dense group="teamTech">
|
||||
<template #header>
|
||||
<q-item-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<span class="text-weight-medium">{{ t.name }}</span><span v-if="t.group" class="grp q-ml-xs">{{ t.group }}</span>
|
||||
<q-space />
|
||||
<span class="text-caption text-grey-6">{{ (t.skills || []).length }} comp. · cad. {{ Math.round(100 / (Number(t.efficiency) || 1)) }} % · {{ loadedCostRow(t.id) }} $/h</span>
|
||||
</div>
|
||||
<div class="row items-center q-gutter-xs q-mt-xs">
|
||||
<span v-for="sk in (t.skills || []).slice(0, 7)" :key="sk" class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}</span>
|
||||
<span v-if="!(t.skills || []).length" class="text-caption text-grey-5">aucune compétence</span>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
<div class="q-pa-sm">
|
||||
<SkillCadenceTable :tech="t" :catalog="tagCatalog" :get-color="getTagColor" :palette="TAG_PALETTE" show-global
|
||||
@tags-change="items => onTagsChange(t, items)"
|
||||
@set-level="e => setSkillLevel(t, e.sk, e.level)"
|
||||
@set-cadence="e => setSkillEffPct(t, e.sk, e.pct)"
|
||||
@set-global="pct => setGlobalCadence(t, pct)"
|
||||
@create-tag="onCreateRosterTag" @update-tag="onUpdateRosterTag" />
|
||||
<q-separator class="q-my-sm" />
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">💵 Coût <span class="text-grey-5 text-weight-regular">chargé/h = salaire × (1 + charges %) + autres</span></div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-input dense outlined type="number" step="0.5" v-model.number="teamCost[t.id].salary" style="width:118px" label="Salaire/h" @blur="saveCostRow(t)" />
|
||||
<q-input dense outlined type="number" step="1" v-model.number="teamCost[t.id].charges" style="width:118px" label="Charges %" @blur="saveCostRow(t)" />
|
||||
<q-input dense outlined type="number" step="0.5" v-model.number="teamCost[t.id].other" style="width:118px" label="Autres/h" @blur="saveCostRow(t)" />
|
||||
<div class="text-weight-bold text-center">= {{ loadedCostRow(t.id) }} $/h</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
@ -733,43 +780,13 @@
|
|||
<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" sortable placeholder="Cliquez pour ajouter 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">Cadence <HelpHint title="Cadence (vitesse)" text="Vitesse du technicien pour cette compétence. 100 % = cadence normale ; plus haut = plus rapide (fait plus de jobs, ex. 200 % = deux fois plus) ; sous 100 % = plus lent. Vide = hérite de la cadence globale du tech. Le dispatch auto en tient compte pour estimer les durées." /></div>
|
||||
</div>
|
||||
<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">
|
||||
<q-btn v-for="c in TAG_PALETTE" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="onUpdateRosterTag({ name: sk, color: c })" />
|
||||
</div>
|
||||
<div class="row items-center no-wrap q-mt-xs q-px-xs">
|
||||
<span class="text-caption text-grey-7 q-mr-sm">Perso</span>
|
||||
<input type="color" :value="getTagColor(sk)" @change="e => onUpdateRosterTag({ name: sk, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" />
|
||||
<span class="text-caption text-grey-5 q-ml-sm">toute couleur</span>
|
||||
</div>
|
||||
</div></q-menu>
|
||||
</q-btn>
|
||||
<span class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}</span>
|
||||
</div>
|
||||
<div style="width:90px" class="text-center no-wrap">
|
||||
<q-icon v-for="n in 5" :key="n" :name="skillLevelOf(skillDialog, sk) >= n ? 'star' : 'star_outline'" :color="skillLevelOf(skillDialog, sk) >= n ? 'indigo' : 'grey-4'" size="16px" class="cursor-pointer" @click="setSkillLevel(skillDialog, sk, skillLevelOf(skillDialog, sk) === n ? 0 : n)" />
|
||||
</div>
|
||||
<div style="width:88px">
|
||||
<q-input dense outlined type="number" step="25" min="20" max="1000" :model-value="(effBufKey(skillDialog, sk) in effBuf) ? effBuf[effBufKey(skillDialog, sk)] : skillEffPct(skillDialog, sk)" @update:model-value="v => { effBuf[effBufKey(skillDialog, sk)] = v }" @blur="commitSkillEff(skillDialog, sk)" @keyup.enter="commitSkillEff(skillDialog, sk)" suffix="%" placeholder="glob." input-class="text-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> ★ = maîtrise · <b>Cadence</b> : <b>100 %</b> = normale · <b>200 %</b> = deux fois plus de jobs · < 100 % = plus lent · vide = hérite du global · × sur le chip = retirer.</div>
|
||||
<div class="text-caption text-green-7 q-mt-xs row items-center no-wrap" style="gap:3px"><q-icon name="cloud_done" size="13px" />Enregistré automatiquement — pas de bouton à cliquer.</div>
|
||||
</div>
|
||||
<!-- Éditeur compétences/cadence PARTAGÉ (même gabarit que l'outil « Équipe ») -->
|
||||
<SkillCadenceTable :tech="skillDialog" :catalog="tagCatalog" :get-color="getTagColor" :palette="TAG_PALETTE"
|
||||
@tags-change="items => onTagsChange(skillDialog, items)"
|
||||
@set-level="e => setSkillLevel(skillDialog, e.sk, e.level)"
|
||||
@set-cadence="e => setSkillEffPct(skillDialog, e.sk, e.pct)"
|
||||
@create-tag="onCreateRosterTag" @update-tag="onUpdateRosterTag" />
|
||||
<div v-if="(skillDialog.skills || []).length" class="text-caption text-green-7 q-mt-xs row items-center no-wrap" style="gap:3px"><q-icon name="cloud_done" size="13px" />Enregistré automatiquement — pas de bouton à cliquer.</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire</div>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
|
|
@ -1915,6 +1932,7 @@ import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
|
|||
import HelpHint from 'src/components/shared/HelpHint.vue'
|
||||
import { skillIcon, skillSym, markerIcon } from 'src/composables/useSkillIcons' // SOURCE UNIQUE des icônes de compétences (réutilisée fiche client)
|
||||
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags/compétences PARTAGÉ (chips colorées, création + palette, niveaux) — SOURCE UNIQUE : jobs ET techs
|
||||
import SkillCadenceTable from 'src/components/planif/SkillCadenceTable.vue' // éditeur compétences+cadence PARTAGÉ (volet tech + outil Équipe)
|
||||
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
|
||||
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
|
||||
import OverflowMenu from 'src/components/shared/OverflowMenu.vue' // « ⋮ » standardisé pour les actions secondaires (désencombrer la barre)
|
||||
|
|
@ -2046,7 +2064,7 @@ function isHidden (id) { return hiddenTechs.value.includes(id) }
|
|||
function toggleHidden (id) { const i = hiddenTechs.value.indexOf(id); if (i >= 0) hiddenTechs.value.splice(i, 1); else hiddenTechs.value.push(id); localStorage.setItem('roster-hidden-techs-v1', JSON.stringify(hiddenTechs.value)) }
|
||||
const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).length)
|
||||
const showShiftEditor = ref(false) // v-model de <ShiftTypesDialog> (editTpls/newTpl/fonctions déplacés dans le composant)
|
||||
const showTeamEditor = ref(false); const editTechs = ref([])
|
||||
const showTeamEditor = ref(false); const editTechs = ref([]); const teamCost = reactive({})
|
||||
const notifySms = ref(false)
|
||||
// ── Notifier les techs de leur tournée (SMS/Email + lien token) : aperçu d'abord (compose sans envoi), puis envoi. ──
|
||||
const notifyDlg = reactive({ open: false, loading: false, sending: false, techs: [] })
|
||||
|
|
@ -5715,7 +5733,20 @@ function techRole (t) {
|
|||
}
|
||||
function roleIcon (t) { const r = techRole(t); return r ? ROLE_ICON[r] : null }
|
||||
function roleLabel (t) { const r = techRole(t); return r ? ROLE_LABEL[r] : '' }
|
||||
function openTeamEditor () { editTechs.value = techs.value.map(t => ({ id: t.id, name: t.name, group: t.group, skills: [...(t.skills || [])], efficiency: t.efficiency || 1, salary: t.cost_salary_h || 0, charges: t.cost_charges_pct || 0, other: t.cost_other_h || 0 })); showTeamEditor.value = true }
|
||||
// Opère sur les objets tech LIVE (techs.value) → SkillCadenceTable + les handlers de compétence/cadence persistent
|
||||
// directement (skill_levels/skill_eff/skills/efficiency). Le coût passe par un tampon (teamCost) sauvé au blur.
|
||||
function openTeamEditor () {
|
||||
editTechs.value = techs.value
|
||||
for (const t of techs.value) teamCost[t.id] = { salary: t.cost_salary_h || 0, charges: t.cost_charges_pct || 0, other: t.cost_other_h || 0 }
|
||||
showTeamEditor.value = true
|
||||
}
|
||||
function loadedCostRow (id) { const c = teamCost[id] || {}; return Math.round(((Number(c.salary) || 0) * (1 + (Number(c.charges) || 0) / 100) + (Number(c.other) || 0)) * 100) / 100 }
|
||||
async function saveCostRow (t) {
|
||||
const c = teamCost[t.id] || {}
|
||||
try { await roster.setTechCost(t.id, { salary: c.salary, charges: c.charges, other: c.other }); t.cost_salary_h = Number(c.salary) || 0; t.cost_charges_pct = Number(c.charges) || 0; t.cost_other_h = Number(c.other) || 0; t.cost_h = loadedCostRow(t.id); $q.notify({ type: 'positive', message: t.name + ' : ' + loadedCostRow(t.id) + ' $/h chargé' }) } catch (e) { err(e) }
|
||||
}
|
||||
// Cadence GLOBALE du tech (émise par SkillCadenceTable, en %) → facteur + persiste (défaut 100 % ↔ facteur 1).
|
||||
function setGlobalCadence (t, pct) { t.efficiency = factorFromPct(pct); saveEff(t) }
|
||||
// Éditeur d'équipe (table) : TagEditor émet un tableau → t.skills en tableau, puis persiste (l'API attend une CSV).
|
||||
function onTeamSkills (t, items) { t.skills = normSkillList(items); saveSkills(t) }
|
||||
async function saveSkills (t) { const csv = normSkillList(t.skills).join(','); try { await roster.setTechSkills(t.id, csv); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.skills = csv ? csv.split(',') : []; $q.notify({ type: 'positive', message: t.name + ' : compétences enregistrées' }) } catch (e) { err(e) } }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user