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:
louispaulb 2026-07-09 14:57:01 -04:00
parent 04fb52426f
commit 9145624949
2 changed files with 187 additions and 59 deletions

View 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&nbsp;%</b> = normale · <b>200&nbsp;%</b> = deux fois plus de jobs · &lt;&nbsp;100&nbsp;% = 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>

View File

@ -76,8 +76,7 @@
<q-item-section avatar><q-icon name="warehouse" color="blue-grey-7" /></q-item-section> <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-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>
<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="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="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="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 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 clickable>
<q-item-section avatar><q-icon name="bookmark" color="brown" /></q-item-section> <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" /> <LeaveDialog v-model="showLeave" :techs="techs" :tech-options="techOptions" />
<q-dialog v-model="showTeamEditor"> <q-dialog v-model="showTeamEditor">
<q-card style="min-width:900px"> <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 cadence &amp; coût</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section> <q-card-section class="row items-center q-pb-none"><div class="text-subtitle1 text-weight-bold">Équipe compétences, cadence &amp; coût</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
<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> <!-- Gérer les compétences (catalogue global) fusionné ici : renommer / recolorer / supprimer partout -->
<div style="max-height:55vh;overflow:auto"> <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">
<table class="demand-tbl" style="width:100%"> <div class="q-pa-sm">
<thead><tr><th>Technicien</th><th>Compétences</th><th>Efficacité&nbsp;%</th><th>Salaire/h</th><th>Charges %</th><th>Autres/h</th><th>Coût chargé/h</th></tr></thead> <div v-if="!managedTags.length" class="text-grey-6 text-caption">Aucune compétence ajoute-en via un technicien ci-dessous.</div>
<tbody> <q-list v-else dense separator>
<tr v-for="t in editTechs" :key="t.id"> <q-item v-for="tg in managedTags" :key="tg.label">
<td>{{ t.name }}<span v-if="t.group" class="grp">{{ t.group }}</span></td> <q-item-section avatar>
<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> <q-btn flat dense round size="sm" icon="circle" :style="{ color: tg.color }"><q-tooltip>Couleur</q-tooltip>
<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> <q-menu><div class="q-pa-xs" style="width:208px">
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.salary" style="width:80px" @blur="saveCost(t)" /></td> <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>
<td><q-input dense outlined type="number" step="1" v-model.number="t.charges" style="width:80px" @blur="saveCost(t)" /></td> <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>
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.other" style="width:80px" @blur="saveCost(t)" /></td> </div></q-menu>
<td class="text-weight-bold text-center">{{ loadedCost(t) }} $</td> </q-btn>
</tr> </q-item-section>
</tbody> <q-item-section>
</table> <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> </div>
</q-card-section> </q-card-section>
</q-card> </q-card>
@ -733,43 +780,13 @@
<q-menu v-model="skillMenuShown" :target="skillMenuTarget" anchor="bottom left" self="top left" no-focus max-height="80vh"> <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 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> <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…" <!-- Éditeur compétences/cadence PARTAGÉ (même gabarit que l'outil « Équipe ») -->
@update:model-value="items => onTagsChange(skillDialog, items)" <SkillCadenceTable :tech="skillDialog" :catalog="tagCatalog" :get-color="getTagColor" :palette="TAG_PALETTE"
@create="onCreateRosterTag" /> @tags-change="items => onTagsChange(skillDialog, items)"
<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> @set-level="e => setSkillLevel(skillDialog, e.sk, e.level)"
<div v-if="(skillDialog.skills || []).length" class="q-mt-md"> @set-cadence="e => setSkillEffPct(skillDialog, e.sk, e.pct)"
<div class="row items-center text-caption text-grey-6 q-pb-xs"> @create-tag="onCreateRosterTag" @update-tag="onUpdateRosterTag" />
<div class="col">Compétence</div> <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>
<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&nbsp;%</b> = normale · <b>200&nbsp;%</b> = deux fois plus de jobs · &lt;&nbsp;100&nbsp;% = 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>
<q-separator class="q-my-sm" /> <q-separator class="q-my-sm" />
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire</div> <div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire</div>
<div class="row items-center q-gutter-xs"> <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 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 { 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 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 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 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) 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)) } 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 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 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) const notifySms = ref(false)
// Notifier les techs de leur tournée (SMS/Email + lien token) : aperçu d'abord (compose sans envoi), puis envoi. // 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: [] }) 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 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 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). // É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) } 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) } } 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) } }