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:
louispaulb 2026-07-07 21:06:15 -04:00
parent 7ca3ceef66
commit 987ae98bfc
2 changed files with 23 additions and 50 deletions

View File

@ -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>

View File

@ -195,7 +195,7 @@
<tr v-for="(d, i) in demand" :key="i"> <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-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><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" 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.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> <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> <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>
<div class="pm-opt-label">Compétences requises</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="pm-opt-label">Reporter (date prévue)</div>
<div class="row q-gutter-sm items-center"> <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)" /> <q-btn dense unelevated no-caps color="indigo" icon="snooze" label="+1 jour" @click="snoozeJob(jobSheet.job, 1)" />
@ -700,7 +700,7 @@
<tbody> <tbody>
<tr v-for="t in editTechs" :key="t.id"> <tr v-for="t in editTechs" :key="t.id">
<td>{{ t.name }}<span v-if="t.group" class="grp">{{ t.group }}</span></td> <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="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="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="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. --> <!-- 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"> <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> <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> </div>
<!-- Attribuer cette compétence à un tech précis il sera matché au dispatch automatique. --> <!-- 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"> <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 { 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 TechSelect from 'src/components/shared/TechSelect.vue'
import TicketStatusControl from 'src/components/shared/TicketStatusControl.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/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 partagé (Dispatch) : condensé, création à la volée, couleurs
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 UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) Dispatch déprécié, tout ici 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 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. // 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 : '—') } 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 jdSkillTech = ref(null)
const jdSkillBusy = ref(false) 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. // 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) { 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] || '' 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) } } 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 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 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)) } 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) { async function setPoolSkills (j, list) {
if (!j) return 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 j.required_skills = arr; j.required_skill = arr[0] || '' // affichage/couleur/icône immédiats
if (!j.name) return if (!j.name) return
try { await roster.setJobSkills(j.name, arr); $q.notify({ type: 'positive', message: 'Compétences : ' + (arr.join(', ') || '—'), timeout: 1600 }) } 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 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 || []).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 } 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 }
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) } } // É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 // congés / disponibilités
async function openLeave () { showLeave.value = true; await loadLeave() } async function openLeave () { showLeave.value = true; await loadLeave() }
@ -5865,7 +5872,7 @@ async function doWeekStatus (mode) {
} }
// demande // 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) // 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 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) } } catch (e) { err(e) }
} }
function saveDemand () { localStorage.setItem(LS_DEMAND, JSON.stringify(demand.value)) } 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() } function removeDemand (i) { demand.value = demand.value.filter((_, j) => j !== i); saveDemand() }
async function applyDemand () { async function applyDemand () {
if (!demand.value.length) { $q.notify({ type: 'warning', message: 'Aucune ligne de demande' }); return } 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 jobH = Number(row.job_h) || 0
const sh = (tplByName.value[row.shift] && tplByName.value[row.shift].hours) || 8 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 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) if (reqs.length) await roster.bulkRequirements(reqs)