planif : éditeurs de compétences unifiés sur TagEditor (fin de SkillSelect)
Régression corrigée : les 4 sélecteurs de compétences de Planification utilisaient
SkillSelect (chips plates, CSV, sans couleurs ni catalogue) au lieu du composant
riche TagEditor déjà employé pour les compétences d'un technicien.
- jobDetail + feuille du pool (« Compétences requises ») : SkillSelect → TagEditor
(chips COLORÉES, autosuggest depuis tagCatalog, création à la volée + palette 12 couleurs)
- table d'équipe + table de demande de personnel : idem → TagEditor (compact)
- SkillSelect.vue SUPPRIMÉ (0 référence) ; import retiré
- normSkillList() : normalisation unique (TagEditor émet un tableau de libellés/{tag} ;
rétro-compat CSV) → réutilisée par jobDetail/pool/équipe/demande
- demande : d.skills passe de CSV à tableau (migration au chargement + CSV rétabli
à la génération des besoins)
RÉUTILISER les composants partagés (TagEditor pour toute saisie de compétences/tags),
ne pas reconstruire d'équivalent inférieur.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7ca3ceef66
commit
987ae98bfc
|
|
@ -1,35 +0,0 @@
|
|||
<template>
|
||||
<!-- Saisie de compétences en CHIPS (cohérent partout). Stocke/émet une chaîne
|
||||
CSV ("fibre,cuivre") pour rester compatible avec le backend existant.
|
||||
Tape + Entrée = nouvelle compétence ; suggestions proposées. -->
|
||||
<q-select
|
||||
dense outlined multiple use-input use-chips hide-dropdown-icon
|
||||
new-value-mode="add-unique" input-debounce="0"
|
||||
:model-value="arr"
|
||||
@update:model-value="onChange"
|
||||
:options="filtered" @filter="onFilter"
|
||||
:label="label" :style="style"
|
||||
placeholder="fibre, cuivre…" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
const props = defineProps({
|
||||
modelValue: { type: [String, Array], default: '' },
|
||||
suggestions: { type: Array, default: () => ['fibre', 'cuivre', 'aérien', 'souterrain', 'TV', 'téléphonie', 'installation', 'réparation', 'épissure'] },
|
||||
label: { type: String, default: 'Compétences' },
|
||||
style: { type: String, default: 'min-width:150px' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const arr = computed(() => Array.isArray(props.modelValue)
|
||||
? props.modelValue
|
||||
: String(props.modelValue || '').split(',').map(s => s.trim()).filter(Boolean))
|
||||
const filtered = ref(props.suggestions)
|
||||
function onFilter (val, update) {
|
||||
update(() => {
|
||||
const n = (val || '').toLowerCase()
|
||||
filtered.value = props.suggestions.filter(s => s.toLowerCase().includes(n))
|
||||
})
|
||||
}
|
||||
function onChange (v) { emit('update:modelValue', (v || []).join(',')) }
|
||||
</script>
|
||||
|
|
@ -195,7 +195,7 @@
|
|||
<tr v-for="(d, i) in demand" :key="i">
|
||||
<td><q-select dense options-dense outlined v-model="d.shift" :options="tplOptions" emit-value map-options style="min-width:150px" @update:model-value="saveDemand" /></td>
|
||||
<td><q-input dense outlined v-model="d.zone" style="width:120px" @update:model-value="saveDemand" /></td>
|
||||
<td><SkillSelect v-model="d.skills" style="min-width:150px" @update:model-value="saveDemand" /></td>
|
||||
<td><TagEditor :model-value="Array.isArray(d.skills) ? d.skills : []" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" compact placeholder="+ compétence" style="min-width:150px" @update:model-value="items => onDemandSkills(d, items)" @create="onCreateRosterTag" /></td>
|
||||
<td><q-input dense outlined type="number" step="0.5" v-model.number="d.job_h" placeholder="0" style="width:80px" @update:model-value="saveDemand" /></td>
|
||||
<td><q-input dense outlined type="number" v-model.number="d.weekday" style="width:70px" @update:model-value="saveDemand" /></td>
|
||||
<td><q-input dense outlined type="number" v-model.number="d.weekend" style="width:70px" @update:model-value="saveDemand" /></td>
|
||||
|
|
@ -394,7 +394,7 @@
|
|||
<q-chip v-for="s in POOL_STATUSES" :key="s.value" clickable dense :icon="s.icon" :color="jobSheet.job.status === s.value ? s.color : 'grey-3'" :text-color="jobSheet.job.status === s.value ? 'white' : 'grey-8'" @click="setJobStatus(jobSheet.job, s.value)">{{ s.label }}</q-chip>
|
||||
</div>
|
||||
<div class="pm-opt-label">Compétences requises</div>
|
||||
<SkillSelect :model-value="(jobSheet.job.required_skills && jobSheet.job.required_skills.length) ? jobSheet.job.required_skills : (jobSheet.job.required_skill ? [jobSheet.job.required_skill] : [])" @update:model-value="list => setPoolSkills(jobSheet.job, list)" label="" style="width:100%" />
|
||||
<TagEditor :model-value="(jobSheet.job.required_skills && jobSheet.job.required_skills.length) ? jobSheet.job.required_skills : (jobSheet.job.required_skill ? [jobSheet.job.required_skill] : [])" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" placeholder="Ajouter une compétence requise…" style="width:100%" @update:model-value="list => setPoolSkills(jobSheet.job, list)" @create="onCreateRosterTag" />
|
||||
<div class="pm-opt-label">Reporter (date prévue)</div>
|
||||
<div class="row q-gutter-sm items-center">
|
||||
<q-btn dense unelevated no-caps color="indigo" icon="snooze" label="+1 jour" @click="snoozeJob(jobSheet.job, 1)" />
|
||||
|
|
@ -700,7 +700,7 @@
|
|||
<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><SkillSelect v-model="t.skills" style="min-width:160px" @update:model-value="saveSkills(t)" /></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>
|
||||
|
|
@ -1490,7 +1490,7 @@
|
|||
<!-- Compétences REQUISES (dispatch auto) — LISTE : sélecteur/créateur en chips (autosuggest). Le tech doit les avoir TOUTES. 1re = principale. -->
|
||||
<div class="jd-skill row items-center q-gutter-sm q-mb-xs">
|
||||
<q-icon name="construction" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Compétences requises</span>
|
||||
<SkillSelect :model-value="jobDetail.skills" @update:model-value="jdSetJobSkills" style="min-width:220px;flex:1" />
|
||||
<TagEditor :model-value="jobDetail.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" placeholder="Ajouter une compétence requise…" style="min-width:220px;flex:1" @update:model-value="jdSetJobSkills" @create="onCreateRosterTag" />
|
||||
</div>
|
||||
<!-- Attribuer cette compétence à un tech précis → il sera matché au dispatch automatique. -->
|
||||
<div v-if="jobDetail.skill" class="row items-center q-gutter-xs q-mb-sm no-wrap">
|
||||
|
|
@ -2056,8 +2056,7 @@ import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (poli
|
|||
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
|
||||
import TechSelect from 'src/components/shared/TechSelect.vue'
|
||||
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
|
||||
import SkillSelect from 'src/components/shared/SkillSelect.vue'
|
||||
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags partagé (Dispatch) : condensé, création à la volée, couleurs
|
||||
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 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 UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) — Dispatch déprécié, tout ici
|
||||
|
|
@ -2502,12 +2501,18 @@ const geoTimeline = computed(() => {
|
|||
function techNameById (id) { const t = (techs.value || []).find(x => x.id === id); return t ? t.name : '' } // repli nom d'un assistant depuis l'id
|
||||
// Nom d'assistant ROBUSTE : une vieille ligne a pu être persistée avec tech_name = la CHAÎNE « undefined »/« null » → on la traite comme vide et on résout par l'id.
|
||||
function jdAssistantName (a) { const bad = v => !v || v === 'undefined' || v === 'null'; if (a && !bad(a.tech_name)) return a.tech_name; const n = techNameById(a && a.tech_id); return n || (a && !bad(a.tech_id) ? a.tech_id : '—') }
|
||||
// Éditeur de COMPÉTENCES REQUISES du job (SkillSelect multi + création) + attribution à un tech (dispatch auto).
|
||||
// Éditeur de COMPÉTENCES REQUISES du job (TagEditor partagé : chips colorées + création + palette) + attribution à un tech (dispatch auto).
|
||||
const jdSkillTech = ref(null)
|
||||
const jdSkillBusy = ref(false)
|
||||
// Normalise la sortie d'un éditeur de compétences → libellés propres dédupliqués.
|
||||
// TagEditor émet un tableau de libellés (ou de {tag}) ; on tolère aussi une CSV (rétro-compat).
|
||||
function normSkillList (list) {
|
||||
const raw = Array.isArray(list) ? list : String(list || '').split(',')
|
||||
return [...new Set(raw.map(x => (typeof x === 'string' ? x : (x && x.tag) || '')).map(s => String(s).trim()).filter(Boolean))]
|
||||
}
|
||||
// LISTE de compétences requises du job → store hub /roster/job-skills (PAS un champ ERPNext). required_skill(principale)=1re. Le tech doit les avoir TOUTES.
|
||||
async function jdSetJobSkills (list) {
|
||||
const arr = [...new Set((Array.isArray(list) ? list : String(list || '').split(',')).map(s => String(s).trim()).filter(Boolean))] // SkillSelect émet une CSV ("a,b"), pas un tableau
|
||||
const arr = normSkillList(list)
|
||||
jobDetail.skills = arr; jobDetail.skill = arr[0] || ''
|
||||
if (jobDetail.name) { try { await roster.setJobSkills(jobDetail.name, arr); if (typeof reloadPool === 'function') reloadPool() } catch (e) { err(e) } }
|
||||
}
|
||||
|
|
@ -5502,10 +5507,10 @@ async function patchJob (j, patch, okMsg) {
|
|||
function toggleUrgent (j) { const on = j.priority === 'high'; patchJob(j, { priority: on ? 'medium' : 'high' }, on ? 'Priorité moyenne' : '⚡ Urgent') }
|
||||
function setJobPriority (j, p) { patchJob(j, { priority: p }, 'Priorité : ' + ((POOL_PRIOS.find(x => x.value === p) || {}).label || p)) }
|
||||
function setJobStatus (j, s) { patchJob(j, { status: s }, 'Statut : ' + ((POOL_STATUSES.find(x => x.value === s) || {}).label || s)) }
|
||||
// Compétences requises (LISTE) du job depuis la feuille du pool → store hub /roster/job-skills (PAS un champ ERPNext). SkillSelect émet une CSV. Optimiste + réconcilie sur échec.
|
||||
// Compétences requises (LISTE) du job depuis la feuille du pool → store hub /roster/job-skills (PAS un champ ERPNext). Optimiste + réconcilie sur échec.
|
||||
async function setPoolSkills (j, list) {
|
||||
if (!j) return
|
||||
const arr = [...new Set((Array.isArray(list) ? list : String(list || '').split(',')).map(s => String(s).trim()).filter(Boolean))]
|
||||
const arr = normSkillList(list)
|
||||
j.required_skills = arr; j.required_skill = arr[0] || '' // affichage/couleur/icône immédiats
|
||||
if (!j.name) return
|
||||
try { await roster.setJobSkills(j.name, arr); $q.notify({ type: 'positive', message: 'Compétences : ' + (arr.join(', ') || '—'), timeout: 1600 }) }
|
||||
|
|
@ -5788,8 +5793,10 @@ 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 || []).join(', '), 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 }
|
||||
async function saveSkills (t) { try { await roster.setTechSkills(t.id, t.skills || ''); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.skills = (t.skills || '').split(',').map(s => s.trim()).filter(Boolean); $q.notify({ type: 'positive', message: t.name + ' : compétences enregistrées' }) } catch (e) { err(e) } }
|
||||
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 }
|
||||
// É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) } }
|
||||
|
||||
// congés / disponibilités
|
||||
async function openLeave () { showLeave.value = true; await loadLeave() }
|
||||
|
|
@ -5865,7 +5872,7 @@ async function doWeekStatus (mode) {
|
|||
}
|
||||
|
||||
// demande
|
||||
function loadLS () { try { demand.value = JSON.parse(localStorage.getItem(LS_DEMAND) || '[]') } catch { demand.value = [] } try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } }
|
||||
function loadLS () { try { demand.value = JSON.parse(localStorage.getItem(LS_DEMAND) || '[]') } catch { demand.value = [] } demand.value.forEach(d => { if (!Array.isArray(d.skills)) d.skills = String(d.skills || '').split(',').map(s => s.trim()).filter(Boolean) }); /* migration CSV→tableau (ancien SkillSelect) */ try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } }
|
||||
|
||||
// ── Rotation de garde par département (récurrence + rotation) ────────────────
|
||||
const GARDE_EPOCH = '2026-01-05' // lundi de référence pour l'index de semaine
|
||||
|
|
@ -5959,7 +5966,8 @@ async function applyGardeRules () {
|
|||
} catch (e) { err(e) }
|
||||
}
|
||||
function saveDemand () { localStorage.setItem(LS_DEMAND, JSON.stringify(demand.value)) }
|
||||
function addDemand () { demand.value = [...demand.value, { shift: templates.value[0] && templates.value[0].name, zone: 'Montréal', skills: '', job_h: 0, weekday: 1, weekend: 0, holiday: 0 }]; saveDemand() }
|
||||
function onDemandSkills (d, items) { d.skills = normSkillList(items); saveDemand() } // TagEditor émet un tableau ; d.skills reste un tableau (→ CSV à la génération)
|
||||
function addDemand () { demand.value = [...demand.value, { shift: templates.value[0] && templates.value[0].name, zone: 'Montréal', skills: [], job_h: 0, weekday: 1, weekend: 0, holiday: 0 }]; saveDemand() }
|
||||
function removeDemand (i) { demand.value = demand.value.filter((_, j) => j !== i); saveDemand() }
|
||||
async function applyDemand () {
|
||||
if (!demand.value.length) { $q.notify({ type: 'warning', message: 'Aucune ligne de demande' }); return }
|
||||
|
|
@ -5974,7 +5982,7 @@ async function applyDemand () {
|
|||
const jobH = Number(row.job_h) || 0
|
||||
const sh = (tplByName.value[row.shift] && tplByName.value[row.shift].hours) || 8
|
||||
const count = jobH > 0 ? Math.max(1, Math.ceil(n * jobH / sh)) : n // mode jobs → effectif
|
||||
reqs.push({ requirement_date: d.iso, shift_template: row.shift, zone: row.zone || '', required_count: count, required_skills: row.skills || '' })
|
||||
reqs.push({ requirement_date: d.iso, shift_template: row.shift, zone: row.zone || '', required_count: count, required_skills: normSkillList(row.skills).join(',') })
|
||||
}
|
||||
}
|
||||
if (reqs.length) await roster.bulkRequirements(reqs)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user