- Couleurs liées aux skills (éditable/cohérent) : hub deptToSkill() déduit une compétence du type legacy
→ /roster/unassigned-jobs renvoie required_skill ; PlanificationPage colore la carte par getTagColor(required_skill)
(même couleur que le chip skill) ; bordure 5px
- Fil complet du ticket : hub /dispatch/legacy-sync/ticket-thread (ticket_msg + auteur staff, HTML nettoyé) ;
api legacyTicketThread ; RightPanel bouton « 💬 Voir le fil / commentaires » (chargé au clic, messages+auteurs+dates)
- Order-by du pool dispatch : useBottomPanel.bottomSort (date|city|priority) + dropdown ⇅ dans BottomPanel
(ville = 2e segment adresse / token sujet avant |)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1674 lines
152 KiB
Vue
1674 lines
152 KiB
Vue
<template>
|
||
<q-page padding>
|
||
<!-- Rangée 1 : actions principales (nav · Outils · Générer · Publier) -->
|
||
<div class="row items-center q-mb-sm q-gutter-xs">
|
||
<div class="text-h6 text-weight-bold">Planification</div>
|
||
<q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
|
||
<q-chip v-if="offShiftWeekCount" dense size="sm" color="orange-8" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour où la ressource n'a AUCUN quart publié. Repère le ⚠ dans la grille → publier un quart ou réassigner.</q-tooltip></q-chip>
|
||
<q-space />
|
||
<q-btn-group flat>
|
||
<q-btn dense flat icon="chevron_left" @click="navWeek(-1)"><q-tooltip>Semaine précédente</q-tooltip></q-btn>
|
||
<q-btn dense flat label="Auj." @click="navToday" />
|
||
<q-btn dense flat icon="chevron_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn>
|
||
</q-btn-group>
|
||
<q-input dense outlined type="date" v-model="start" style="width:150px" @update:model-value="onWeekChange" />
|
||
<q-select dense outlined v-model="days" :options="[7, 14]" style="width:76px" emit-value map-options @update:model-value="onDaysChange" />
|
||
<q-btn dense flat round icon="undo" :disable="!history.length" @click="undo"><q-tooltip>Annuler (Ctrl+Z)</q-tooltip></q-btn>
|
||
<q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn>
|
||
<q-separator vertical class="q-mx-xs" />
|
||
<!-- Menu OUTILS : regroupe la config secondaire pour désencombrer -->
|
||
<q-btn-dropdown dense outline color="grey-8" icon="build" label="Outils" no-caps>
|
||
<q-list dense style="min-width:230px">
|
||
<q-item clickable v-close-popup @click="showDemand = !showDemand">
|
||
<q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section>
|
||
<q-item-section>Demande de personnel</q-item-section>
|
||
<q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></q-item-section>
|
||
</q-item>
|
||
<q-item clickable>
|
||
<q-item-section avatar><q-icon name="bookmark" color="brown" /></q-item-section>
|
||
<q-item-section>Modèles de semaine</q-item-section>
|
||
<q-item-section side><q-icon name="chevron_right" color="grey-6" /></q-item-section>
|
||
<q-menu anchor="top end" self="top start">
|
||
<q-list dense style="min-width:250px">
|
||
<q-item clickable v-close-popup @click="saveTemplate"><q-item-section avatar><q-icon name="save" /></q-item-section><q-item-section>Enregistrer la semaine…</q-item-section></q-item>
|
||
<q-separator v-if="weekTemplates.length" />
|
||
<q-item-label v-if="weekTemplates.length" header>Appliquer</q-item-label>
|
||
<q-item v-for="(tm, i) in weekTemplates" :key="i" clickable @click="applyTemplate(tm)">
|
||
<q-item-section avatar><q-btn flat dense round size="sm" :icon="tm.default ? 'star' : 'star_border'" :color="tm.default ? 'amber-8' : 'grey-5'" @click.stop="setDefaultTemplate(i)"><q-tooltip>Modèle par défaut (★)</q-tooltip></q-btn></q-item-section>
|
||
<q-item-section>{{ tm.name }}</q-item-section>
|
||
<q-item-section side><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click.stop="deleteTemplate(i)" /></q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-item>
|
||
<q-item clickable v-close-popup @click="openGarde"><q-item-section avatar><q-icon name="shield" color="brown" /></q-item-section><q-item-section>Rotation de garde</q-item-section></q-item>
|
||
<q-separator />
|
||
<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="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="openAssignPanel"><q-item-section avatar><q-icon name="drag_indicator" color="deep-purple" /></q-item-section><q-item-section>Jobs à assigner (glisser-déposer)</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="$router.push('/dispatch')"><q-item-section avatar><q-icon name="open_in_new" color="indigo" /></q-item-section><q-item-section>Tableau Dispatch (détails & priorités)</q-item-section></q-item>
|
||
<q-item clickable v-close-popup @click="openLeave"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
|
||
</q-list>
|
||
</q-btn-dropdown>
|
||
<q-btn v-if="defaultTemplate" dense flat color="amber-9" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn>
|
||
<q-separator vertical class="q-mx-xs" />
|
||
<q-btn unelevated color="primary" icon="auto_awesome" label="Générer" :loading="generating" @click="doGenerate" />
|
||
<q-checkbox v-model="notifySms" label="SMS" dense size="sm"><q-tooltip>Notifier les techs par SMS à la publication</q-tooltip></q-checkbox>
|
||
<q-btn :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
|
||
<q-btn flat dense round icon="refresh" :loading="loading" @click="() => guard(loadWeek)" />
|
||
</div>
|
||
|
||
<!-- Rangée 2 : filtres + légende en popover -->
|
||
<div class="row items-center q-gutter-sm q-mb-sm">
|
||
<q-input dense outlined clearable v-model="search" placeholder="Rechercher un technicien…" style="width:230px"><template #prepend><q-icon name="search" /></template></q-input>
|
||
<q-select dense outlined clearable v-model="groupFilter" :options="groupOptions" emit-value map-options label="Équipe" style="width:180px" />
|
||
<q-input dense outlined type="number" v-model.number="maxHours" label="Max h/sem" style="width:110px" />
|
||
<span class="text-caption text-grey-6">{{ visibleTechs.length }} / {{ techs.length }} techs</span>
|
||
<q-chip v-if="cellClipboard.length" dense size="sm" color="indigo" text-color="white" icon="content_paste">{{ cellClipboard.length }} copié(s)</q-chip>
|
||
<q-space />
|
||
<q-btn dense flat round icon="help_outline" color="grey-7"><q-tooltip>Légende & raccourcis</q-tooltip>
|
||
<q-menu>
|
||
<div class="q-pa-md" style="max-width:420px">
|
||
<div class="text-subtitle2 q-mb-sm">Légende</div>
|
||
<div class="row items-center q-gutter-xs q-mb-xs"><span class="tod-leg"></span><span class="text-grey-8">dispo (matin → soir)</span></div>
|
||
<div class="row items-center q-gutter-xs q-mb-xs"><span class="occ-leg"></span><span class="text-grey-8">occupation (vert → rouge)</span></div>
|
||
<div class="row items-center q-gutter-xs q-mb-xs"><span class="leg-absent"></span><span class="text-grey-8">absent / congé</span></div>
|
||
<div class="row items-center q-gutter-xs q-mb-xs"><span class="leg-garde"></span><span class="text-grey-8">garde (hors bureau)</span></div>
|
||
<div class="row items-center q-gutter-xs q-mb-sm"><span class="free q-mr-xs">·</span><span class="text-grey-8">libre</span><span class="cell-dirty-demo q-ml-md q-mr-xs">J</span><span class="text-grey-8">modifié (non publié)</span></div>
|
||
<div class="text-subtitle2 q-mb-xs">Raccourcis</div>
|
||
<div class="text-caption text-grey-8"><b>glisser</b> = sélection · <b>shift+clic</b> = bloc · clic en-tête = colonne · clic nom = rangée · <b>ctrl+clic</b> = +1 · <b>ctrl+C/V</b> = copier/coller · <b>Suppr/⌫</b> = vider · <b>A</b> = absent · <b>G</b> = garde</div>
|
||
</div>
|
||
</q-menu>
|
||
</q-btn>
|
||
</div>
|
||
|
||
<!-- Filtre par compétence (chip/tag) → n'affiche que les techs capables, triés par priorité -->
|
||
<div v-if="allSkills.length" class="row items-center q-gutter-xs q-mb-sm">
|
||
<q-icon name="sell" size="16px" color="teal" /><span class="text-caption text-grey-7">Compétences :</span>
|
||
<q-chip v-for="sk in allSkills" :key="sk" clickable dense size="sm" :color="skillFilter.includes(sk) ? 'teal' : 'grey-3'" :text-color="skillFilter.includes(sk) ? 'white' : 'grey-8'" @click="toggleSkill(sk)">{{ sk }}</q-chip>
|
||
<template v-if="skillFilter.length">
|
||
<q-btn flat dense size="sm" color="grey-7" icon="close" label="Effacer" @click="skillFilter = []" />
|
||
<span class="text-caption text-teal text-weight-medium">▸ {{ skillFilter.length > 1 ? 'toutes requises (ET)' : 'capables' }} · {{ visibleTechs.length }} tech(s), triés par priorité</span>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Demande -->
|
||
<q-card v-if="showDemand" flat bordered class="q-mb-md">
|
||
<q-card-section class="q-pb-none">
|
||
<div class="row items-center">
|
||
<div class="text-subtitle2 text-weight-bold">Demande — effectif requis par créneau</div><q-space />
|
||
<q-btn dense flat icon="schedule" label="Types de shift" @click="openShiftEditor" />
|
||
<q-btn dense flat icon="add" label="Ajouter" @click="addDemand" />
|
||
<q-btn dense unelevated color="indigo" icon="playlist_add_check" label="Appliquer à la semaine" :loading="applying" class="q-ml-sm" @click="applyDemand" />
|
||
</div>
|
||
<div class="text-caption text-grey-7 q-mt-xs">Coche les jours <b>fériés</b> (F) dans l'en-tête · fin de semaine = sam/dim (auto). Si <b>Durée/job</b> > 0, les nombres = <b>nb de jobs</b> → effectif = ⌈jobs × durée ÷ heures du shift⌉ (compétences requises = colonne Compétences).</div>
|
||
</q-card-section>
|
||
<q-card-section>
|
||
<table class="demand-tbl">
|
||
<thead><tr><th>Modèle</th><th>Zone</th><th>Compétences</th><th>Durée/job (h)</th><th>Semaine</th><th>Fin de sem.</th><th>Férié</th><th></th></tr></thead>
|
||
<tbody>
|
||
<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><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>
|
||
<td><q-input dense outlined type="number" v-model.number="d.holiday" style="width:70px" @update:model-value="saveDemand" /></td>
|
||
<td><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="removeDemand(i)" /></td>
|
||
</tr>
|
||
<tr v-if="!demand.length"><td colspan="8" class="text-grey-6 q-pa-sm">Aucune ligne — clique « Ajouter ».</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</q-card-section>
|
||
</q-card>
|
||
|
||
<q-banner v-if="solverStats" dense rounded class="q-mb-md" :class="solverStats.shortfall ? 'bg-orange-1 text-orange-9' : 'bg-green-1 text-green-9'">
|
||
<q-icon :name="solverStats.shortfall ? 'warning' : 'check_circle'" class="q-mr-xs" />
|
||
{{ solverStats.assignments }} assignations · {{ solverStats.shortfall ? (solverStats.shortfall + ' poste(s) non couvert(s)') : 'couverture complète' }} · équité {{ solverStats.spread }} h · {{ solverStats.ms }} ms
|
||
</q-banner>
|
||
|
||
<!-- Barre d'actions FLOTTANTE (position: fixed) → n'altère JAMAIS la hauteur de la grille : pas de décalage
|
||
du tableau quand la sélection démarre pendant un glisser (le curseur reste sur la rangée visée). -->
|
||
<div v-if="selection.length" class="sel-actions" @mousedown.stop>
|
||
<span class="text-weight-medium q-mr-xs">{{ selection.length }} cellule(s) :</span>
|
||
<q-btn dense unelevated size="sm" color="primary" label="Jour" @click="bulkWindow(8, 17)" />
|
||
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir" @click="bulkWindow(16, 20)" />
|
||
<q-btn dense unelevated size="sm" color="brown" icon="shield" label="Garde" @click="bulkGarde" />
|
||
<q-btn dense unelevated size="sm" color="red-6" icon="event_busy" label="Absent" @click="bulkAbsent" />
|
||
<q-input dense outlined v-model="quickEntry" placeholder="8-17" style="width:84px" @keyup.enter="bulkQuick" @mousedown.stop><q-tooltip>Saisie rapide : 8-17 · 830-16 · 85</q-tooltip></q-input>
|
||
<q-separator vertical class="q-mx-xs" />
|
||
<q-btn dense flat size="sm" icon="content_copy" label="Copier" @click="copyCell" />
|
||
<q-btn dense flat size="sm" icon="content_paste" :label="cellClipboard.length ? ('Coller (' + cellClipboard.length + ')') : 'Coller'" :disable="!cellClipboard.length" @click="pasteCells" />
|
||
<q-btn dense flat size="sm" icon="layers_clear" label="Libérer" @click="clearBulk" />
|
||
<q-btn dense flat size="sm" icon="close" label="Annuler" @click="selection = []" />
|
||
</div>
|
||
|
||
|
||
<div class="grid-wrap">
|
||
<table class="roster-grid">
|
||
<thead>
|
||
<tr>
|
||
<th class="tech-col"><div class="row items-center no-wrap">Ressource<q-space /><q-btn v-if="hiddenCount" flat dense round size="xs" :icon="showHidden ? 'visibility' : 'visibility_off'" :color="showHidden ? 'primary' : 'grey-6'" @click="showHidden = !showHidden"><q-badge floating color="grey-7" style="top:-7px;right:6px">{{ hiddenCount }}</q-badge><q-tooltip>{{ showHidden ? 'Cacher les ressources masquées' : (hiddenCount + ' masquée(s) — afficher en grisé') }}</q-tooltip></q-btn></div></th>
|
||
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
|
||
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
|
||
<q-badge v-if="gapByDay[d.iso]" color="red" floating style="top:2px;right:2px">{{ gapByDay[d.iso] }}</q-badge>
|
||
<div class="hol-toggle" :class="{ on: isHoliday(d.iso) }" @click.stop="toggleHoliday(d.iso)"><q-tooltip>Marquer férié</q-tooltip>F</div>
|
||
<div class="hdr-ruler"><span v-for="tk in axisTicks" :key="tk.h" class="tick" :style="{ left: tk.left }">{{ tk.h }}</span></div>
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(t, ti) in visibleTechs" :key="t.id" :class="{ paused: isPaused(t), 'res-hidden': isHidden(t.id) }">
|
||
<td class="tech-col">
|
||
<div class="tech-row">
|
||
<q-btn flat round dense size="9px" :icon="isRowSelected(ti) ? 'check_box' : 'check_box_outline_blank'" :color="isRowSelected(ti) ? 'primary' : 'grey-5'" @click.stop="maybeSelectRow(ti)"><q-tooltip>Sélectionner la rangée</q-tooltip></q-btn>
|
||
<q-btn flat round dense size="9px" :icon="isPaused(t) ? 'play_arrow' : 'pause'" :color="isPaused(t) ? 'grey' : 'primary'" @click.stop="togglePause(t)"><q-tooltip>{{ isPaused(t) ? 'Réactiver' : 'Pause' }}</q-tooltip></q-btn>
|
||
<q-badge v-if="techRank(t)" color="teal">{{ techRank(t) }}<q-tooltip>Priorité {{ techRank(t) }} — combine maîtrise (niveau) + vitesse (efficacité) + coût.</q-tooltip></q-badge>
|
||
<!-- Clic sur la RESSOURCE (nom ou chips) → propriétés de l'employé (compétences, horaire) -->
|
||
<span class="tech-name clk" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Compétences & horaire de {{ t.name }}</q-tooltip>{{ t.name }}</span>
|
||
<q-icon v-if="roleIcon(t)" :name="roleIcon(t)" size="15px" class="role-ic"><q-tooltip class="bg-grey-9">{{ roleLabel(t) }}</q-tooltip></q-icon>
|
||
<span v-if="t.group" class="grp">{{ t.group }}</span>
|
||
<span v-if="hoursOf(t.id)" class="th" :class="hoursOf(t.id) > maxHours ? 'text-red text-weight-bold' : 'text-grey-6'">{{ hoursOf(t.id) }}h<q-icon v-if="hoursOf(t.id) > maxHours" name="warning" color="red" size="12px" /></span>
|
||
<q-btn flat dense round size="9px" icon="timeline" color="indigo-5" @click.stop="openTimeline(t)"><q-tooltip>Timeline dispatch de {{ t.name }} — jobs de la semaine & priorités</q-tooltip></q-btn>
|
||
<div class="tech-skills clk" @click.stop="openSkillEditor(t, $event)">
|
||
<span v-for="sk in (t.skills || [])" :key="sk" class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}<span v-if="(t.skill_levels || {})[sk] || (t.skill_eff || {})[sk]" class="chip-lvl" :style="{ background: skillEffColor(t, sk) }"><q-tooltip class="bg-grey-9">Niveau {{ (t.skill_levels || {})[sk] || '—' }} · {{ effSuffix(skillEffOf(t, sk)) }}</q-tooltip>{{ (t.skill_levels || {})[sk] || '·' }}</span></span>
|
||
<span v-if="!(t.skills || []).length" class="add-skill-hint">+ compétences</span>
|
||
</div>
|
||
<q-btn flat dense round size="9px" class="hide-eye" :icon="isHidden(t.id) ? 'visibility_off' : 'visibility'" :color="isHidden(t.id) ? 'grey-5' : 'grey-6'" @click.stop="toggleHidden(t.id)"><q-tooltip>{{ isHidden(t.id) ? 'Réafficher / considérer cette ressource' : 'Masquer & ignorer (hors front-line)' }}</q-tooltip></q-btn>
|
||
</div>
|
||
</td>
|
||
<td v-for="(d, di) in dayList" :key="d.iso" class="cell" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso), sel: isSelected(t.id, d.iso), dirty: isCellDirty(t.id, d.iso), 'drop-hover': dropCell === t.id + '|' + d.iso }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)" @dragover.prevent="onCellDragOver(t, d)" @dragleave="dropCell === t.id + '|' + d.iso && (dropCell = null, dropPreview.key = null)" @drop.prevent="onCellDrop($event, t, d)">
|
||
<template v-if="isAbsent(t.id, d.iso) || isPaused(t)">
|
||
<div class="tl"><div class="tl-absent"></div><q-tooltip class="bg-grey-9">Absent · {{ absenceLabel(t.id, d.iso) }}</q-tooltip></div>
|
||
</template>
|
||
<template v-else-if="hasReg(t.id, d.iso) || onGarde(t.id, d.iso)">
|
||
<div class="tl">
|
||
<div v-for="(b, bi) in cellBands(t.id, d.iso)" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
|
||
<div v-for="(b, bi) in cellBlocks(t.id, d.iso)" :key="'j' + bi" class="tl-blk" :style="blockStyle(b, cellPct(t.id, d.iso))"></div>
|
||
<!-- Aperçu d'occupation projetée pendant le drag : barre fantôme + delta -->
|
||
<div v-if="isDropTarget(t.id, d.iso) && projPct(t.id, d.iso) != null" class="tl-proj" :style="{ width: Math.min(100, projPct(t.id, d.iso)) + '%', background: occColor(projPct(t.id, d.iso)) }"></div>
|
||
<q-tooltip class="bg-grey-9" :offset="[0, 6]" max-width="320px">
|
||
<div class="text-weight-medium">{{ cellTip(t.id, d.iso) }}</div>
|
||
<template v-if="cellJobs(t.id, d.iso).length">
|
||
<div class="text-amber-4 q-mt-xs" style="font-size:11px">{{ cellJobs(t.id, d.iso).length }} job(s) · par priorité</div>
|
||
<div v-for="j in cellJobs(t.id, d.iso)" :key="j.name" class="row items-center no-wrap" style="gap:5px;font-size:11px;line-height:1.5">
|
||
<span :style="{ display:'inline-block', width:'7px', height:'7px', borderRadius:'50%', background: prioColor(j.priority), flex:'0 0 auto' }"></span>
|
||
<span v-if="j.start" class="text-grey-4" style="flex:0 0 auto">{{ j.start }}</span>
|
||
<span class="ellipsis">{{ j.subject }}</span><span v-if="j.customer" class="text-grey-5" style="flex:0 0 auto">· {{ j.customer }}</span>
|
||
</div>
|
||
<div class="text-grey-5 q-mt-xs" style="font-size:10px">Outils › Tableau Dispatch pour le détail complet</div>
|
||
</template>
|
||
</q-tooltip>
|
||
</div>
|
||
</template>
|
||
<span v-else-if="offShiftJobs(t.id, d.iso).length" class="offshift-warn" @click.stop="openTimeline(t)"><q-icon name="warning" size="13px" color="orange-8" />{{ offShiftJobs(t.id, d.iso).length }}<q-tooltip class="bg-grey-9">{{ offShiftJobs(t.id, d.iso).length }} job(s) assigné(s) ce jour SANS quart publié — publier un quart ou réassigner. Clic → timeline.</q-tooltip></span>
|
||
<span v-else class="free">·</span>
|
||
<div v-if="isDropTarget(t.id, d.iso)" class="drop-badge" :class="{ over: projPct(t.id, d.iso) >= 100 }">+{{ dropPreview.addH }}h<template v-if="projPct(t.id, d.iso) != null"> → {{ projPct(t.id, d.iso) }}%</template></div>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="!visibleTechs.length"><td :colspan="dayList.length + 1" class="text-grey-6 q-pa-md text-center">Aucun technicien (filtre ?).</td></tr>
|
||
</tbody>
|
||
<tfoot>
|
||
<tr class="sum"><td class="tech-col">👥 Effectif</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ stat(d.iso).staff || '' }}</td></tr>
|
||
<tr class="sum"><td class="tech-col">⏱ Heures</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ stat(d.iso).hours || '' }}</td></tr>
|
||
<tr v-if="hasOnCall" class="sum oncall-row"><td class="tech-col">🛡️ Garde</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ stat(d.iso).on_call || '' }}</td></tr>
|
||
<tr class="sum"><td class="tech-col">🎫 Tickets</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ stat(d.iso).tickets || '' }}</td></tr>
|
||
<tr v-if="weekCost" class="sum"><td class="tech-col">💲 Coût ({{ Math.round(weekCost) }} $/sem)</td><td v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend }">{{ dayCost(d.iso) || '' }}</td></tr>
|
||
</tfoot>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="text-subtitle2 text-weight-bold q-mt-lg q-mb-sm">Couverture — dispo vs requis</div>
|
||
<div v-if="!covRows.length" class="text-grey-6 q-mb-md">Aucun besoin défini. Utilise « Demande » → « Appliquer à la semaine ».</div>
|
||
<div v-else class="grid-wrap">
|
||
<table class="roster-grid">
|
||
<thead><tr><th class="tech-col">Créneau</th><th v-for="d in dayList" :key="d.iso" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }"><div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div></th></tr></thead>
|
||
<tbody><tr v-for="row in covRows" :key="row.key"><td class="tech-col">{{ row.label }}</td><td v-for="d in dayList" :key="d.iso" class="cell cov" :style="covStyle(row.key, d.iso)">{{ covText(row.key, d.iso) }}</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<q-dialog v-model="showShiftEditor">
|
||
<q-card style="min-width:580px">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<div class="text-subtitle1 text-weight-bold">Types de shift</div><q-space />
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section>
|
||
<table class="demand-tbl">
|
||
<thead><tr><th>Nom</th><th>Début</th><th>Fin</th><th>Heures</th><th>Couleur</th><th>🛡️ Garde</th><th></th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="t in editTpls" :key="t.name">
|
||
<td><span class="code-chip" :style="chip(t.color)">{{ (t.template_name||'?')[0].toUpperCase() }}</span> {{ t.template_name }}</td>
|
||
<td><q-input dense outlined type="time" v-model="t.start" style="width:115px" /></td>
|
||
<td><q-input dense outlined type="time" v-model="t.end" style="width:115px" /></td>
|
||
<td class="text-center text-weight-medium">{{ calcHours(t.start, t.end) }} h</td>
|
||
<td><input type="color" v-model="t.color" style="width:36px;height:26px;border:none;background:none" /></td>
|
||
<td class="text-center"><q-toggle dense v-model="t.on_call" :true-value="1" :false-value="0" color="brown"><q-tooltip>Quart de garde (urgences) — non offert au booking</q-tooltip></q-toggle></td>
|
||
<td><q-btn flat dense round size="sm" icon="save" color="primary" @click="saveShiftTpl(t)"><q-tooltip>Enregistrer</q-tooltip></q-btn><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="delShiftTpl(t)" /></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<q-separator class="q-my-md" />
|
||
<div class="row items-center q-gutter-sm">
|
||
<q-input dense outlined v-model="newTpl.template_name" label="Nom (auto si vide)" style="width:150px" />
|
||
<div style="width:230px;padding:0 8px">
|
||
<q-range v-model="newTplRange" :min="0" :max="24" :step="0.5" snap label :left-label-value="fmtH(newTplRange.min) + 'h'" :right-label-value="fmtH(newTplRange.max) + 'h'" color="primary" />
|
||
</div>
|
||
<span class="text-caption text-grey-7 text-weight-medium">{{ fmtH(newTplRange.min) }}h–{{ fmtH(newTplRange.max) }}h · {{ calcHours(newTpl.start, newTpl.end) }} h</span>
|
||
<input type="color" v-model="newTpl.color" style="width:36px;height:26px;border:none;background:none" />
|
||
<q-toggle dense v-model="newTpl.on_call" :true-value="1" :false-value="0" label="🛡️ Garde" color="brown" />
|
||
<q-btn dense unelevated color="primary" icon="add" label="Ajouter" @click="addShiftTpl" />
|
||
</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<q-dialog v-model="showLeave">
|
||
<q-card style="min-width:680px">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<div class="text-subtitle1 text-weight-bold">Congés & disponibilités</div><q-space />
|
||
<q-select dense outlined v-model="leaveFilter" :options="['Demandé', 'Approuvé', 'Refusé', '']" style="width:130px" @update:model-value="loadLeave" />
|
||
<q-btn flat round dense icon="close" v-close-popup class="q-ml-sm" />
|
||
</q-card-section>
|
||
<q-card-section>
|
||
<table class="demand-tbl" style="width:100%">
|
||
<thead><tr><th>Technicien</th><th>Type</th><th>Du</th><th>Au</th><th>Motif</th><th>Statut</th><th></th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="l in leaveRows" :key="l.name">
|
||
<td>{{ l.technician_name || l.technician }}</td><td>{{ l.availability_type }}</td><td>{{ l.from_date }}</td><td>{{ l.to_date }}</td><td>{{ l.reason }}</td>
|
||
<td><q-badge :color="l.status === 'Approuvé' ? 'green' : l.status === 'Refusé' ? 'red' : 'orange'">{{ l.status }}</q-badge></td>
|
||
<td><template v-if="l.status === 'Demandé'"><q-btn flat dense round size="sm" icon="check" color="green" @click="approveLeave(l, false)" /><q-btn flat dense round size="sm" icon="close" color="red" @click="approveLeave(l, true)" /></template></td>
|
||
</tr>
|
||
<tr v-if="!leaveRows.length"><td colspan="7" class="text-grey-6 q-pa-sm">Aucune demande.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<q-separator class="q-my-md" />
|
||
<div class="text-caption text-weight-bold q-mb-xs">Nouvelle demande</div>
|
||
<div class="row items-center q-gutter-sm">
|
||
<TechSelect v-model="newLeave.technician" :options="techOptions" label="Technicien" style="width:200px" />
|
||
<q-select dense outlined v-model="newLeave.availability_type" :options="['Congé', 'Pause', 'Indisponible', 'Maladie']" label="Type" style="width:130px" />
|
||
<q-input dense outlined type="date" v-model="newLeave.from_date" label="Du" style="width:140px" />
|
||
<q-input dense outlined type="date" v-model="newLeave.to_date" label="Au" style="width:140px" />
|
||
<q-input dense outlined v-model="newLeave.reason" label="Motif" style="width:150px" />
|
||
<q-toggle dense v-model="newLeave.long_term" :true-value="1" :false-value="0" label="Longue durée"><q-tooltip>Maternité, invalidité… → à remplacer (pas juste sauter comme des vacances)</q-tooltip></q-toggle>
|
||
<q-btn dense unelevated color="primary" icon="add" label="Créer" @click="createLeave" />
|
||
</div>
|
||
<div class="text-caption text-grey-7 q-mt-sm">Une demande <b>approuvée</b> rend le tech indisponible pour le solveur sur ces dates.</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<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-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>Cadence</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><SkillSelect v-model="t.skills" style="min-width:160px" @update:model-value="saveSkills(t)" /></td>
|
||
<td><q-input dense outlined type="number" step="0.05" v-model.number="t.efficiency" style="width:80px" @blur="saveEff(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="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>
|
||
</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Rotation de garde par département -->
|
||
<q-dialog v-model="showGarde">
|
||
<q-card style="min-width:680px;max-width:760px">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<div class="text-subtitle1 text-weight-bold">🛡️ Rotation de garde (par département)</div><q-space />
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-gutter-y-md">
|
||
<!-- Règles actives -->
|
||
<div v-if="gardeRules.length">
|
||
<div class="text-caption text-weight-bold text-grey-7 q-mb-xs">Règles actives</div>
|
||
<q-list dense bordered class="rounded-borders">
|
||
<q-item v-for="(r, i) in gardeRules" :key="r.id">
|
||
<q-item-section>
|
||
<q-item-label class="text-weight-medium">{{ r.dept }} · {{ shiftName(r.shift) }}<span v-if="r.shiftWeekend"> · WE : {{ shiftName(r.shiftWeekend) }}</span></q-item-label>
|
||
<q-item-label caption>{{ gardeDowLabel(r) }} · dès {{ (r.anchor || '').slice(5) }} · {{ gardeSeqLabel(r) }}</q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side class="row no-wrap">
|
||
<q-btn flat dense round size="sm" icon="edit" color="primary" @click="editGardeRule(r)"><q-tooltip>Modifier (ordre, techs, période)</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="removeGardeRule(i)" />
|
||
</q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
</div>
|
||
|
||
<!-- Éditeur de règle (sous-panneau) -->
|
||
<div class="garde-editor q-pa-md rounded-borders">
|
||
<div class="text-subtitle2 text-weight-bold q-mb-md">{{ editingGardeId ? '✏️ Modifier la règle' : '➕ Nouvelle règle' }}</div>
|
||
|
||
<!-- Section 1 : Quand & quel quart -->
|
||
<div class="text-caption text-weight-bold text-brown-7 q-mb-sm">1 · Quand & quel quart</div>
|
||
<div class="row q-col-gutter-sm">
|
||
<div class="col-12 col-sm-6"><q-select dense outlined class="full-width" v-model="newGardeRule.dept" :options="groupNames" use-input fill-input hide-selected new-value-mode="add-unique" input-debounce="0" label="Département (optionnel)" /></div>
|
||
<div class="col-12 col-sm-6"><q-input dense outlined class="full-width" type="date" v-model="newGardeRule.anchor" label="Rotation démarre la semaine du" /></div>
|
||
<div class="col-12 col-sm-6"><q-select dense outlined class="full-width" v-model="newGardeRule.shift" :options="gardeTemplateOptions" emit-value map-options label="Quart en semaine (soir)" /></div>
|
||
<div class="col-12 col-sm-6"><q-select dense outlined class="full-width" clearable v-model="newGardeRule.shiftWeekend" :options="gardeTemplateOptions" emit-value map-options label="Quart fin de semaine" hint="sinon = quart de semaine" /></div>
|
||
</div>
|
||
<div class="q-mt-sm">
|
||
<div class="text-caption text-grey-7 q-mb-xs">Jours couverts (hors bureau) :</div>
|
||
<div class="row items-center q-gutter-xs">
|
||
<q-btn :outline="!isSetActive(WD_SEMAINE)" :unelevated="isSetActive(WD_SEMAINE)" dense size="sm" color="brown" no-caps label="Soirs de semaine" @click="toggleWeekdaysSet(WD_SEMAINE)" />
|
||
<q-btn :outline="!isSetActive(WD_FINSEM)" :unelevated="isSetActive(WD_FINSEM)" dense size="sm" color="brown" no-caps label="Fin de semaine" @click="toggleWeekdaysSet(WD_FINSEM)" />
|
||
<span class="text-grey-5 q-mx-xs">·</span>
|
||
<q-chip v-for="dw in GARDE_DOW" :key="dw.v" clickable dense size="sm" :color="newGardeRule.weekdays.includes(dw.v) ? 'brown' : 'grey-4'" :text-color="newGardeRule.weekdays.includes(dw.v) ? 'white' : 'grey-8'" @click="toggleGardeDow(dw.v)">{{ dw.l }}</q-chip>
|
||
</div>
|
||
</div>
|
||
|
||
<q-separator class="q-my-md" />
|
||
|
||
<!-- Section 2 : Séquence de rotation -->
|
||
<div class="text-caption text-weight-bold text-brown-7 q-mb-sm">2 · Séquence de rotation</div>
|
||
<div class="row items-center q-gutter-sm">
|
||
<q-select dense outlined v-model="gardePick" :options="techOptions" emit-value map-options label="Ajouter un tech à la suite" style="min-width:240px" class="col" />
|
||
<q-btn dense unelevated color="brown" icon="add" label="Ajouter" :disable="!gardePick" @click="addTechToSeq" />
|
||
</div>
|
||
<div v-if="newGardeRule.steps.length" class="q-mt-sm">
|
||
<div v-for="(s, i) in newGardeRule.steps" :key="i" class="row items-center no-wrap q-gutter-xs q-mb-xs">
|
||
<span class="text-caption text-weight-bold text-brown-7" style="min-width:22px">{{ i + 1 }}.</span>
|
||
<q-select dense outlined options-dense v-model="s.tech" :options="techOptions" emit-value map-options class="col" />
|
||
<q-input dense outlined type="number" min="1" v-model.number="s.weeks" style="width:88px" suffix="sem." />
|
||
<q-btn flat dense round size="xs" icon="arrow_upward" :disable="i === 0" @click="moveTech(i, -1)"><q-tooltip>Monter</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="xs" icon="arrow_downward" :disable="i === newGardeRule.steps.length - 1" @click="moveTech(i, 1)"><q-tooltip>Descendre</q-tooltip></q-btn>
|
||
<q-btn flat dense round size="xs" icon="close" color="grey-6" @click="newGardeRule.steps.splice(i, 1)"><q-tooltip>Retirer</q-tooltip></q-btn>
|
||
</div>
|
||
<div class="text-caption text-grey-6">« sem. » = semaines consécutives. Pour <b>N semaines d'écart</b> entre 2 tours d'un tech, mets <b>N+1 étapes</b> (ex. A→B→C = 2 sem. d'écart).</div>
|
||
</div>
|
||
<div v-else class="text-caption text-grey-6 q-mt-xs">Ajoute les techs dans l'ordre de passage (doublons permis pour des tours inégaux).</div>
|
||
<div v-if="gardePreview.length" class="q-mt-sm bg-brown-1 rounded-borders q-pa-sm">
|
||
<div class="text-caption text-weight-medium text-brown-9">Aperçu — qui est de garde :</div>
|
||
<div class="row q-gutter-xs q-mt-xs">
|
||
<q-chip v-for="(p, i) in gardePreview" :key="i" dense size="sm" color="white" text-color="brown-9">{{ p.week.slice(8) }}/{{ p.week.slice(5, 7) }} → {{ p.name }}</q-chip>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row items-center q-mt-md">
|
||
<q-btn dense unelevated color="brown" :icon="editingGardeId ? 'save' : 'add'" :label="editingGardeId ? 'Mettre à jour la règle' : 'Enregistrer la règle'" @click="addGardeRule" />
|
||
<q-btn v-if="editingGardeId" flat dense class="q-ml-xs" label="Annuler" @click="editingGardeId = null; newGardeRule.steps = []; newGardeRule.weekdays = []" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section 3 : Publication -->
|
||
<div class="row items-center q-gutter-sm">
|
||
<div class="text-caption text-grey-6 col">La grille montre la garde <b>en direct</b> (calque). « Publier » la matérialise sur l'horizon (remplacement propre) pour dispatch & les techs. Vacances ⇒ substitut auto.</div>
|
||
<q-select dense outlined v-model="gardeHorizon" :options="[4, 8, 12, 26]" emit-value map-options style="width:96px" label="semaines" />
|
||
<q-btn dense unelevated color="primary" icon="cloud_upload" label="Publier la garde" @click="applyGardeRules" />
|
||
</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Éditeur de compétences (modal, comme Dispatch) : overflow visible → dropdown + niveau/couleur jamais clippés -->
|
||
<!-- Éditeur compétences : POPOVER ancré au clic (sur la rangée), liste déjà ouverte (autofocus) -->
|
||
<q-menu v-model="skillMenuShown" :target="skillMenuTarget" anchor="bottom left" self="top left" no-focus max-height="80vh">
|
||
<div v-if="skillDialog" class="q-pa-sm" style="width:368px;min-height:300px" @click.stop @mousedown.stop>
|
||
<div class="row items-center q-mb-xs"><div class="text-subtitle2 text-weight-bold col ellipsis">🏷 {{ skillDialog.name }}</div><q-btn flat dense round size="sm" icon="close" v-close-popup /></div>
|
||
<TagEditor :model-value="skillDialog.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" autofocus placeholder="Chercher ou créer une compétence…"
|
||
@update:model-value="items => onTagsChange(skillDialog, items)"
|
||
@create="onCreateRosterTag" />
|
||
<div v-if="(skillDialog.skills || []).length" class="q-mt-md">
|
||
<div class="row items-center text-caption text-grey-6 q-pb-xs">
|
||
<div class="col">Compétence</div>
|
||
<div style="width:90px" class="text-center">Score</div>
|
||
<div style="width:88px" class="text-center">Efficacité</div>
|
||
</div>
|
||
<div v-for="sk in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
|
||
<div class="col row items-center no-wrap">
|
||
<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="5" debounce="600" :model-value="skillEffPct(skillDialog, sk)" @update:model-value="v => setSkillEffPct(skillDialog, sk, v)" 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>Efficacité</b> : <b>+</b> plus vite / <b>−</b> plus lent pour CETTE compétence (ex. <b>+80</b>), vide = globale · × sur le chip = retirer.</div>
|
||
</div>
|
||
<q-separator class="q-my-sm" />
|
||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire — semaine affichée</div>
|
||
<div class="row q-gutter-xs">
|
||
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h (L–V)" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)" />
|
||
<q-btn dense outline size="sm" no-caps color="primary" label="4×10h (L–J)" @click="applyWeekPreset(skillDialog, [1,2,3,4], 7, 17)" />
|
||
<q-btn dense outline size="sm" no-caps color="primary" label="3×12h (L–M)" @click="applyWeekPreset(skillDialog, [1,2,3], 7, 19)" />
|
||
</div>
|
||
</div>
|
||
</q-menu>
|
||
|
||
<!-- Impact d'un retrait de compétence : jobs assignés devenus invalides → redistribuer -->
|
||
<q-dialog v-model="skillImpactOpen">
|
||
<q-card style="min-width:420px;max-width:520px" v-if="skillImpactDialog">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<q-icon name="warning" color="orange" size="22px" class="q-mr-sm" />
|
||
<div class="text-subtitle1 text-weight-bold col">{{ skillImpactDialog.kind === 'absence' ? 'Absence — impact' : 'Compétence retirée — impact' }}</div>
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section>
|
||
<div v-if="skillImpactDialog.kind === 'absence'" class="text-body2 q-mb-sm"><b>{{ skillImpactDialog.jobs.length }}</b> job(s) assigné(s) à <b>{{ skillImpactDialog.tech.name }}</b> tombent sur son absence — à redistribuer.</div>
|
||
<div v-else class="text-body2 q-mb-sm"><b>{{ skillImpactDialog.jobs.length }}</b> job(s) assigné(s) à <b>{{ skillImpactDialog.tech.name }}</b> exigent « <b>{{ skillImpactDialog.skill }}</b> », qu'on vient de retirer — il/elle ne peut plus les faire.</div>
|
||
<q-list dense bordered class="rounded-borders" style="max-height:320px;overflow:auto">
|
||
<q-item v-for="j in skillImpactDialog.jobs" :key="j.name" class="q-py-sm">
|
||
<q-item-section>
|
||
<q-item-label>{{ j.service_type || 'Job' }}{{ j.customer_name ? ' — ' + j.customer_name : '' }}</q-item-label>
|
||
<q-item-label caption><q-icon name="event" size="12px" /> {{ j.scheduled_date ? j.scheduled_date.slice(5) : '—' }} {{ j.start_time ? j.start_time.slice(0, 5) : '' }} · {{ j.location_label || j.service_location || '—' }}</q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side style="min-width:188px">
|
||
<q-select dense outlined options-dense :model-value="impactPlan[j.name]" :options="candidateOptions(j.name)" emit-value map-options :loading="loadingCandidates" label="Réassigner à" @update:model-value="v => { impactPlan[j.name] = v }" />
|
||
</q-item-section>
|
||
</q-item>
|
||
</q-list>
|
||
<div class="text-caption text-grey-6 q-mt-sm">Choix <b>classés</b> = techs qualifiés <b>libres au même créneau</b> (le 1er = suggéré). « À recontacter » → file RDV (créneaux filtrés, SMS client possible).</div>
|
||
</q-card-section>
|
||
<q-card-actions align="right">
|
||
<q-btn flat label="Ignorer" v-close-popup :disable="redistributing" />
|
||
<q-btn outline color="grey-8" no-caps label="Tout à recontacter" :loading="redistributing" @click="doRedistribute('requeue')" />
|
||
<q-btn unelevated color="primary" icon="check" no-caps label="Appliquer" :loading="redistributing" @click="applyImpactPlan" />
|
||
</q-card-actions>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Gestionnaire global des compétences (tags) : renommer / recolorer / supprimer partout -->
|
||
<q-dialog v-model="showTagManager">
|
||
<q-card style="min-width:420px;max-width:540px">
|
||
<q-card-section class="row items-center q-pb-none">
|
||
<div class="text-subtitle1 text-weight-bold col">🏷 Gérer les compétences</div>
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section style="max-height:62vh;overflow:auto">
|
||
<div v-if="!managedTags.length" class="text-grey-6">Aucune compétence — ajoute-en via un employé.</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 class="text-caption text-grey-6 q-mt-sm">Renommer/supprimer s'applique à <b>tous les techniciens</b> qui ont la compétence. Recolorer change la couleur partout.</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Panneau FLOTTANT déplaçable : jobs à assigner (groupes parent-enfant) → glisser sur une case (tech × jour) -->
|
||
<div v-if="assignPanel.open" class="assign-panel" :style="{ left: assignPanel.x + 'px', top: assignPanel.y + 'px' }">
|
||
<div class="assign-hdr" @mousedown="panelHeaderDown">
|
||
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignPanel.jobs.length }})</span><q-space />
|
||
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
|
||
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
|
||
</div>
|
||
<div class="assign-body">
|
||
<div v-if="assignPanel.loading" class="text-grey-6 q-pa-md text-center">Chargement…</div>
|
||
<div v-else-if="!assignPanel.jobs.length" class="text-grey-6 q-pa-md text-center">Aucun job à assigner 🎉</div>
|
||
<div v-for="grp in assignGroups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': groupSelected(grp) }">
|
||
<div v-if="grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)</div>
|
||
<div v-for="(j, idx) in grp.jobs" :key="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name) }" :style="{ borderLeft: '5px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
||
<div class="row items-center no-wrap">
|
||
<q-checkbox dense size="xs" :model-value="!!selectedJobs[j.name]" @update:model-value="selectedJobs[j.name] = $event" @click.stop @mousedown.stop class="q-mr-xs" />
|
||
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
|
||
<q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
|
||
<span class="ellipsis text-weight-medium">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
|
||
<q-space />
|
||
<q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon>
|
||
</div>
|
||
<div class="assign-sub">
|
||
<span v-if="j.required_skill" class="assign-skill" :style="{ background: getTagColor(j.required_skill) }">{{ j.required_skill }}</span>
|
||
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · {{ j.duration_h || 1 }}h
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="assignPanel.jobs.length" class="assign-foot">
|
||
<template v-if="selectedNames.length"><b>{{ selectedNames.length }}</b> sélectionné(s) · <b>{{ selectedHours }}h</b> — glisse la sélection sur un tech</template>
|
||
<template v-else>Coche des jobs (groupe lié surligné), puis glisse la sélection sur une case <b>tech × jour</b>. <q-icon name="home_repair_service" size="11px" color="teal" />=terrain · <q-icon name="cloud" size="11px" color="grey-5" />=à distance · 🔒=bloqué.</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Timeline contextuelle d'une ressource : dispatch des jobs de la semaine visible (heures + priorités) -->
|
||
<q-dialog v-model="timelineDlg.open">
|
||
<q-card style="min-width:560px;max-width:780px">
|
||
<q-card-section class="row items-center q-pb-sm">
|
||
<q-icon name="timeline" color="indigo" size="22px" class="q-mr-sm" />
|
||
<div class="text-subtitle1 text-weight-bold">Timeline — {{ timelineDlg.tech && timelineDlg.tech.name }}</div>
|
||
<q-space />
|
||
<q-btn flat dense no-caps size="sm" icon="open_in_new" label="Dispatch" color="indigo" @click="gotoDispatch(timelineDlg.tech)"><q-tooltip>Ouvrir le tableau Dispatch sur cette ressource</q-tooltip></q-btn>
|
||
<q-btn flat round dense icon="close" v-close-popup />
|
||
</q-card-section>
|
||
<q-card-section class="q-pt-none" style="max-height:70vh;overflow:auto">
|
||
<div v-if="!timelineDays.length" class="text-grey-6 q-pa-md text-center">Aucun job planifié cette semaine pour cette ressource.</div>
|
||
<div v-for="day in timelineDays" :key="day.iso" class="tldlg-day">
|
||
<div class="row items-center q-mb-xs">
|
||
<div class="text-weight-medium" :class="{ 'text-deep-orange-7': day.weekend }">{{ day.label }}</div>
|
||
<q-badge v-if="day.offShift" color="orange-8" class="q-ml-sm"><q-icon name="warning" size="11px" class="q-mr-xs" />hors quart publié</q-badge>
|
||
<q-space />
|
||
<q-badge v-if="day.pct != null" text-color="white" :style="{ background: occColor(day.pct) }">{{ day.usedH }}h · {{ day.pct }}%</q-badge>
|
||
<q-badge v-else color="grey-5" class="q-ml-xs">{{ day.usedH }}h</q-badge>
|
||
</div>
|
||
<div v-if="!day.offShift" class="tldlg-bar">
|
||
<div v-for="(b, bi) in day.bands" :key="'b' + bi" class="tl-shift" :class="{ oncall: b.oncall }" :style="{ left: b.left, width: b.width, background: b.bg || undefined }"></div>
|
||
<div v-for="(b, bi) in day.blocks" :key="'k' + bi" class="tl-blk" :style="blockStyle(b, day.pct)"></div>
|
||
<span v-for="tk in axisTicks" :key="'t' + tk.h" class="tldlg-tick" :style="{ left: tk.left }">{{ tk.h }}</span>
|
||
</div>
|
||
<div v-for="j in day.jobs" :key="j.name" class="tldlg-job">
|
||
<span class="tldlg-dot" :style="{ background: prioColor(j.priority) }"><q-tooltip>{{ j.priority }}</q-tooltip></span>
|
||
<span class="tldlg-time">{{ j.start || '—' }}</span>
|
||
<span class="ellipsis">{{ j.subject }}</span>
|
||
<span v-if="j.customer" class="text-grey-6 ellipsis">· {{ j.customer }}</span>
|
||
<q-space /><span class="text-grey-6" style="flex:0 0 auto">{{ j.dur }}h</span>
|
||
</div>
|
||
</div>
|
||
</q-card-section>
|
||
<q-card-section v-if="timelineDays.length" class="q-pt-none text-caption text-grey-6">Trié par priorité puis heure · 🔴 urgent 🟠 élevée 🔵 moyenne ⚪ basse. Heures posées en premier-trou-libre.</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<q-menu v-model="menu.show" :target="menu.target" anchor="bottom left" self="top left" max-height="85vh">
|
||
<q-list dense style="width:262px;user-select:none;-webkit-user-select:none">
|
||
<q-item-label header class="q-py-xs">{{ menu.tech && menu.tech.name }} — {{ menu.day && menu.day.dnum }}</q-item-label>
|
||
<!-- 4 actions : Jour · Soir · Garde · Absent -->
|
||
<div class="row q-gutter-xs q-px-sm q-pb-xs">
|
||
<q-btn dense unelevated size="sm" color="primary" label="Jour 8–17" class="col" @click="quickShift(8, 17)" />
|
||
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 16–20" class="col" @click="quickShift(16, 20)" />
|
||
</div>
|
||
<div class="row q-gutter-xs q-px-sm q-pb-xs">
|
||
<q-btn dense :unelevated="menuIsGarde" :outline="!menuIsGarde" size="sm" color="brown" icon="shield" :label="menuIsGarde ? 'Garde ✓' : 'Garde'" class="col" @click="toggleGardeMenu"><q-tooltip>Mettre / retirer de garde (G) — en parallèle d'un shift</q-tooltip></q-btn>
|
||
<q-btn dense :unelevated="menuIsAbsent" :outline="!menuIsAbsent" size="sm" color="red-6" icon="event_busy" :label="menuIsAbsent ? 'Absent ✓' : 'Absent'" class="col" @click="toggleAbsentMenu"><q-tooltip>Marquer / retirer absent (A)</q-tooltip></q-btn>
|
||
</div>
|
||
<!-- Saisie rapide d'heures : 8-17 · 830-16 · 85 (=8→17) -->
|
||
<div class="q-px-sm q-pb-xs" @click.stop @mousedown.stop>
|
||
<q-input dense outlined v-model="quickEntry" placeholder="Heures : 8-17 · 830-16 · 85" @keyup.enter="applyQuick()">
|
||
<template #append><q-btn flat dense round size="sm" icon="keyboard_return" color="primary" @click="applyQuick()"><q-tooltip>Appliquer</q-tooltip></q-btn></template>
|
||
</q-input>
|
||
</div>
|
||
<!-- Plage personnalisée (slider replié) -->
|
||
<q-expansion-item dense dense-toggle icon="tune" label="Personnaliser la plage" header-class="text-caption text-grey-7">
|
||
<div class="q-px-md q-pb-sm" @click.stop @mousedown.stop>
|
||
<q-range v-model="menuRange" :min="0" :max="24" :step="0.5" snap color="primary" class="q-mt-sm" />
|
||
<div class="row items-center no-wrap q-gutter-sm">
|
||
<span class="text-caption text-weight-bold">{{ fmtH(menuRange.min) }}h–{{ fmtH(menuRange.max) }}h</span>
|
||
<q-space />
|
||
<q-btn dense unelevated size="sm" color="primary" label="Appliquer" @click="applyMenuRange" />
|
||
</div>
|
||
</div>
|
||
</q-expansion-item>
|
||
<q-separator />
|
||
<!-- Shifts en place + actions compactes -->
|
||
<q-item v-for="a in menuCellShifts" :key="'c' + a.shift" dense>
|
||
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section>
|
||
<q-item-section side><q-btn flat dense round size="sm" icon="close" color="grey-7" @click="removeShiftFromMenu(a)"><q-tooltip>Retirer</q-tooltip></q-btn></q-item-section>
|
||
</q-item>
|
||
<div class="row items-center q-px-sm q-py-xs q-gutter-sm">
|
||
<q-btn flat dense size="sm" icon="content_copy" color="grey-8" @click="copyFromMenu"><q-tooltip>Copier la case</q-tooltip></q-btn>
|
||
<q-btn flat dense size="sm" icon="content_paste" color="grey-8" :disable="!cellClipboard.length" @click="pasteFromMenu"><q-tooltip>Coller{{ cellClipboard.length ? ' (' + cellClipboard.length + ')' : '' }}</q-tooltip></q-btn>
|
||
<q-space />
|
||
<q-btn v-if="menuCellShifts.length" flat dense size="sm" icon="layers_clear" color="grey-8" label="Vider" @click="clearOne" />
|
||
</div>
|
||
</q-list>
|
||
</q-menu>
|
||
</q-page>
|
||
</template>
|
||
|
||
<script setup>
|
||
/**
|
||
* PlanificationPage — grille hebdomadaire roster (ressources × jours) + prise en charge dispatch.
|
||
* Backend : targo-hub /roster/* (src/api/roster.js) → ERPNext facturation + solveur OR-Tools.
|
||
* Voir aussi : services/targo-hub/lib/roster.js (endpoints) et docs/ROSTER.md (vue d'ensemble).
|
||
*
|
||
* CARTE DES SECTIONS (chercher les bannières « ── … ── ») :
|
||
* 1. État réactif & constantes ............ refs techs/templates/assignments, LS_*, dayList
|
||
* 2. Calque de garde LIVE ................. gardeOverlay (règles) ⊕ manualGarde (touche G) → gardeEffective
|
||
* 3. Filtres & scoring priorité ........... skillFilter (ET), techCompetence/techSpeed/techProximity → priorityScores
|
||
* 4. Ressources masquées .................. hiddenTechs / visibleTechs
|
||
* 5. Compétences inline ................... skillLevel (1-5) + skillEff (%/compétence) + TagEditor + gestionnaire global
|
||
* 6. Sélection / peinture cellules ........ souris (onDown/onEnter) + clavier (onKey : A absent, G garde, copier/coller)
|
||
* 7. Occupation & timeline ................ occCells → cellBands/cellBlocks/cellPct/cellJobs ; openTimeline (dialogue)
|
||
* 8. Panneau « jobs à assigner » .......... multi-sélection + terrain/distant + drag-drop + aperçu occupation projetée
|
||
* 9. Dialogue d'impact .................... retrait compétence / absence → redistribution (candidats classés)
|
||
* 10. Chargement & solveur ................. loadBase/loadWeek/loadStats · doGenerate/doPublish
|
||
* 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor
|
||
*/
|
||
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
|
||
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
|
||
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
|
||
import { onBeforeRouteLeave, useRouter } from 'vue-router'
|
||
import { useQuasar } from 'quasar'
|
||
import * as roster from 'src/api/roster'
|
||
import { legacyDeptColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé avec le board Dispatch)
|
||
import TechSelect from 'src/components/shared/TechSelect.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
|
||
|
||
const $q = useQuasar()
|
||
const router = useRouter()
|
||
const DIRTY_MSG = 'Vous avez des modifications non publiées. Les abandonner ?'
|
||
|
||
const techs = ref([])
|
||
const templates = ref([])
|
||
const assignments = ref([])
|
||
const coverageData = ref([])
|
||
const dailyStats = ref([])
|
||
const solverStats = ref(null)
|
||
const loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = ref(false)
|
||
const days = ref(7)
|
||
const start = ref(upcomingMonday())
|
||
const lastWeek = reactive({ start: start.value, days: days.value })
|
||
const showDemand = ref(false)
|
||
const drag = reactive({ on: false, ti: 0, di: 0, moved: false, base: [] })
|
||
const justDragged = ref(false)
|
||
const selection = ref([])
|
||
const activeCell = ref(null) // dernière case cliquée {id, name, iso} — pour copier/coller au clavier sans multi-sélection
|
||
const anchor = ref(null)
|
||
const demand = ref([]); const holidays = ref([]); const weekTemplates = ref([])
|
||
const gardeRules = ref([]); const showGarde = ref(false)
|
||
const manualGarde = ref({}) // overrides manuels de garde : 'techId|iso' → 'on' | 'off' (touche « G »)
|
||
const newGardeRule = reactive({ dept: '', shift: '', shiftWeekend: '', weekdays: [], anchor: '', steps: [] })
|
||
const GARDE_DOW = [{ v: 1, l: 'L' }, { v: 2, l: 'M' }, { v: 3, l: 'M' }, { v: 4, l: 'J' }, { v: 5, l: 'V' }, { v: 6, l: 'S' }, { v: 0, l: 'D' }]
|
||
const history = ref([]); const future = ref([])
|
||
const search = ref(''); const groupFilter = ref(null); const maxHours = ref(40); const skillFilter = ref([])
|
||
// Ressources masquées (hors front-line : compta, etc.) — masquées ET ignorées du calcul ; localStorage.
|
||
const hiddenTechs = ref([]); const showHidden = ref(false)
|
||
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); const editTpls = ref([])
|
||
const showTeamEditor = ref(false); const editTechs = ref([])
|
||
const notifySms = ref(false)
|
||
const showLeave = ref(false); const leaveRows = ref([]); const leaveFilter = ref('Demandé')
|
||
const newLeave = reactive({ technician: '', availability_type: 'Congé', from_date: '', to_date: '', reason: '', long_term: 0 })
|
||
const newTpl = reactive({ template_name: '', start: '08:00', end: '16:00', color: '#1976d2', on_call: 0 })
|
||
function numToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
|
||
// Slider à 2 poignées pour le nouveau modèle (heures custom) ↔ newTpl.start/end
|
||
const newTplRange = computed({ get: () => ({ min: hToNum(newTpl.start) || 8, max: hToNum(newTpl.end) || 16 }), set: (v) => { newTpl.start = numToTime(v.min); newTpl.end = numToTime(v.max) } })
|
||
|
||
const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1'
|
||
|
||
function upcomingMonday () { const d = new Date(); d.setDate(d.getDate() + ((1 - d.getDay() + 7) % 7)); return d.toISOString().slice(0, 10) }
|
||
function thisMonday () { const d = new Date(); const diff = (d.getDay() === 0 ? -6 : 1) - d.getDay(); d.setDate(d.getDate() + diff); return d.toISOString().slice(0, 10) }
|
||
function addDaysISO (iso, n) { const [y, m, d] = iso.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); dt.setUTCDate(dt.getUTCDate() + n); return dt.toISOString().slice(0, 10) }
|
||
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
|
||
const dayList = computed(() => {
|
||
const [y, m, dd] = start.value.split('-').map(Number); const base = new Date(Date.UTC(y, m - 1, dd)); const out = []
|
||
for (let i = 0; i < days.value; i++) { const d = new Date(base); d.setUTCDate(d.getUTCDate() + i); const iso = d.toISOString().slice(0, 10); const dow = d.getUTCDay(); out.push({ iso, dow: FR_DOW[dow], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dow === 0 || dow === 6 }) }
|
||
return out
|
||
})
|
||
function dowOf (iso) { const [y, m, d] = iso.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).getUTCDay() }
|
||
|
||
const tplOptions = computed(() => templates.value.map(t => ({ label: t.template_name, value: t.name })))
|
||
const techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id })))
|
||
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
|
||
// ── CALQUE de garde LIVE ──────────────────────────────────────────────────
|
||
// La garde n'est PAS matérialisée pour l'affichage : on la recalcule à la volée depuis les règles
|
||
// (rotation déterministe ancrée + saut d'absent, via rotationTech). Éditer la séquence ou marquer
|
||
// une absence se reflète INSTANTANÉMENT, sans régénérer → fini la désync « la suite est bousillée ».
|
||
// Clé techId|iso → nom du shift de garde (semaine vs week-end). Vacances ⇒ substitut auto.
|
||
const gardeOverlay = computed(() => {
|
||
const map = {}; const rules = gardeRules.value; if (!rules.length) return map
|
||
for (const d of dayList.value) {
|
||
const dow = dowOf(d.iso); const weekend = (dow === 0 || dow === 6)
|
||
for (const rule of rules) {
|
||
if (!(rule.weekdays || []).includes(dow)) continue
|
||
const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift
|
||
if (!tplByName.value[sh]) continue
|
||
const id = rotationTech(rule, d.iso); if (!id) continue
|
||
map[id + '|' + d.iso] = sh // si 2 règles visent le même tech/jour, la dernière gagne (rare)
|
||
}
|
||
}
|
||
return map
|
||
})
|
||
// Shift de garde à utiliser pour un ajout MANUEL un jour donné : celui des règles (semaine/WE), sinon 1er modèle on_call.
|
||
function gardeShiftForDay (iso) {
|
||
const dow = dowOf(iso); const weekend = (dow === 0 || dow === 6)
|
||
for (const rule of gardeRules.value) { if (!(rule.weekdays || []).includes(dow)) continue; const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift; if (tplByName.value[sh]) return sh }
|
||
const oc = templates.value.find(t => t.on_call); return oc ? oc.name : null
|
||
}
|
||
// Garde EFFECTIVE = rotation (gardeOverlay) + overrides MANUELS (touche « G »/menu, localStorage) :
|
||
// manualGarde[key]='on' → ajoute la garde (shift du jour) ; 'off' → la retire (override d'une garde de règle).
|
||
// Permet de DÉPLACER la garde à la main (tech en congé → la placer ailleurs) SANS toucher aux règles.
|
||
const gardeEffective = computed(() => {
|
||
const base = { ...gardeOverlay.value }
|
||
for (const key in manualGarde.value) {
|
||
const v = manualGarde.value[key]
|
||
if (v === 'off') delete base[key]
|
||
else if (v === 'on') { const sh = gardeShiftForDay(key.split('|')[1]); if (sh) base[key] = sh }
|
||
}
|
||
return base
|
||
})
|
||
function onGarde (techId, iso) { return !!gardeEffective.value[techId + '|' + iso] }
|
||
// Nb de techs de garde par jour (garde EFFECTIVE) → ligne de pied cohérente avec la grille
|
||
const gardeCountByDate = computed(() => { const m = {}; for (const k in gardeEffective.value) { const iso = k.split('|')[1]; m[iso] = (m[iso] || 0) + 1 } return m })
|
||
function chip (color) { return { background: color || '#1976d2', color: '#fff' } }
|
||
|
||
// techs visibles (recherche + groupe + tri)
|
||
const groupOptions = computed(() => { const s = new Set(); for (const t of techs.value) if (t.group) s.add(t.group); return [...s].sort().map(g => ({ label: g, value: g })) })
|
||
// Compétences distinctes (chips de filtrage) + score de PRIORITÉ provisoire.
|
||
const allSkills = computed(() => [...new Set(techs.value.flatMap(t => t.skills || []))].sort())
|
||
// Édition des compétences dans la cellule du nom (popover) + suggestions ALIGNÉES sur les catégories de job.
|
||
const jobTypes = ref([]) // service_types distincts (catégories de job) → suggérés comme compétences
|
||
const skillDialog = ref(null) // tech dont on édite les compétences
|
||
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
|
||
const skillMenuShown = ref(false)
|
||
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true }
|
||
// TagEditor (showLevel) ↔ tech : skills = libellés (CSV) · skill_levels = {compétence: niveau 1–5} (JSON).
|
||
// Par compétence : SCORE (maîtrise 1–5, qualité) + EFFICACITÉ (facteur vitesse ; défaut = efficacité globale).
|
||
function skillLevelOf (t, sk) { return (t.skill_levels && t.skill_levels[sk]) || 0 } // 0 = non défini (étoiles vides) ; pas de défaut trompeur
|
||
// Applique un horaire standard à CE tech sur la semaine affichée (presets type Dispatch).
|
||
async function applyWeekPreset (t, dows, min, max) {
|
||
const tpl = await ensureWindowTpl(min, max); if (!tpl) return
|
||
pushHistory()
|
||
for (const d of dayList.value) { if (dows.includes(dowOf(d.iso))) { clearLocal(t.id, d.iso); addShift(t.id, t.name, d.iso, tpl) } }
|
||
skillMenuShown.value = false
|
||
$q.notify({ type: 'positive', message: t.name + ' : horaire appliqué (semaine affichée) — pense à Publier', timeout: 2500 })
|
||
}
|
||
function skillEffOf (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? Number(e) : (Number(t.efficiency) || 1) } // facteur (pour le calcul de priorité)
|
||
// Efficacité saisie en % de PERFORMANCE : + = plus VITE (moins de temps) · − = plus LENT. Conversion ↔ facteur.
|
||
function effPctOf (factor) { return Math.round((1 - (Number(factor) || 1)) * 100) }
|
||
function factorFromPct (pct) { const f = 1 - (Number(pct) || 0) / 100; return Math.max(0.1, Math.min(3, Math.round(f * 100) / 100)) }
|
||
function skillEffPct (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? effPctOf(e) : '' } // '' = hérite de la globale
|
||
function setSkillEffPct (t, sk, pct) { const m = { ...(t.skill_eff || {}) }; if (pct === '' || pct == null) delete m[sk]; else m[sk] = factorFromPct(pct); t.skill_eff = m; queueSkillSave(t) } // libre (ex. +80)
|
||
function setSkillLevel (t, sk, v) { if (!t.skill_levels) t.skill_levels = {}; t.skill_levels = { ...t.skill_levels, [sk]: v }; queueSkillSave(t) }
|
||
// Efficacité GLOBALE du tech, éditable/réinitialisable depuis le popover (débouncée via setTechEfficiency).
|
||
// Couleur du cercle (vitesse PAR compétence) : vite → vert · normal → bleu-gris · lent → rouge. Intensité ∝ écart.
|
||
function effColor (factor) { const p = effPctOf(factor); if (!p) return '#607d8b'; const a = Math.min(Math.abs(p), 30) / 30; const hue = p > 0 ? 122 : 4; return 'hsl(' + hue + ',' + Math.round(50 + a * 28) + '%,' + (44 - Math.round(a * 8)) + '%)' }
|
||
function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e == null || e === '') ? '#607d8b' : effColor(Number(e)) } // neutre si pas d'override (pure par-compétence)
|
||
function onTagsChange (t, items) {
|
||
const newLabels = (items || []).map(x => typeof x === 'string' ? x : x.tag).filter(Boolean)
|
||
const removed = (t.skills || []).filter(s => !newLabels.includes(s)) // compétences retirées → vérifier l'impact sur les jobs assignés
|
||
t.skills = newLabels; queueSkillSave(t)
|
||
if (removed.length) checkSkillImpact(t, removed)
|
||
}
|
||
// IROPS : un retrait de compétence peut invalider des jobs assignés qui l'exigent → proposer de redistribuer.
|
||
const skillImpactDialog = ref(null) // { tech, skill, jobs }
|
||
const skillImpactOpen = computed({ get: () => !!skillImpactDialog.value, set: v => { if (!v) skillImpactDialog.value = null } })
|
||
const redistributing = ref(false)
|
||
const impactCandidates = reactive({}) // jobName → [{tech,tech_name}] candidats classés
|
||
const impactPlan = reactive({}) // jobName → techId choisi | '__requeue'
|
||
const loadingCandidates = ref(false)
|
||
function candidateOptions (jobName) { return [...((impactCandidates[jobName] || []).map(x => ({ label: x.tech_name || x.tech, value: x.tech }))), { label: '→ À recontacter (client)', value: '__requeue' }] }
|
||
async function loadImpactCandidates () { // candidats classés par job + pré-sélection du meilleur
|
||
const d = skillImpactDialog.value; if (!d) return
|
||
loadingCandidates.value = true
|
||
for (const k in impactCandidates) delete impactCandidates[k]; for (const k in impactPlan) delete impactPlan[k]
|
||
for (const j of d.jobs) {
|
||
try { const r = await roster.jobCandidates(j.name, d.tech.id); impactCandidates[j.name] = r.candidates || []; impactPlan[j.name] = (r.candidates && r.candidates[0]) ? r.candidates[0].tech : '__requeue' }
|
||
catch (e) { impactCandidates[j.name] = []; impactPlan[j.name] = '__requeue' }
|
||
}
|
||
loadingCandidates.value = false
|
||
}
|
||
async function checkSkillImpact (t, removedSkills) {
|
||
for (const sk of removedSkills) {
|
||
try { const r = await roster.skillImpact(t.id, sk); if (r.jobs && r.jobs.length) { skillImpactDialog.value = { tech: t, kind: 'skill', skill: sk, jobs: r.jobs }; await loadImpactCandidates(); return } } catch (e) { /* non bloquant */ }
|
||
}
|
||
}
|
||
// Même principe pour une ABSENCE : jobs assignés tombant sur les jours d'absence → dialogue de redistribution.
|
||
async function checkAbsenceImpact (targets) {
|
||
const byTech = {}; for (const k of targets) { const [tid, iso] = k.split('|'); (byTech[tid] = byTech[tid] || []).push(iso) }
|
||
for (const tid in byTech) {
|
||
try { const r = await roster.absenceImpact(tid, byTech[tid]); if (r.jobs && r.jobs.length) { const t = techs.value.find(x => x.id === tid) || { id: tid, name: tid }; skillImpactDialog.value = { tech: t, kind: 'absence', jobs: r.jobs }; await loadImpactCandidates(); return } } catch (e) { /* non bloquant */ }
|
||
}
|
||
}
|
||
async function applyImpactPlan () { // applique le plan choisi (tech par job) puis RECHARGE (occupation à jour)
|
||
const d = skillImpactDialog.value; if (!d) return
|
||
redistributing.value = true
|
||
const plan = d.jobs.map(j => (!impactPlan[j.name] || impactPlan[j.name] === '__requeue') ? { job: j.name, requeue: true } : { job: j.name, tech: impactPlan[j.name] })
|
||
try { const r = await roster.redistributePlan(plan); $q.notify({ type: 'positive', message: (r.reassigned || 0) + ' réassigné(s) · ' + (r.requeued || 0) + ' à recontacter', timeout: 4500 }); skillImpactDialog.value = null; await loadWeek() }
|
||
catch (e) { err(e) } finally { redistributing.value = false }
|
||
}
|
||
async function doRedistribute (mode) { // « Tout à recontacter » (bascule simple)
|
||
const d = skillImpactDialog.value; if (!d) return
|
||
redistributing.value = true
|
||
try {
|
||
const r = await roster.redistributeSkillJobs(d.jobs.map(j => j.name), d.kind === 'skill' ? d.skill : '', mode) // absence → compétence par job côté hub
|
||
$q.notify({ type: 'positive', message: (r.reassigned || 0) + ' réassigné(s) · ' + (r.requeued || 0) + ' à recontacter', timeout: 4500 })
|
||
skillImpactDialog.value = null; await loadWeek() // refresh : occupation/jobs à jour (fix bar 4h résiduelle)
|
||
} catch (e) { err(e) } finally { redistributing.value = false }
|
||
}
|
||
// ── Panneau FLOTTANT « jobs à assigner » (multi-sélection + glisser-déposer + aperçu d'occupation) ──
|
||
const assignPanel = reactive({ open: false, x: 40, y: 130, jobs: [], loading: false })
|
||
const draggingJobName = ref(null); const dropCell = ref(null); const dragHours = ref(0)
|
||
const selectedJobs = reactive({}) // jobName → true
|
||
const dropPreview = reactive({ key: null, addH: 0 })
|
||
const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours de glissé (source estompée) + fantôme custom
|
||
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = (await roster.unassignedJobs()).jobs || [] } catch (e) { err(e) } finally { assignPanel.loading = false } }
|
||
const assignGroups = computed(() => { // regroupe par parent_job (ou nom propre), ordonné par step_order
|
||
const g = {}; for (const j of assignPanel.jobs) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
|
||
return Object.keys(g).map(k => ({ key: k, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
|
||
})
|
||
// Terrain vs à distance : l'activation / config / netadmin ne va PAS à un tech sur site (heuristique skill + type/sujet).
|
||
function jobIsOnsite (j) {
|
||
const txt = ((j.required_skill || '') + ' ' + (j.service_type || '') + ' ' + (j.subject || '')).toLowerCase()
|
||
if (/activation|config|netadmin|à distance|a distance|distant|remote|provision/.test(txt)) return false
|
||
return true
|
||
}
|
||
const selectedNames = computed(() => assignPanel.jobs.filter(j => selectedJobs[j.name]).map(j => j.name))
|
||
const jobHours = (j) => Number(j.duration_h) || 1 // défaut système = 1h (cf. hub)
|
||
const selectedHours = computed(() => Math.round(assignPanel.jobs.filter(j => selectedJobs[j.name]).reduce((s, j) => s + jobHours(j), 0) * 10) / 10)
|
||
function toggleGroupSel (grp) { const anyOnsiteOff = grp.jobs.some(j => jobIsOnsite(j) && !selectedJobs[j.name]); for (const j of grp.jobs) selectedJobs[j.name] = anyOnsiteOff ? jobIsOnsite(j) : false } // (dé)sélectionne le groupe ; pré-coche les terrain
|
||
function groupSelected (grp) { return grp.jobs.some(j => selectedJobs[j.name]) }
|
||
function onJobDragStart (ev, job) {
|
||
const names = selectedJobs[job.name] ? selectedNames.value : [job.name] // job non coché → on glisse juste lui
|
||
draggingJobName.value = names.join(','); dragHours.value = Math.round(assignPanel.jobs.filter(j => names.includes(j.name)).reduce((s, j) => s + jobHours(j), 0) * 10) / 10
|
||
draggingSet.clear(); names.forEach(n => draggingSet.add(n)) // source estompée (feedback)
|
||
try {
|
||
ev.dataTransfer.setData('text/plain', names.join(',')); ev.dataTransfer.effectAllowed = 'move'
|
||
// Fantôme COMPACT et semi-transparent décalé sous le curseur → ne masque plus le badge d'occupation projetée.
|
||
const g = document.createElement('div')
|
||
g.textContent = (names.length > 1 ? names.length + ' jobs · ' : '') + dragHours.value + 'h'
|
||
g.style.cssText = 'position:fixed;top:-1000px;left:-1000px;padding:2px 9px;background:rgba(94,53,177,.78);color:#fff;font:600 11px sans-serif;border-radius:10px;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,.3)'
|
||
document.body.appendChild(g); _dragGhost = g
|
||
ev.dataTransfer.setDragImage(g, -12, -10) // curseur en haut-gauche du fantôme → fantôme bas-droite, badge (haut) lisible
|
||
} catch (e) {}
|
||
}
|
||
function onJobDragEnd () { dropCell.value = null; dropPreview.key = null; draggingSet.clear(); if (_dragGhost) { _dragGhost.remove(); _dragGhost = null } }
|
||
function onCellDragOver (t, d) { dropCell.value = t.id + '|' + d.iso; dropPreview.key = t.id + '|' + d.iso; dropPreview.addH = dragHours.value }
|
||
async function onCellDrop (ev, t, d) {
|
||
dropCell.value = null; dropPreview.key = null
|
||
const raw = (ev.dataTransfer && ev.dataTransfer.getData('text/plain')) || draggingJobName.value; draggingJobName.value = null
|
||
const names = (raw || '').split(',').filter(Boolean); if (!names.length) return
|
||
// Garde-fou : un job « On Hold » attend une tâche précédente → on REFUSE de l'assigner (≠ simple 🔒 visuel).
|
||
const statusBy = Object.fromEntries(assignPanel.jobs.map(j => [j.name, j.status]))
|
||
const blocked = names.filter(n => statusBy[n] === 'On Hold'); const assignable = names.filter(n => statusBy[n] !== 'On Hold')
|
||
if (blocked.length) $q.notify({ type: 'warning', message: blocked.length + ' job(s) en attente d\'une tâche précédente — non assigné(s). Termine d\'abord l\'étape requise.', timeout: 4000 })
|
||
if (!assignable.length) return
|
||
let ok = 0
|
||
for (const jn of assignable) { try { await roster.assignJob(jn, t.id, d.iso); ok++; delete selectedJobs[jn] } catch (e) { err(e) } } // SÉQUENTIEL (frappe_pg)
|
||
assignPanel.jobs = assignPanel.jobs.filter(j => !assignable.includes(j.name)) // les bloqués restent dans le panneau
|
||
$q.notify({ type: 'positive', message: ok + ' job(s) assigné(s) à ' + t.name + ' · ' + d.dnum, timeout: 2800 }); await loadWeek()
|
||
}
|
||
let _panelDrag = null // déplacement du panneau via son en-tête
|
||
function panelHeaderDown (ev) { _panelDrag = { dx: ev.clientX - assignPanel.x, dy: ev.clientY - assignPanel.y }; document.addEventListener('mousemove', panelMove); document.addEventListener('mouseup', panelUp) }
|
||
function panelMove (ev) { if (!_panelDrag) return; assignPanel.x = Math.max(0, ev.clientX - _panelDrag.dx); assignPanel.y = Math.max(0, ev.clientY - _panelDrag.dy) }
|
||
function panelUp () { _panelDrag = null; document.removeEventListener('mousemove', panelMove); document.removeEventListener('mouseup', panelUp) }
|
||
|
||
// ── Timeline contextuelle d'une RESSOURCE (dispatch des jobs de la semaine visible) ──
|
||
// Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) → 0 nouvel appel réseau.
|
||
const timelineDlg = reactive({ open: false, tech: null })
|
||
function openTimeline (t) { timelineDlg.tech = t; timelineDlg.open = true }
|
||
// Deep-link vers le tableau Dispatch focalisé sur la ressource + son 1er jour avec jobs (sinon début de semaine).
|
||
function gotoDispatch (t) {
|
||
const q = {}
|
||
if (t) q.tech = t.id
|
||
q.date = (timelineDays.value[0] && timelineDays.value[0].iso) || start.value
|
||
router.push({ path: '/dispatch', query: q })
|
||
}
|
||
const timelineDays = computed(() => {
|
||
const t = timelineDlg.tech; if (!t) return []
|
||
const out = []
|
||
for (const d of dayList.value) {
|
||
const shift = hasReg(t.id, d.iso) || onGarde(t.id, d.iso)
|
||
const jobs = shift ? cellJobs(t.id, d.iso) : rawCellJobs(t.id, d.iso) // hors quart : jobs bruts
|
||
if (!jobs.length && !shift) continue // on saute les jours vides
|
||
const o = cellOcc(t.id, d.iso)
|
||
const usedH = shift ? (o ? o.usedH : 0) : Math.round(jobs.reduce((s, j) => s + (j.dur || 0), 0) * 10) / 10
|
||
out.push({ iso: d.iso, label: d.dow + ' ' + d.dnum, weekend: d.weekend, bands: cellBands(t.id, d.iso), blocks: cellBlocks(t.id, d.iso), jobs, pct: shift ? cellPct(t.id, d.iso) : null, usedH, offShift: !shift && jobs.length > 0 })
|
||
}
|
||
return out
|
||
})
|
||
// Sauvegarde DEBOUNCÉE + silencieuse (succès) : coalesce les clics rapides → 1 seul appel (évite « load fail » concurrents).
|
||
let _skillSaveTimer = null
|
||
function queueSkillSave (t) { if (_skillSaveTimer) clearTimeout(_skillSaveTimer); _skillSaveTimer = setTimeout(() => { _skillSaveTimer = null; doSaveSkillData(t) }, 500) }
|
||
async function doSaveSkillData (t) {
|
||
const sset = new Set(t.skills || [])
|
||
const lv = {}; for (const k in (t.skill_levels || {})) if (sset.has(k)) lv[k] = t.skill_levels[k]
|
||
const ef = {}; for (const k in (t.skill_eff || {})) if (sset.has(k)) ef[k] = t.skill_eff[k]
|
||
t.skill_levels = lv; t.skill_eff = ef
|
||
try { await roster.setTechSkills(t.id, (t.skills || []).join(','), lv, ef) } catch (e) { $q.notify({ type: 'negative', message: 'Échec sauvegarde compétences — réessaie', timeout: 1800 }) }
|
||
}
|
||
// ── Catalogue de compétences (réutilise TagEditor) : couleurs persistées + création/suppression ──
|
||
// Palette élargie (incl. roses/magentas) + sélecteur HTML natif pour toute couleur.
|
||
const TAG_PALETTE = [
|
||
'#6366f1', '#3b82f6', '#0ea5e9', '#06b6d4', '#14b8a6', '#10b981', '#22c55e', '#84cc16',
|
||
'#eab308', '#f59e0b', '#f97316', '#ef4444', '#f43f5e', '#fb7185', '#ec4899', '#f472b6',
|
||
'#db2777', '#d946ef', '#a855f7', '#8b5cf6', '#78716c', '#64748b', '#94a3b8', '#111827',
|
||
]
|
||
function hashColor (label) { let h = 0; for (const c of String(label)) h = (h * 31 + c.charCodeAt(0)) >>> 0; return TAG_PALETTE[h % TAG_PALETTE.length] }
|
||
const customTags = ref([]) // [{label,color}] créés à la volée (localStorage)
|
||
function saveCustomTags () { localStorage.setItem('roster-skill-tags-v1', JSON.stringify(customTags.value)) }
|
||
function getTagColor (label) { const ct = customTags.value.find(x => x.label === label); return (ct && ct.color) || hashColor(label) }
|
||
// Couleur d'une carte job = COULEUR DE SA COMPÉTENCE (éditable via le gestionnaire de tags → cohérent + simple).
|
||
// required_skill est renseigné côté hub (skill explicite, sinon déduit du type legacy). Repli : couleur du type.
|
||
function panelJobColor (j) { return j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae') }
|
||
const tagCatalog = computed(() => {
|
||
const m = new Map()
|
||
for (const ct of customTags.value) m.set(ct.label, { name: ct.label, label: ct.label, color: ct.color || hashColor(ct.label), category: 'Custom' })
|
||
for (const s of [...allSkills.value, ...jobTypes.value, 'installation', 'réparation', 'support', 'fibre', 'aérien', 'épissure']) if (s && !m.has(s)) m.set(s, { name: s, label: s, color: getTagColor(s), category: jobTypes.value.includes(s) ? 'Type de job' : 'Compétence' })
|
||
return [...m.values()]
|
||
})
|
||
function onCreateRosterTag ({ label, color }) { if (label && !customTags.value.some(x => x.label === label)) { customTags.value.push({ label, color: color || hashColor(label) }); saveCustomTags() } }
|
||
function onUpdateRosterTag ({ name, color }) { const ct = customTags.value.find(x => x.label === name); if (ct) ct.color = color; else customTags.value.push({ label: name, color }); saveCustomTags() }
|
||
// (onDeleteRosterTag retiré : la suppression de tag passe par deleteTagGlobal du gestionnaire ci-dessous)
|
||
// ── Gestionnaire global de compétences (tags) : renommer / supprimer PARTOUT, recolorer, voir l'usage ──
|
||
const showTagManager = ref(false)
|
||
const managedTags = computed(() => {
|
||
const labels = new Set([...techs.value.flatMap(t => t.skills || []), ...customTags.value.map(c => c.label)])
|
||
return [...labels].filter(Boolean).sort((a, b) => a.localeCompare(b)).map(l => ({ label: l, color: getTagColor(l), count: techs.value.filter(t => (t.skills || []).includes(l)).length }))
|
||
})
|
||
async function renameTagGlobal (oldL, newL) { // remplace le label sur TOUS les techs (skills + niveaux + eff) + catalogue
|
||
newL = String(newL || '').trim(); if (!newL || newL === oldL) return
|
||
for (const t of techs.value) { // SÉQUENTIEL (pas de Promise.all sur erp)
|
||
if (!(t.skills || []).includes(oldL)) continue
|
||
t.skills = t.skills.map(s => s === oldL ? newL : s)
|
||
if (t.skill_levels && t.skill_levels[oldL] != null) { const m = { ...t.skill_levels, [newL]: t.skill_levels[oldL] }; delete m[oldL]; t.skill_levels = m }
|
||
if (t.skill_eff && t.skill_eff[oldL] != null) { const m = { ...t.skill_eff, [newL]: t.skill_eff[oldL] }; delete m[oldL]; t.skill_eff = m }
|
||
try { await roster.setTechSkills(t.id, t.skills.join(','), t.skill_levels || {}, t.skill_eff || {}) } catch (e) { err(e) }
|
||
}
|
||
const ct = customTags.value.find(c => c.label === oldL); if (ct) ct.label = newL; else customTags.value.push({ label: newL, color: getTagColor(oldL) })
|
||
saveCustomTags(); await syncSkillByType({ [oldL]: newL }, null) // cohérence avec la table type de job → compétence (booking)
|
||
$q.notify({ type: 'positive', message: '« ' + oldL +' » renommée « ' + newL + ' » partout', timeout: 2500 })
|
||
}
|
||
// Propage rename/delete d'un tag vers la table booking skill_by_type (Copilote #56) → cohérence globale du filtre.
|
||
async function syncSkillByType (renames, deletes) {
|
||
try {
|
||
const d = await roster.getPolicy(); const map = { ...((d.policy && d.policy.booking && d.policy.booking.skill_by_type) || {}) }; let changed = false
|
||
for (const k in map) { if (deletes && deletes.includes(map[k])) { delete map[k]; changed = true } else if (renames && renames[map[k]]) { map[k] = renames[map[k]]; changed = true } }
|
||
if (changed) await roster.savePolicy({ booking: { skill_by_type: map } })
|
||
} catch (e) { /* non bloquant */ }
|
||
}
|
||
async function deleteTagGlobal (tg) {
|
||
if (tg.count && !window.confirm('Supprimer « ' + tg.label + ' » de ' + tg.count + ' technicien(s) ?')) return
|
||
for (const t of techs.value) {
|
||
if (!(t.skills || []).includes(tg.label)) continue
|
||
t.skills = t.skills.filter(s => s !== tg.label)
|
||
if (t.skill_levels) { const m = { ...t.skill_levels }; delete m[tg.label]; t.skill_levels = m }
|
||
if (t.skill_eff) { const m = { ...t.skill_eff }; delete m[tg.label]; t.skill_eff = m }
|
||
try { await roster.setTechSkills(t.id, t.skills.join(','), t.skill_levels || {}, t.skill_eff || {}) } catch (e) { err(e) }
|
||
}
|
||
customTags.value = customTags.value.filter(c => c.label !== tg.label); saveCustomTags()
|
||
await syncSkillByType(null, [tg.label]) // retire aussi le tag de la table type de job → compétence (booking)
|
||
$q.notify({ type: 'info', message: '« ' + tg.label + ' » supprimée partout', timeout: 2000 })
|
||
}
|
||
function toggleSkill (sk) { const i = skillFilter.value.indexOf(sk); if (i >= 0) skillFilter.value.splice(i, 1); else skillFilter.value.push(sk) }
|
||
function techHasSkill (t) { const sk = (t.skills || []); return !skillFilter.value.length || skillFilter.value.every(f => sk.includes(f)) } // ET : toutes les compétences cochées requises
|
||
// Score de priorité (0 = meilleur) sur les techs qualifiés. DEUX dimensions DISTINCTES + coût :
|
||
// • COMPÉTENCE = niveau 1–5 dans la/les compétence(s) filtrée(s) (qualité ; haut = mieux)
|
||
// • VITESSE = efficiency (<1 = plus rapide = mieux) — un tech peut être très compétent MAIS lent
|
||
// • COÛT = coût chargé $/h. Pondéré compétence 0,4 · vitesse 0,3 · coût 0,3 (sans filtre : vitesse/coût 50/50).
|
||
function techCompetence (t) { const f = skillFilter.value; if (!f.length) return null; const ls = f.map(s => (t.skill_levels && t.skill_levels[s]) || 1); return ls.reduce((a, b) => a + b, 0) / ls.length }
|
||
// Vitesse retenue = efficacité PAR compétence filtrée (skillEffOf, fallback global) ; sinon efficacité globale.
|
||
function techSpeed (t) { const f = skillFilter.value; if (!f.length) return Number(t.efficiency) || 1; const es = f.map(s => skillEffOf(t, s)); return es.reduce((a, b) => a + b, 0) / es.length }
|
||
// PROXIMITÉ (à venir) : on gère le secteur MANUELLEMENT pour l'instant (zones). Ce hook renverra
|
||
// plus tard une distance normalisée 0..1 (0 = sur place) job↔tech (lat/lng de la Service Location vs
|
||
// secteur/base du tech). Tant qu'il retourne null, le score reste inchangé (poids redistribué).
|
||
// eslint-disable-next-line no-unused-vars
|
||
function techProximity (t /*, job */) { return null } // TODO proximité : brancher une fois lat/lng dispo
|
||
const priorityScores = computed(() => {
|
||
const cands = techs.value.filter(t => !isHidden(t.id) && techHasSkill(t)); const m = {}
|
||
if (!cands.length) return m
|
||
const effs = cands.map(techSpeed); const costs = cands.map(t => Number(t.cost_h) || 0)
|
||
const eMin = Math.min(...effs); const eMax = Math.max(...effs); const cMin = Math.min(...costs); const cMax = Math.max(...costs)
|
||
const norm = (v, lo, hi) => (hi > lo ? (v - lo) / (hi - lo) : 0)
|
||
for (const t of cands) {
|
||
const eff = norm(techSpeed(t), eMin, eMax); const cost = norm(Number(t.cost_h) || 0, cMin, cMax)
|
||
const comp = techCompetence(t)
|
||
const prox = techProximity(t) // null pour l'instant → ignoré (secteur manuel)
|
||
let s
|
||
if (comp == null) s = 0.5 * eff + 0.5 * cost
|
||
else { const compN = (Math.min(5, Math.max(1, comp)) - 1) / 4; s = 0.4 * (1 - compN) + 0.3 * eff + 0.3 * cost } // compétence ⊕ vitesse(par-skill) ⊕ coût
|
||
if (prox != null) s = 0.8 * s + 0.2 * prox // quand la proximité arrivera : 20% du score (réservé)
|
||
m[t.id] = s
|
||
}
|
||
return m
|
||
})
|
||
function techScore (t) { const v = priorityScores.value[t.id]; return v == null ? 0 : v }
|
||
function techRank (t) { if (!skillFilter.value.length) return null; const i = visibleTechs.value.findIndex(x => x.id === t.id); return i >= 0 ? i + 1 : null }
|
||
const visibleTechs = computed(() => {
|
||
const q = search.value.trim().toLowerCase()
|
||
const list = techs.value.filter(t => (showHidden.value || !isHidden(t.id)) && (!groupFilter.value || t.group === groupFilter.value) && techHasSkill(t) && (!q || (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q)))
|
||
// Filtre par compétence actif → on TRIE par priorité (meilleur score d'abord) ; sinon ordre équipe/nom.
|
||
if (skillFilter.value.length) return list.slice().sort((a, b) => techScore(a) - techScore(b) || (a.name || '').localeCompare(b.name || ''))
|
||
return list.slice().sort((a, b) => (a.group || '~').localeCompare(b.group || '~') || (a.name || '').localeCompare(b.name || ''))
|
||
})
|
||
|
||
const cellsByTechDay = computed(() => { const m = {}; for (const a of assignments.value) { const t = (m[a.tech] || (m[a.tech] = {})); (t[a.date] || (t[a.date] = [])).push(a) } return m })
|
||
function cellsOf (techId, iso) { return (cellsByTechDay.value[techId] && cellsByTechDay.value[techId][iso]) || [] }
|
||
function isPaused (t) { return t.status === 'En pause' }
|
||
function hoursOf (techId) { let h = 0; for (const a of assignments.value) { if (a.tech !== techId) continue; const t = tplByName.value[a.shift]; if (t && t.on_call) continue; h += Number(a.hours) || 0 } return h } // garde exclue (mise en dispo, pas travaillée)
|
||
|
||
const serverSet = ref(new Set())
|
||
const currentSet = computed(() => new Set(assignments.value.map(a => a.tech + '|' + a.date + '|' + a.shift)))
|
||
const diffKeys = computed(() => { const cur = currentSet.value; const srv = serverSet.value; const d = []; for (const k of cur) if (!srv.has(k)) d.push(k); for (const k of srv) if (!cur.has(k)) d.push(k); return d })
|
||
const dirty = computed(() => diffKeys.value.length > 0)
|
||
const dirtyCount = computed(() => diffKeys.value.length)
|
||
const dirtyCells = computed(() => new Set(diffKeys.value.map(k => k.slice(0, k.lastIndexOf('|')))))
|
||
function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' + iso) }
|
||
|
||
const holSet = computed(() => new Set(holidays.value))
|
||
function isHoliday (iso) { return holSet.value.has(iso) }
|
||
function toggleHoliday (iso) { holidays.value = isHoliday(iso) ? holidays.value.filter(x => x !== iso) : [...holidays.value, iso]; localStorage.setItem(LS_HOL, JSON.stringify(holidays.value)) }
|
||
const selSet = computed(() => new Set(selection.value))
|
||
function isSelected (techId, iso) { return selSet.value.has(techId + '|' + iso) }
|
||
|
||
const statByDate = computed(() => Object.fromEntries(dailyStats.value.map(s => [s.date, s])))
|
||
function stat (iso) { const s = statByDate.value[iso] || {}; const g = gardeCountByDate.value[iso]; return g != null ? { ...s, on_call: g } : s } // on_call = calque live (cohérent avec la grille)
|
||
const hasOnCall = computed(() => Object.keys(gardeEffective.value).length > 0 || dailyStats.value.some(s => s.on_call > 0))
|
||
|
||
// Micro-timeline 24 h par cellule : fenêtre(s) du shift = bande neutre, jobs pris = trait coloré.
|
||
const occByTechDay = ref({})
|
||
const absByTechDay = ref({}) // tech|date → type d'absence (En pause / Congé / Maladie…) → hachuré
|
||
function isAbsent (techId, iso) { return !!absByTechDay.value[techId + '|' + iso] }
|
||
function absenceLabel (techId, iso) { return absByTechDay.value[techId + '|' + iso] || 'Absent' }
|
||
async function reloadAbsences () { try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) {} }
|
||
// Bascule absence d'1 jour sur des cases (clic + « A » ou menu). Si toutes absentes → retire ; sinon marque.
|
||
async function toggleAbsentCells (targets) {
|
||
if (!targets || !targets.length) return
|
||
const allAbsent = targets.every(k => { const [tid, iso] = k.split('|'); return isAbsent(tid, iso) })
|
||
for (const k of targets) { const [tid, iso] = k.split('|'); try { await roster.setAbsence(tid, iso, 'Congé', allAbsent) } catch (e) { err(e) } }
|
||
await reloadAbsences()
|
||
$q.notify({ type: 'info', message: allAbsent ? 'Absence retirée' : (targets.length + ' absence(s) marquée(s)') })
|
||
if (!allAbsent) await checkAbsenceImpact(targets) // marquage → vérifier les jobs assignés impactés (IROPS)
|
||
}
|
||
function saveManualGarde () { localStorage.setItem(LS_GARDE_MANUAL, JSON.stringify(manualGarde.value)) }
|
||
// Override manuel : ne stocke QUE les écarts vs règles (want===défaut-règle → on retire l'override) → carte minimale.
|
||
function setGardeCell (m, key, want) { const ruleHas = !!gardeOverlay.value[key]; if (want === ruleHas) delete m[key]; else m[key] = want ? 'on' : 'off' }
|
||
// Bascule la garde sur des cases (clic + « G » ou menu). Toutes déjà de garde → retire ; sinon ajoute.
|
||
function toggleGardeCells (targets) {
|
||
if (!targets || !targets.length) return
|
||
const allG = targets.every(k => { const [tid, iso] = k.split('|'); return onGarde(tid, iso) })
|
||
const want = !allG // tout en garde ⇒ on retire ; sinon on place
|
||
if (want && !templates.value.some(t => t.on_call)) { $q.notify({ type: 'warning', message: 'Aucun modèle de garde (🛡️) — créez-en un dans « Types de shift »' }); return }
|
||
const m = { ...manualGarde.value }; let n = 0
|
||
for (const k of targets) { const [tid, iso] = k.split('|'); if (onGarde(tid, iso) !== want) { setGardeCell(m, k, want); n++ } }
|
||
manualGarde.value = m; saveManualGarde()
|
||
$q.notify({ type: 'info', message: want ? (n + ' garde(s) placée(s)') : (n + ' garde(s) retirée(s)') })
|
||
}
|
||
function hToNum (t) { if (!t) return null; const p = String(t).split(':'); return Number(p[0]) + (Number(p[1]) || 0) / 60 }
|
||
function fmtH (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return mm ? (hh + ':' + String(mm).padStart(2, '0')) : ('' + hh) }
|
||
// Axe ADAPTATIF : se cale sur l'amplitude réelle des shifts réguliers + la garde AFFICHÉE (calque),
|
||
// pour que les quarts de garde de soir/nuit (17h–minuit, 8h–minuit) soient visibles sur l'échelle.
|
||
const axisBounds = computed(() => {
|
||
let lo = Infinity; let hi = -Infinity
|
||
const grow = (t) => { if (!t) return; const s = hToNum(t.start_time); const e = hToNum(t.end_time); if (s != null) lo = Math.min(lo, s); if (e != null) hi = Math.max(hi, e <= s ? 24 : e) }
|
||
for (const techId in cellsByTechDay.value) { const day = cellsByTechDay.value[techId]; for (const iso in day) for (const a of day[iso]) { const t = tplByName.value[a.shift]; if (!t || t.on_call) continue; grow(t) } }
|
||
for (const sh of new Set(Object.values(gardeEffective.value))) grow(tplByName.value[sh]) // garde visible (calque + manuel)
|
||
if (!isFinite(lo) || !isFinite(hi)) return { min: 7, max: 19 }
|
||
lo = Math.max(0, Math.floor(lo)); hi = Math.min(24, Math.ceil(hi)); if (hi - lo < 4) hi = Math.min(24, lo + 4)
|
||
return { min: lo, max: hi }
|
||
})
|
||
// Graduations horaires pour la règle d'en-tête (alignées sur l'axe adaptatif)
|
||
const axisTicks = computed(() => {
|
||
const b = axisBounds.value; const span = (b.max - b.min) || 24
|
||
const step = span > 13 ? 4 : (span > 7 ? 3 : 2); const out = []
|
||
for (let h = Math.ceil(b.min / step) * step; h <= b.max; h += step) out.push({ h, left: ((h - b.min) / span * 100) + '%' })
|
||
return out
|
||
})
|
||
function pos (s, e) { const b = axisBounds.value; const span = (b.max - b.min) || 24; const L = Math.max(0, (s - b.min) / span * 100); const R = Math.min(100, (e - b.min) / span * 100); return { left: L + '%', width: Math.max(1.5, R - L) + '%' } }
|
||
// Barre de temps PÂLE : bleu très pâle le matin → violet pâle le soir (repère discret du « quand »)
|
||
function todColor (h) { const t = Math.max(0, Math.min(1, (h - 6) / 15)); return 'hsl(' + Math.round(210 + t * 60) + ',45%,' + Math.round(91 - t * 8) + '%)' }
|
||
function bandGradient (s, e) { return 'linear-gradient(to right, ' + todColor(s) + ', ' + todColor(e) + ')' }
|
||
// Bandes = shifts réguliers (dégradé) + GARDE en CALQUE LIVE (calculée depuis les règles, pointillé ambre).
|
||
// La garde n'est PAS stockée : on l'ignore dans cellsOf (on_call) et on la recalcule → temps réel, pas de désync.
|
||
function pushBand (out, s, e, opt) { if (s == null || e == null) return; if (e <= s) { out.push({ ...pos(s, 24), ...opt }); out.push({ ...pos(0, e), ...opt }) } else out.push({ ...pos(s, e), ...opt }) }
|
||
function cellBands (techId, iso) {
|
||
const out = []
|
||
for (const a of cellsOf(techId, iso)) {
|
||
const t = tplByName.value[a.shift]; if (!t || t.on_call) continue // garde stockée ignorée → gérée par le calque live
|
||
const s = hToNum(t.start_time); const e = hToNum(t.end_time); if (s == null || e == null) continue
|
||
pushBand(out, s, e, { oncall: false, bg: bandGradient(s, e <= s ? 24 : e) })
|
||
}
|
||
const gShift = gardeEffective.value[techId + '|' + iso] // garde EFFECTIVE (règles + overrides manuels « G »)
|
||
if (gShift) { const t = tplByName.value[gShift]; if (t) pushBand(out, hToNum(t.start_time), hToNum(t.end_time), { oncall: true }) }
|
||
return out
|
||
}
|
||
// Barre de statut OPAQUE selon l'occupation : vert (peu) → orange (plein) → rouge (surbooké).
|
||
function occColor (pct) { if (pct == null) return '#9e9e9e'; if (pct >= 100) return '#e53935'; const t = Math.max(0, Math.min(1, pct / 100)); return 'hsl(' + Math.round(122 - t * 90) + ',68%,44%)' }
|
||
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: occColor(pct) } }
|
||
// Fenêtre des shifts (garde=true → seulement les quarts de garde ; garde=false → réguliers)
|
||
function winOf (techId, iso, garde) { let s = Infinity; let e = -Infinity; for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || (!!t.on_call) !== garde) continue; const st = hToNum(t.start_time); const en = hToNum(t.end_time); if (st != null) s = Math.min(s, st); if (en != null) e = Math.max(e, en) } return isFinite(s) ? { s, e } : null }
|
||
const occCells = computed(() => {
|
||
const m = {}; const ct = cellsByTechDay.value
|
||
for (const techId in ct) for (const iso in ct[techId]) {
|
||
const cells = ct[techId][iso]; if (!cells.length) continue
|
||
let bookableH = 0; let hasGarde = false; let hasReg = false
|
||
for (const a of cells) { const t = tplByName.value[a.shift]; if (t && t.on_call) hasGarde = true; else { hasReg = true; bookableH += Number(a.hours) || 0 } }
|
||
const o = occByTechDay.value[techId + '|' + iso] || { h: 0, blocks: [], jobs: [] }
|
||
m[techId + '|' + iso] = { bookableH, usedH: Math.round((o.h || 0) * 10) / 10, hasGarde, hasReg, pct: bookableH > 0 ? Math.round((o.h || 0) / bookableH * 100) : null, blocks: o.blocks || [], jobs: o.jobs || [] }
|
||
}
|
||
return m
|
||
})
|
||
function cellOcc (techId, iso) { return occCells.value[techId + '|' + iso] || null }
|
||
function hasReg (techId, iso) { return cellsOf(techId, iso).some(a => { const t = tplByName.value[a.shift]; return t && !t.on_call }) } // a au moins un shift régulier (garde exclue)
|
||
function cellBlocks (techId, iso) { const o = cellOcc(techId, iso); return o ? o.blocks : [] }
|
||
function cellPct (techId, iso) { const o = cellOcc(techId, iso); return o ? o.pct : null }
|
||
function cellJobs (techId, iso) { const o = cellOcc(techId, iso); return o ? (o.jobs || []) : [] } // jobs du jour, déjà triés priorité→heure côté hub
|
||
function rawCellJobs (techId, iso) { const o = occByTechDay.value[techId + '|' + iso]; return o ? (o.jobs || []) : [] } // jobs BRUTS (inclut les jours SANS quart publié)
|
||
function offShiftJobs (techId, iso) { return (hasReg(techId, iso) || onGarde(techId, iso)) ? [] : rawCellJobs(techId, iso) } // jobs assignés un jour où le tech n'a AUCUN quart publié
|
||
const offShiftWeekCount = computed(() => { let n = 0; for (const t of visibleTechs.value) for (const d of dayList.value) n += offShiftJobs(t.id, d.iso).length; return n }) // total jobs hors quart sur la période visible
|
||
function prioColor (p) { return p === 'urgent' ? '#ef4444' : p === 'high' ? '#f59e0b' : p === 'medium' ? '#6366f1' : '#9e9e9e' }
|
||
// Aperçu en survol de drop : occupation projetée si on dépose la sélection ici.
|
||
function isDropTarget (techId, iso) { return dropPreview.key === techId + '|' + iso }
|
||
function projPct (techId, iso) { const o = cellOcc(techId, iso); if (!o || !o.bookableH) return null; return Math.round((o.usedH + dropPreview.addH) / o.bookableH * 100) }
|
||
function cellTip (techId, iso) {
|
||
const parts = []
|
||
if (hasReg(techId, iso)) parts.push(cellInterval(techId, iso))
|
||
const o = cellOcc(techId, iso); if (o && o.bookableH) parts.push(o.usedH + ' h occupé / ' + o.bookableH + ' h offrable (' + o.pct + ' %)')
|
||
const g = gardeEffective.value[techId + '|' + iso]; if (g) { const t = tplByName.value[g]; const nm = (t && t.template_name) || 'Garde'; const hrs = t ? (' ' + (t.start_time || '').slice(0, 5) + '–' + (t.end_time || '').slice(0, 5)) : ''; parts.push('🛡️ ' + nm + hrs) }
|
||
return parts.join(' · ')
|
||
}
|
||
function cellInterval (techId, iso) {
|
||
return cellsOf(techId, iso).filter(a => { const t = tplByName.value[a.shift]; return !(t && t.on_call) }).map(a => { const t = tplByName.value[a.shift]; const nm = (t && t.template_name) ? t.template_name.split(' ')[0] + ' ' : ''; return (t && t.start_time) ? (nm + t.start_time.slice(0, 5) + '–' + (t.end_time || '').slice(0, 5)) : (a.shift_name || a.shift) }).join(' + ')
|
||
}
|
||
|
||
// coût de main-d'œuvre (coût chargé × heures)
|
||
const costByTech = computed(() => Object.fromEntries(techs.value.map(t => [t.id, t.cost_h || 0])))
|
||
const costByDate = computed(() => { const m = {}; for (const a of assignments.value) { const t = tplByName.value[a.shift]; if (t && t.on_call) continue; m[a.date] = (m[a.date] || 0) + (Number(a.hours) || 0) * (costByTech.value[a.tech] || 0) } return m }) // garde exclue du coût de main-d'œuvre
|
||
const weekCost = computed(() => Object.values(costByDate.value).reduce((s, v) => s + v, 0))
|
||
function dayCost (iso) { return Math.round(costByDate.value[iso] || 0) }
|
||
|
||
const shiftName = (s) => { const t = tplByName.value[s]; return t ? (t.template_name || s) : s }
|
||
const covRows = computed(() => { const seen = {}; const rows = []; for (const c of coverageData.value) { const key = c.shift + '|' + c.zone; if (!seen[key]) { seen[key] = true; rows.push({ key, label: shiftName(c.shift) + ' · ' + c.zone }) } } return rows })
|
||
const covByKeyDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.shift + '|' + c.zone + '|' + c.date] = c; return m })
|
||
const gapByDay = computed(() => { const m = {}; for (const c of coverageData.value) m[c.date] = (m[c.date] || 0) + (c.shortfall || 0); return m })
|
||
function covCell (key, iso) { return covByKeyDay.value[key + '|' + iso] }
|
||
function covText (key, iso) { const c = covCell(key, iso); return c ? (c.assigned + '/' + c.required) : '' }
|
||
function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; return c.shortfall > 0 ? { background: '#ffcdd2', color: '#b71c1c', fontWeight: 700 } : { background: '#c8e6c9', color: '#1b5e20' } }
|
||
|
||
// undo / redo
|
||
function snap () { return JSON.parse(JSON.stringify(assignments.value)) }
|
||
function pushHistory () { history.value.push(snap()); if (history.value.length > 40) history.value.shift(); future.value = [] }
|
||
function undo () { if (!history.value.length) return; future.value.push(snap()); assignments.value = history.value.pop() }
|
||
function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = future.value.pop() }
|
||
|
||
// garde anti-perte
|
||
function guard (fn) { if (dirty.value && !window.confirm(DIRTY_MSG)) return; fn() }
|
||
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
|
||
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
|
||
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
|
||
function navToday () { guard(() => { start.value = thisMonday(); loadWeek() }) }
|
||
|
||
// chargement
|
||
async function loadBase () { const tr = await roster.listTechnicians(); techs.value = tr.technicians || []; const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
|
||
async function refreshTemplates () { const tp = await roster.listTemplates(); templates.value = tp.templates || [] }
|
||
|
||
// cadence / efficacité par tech
|
||
// Libellé orienté PERFORMANCE/vitesse (≠ libellé "facteur temps") : facteur 0,9 = plus rapide, 1,2 = plus lent.
|
||
function effSuffix (e) { const d = Math.round((1 - Number(e)) * 100); if (!d) return 'normal'; return d > 0 ? ('+' + d + '% vite') : ('−' + Math.abs(d) + '% lent') }
|
||
// RÔLE dérivé des compétences (tags) : support→casque · installation→échelle · réparation/terrain→handyman.
|
||
const ROLE_ICON = { support: symOutlinedHeadsetMic, install: symOutlinedToolsLadder, repair: symOutlinedHandyman }
|
||
const ROLE_LABEL = { support: 'Support / service client', install: 'Installation', repair: 'Réparation / terrain' }
|
||
function techRole (t) {
|
||
const s = (t.skills || []).map(x => String(x).toLowerCase())
|
||
if (s.some(x => x.includes('support') || x.includes('service'))) return 'support'
|
||
if (s.some(x => x.includes('install'))) return 'install'
|
||
if (s.some(x => x.includes('répar') || x.includes('repar') || x.includes('fusion') || x.includes('terrain'))) return 'repair'
|
||
return 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 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) } }
|
||
|
||
// congés / disponibilités
|
||
async function openLeave () { showLeave.value = true; await loadLeave() }
|
||
async function loadLeave () { try { leaveRows.value = (await roster.listAvailability(leaveFilter.value)).availability || [] } catch (e) { err(e) } }
|
||
async function approveLeave (l, reject) { try { await roster.approveAvailability(l.name, { reject, approver: 'ops' }); $q.notify({ type: reject ? 'info' : 'positive', message: (l.technician_name || l.technician) + (reject ? ' refusé' : ' approuvé') }); await loadLeave() } catch (e) { err(e) } }
|
||
async function createLeave () {
|
||
if (!newLeave.technician || !newLeave.from_date || !newLeave.to_date) { $q.notify({ type: 'warning', message: 'Technicien + dates requis' }); return }
|
||
const t = techs.value.find(x => x.id === newLeave.technician)
|
||
try { await roster.requestAvailability({ technician: newLeave.technician, technician_name: t ? t.name : '', availability_type: newLeave.availability_type, from_date: newLeave.from_date, to_date: newLeave.to_date, reason: newLeave.reason, long_term: newLeave.long_term }); $q.notify({ type: 'positive', message: 'Demande créée' }); newLeave.from_date = ''; newLeave.to_date = ''; newLeave.reason = ''; newLeave.long_term = 0; await loadLeave() } catch (e) { err(e) }
|
||
}
|
||
async function saveEff (t) { const eff = Number(t.efficiency) || 1; try { await roster.setTechEfficiency(t.id, eff); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.efficiency = eff; $q.notify({ type: 'positive', message: t.name + ' : cadence ' + eff }) } catch (e) { err(e) } }
|
||
function loadedCost (t) { return Math.round(((Number(t.salary) || 0) * (1 + (Number(t.charges) || 0) / 100) + (Number(t.other) || 0)) * 100) / 100 }
|
||
async function saveCost (t) { try { await roster.setTechCost(t.id, { salary: t.salary, charges: t.charges, other: t.other }); const tt = techs.value.find(x => x.id === t.id); if (tt) { tt.cost_salary_h = Number(t.salary) || 0; tt.cost_charges_pct = Number(t.charges) || 0; tt.cost_other_h = Number(t.other) || 0; tt.cost_h = loadedCost(t) } $q.notify({ type: 'positive', message: t.name + ' : ' + loadedCost(t) + ' $/h chargé' }) } catch (e) { err(e) } }
|
||
|
||
// éditeur de types de shift (intervalle d'heures)
|
||
function calcHours (st, et) { if (!st || !et) return 0; const [h1, m1] = st.split(':').map(Number); const [h2, m2] = et.split(':').map(Number); let mins = (h2 * 60 + m2) - (h1 * 60 + m1); if (mins < 0) mins += 1440; return Math.round(mins / 60 * 100) / 100 }
|
||
function openShiftEditor () { editTpls.value = templates.value.map(t => ({ name: t.name, template_name: t.template_name, start: (t.start_time || '08:00:00').slice(0, 5), end: (t.end_time || '16:00:00').slice(0, 5), color: t.color || '#1976d2', on_call: t.on_call ? 1 : 0 })); showShiftEditor.value = true }
|
||
async function saveShiftTpl (t) { try { await roster.updateTemplate(t.name, { start_time: t.start + ':00', end_time: t.end + ':00', hours: calcHours(t.start, t.end), color: t.color, on_call: t.on_call ? 1 : 0 }); await refreshTemplates(); $q.notify({ type: 'positive', message: t.template_name + ' enregistré (' + calcHours(t.start, t.end) + ' h)' }) } catch (e) { err(e) } }
|
||
async function addShiftTpl () { const nm = (newTpl.template_name || '').trim() || (fmtH(hToNum(newTpl.start)) + 'h–' + fmtH(hToNum(newTpl.end)) + 'h'); try { await roster.createTemplate({ template_name: nm, start_time: newTpl.start + ':00', end_time: newTpl.end + ':00', hours: calcHours(newTpl.start, newTpl.end), color: newTpl.color, default_required: 1, on_call: newTpl.on_call ? 1 : 0 }); newTpl.template_name = ''; newTpl.on_call = 0; await refreshTemplates(); openShiftEditor(); $q.notify({ type: 'positive', message: 'Type « ' + nm + ' » ajouté' }) } catch (e) { err(e) } }
|
||
async function delShiftTpl (t) { if (!window.confirm('Supprimer le type « ' + t.template_name + ' » ?')) return; try { await roster.deleteShiftTemplate(t.name); await refreshTemplates(); editTpls.value = editTpls.value.filter(x => x.name !== t.name); $q.notify({ type: 'info', message: 'Type supprimé' }) } catch (e) { err(e) } }
|
||
function snapshotServer (list) { serverSet.value = new Set(list.map(a => a.tech + '|' + a.date + '|' + a.shift)) }
|
||
async function loadWeek () {
|
||
loading.value = true
|
||
try {
|
||
const a = await roster.listAssignments(start.value, days.value); assignments.value = a.assignments || []
|
||
snapshotServer(assignments.value); history.value = []; future.value = []; solverStats.value = null
|
||
lastWeek.start = start.value; lastWeek.days = days.value
|
||
const c = await roster.getCoverage(start.value, days.value); coverageData.value = c.coverage || []
|
||
await loadStats()
|
||
} catch (e) { err(e) } finally { loading.value = false }
|
||
}
|
||
async function loadStats () {
|
||
try { const s = await roster.getStats(start.value, days.value); dailyStats.value = s.stats || [] } catch (e) { /* non bloquant */ }
|
||
try { const o = await roster.getOccupancy(start.value, days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ }
|
||
try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) { /* non bloquant */ }
|
||
}
|
||
|
||
async function doGenerate () {
|
||
generating.value = true
|
||
try {
|
||
const res = await roster.generate(start.value, days.value)
|
||
if (res.status !== 'OPTIMAL' && res.status !== 'FEASIBLE') { err(new Error(res.error || res.message || ('solveur: ' + res.status))); return }
|
||
pushHistory(); assignments.value = res.assignments || []; coverageData.value = res.coverage_report || []
|
||
solverStats.value = { assignments: (res.assignments || []).length, shortfall: res.total_shortfall || 0, spread: res.spread_hours || 0, ms: res.solve_ms || 0 }
|
||
$q.notify({ type: 'positive', message: 'Horaire généré : ' + solverStats.value.assignments + ' assignations (non publié)' })
|
||
} catch (e) { err(e) } finally { generating.value = false }
|
||
}
|
||
async function doPublish () {
|
||
publishing.value = true
|
||
try {
|
||
// Réécriture de semaine : efface la période + recrée la grille (anti-doublons).
|
||
const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value)
|
||
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
|
||
await loadWeek()
|
||
} catch (e) { err(e) } finally { publishing.value = false }
|
||
}
|
||
|
||
// 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 = [] } }
|
||
|
||
// ── 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 gardeTemplateOptions = computed(() => templates.value.slice().sort((a, b) => (b.on_call ? 1 : 0) - (a.on_call ? 1 : 0)).map(t => ({ label: t.template_name + (t.on_call ? ' 🛡️' : ''), value: t.name })))
|
||
const groupNames = computed(() => [...new Set(techs.value.map(t => t.group).filter(Boolean))].sort())
|
||
const editingGardeId = ref(null); const gardePick = ref(null)
|
||
function d2ms (iso) { const a = iso.split('-').map(Number); return Date.UTC(a[0], a[1] - 1, a[2]) }
|
||
function mondayISO (iso) { return addDaysISO(iso, -((dowOf(iso) + 6) % 7)) }
|
||
function weekNo (iso) { return Math.round((d2ms(mondayISO(iso)) - d2ms(GARDE_EPOCH)) / (7 * 86400000)) } // n° de semaine absolu (réf. lundi)
|
||
function openGarde () { if (!newGardeRule.anchor) newGardeRule.anchor = mondayISO(start.value); showGarde.value = true }
|
||
// Séquence = étapes {tech, weeks}. Ajouter à la suite (doublons OK), réordonner, retirer.
|
||
function addTechToSeq () { if (gardePick.value) { newGardeRule.steps.push({ tech: gardePick.value, weeks: 1 }); gardePick.value = null } }
|
||
function moveTech (i, dir) { const a = newGardeRule.steps; const j = i + dir; if (j < 0 || j >= a.length) return; const x = a[i]; a.splice(i, 1); a.splice(j, 0, x) }
|
||
function editGardeRule (r) {
|
||
Object.assign(newGardeRule, { dept: r.dept || '', shift: r.shift, shiftWeekend: r.shiftWeekend || '', weekdays: [...(r.weekdays || [])], anchor: r.anchor || mondayISO(start.value), steps: ruleSteps(r) })
|
||
editingGardeId.value = r.id
|
||
}
|
||
const WD_SEMAINE = [1, 2, 3, 4, 5]; const WD_FINSEM = [6, 0]
|
||
function isSetActive (set) { return set.length && set.every(v => newGardeRule.weekdays.includes(v)) }
|
||
function toggleWeekdaysSet (set) { if (isSetActive(set)) newGardeRule.weekdays = newGardeRule.weekdays.filter(v => !set.includes(v)); else newGardeRule.weekdays = [...new Set([...newGardeRule.weekdays, ...set])] }
|
||
function toggleGardeDow (v) { const i = newGardeRule.weekdays.indexOf(v); if (i >= 0) newGardeRule.weekdays.splice(i, 1); else newGardeRule.weekdays.push(v) }
|
||
// ── Moteur de rotation : on PARCOURT la séquence semaine par semaine depuis l'ANCRAGE ──
|
||
function cycleWeeks (steps) { return (steps || []).reduce((s, x) => s + (Number(x.weeks) || 1), 0) }
|
||
function stepTechAt (steps, w) { for (const s of steps) { const n = Number(s.weeks) || 1; if (w < n) return s.tech; w -= n } return steps[0] && steps[0].tech }
|
||
// Rétrocompat : règle au nouveau format (steps) OU à l'ancien (techs[]+periodWeeks). Collapse les doublons consécutifs.
|
||
function ruleSteps (r) {
|
||
if (r.steps && r.steps.length) return r.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 }))
|
||
const p = r.periodWeeks || 1; const out = []
|
||
for (const t of (r.techs || [])) { const last = out[out.length - 1]; if (last && last.tech === t) last.weeks += p; else out.push({ tech: t, weeks: p }) }
|
||
return out
|
||
}
|
||
function ruleAnchor (r) { return r.anchor || GARDE_EPOCH } // ancrage stable si non défini (vieilles règles)
|
||
function rotationTech (rule, iso) {
|
||
const steps = ruleSteps(rule); const cyc = cycleWeeks(steps); if (!cyc) return null
|
||
const w0 = (((weekNo(iso) - weekNo(ruleAnchor(rule))) % cyc) + cyc) % cyc
|
||
for (let k = 0; k < cyc; k++) { const id = stepTechAt(steps, (w0 + k) % cyc); if (id && !isAbsent(id, iso)) return id } // saut d'absent
|
||
return stepTechAt(steps, w0)
|
||
}
|
||
// Aperçu : qui est de garde, semaine par semaine, depuis l'ancrage — reflète la file en cours d'édition (ignore absences)
|
||
const gardePreview = computed(() => {
|
||
const rule = newGardeRule; const cyc = cycleWeeks(rule.steps); if (!cyc || !rule.weekdays.length) return []
|
||
const anchor = rule.anchor || mondayISO(start.value); const out = []
|
||
for (let i = 0; i < Math.min(14, cyc + 4); i++) {
|
||
const ws = addDaysISO(mondayISO(anchor), i * 7); const w0 = (((weekNo(ws) - weekNo(anchor)) % cyc) + cyc) % cyc
|
||
const id = stepTechAt(rule.steps, w0)
|
||
out.push({ week: ws, name: (techs.value.find(t => t.id === id) || {}).name || id })
|
||
}
|
||
return out
|
||
})
|
||
function saveGarde () { localStorage.setItem(LS_GARDE, JSON.stringify(gardeRules.value)) }
|
||
function addGardeRule () {
|
||
if (!newGardeRule.shift || !newGardeRule.steps.length || !newGardeRule.weekdays.length) { $q.notify({ type: 'warning', message: 'Shift, jours et au moins un tech requis' }); return }
|
||
const rule = { id: editingGardeId.value || Date.now(), dept: newGardeRule.dept || '—', shift: newGardeRule.shift, shiftWeekend: newGardeRule.shiftWeekend || '', weekdays: [...newGardeRule.weekdays], anchor: newGardeRule.anchor || mondayISO(start.value), steps: newGardeRule.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 })) }
|
||
if (editingGardeId.value) gardeRules.value = gardeRules.value.map(r => r.id === editingGardeId.value ? rule : r)
|
||
else gardeRules.value = [...gardeRules.value, rule]
|
||
saveGarde(); editingGardeId.value = null; newGardeRule.steps = []; newGardeRule.weekdays = []
|
||
$q.notify({ type: 'positive', message: 'Règle enregistrée — clique « Générer la garde » pour l\'appliquer' })
|
||
}
|
||
function removeGardeRule (i) { gardeRules.value = gardeRules.value.filter((_, j) => j !== i); saveGarde(); if (editingGardeId.value && !gardeRules.value.some(r => r.id === editingGardeId.value)) editingGardeId.value = null }
|
||
function gardeDowLabel (r) { return (r.weekdays || []).map(w => (GARDE_DOW.find(x => x.v === w) || {}).l).join('') }
|
||
function gardeSeqLabel (r) { return ruleSteps(r).map(s => ((techs.value.find(t => t.id === s.tech) || {}).name || s.tech) + (s.weeks > 1 ? ' ×' + s.weeks : '')).join(' → ') }
|
||
// Génère les gardes de la semaine affichée selon les règles (rotation par département)
|
||
const gardeHorizon = ref(8) // nb de semaines à matérialiser (évènement récurrent)
|
||
// Génère la garde sur un HORIZON (plusieurs semaines) et l'écrit directement (publié) → navigable semaine par semaine.
|
||
async function applyGardeRules () {
|
||
if (!gardeRules.value.length && !Object.keys(manualGarde.value).length) { $q.notify({ type: 'info', message: 'Aucune règle — ajoute-en une' }); return }
|
||
if (dirty.value && !window.confirm('Les modifications non publiées de la grille seront rechargées. Continuer ?')) return
|
||
const weeks = gardeHorizon.value || 8; const wk0 = mondayISO(start.value)
|
||
// Construit la même garde EFFECTIVE que l'affichage, mais sur tout l'horizon : rotation par règles…
|
||
const horizon = new Set(); for (let i = 0; i < weeks * 7; i++) horizon.add(addDaysISO(wk0, i))
|
||
const map = {}
|
||
for (const iso of horizon) {
|
||
const dow = dowOf(iso); const weekend = (dow === 0 || dow === 6)
|
||
for (const rule of gardeRules.value) {
|
||
if (!rule.weekdays.includes(dow)) continue
|
||
const sh = (weekend && rule.shiftWeekend) ? rule.shiftWeekend : rule.shift
|
||
if (!tplByName.value[sh]) continue
|
||
const id = rotationTech(rule, iso); if (!id) continue
|
||
map[id + '|' + iso] = sh
|
||
}
|
||
}
|
||
// … + overrides MANUELS « G » dans l'horizon (déplacements faits à la main) → publiés aussi.
|
||
for (const key in manualGarde.value) { const iso = key.split('|')[1]; if (!horizon.has(iso)) continue; const v = manualGarde.value[key]; if (v === 'off') delete map[key]; else if (v === 'on') { const sh = gardeShiftForDay(iso); if (sh) map[key] = sh } }
|
||
const list = []
|
||
for (const key in map) { const [id, iso] = key.split('|'); const sh = map[key]; const tpl = tplByName.value[sh]; const t = techs.value.find(x => x.id === id); list.push({ tech: id, tech_name: t ? t.name : id, date: iso, shift: sh, hours: (tpl && tpl.hours) || 8, zone: (tpl && tpl.zone) || '' }) }
|
||
const shifts = [...new Set([...gardeRules.value.flatMap(r => [r.shift, r.shiftWeekend].filter(Boolean)), ...Object.values(map)])]
|
||
try {
|
||
const r = await roster.applyGardeHorizon(wk0, weeks, list, shifts)
|
||
showGarde.value = false; await loadWeek()
|
||
$q.notify({ type: 'positive', message: `Garde publiée sur ${weeks} sem. : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + '. La grille la montrait déjà en direct ; c\'est maintenant visible par dispatch et les techs.', timeout: 6000 })
|
||
} 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 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 }
|
||
applying.value = true
|
||
try {
|
||
await roster.clearRequirements(start.value, days.value)
|
||
const reqs = []
|
||
for (const d of dayList.value) {
|
||
const slot = isHoliday(d.iso) ? 'holiday' : (d.weekend ? 'weekend' : 'weekday')
|
||
for (const row of demand.value) {
|
||
const n = Number(row[slot]) || 0; if (n <= 0 || !row.shift) continue
|
||
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 || '' })
|
||
}
|
||
}
|
||
if (reqs.length) await roster.bulkRequirements(reqs)
|
||
await loadWeek(); $q.notify({ type: 'positive', message: 'Demande appliquée : ' + reqs.length + ' besoins' })
|
||
} catch (e) { err(e) } finally { applying.value = false }
|
||
}
|
||
|
||
// modèles de semaine
|
||
function saveTemplate () {
|
||
$q.dialog({ title: 'Nouveau modèle', message: "Nom du modèle d'horaire", prompt: { model: '', type: 'text' }, cancel: true }).onOk(name => {
|
||
if (!name) return; const byDow = {}
|
||
for (const a of assignments.value) { const dow = dowOf(a.date); (byDow[dow] || (byDow[dow] = {}))[a.tech] = a.shift }
|
||
weekTemplates.value = [...weekTemplates.value, { name, byDow }]; localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value))
|
||
$q.notify({ type: 'positive', message: 'Modèle « ' + name + ' » enregistré' })
|
||
})
|
||
}
|
||
function deleteTemplate (i) { weekTemplates.value = weekTemplates.value.filter((_, j) => j !== i); localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value)) }
|
||
// Modèle par défaut (★) — un seul à la fois, appliqué en 1 clic
|
||
const defaultTemplate = computed(() => weekTemplates.value.find(t => t.default) || null)
|
||
function setDefaultTemplate (i) { weekTemplates.value = weekTemplates.value.map((t, j) => ({ ...t, default: j === i ? !t.default : false })); localStorage.setItem(LS_TPL, JSON.stringify(weekTemplates.value)) }
|
||
function applyDefault () { const d = defaultTemplate.value; if (!d) { $q.notify({ type: 'info', message: 'Aucun modèle par défaut — marque-en un avec ★ dans Modèles' }); return } applyTemplate(d) }
|
||
function countPatternDays (tm, techId) { let n = 0; for (const d of dayList.value) { const map = tm.byDow[dowOf(d.iso)]; if (map && map[techId]) n++ } return n }
|
||
// Application « consciente des absences » : on n'assigne pas un tech absent ce jour-là.
|
||
// Absent toute la semaine (≈ congé permanent: maternité/blessure) → flag « à remplacer ».
|
||
// Absent quelques jours (≈ vacances) → on saute juste ces jours, le reste du patron tient.
|
||
function applyTemplate (tm) {
|
||
pushHistory()
|
||
const skipped = {}; let applied = 0
|
||
for (const d of dayList.value) {
|
||
const map = tm.byDow[dowOf(d.iso)]; if (!map) continue
|
||
for (const techId in map) {
|
||
if (isAbsent(techId, d.iso)) { skipped[techId] = (skipped[techId] || 0) + 1; continue }
|
||
const tpl = tplByName.value[map[techId]]; if (!tpl) continue
|
||
const t = techs.value.find(x => x.id === techId); setCellReplace(techId, t ? t.name : techId, d.iso, tpl); applied++
|
||
}
|
||
}
|
||
const fullOut = []; const partial = []
|
||
for (const techId in skipped) {
|
||
const t = techs.value.find(x => x.id === techId); const name = (t ? t.name : techId)
|
||
const type = absByTechDay.value[techId + '|' + (dayList.value.find(d => isAbsent(techId, d.iso)) || {}).iso] || ''
|
||
const lbl = name + (type ? ' (' + type + ')' : '')
|
||
if (/longue durée/i.test(type) || skipped[techId] >= countPatternDays(tm, techId)) fullOut.push(lbl); else partial.push(lbl)
|
||
}
|
||
let msg = 'Modèle « ' + tm.name + ' » appliqué (' + applied + ' assignations)'
|
||
if (partial.length) msg += ' · absence partielle ignorée : ' + partial.join(', ')
|
||
if (fullOut.length) msg += ' · ABSENT toute la semaine — à remplacer : ' + fullOut.join(', ')
|
||
$q.notify({ type: fullOut.length ? 'warning' : 'positive', message: msg, timeout: fullOut.length ? 9000 : 4500, multiLine: true })
|
||
}
|
||
|
||
// édition + sélection
|
||
const menu = reactive({ show: false, target: null, tech: null, day: null })
|
||
const menuRange = ref({ min: 8, max: 16 }); const quickEntry = ref('')
|
||
function rect (sti, sdi, eti, edi) {
|
||
const t0 = Math.min(sti, eti), t1 = Math.max(sti, eti), d0 = Math.min(sdi, edi), d1 = Math.max(sdi, edi)
|
||
const out = []
|
||
for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) out.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso)
|
||
return out
|
||
}
|
||
function onDown (ti, di, ev) { if (ev.button !== 0 || ev.shiftKey || ev.ctrlKey || ev.metaKey) return; drag.on = true; drag.ti = ti; drag.di = di; drag.moved = false; drag.base = [] }
|
||
function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.value = [...new Set([...drag.base, ...rect(drag.ti, drag.di, ti, di)])] }
|
||
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
|
||
function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }] }
|
||
function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept }
|
||
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
|
||
function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)) }
|
||
function onCellClick (t, d, ev, ti, di) {
|
||
if (justDragged.value) { justDragged.value = false; return }
|
||
activeCell.value = { id: t.id, name: t.name, iso: d.iso } // mémorise la case pour Cmd+C/V
|
||
if (ev.shiftKey && anchor.value) { selectBlock(ti, di); return }
|
||
if (ev.ctrlKey || ev.metaKey) { const k = t.id + '|' + d.iso; selection.value = selSet.value.has(k) ? selection.value.filter(x => x !== k) : [...selection.value, k]; anchor.value = { ti, di }; return }
|
||
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d; menu.target = ev.currentTarget
|
||
const wr = winOf(t.id, d.iso, false); quickEntry.value = ''
|
||
menuRange.value = wr ? { min: wr.s, max: wr.e } : { min: 8, max: 16 }
|
||
menu.show = true
|
||
}
|
||
function selectBlock (ti, di) { const a = anchor.value; const t0 = Math.min(a.ti, ti); const t1 = Math.max(a.ti, ti); const d0 = Math.min(a.di, di); const d1 = Math.max(a.di, di); const add = []; for (let i = t0; i <= t1; i++) for (let j = d0; j <= d1; j++) add.push(visibleTechs.value[i].id + '|' + dayList.value[j].iso); selection.value = [...new Set([...selection.value, ...add])] }
|
||
function maybeSelectCol (di) { const ks = visibleTechs.value.map(t => t.id + '|' + dayList.value[di].iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
|
||
function maybeSelectRow (ti) { const ks = dayList.value.map(d => visibleTechs.value[ti].id + '|' + d.iso); const all = ks.every(k => selSet.value.has(k)); selection.value = all ? selection.value.filter(k => !ks.includes(k)) : [...new Set([...selection.value, ...ks])] }
|
||
function isRowSelected (ti) { const t = visibleTechs.value[ti]; if (!t || !dayList.value.length) return false; return dayList.value.every(d => selSet.value.has(t.id + '|' + d.iso)) } // rangée entière sélectionnée ?
|
||
const menuCellShifts = computed(() => (menu.tech && menu.day) ? cellsOf(menu.tech.id, menu.day.iso) : [])
|
||
const menuIsAbsent = computed(() => (menu.tech && menu.day) ? isAbsent(menu.tech.id, menu.day.iso) : false)
|
||
function toggleAbsentMenu () { if (menu.tech && menu.day) { toggleAbsentCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } }
|
||
const menuIsGarde = computed(() => (menu.tech && menu.day) ? onGarde(menu.tech.id, menu.day.iso) : false)
|
||
function toggleGardeMenu () { if (menu.tech && menu.day) { toggleGardeCells([menu.tech.id + '|' + menu.day.iso]); menu.show = false } }
|
||
function removeShiftFromMenu (a) { pushHistory(); removeShift(a.tech, a.date, a.shift) }
|
||
function clearOne () { if (menu.tech && menu.day) { pushHistory(); clearLocal(menu.tech.id, menu.day.iso); menu.show = false } }
|
||
// Menu : copier / coller (marche au clic, sans Cmd+clic) + ajuster l'horaire au slider
|
||
function copyFromMenu () { if (!menu.tech || !menu.day) return; cellClipboard.value = cellsOf(menu.tech.id, menu.day.iso).map(a => a.shift); $q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — ouvre une autre case puis Coller') : 'Case vide copiée (Coller la videra)' }) }
|
||
function pasteFromMenu () { if (!menu.tech || !menu.day) return; pushHistory(); if (!cellClipboard.value.length) { clearLocal(menu.tech.id, menu.day.iso) } else { for (const name of cellClipboard.value) { const tpl = tplByName.value[name]; if (tpl) addShift(menu.tech.id, menu.tech.name, menu.day.iso, tpl) } } menu.show = false }
|
||
// Applique une fenêtre [min,max] à la case du menu : trouve/crée un modèle auto-nommé puis remplace.
|
||
// Trouve OU crée un modèle de shift régulier pour une plage horaire (réutilisé par menu + barre de sélection).
|
||
async function ensureWindowTpl (min, max) {
|
||
const s = numToTime(min); const e = numToTime(max); const nm = fmtH(min) + 'h–' + fmtH(max) + 'h'
|
||
let tpl = templates.value.find(t => t.template_name === nm)
|
||
if (!tpl) { try { await roster.createTemplate({ template_name: nm, start_time: s + ':00', end_time: e + ':00', hours: calcHours(s, e), color: '#1976d2', default_required: 1, on_call: 0 }); await refreshTemplates(); tpl = templates.value.find(t => t.template_name === nm) } catch (e2) { err(e2); return null } }
|
||
return tpl
|
||
}
|
||
async function applyWindow (min, max) {
|
||
if (!menu.tech || !menu.day || max <= min) return
|
||
const tpl = await ensureWindowTpl(min, max)
|
||
if (tpl) { pushHistory(); setCellReplace(menu.tech.id, menu.tech.name, menu.day.iso, tpl); menu.show = false }
|
||
}
|
||
function quickShift (min, max) { return applyWindow(min, max) }
|
||
async function applyMenuRange () { return applyWindow(menuRange.value.min, menuRange.value.max) }
|
||
// Saisie rapide d'heures : « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=8→17, dernier chiffre en pm si ≤ début).
|
||
function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) }
|
||
function parseQuickShift (str) {
|
||
const s = (str || '').trim().toLowerCase(); if (!s) return null
|
||
if (/[-–—]|to|→|\s/.test(s)) { const p = s.split(/[-–—]|to|→|\s+/).filter(Boolean); if (p.length < 2) return null; const a = parseHM(p[0]); const b = parseHM(p[1]); return (a == null || b == null || b <= a || b > 24) ? null : { min: a, max: b } }
|
||
if (/^\d{2}$/.test(s)) { const a = Number(s[0]); let b = Number(s[1]); if (b <= a) b += 12; return (b <= a || b > 24) ? null : { min: a, max: b } } // « 85 » = 8→17
|
||
return null
|
||
}
|
||
function applyQuick () { const r = parseQuickShift(quickEntry.value); if (!r) { $q.notify({ type: 'warning', message: 'Format : 8-17 · 8:30-16 · 85' }); return } quickEntry.value = ''; applyWindow(r.min, r.max) }
|
||
function assignBulk (tpl) { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); addShift(tid, t ? t.name : tid, iso, tpl) } selection.value = [] }
|
||
// ── Barre de sélection : mêmes 4 actions que le menu de cellule ──
|
||
async function bulkWindow (min, max) { if (!selection.value.length) return; const tpl = await ensureWindowTpl(min, max); if (tpl) assignBulk(tpl) }
|
||
function bulkQuick () { const r = parseQuickShift(quickEntry.value); if (!r) { $q.notify({ type: 'warning', message: 'Format : 8-17 · 8:30-16 · 85' }); return } quickEntry.value = ''; bulkWindow(r.min, r.max) }
|
||
function bulkGarde () { const t = selection.value.slice(); toggleGardeCells(t); selection.value = [] }
|
||
function bulkAbsent () { const t = selection.value.slice(); toggleAbsentCells(t); selection.value = [] }
|
||
function clearBulk () { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); clearLocal(tid, iso) } selection.value = [] }
|
||
// Copier-coller une case (bâtir l'horaire vite) : copie les shifts de la 1re case sélectionnée → colle dans les autres
|
||
const cellClipboard = ref([])
|
||
function copyCell () {
|
||
const k = selection.value[0] || (activeCell.value && (activeCell.value.id + '|' + activeCell.value.iso))
|
||
if (!k) { $q.notify({ type: 'warning', message: 'Clique ou sélectionne une case d\'abord' }); return }
|
||
const [tid, iso] = k.split('|'); cellClipboard.value = cellsOf(tid, iso).map(a => a.shift)
|
||
$q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — sélectionne des cases puis Coller (ou Cmd+V)') : 'Case vide copiée (Coller videra les cases)' })
|
||
}
|
||
function pasteCells () {
|
||
const targets = selection.value.length ? selection.value.slice() : (activeCell.value ? [activeCell.value.id + '|' + activeCell.value.iso] : [])
|
||
if (!targets.length) { $q.notify({ type: 'warning', message: 'Sélectionne les cases cibles' }); return }
|
||
pushHistory()
|
||
for (const k of targets) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); if (!cellClipboard.value.length) { clearLocal(tid, iso); continue } for (const name of cellClipboard.value) { const tpl = tplByName.value[name]; if (tpl) addShift(tid, t ? t.name : tid, iso, tpl) } }
|
||
if (selection.value.length) selection.value = []
|
||
}
|
||
|
||
async function togglePause (t) { try { const paused = !isPaused(t); await roster.pauseTechnician(t.id, paused); t.status = paused ? 'En pause' : 'Disponible'; $q.notify({ type: 'info', message: t.name + (paused ? ' en pause' : ' réactivé') }) } catch (e) { err(e) } }
|
||
function err (e) { $q.notify({ type: 'negative', message: '' + (e.message || e) }) }
|
||
|
||
function onKey (e) {
|
||
const tag = (e.target && e.target.tagName) || ''
|
||
if (/INPUT|TEXTAREA|SELECT/.test(tag) || (e.target && e.target.isContentEditable)) return // ne pas intercepter quand on tape dans un champ
|
||
const k = e.key.toLowerCase()
|
||
if ((e.ctrlKey || e.metaKey) && k === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return }
|
||
if ((e.ctrlKey || e.metaKey) && k === 'c' && (selection.value.length || activeCell.value)) { e.preventDefault(); menu.show = false; copyCell(); return }
|
||
if ((e.ctrlKey || e.metaKey) && k === 'v' && (selection.value.length || activeCell.value)) { e.preventDefault(); menu.show = false; pasteCells(); return }
|
||
if ((k === 'delete' || k === 'backspace') && (selection.value.length || activeCell.value)) {
|
||
e.preventDefault(); menu.show = false
|
||
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
|
||
pushHistory()
|
||
for (const key of targets) { const [tid, iso] = key.split('|'); clearLocal(tid, iso) }
|
||
if (selection.value.length) selection.value = []
|
||
}
|
||
if (k === 'a' && !e.altKey && (selection.value.length || activeCell.value)) { // « A » = bascule absent
|
||
e.preventDefault(); menu.show = false
|
||
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
|
||
toggleAbsentCells(targets); if (selection.value.length) selection.value = []
|
||
}
|
||
if (k === 'g' && !e.altKey && !e.ctrlKey && !e.metaKey && (selection.value.length || activeCell.value)) { // « G » = bascule garde (manuel)
|
||
e.preventDefault(); menu.show = false
|
||
const targets = selection.value.length ? selection.value.slice() : [activeCell.value.id + '|' + activeCell.value.iso]
|
||
toggleGardeCells(targets); if (selection.value.length) selection.value = []
|
||
}
|
||
}
|
||
function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } }
|
||
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek(); try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || [] } catch (e) { /* catégories de job pour suggestions */ } })
|
||
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload) })
|
||
onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Barre d'actions de sélection : flottante (fixed) → hors flux, aucune incidence sur la hauteur de la grille. */
|
||
.sel-actions { position: fixed; left: 50%; transform: translateX(-50%); bottom: 18px; z-index: 4000; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; max-width: 94vw; padding: 6px 12px; background: #e0f2f1; color: #00695c; border: 1px solid #4db6ac; border-radius: 9px; box-shadow: 0 6px 22px rgba(0,0,0,.20); }
|
||
.garde-editor { background: #faf8f6; border: 1px solid #e8e0d8; } /* sous-panneau éditeur de garde */
|
||
/* Panneau flottant « jobs à assigner » (déplaçable, glisser-déposer) */
|
||
.assign-panel { position: fixed; z-index: 5000; width: 320px; max-height: 72vh; background: #fff; border: 1px solid #cfd8dc; border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,.24); display: flex; flex-direction: column; }
|
||
.assign-hdr { display: flex; align-items: center; gap: 5px; padding: 6px 10px; background: #5e35b1; color: #fff; border-radius: 8px 8px 0 0; cursor: move; font-weight: 600; font-size: 13px; user-select: none; }
|
||
.assign-body { overflow: auto; padding: 5px; }
|
||
.assign-grp { margin-bottom: 6px; border-radius: 7px; padding: 2px; }
|
||
.assign-grp.grp-hl { background: #ede7f6; box-shadow: inset 0 0 0 1px #b39ddb; } /* groupe lié surligné dès qu'un membre est coché */
|
||
.assign-grp-hdr { font-size: 10px; font-weight: 700; color: #5e35b1; padding: 2px 6px; cursor: pointer; display: flex; align-items: center; gap: 3px; }
|
||
.assign-grp-hdr:hover { text-decoration: underline; }
|
||
.assign-job { border: 1px solid #e0e0e0; border-radius: 6px; padding: 3px 7px; margin: 3px 0; cursor: grab; background: #fafafa; font-size: 12px; }
|
||
.assign-job:hover { border-color: #5e35b1; background: #f3e9fb; }
|
||
.assign-job:active { cursor: grabbing; }
|
||
.assign-job.sel { border-color: #00897b; background: #e0f2f1; box-shadow: inset 0 0 0 1px #00897b; } /* sélectionné = à dispatcher */
|
||
.assign-job.child { margin-left: 14px; border-left: 3px solid #b39ddb; }
|
||
.assign-job.blocked { opacity: .65; }
|
||
.assign-sub { font-size: 10px; color: #888; margin-top: 1px; }
|
||
.assign-skill { display: inline-block; color: #fff; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; margin-right: 3px; }
|
||
.assign-foot { border-top: 1px solid #e0e0e0; padding: 6px 9px; font-size: 11px; color: #555; line-height: 1.45; background: #fafafa; border-radius: 0 0 8px 8px; }
|
||
.assign-job.dragging { opacity: .4; } /* source estompée pendant le glissé (le fantôme compact suit le curseur) */
|
||
/* Dialogue Timeline d'une ressource */
|
||
.tldlg-day { padding: 7px 0; border-bottom: 1px solid #eee; }
|
||
.tldlg-bar { position: relative; height: 18px; background: #f1f3f5; border-radius: 3px; overflow: hidden; margin-bottom: 4px; }
|
||
.tldlg-tick { position: absolute; top: 1px; font-size: 8px; color: #90a4ae; transform: translateX(-50%); pointer-events: none; }
|
||
.tldlg-job { display: flex; align-items: center; gap: 6px; font-size: 12px; padding: 2px 2px 2px 4px; border-radius: 4px; }
|
||
.tldlg-job:hover { background: #f5f5f5; }
|
||
.tldlg-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
|
||
.tldlg-time { font-variant-numeric: tabular-nums; color: #555; flex: 0 0 auto; min-width: 34px; }
|
||
.cell.drop-hover { outline: 2px dashed #5e35b1; outline-offset: -2px; background: #f3e9fb; }
|
||
.tl-proj { position: absolute; top: 0; bottom: 0; left: 0; border-radius: 2px; opacity: .42; outline: 1px dashed rgba(0,0,0,.35); outline-offset: -1px; } /* aperçu occupation projetée (fantôme) */
|
||
.drop-badge { position: absolute; top: -8px; right: 2px; z-index: 6; background: #5e35b1; color: #fff; font-size: 9px; font-weight: 700; padding: 0 4px; border-radius: 5px; white-space: nowrap; box-shadow: 0 1px 4px rgba(0,0,0,.3); pointer-events: none; }
|
||
.drop-badge.over { background: #e53935; } /* projection ≥ 100% = surbooké */
|
||
.grid-wrap { overflow-x: auto; border: 1px solid #e0e0e0; border-radius: 6px; max-width: 100%; }
|
||
.roster-grid { border-collapse: collapse; font-size: 12px; width: 100%; user-select: none; -webkit-user-select: none; }
|
||
.roster-grid th, .roster-grid td { border: 1px solid #eee; text-align: center; padding: 2px; }
|
||
.roster-grid thead th { position: sticky; top: 0; background: #fafafa; z-index: 1; }
|
||
.tech-col { position: sticky; left: 0; background: #fff; text-align: left !important; white-space: nowrap; padding: 2px 8px !important; min-width: 240px; max-width: 300px; z-index: 2; }
|
||
.roster-grid thead .tech-col { z-index: 3; }
|
||
.roster-grid tbody tr:hover td { background: #f0f7ff; }
|
||
.roster-grid tbody tr:hover .tech-col { background: #f0f7ff; }
|
||
th.weekend, td.weekend { background: #f5f5f5; }
|
||
th.holiday, td.holiday { background: #fff3e0; }
|
||
th.clk, td.clk { cursor: pointer; }
|
||
.dow { font-size: 10px; color: #999; text-transform: uppercase; }
|
||
.dnum { font-size: 11px; font-weight: 600; }
|
||
.grp { font-size: 9px; color: #999; background: #f0f0f0; border-radius: 3px; padding: 0 4px; margin-left: 2px; }
|
||
.role-ic { color: #546e7a; vertical-align: middle; margin-right: 3px; display: inline-flex; } /* icône de rôle monochrome (Lucide) */
|
||
.tech-row { display: flex; align-items: center; gap: 3px; flex-wrap: nowrap; min-width: 0; }
|
||
.tech-name { white-space: nowrap; flex-shrink: 0; }
|
||
.th { white-space: nowrap; flex-shrink: 0; }
|
||
.tech-row > .q-btn, .tech-row > .q-badge, .tech-row > .role-ic, .tech-row > .grp, .tech-row > .eff { flex-shrink: 0; }
|
||
.tech-skills { display: flex; align-items: center; gap: 2px; overflow: hidden; flex: 1 1 auto; min-width: 0; } /* chips inline, débordement clippé */
|
||
.skill-edit-btn { flex-shrink: 0; }
|
||
.skill-chip { font-size: 10px; line-height: 15px; height: 15px; padding: 0 5px; border-radius: 8px; color: #fff; font-weight: 600; white-space: nowrap; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
|
||
.chip-lvl { display: inline-flex; align-items: center; justify-content: center; min-width: 13px; height: 13px; padding: 0 1px; border-radius: 50%; background: rgba(0,0,0,.32); font-size: 8px; font-weight: 800; box-shadow: 0 0 0 1.5px #fff; margin-left: 1px; } /* niveau 1–5 · contour blanc pour détacher la couleur (vitesse) */
|
||
.add-skill-hint { font-size: 10px; color: #9e9e9e; font-style: italic; } /* invite quand aucune compétence */
|
||
.hide-eye { flex-shrink: 0; opacity: .45; } .tech-row:hover .hide-eye { opacity: 1; } /* œil masquer (discret, visible au survol) */
|
||
tr.res-hidden { opacity: .5; background: repeating-linear-gradient(45deg, #fafafa, #fafafa 6px, #f0f0f0 6px, #f0f0f0 12px); } /* ressource masquée affichée en grisé */
|
||
tr.res-hidden .hide-eye { opacity: 1; }
|
||
.tech-name.clk:hover { text-decoration: underline; }
|
||
.hol-toggle { font-size: 9px; color: #ccc; cursor: pointer; border: 1px solid #eee; border-radius: 3px; width: 14px; margin: 1px auto 0; line-height: 12px; }
|
||
.hol-toggle.on { background: #ff9800; color: #fff; border-color: #ff9800; }
|
||
.cell { cursor: pointer; min-height: 24px; position: relative; }
|
||
.cell:hover { outline: 2px solid #1976d2; outline-offset: -2px; }
|
||
.cell.sel { outline: 2px solid #00897b; outline-offset: -2px; background: #e0f2f1; }
|
||
.cell.dirty { box-shadow: inset 0 0 0 2px #ff9800; }
|
||
.cell.cov { cursor: default; font-size: 11px; }
|
||
.code-chip { display: inline-block; min-width: 18px; padding: 1px 5px; border-radius: 4px; font-weight: 700; font-size: 11px; line-height: 16px; margin: 1px; }
|
||
.cell-dirty-demo { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 4px; font-weight: 700; font-size: 11px; background: #1976d2; color: #fff; box-shadow: inset 0 0 0 2px #ff9800; }
|
||
.ch-h { opacity: .7; font-weight: 400; font-size: 9px; margin-left: 1px; }
|
||
.free { color: #ccc; }
|
||
.offshift-warn { display: inline-flex; align-items: center; gap: 1px; font-size: 10px; font-weight: 700; color: #ef6c00; cursor: pointer; line-height: 1; } /* job assigné un jour sans quart publié */
|
||
.hdr-ruler { position: relative; height: 11px; margin-top: 3px; }
|
||
.hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; }
|
||
.hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; }
|
||
.tl { position: relative; height: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; }
|
||
.tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 2px; border: 1px solid rgba(55,65,120,.5); box-sizing: border-box; } /* fenêtre dispo (contour foncé pour la distinguer du fond) */
|
||
.tl-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */
|
||
.tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */
|
||
.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; } /* occupé = barre de statut opaque */
|
||
.tod-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(210,45%,91%), hsl(270,45%,83%)); }
|
||
.occ-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(122,68%,44%), hsl(32,68%,44%)); }
|
||
.leg-absent { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg,#cfcfcf 0,#cfcfcf 3px,#f0f0f0 3px,#f0f0f0 6px); }
|
||
.leg-garde { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; background: rgba(255,179,0,.14); border: 1px dashed #f9a825; }
|
||
tr.paused .tech-col { color: #aaa; }
|
||
tfoot .sum td { background: #fafafa; font-size: 11px; color: #555; font-weight: 600; }
|
||
tfoot .sum .tech-col { background: #fafafa; }
|
||
.eff { font-size: 9px; border-radius: 3px; padding: 0 4px; margin-left: 3px; font-weight: 600; }
|
||
.eff.fast { color: #1b5e20; background: #c8e6c9; }
|
||
.eff.slow { color: #b71c1c; background: #ffe0b2; }
|
||
.demand-tbl { border-collapse: collapse; }
|
||
.demand-tbl th { font-size: 11px; color: #888; font-weight: 600; padding: 2px 6px; text-align: left; }
|
||
.demand-tbl td { padding: 2px 4px; }
|
||
</style>
|