gigafibre-fsm/apps/ops/src/pages/PlanificationPage.vue
louispaulb d0ab57b1b5 Garde: séquence de rotation éditable — doublons permis + remplacer un tech par position
Le multi-select est remplacé par un constructeur de SUITE ordonnée : « Ajouter un tech à la suite »
(push, doublons autorisés → tours inégaux ex. A,A,B,C) ; chaque position a un select pour REMPLACER
le tech, + ↑/↓ pour réordonner, + ✕ pour retirer. Couvre rotations inégales et substitutions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:01:42 -04:00

853 lines
75 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<q-page padding>
<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-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:160px" @update:model-value="onWeekChange" />
<q-select dense outlined v-model="days" :options="[7, 14]" style="width:80px" 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-btn dense :outline="!showDemand" :unelevated="showDemand" color="indigo" icon="tune" label="Demande" @click="showDemand = !showDemand" />
<q-btn dense outline color="brown" icon="bookmark" label="Modèles">
<q-menu>
<q-list dense style="min-width:230px">
<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 comme modèle…</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-btn>
<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-btn dense outline color="brown" icon="shield" label="Garde" @click="showGarde = true"><q-tooltip>Rotation de garde par département</q-tooltip></q-btn>
<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>
<!-- Filtres -->
<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" />
<q-btn dense flat icon="speed" label="Cadence équipe" @click="openTeamEditor" />
<q-btn dense flat icon="beach_access" label="Congés" @click="openLeave" />
<span class="text-caption text-grey-6">{{ visibleTechs.length }} / {{ techs.length }} techs</span>
</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> &gt; 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>
<q-banner v-if="selection.length" dense rounded class="bg-teal-1 text-teal-9 q-mb-sm">
{{ selection.length }} cellule(s) — assigner :
<q-btn v-for="t in presetTemplates" :key="t.name" dense unelevated size="sm" class="q-mx-xs" :style="chip(t.color)" :label="code(t)" @click="assignBulk(t)"><q-tooltip>{{ t.template_name }}</q-tooltip></q-btn>
<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 = []" />
</q-banner>
<div class="row items-center q-gutter-xs q-mb-sm text-caption">
<q-chip v-if="cellClipboard.length" dense size="sm" color="indigo" text-color="white" icon="content_paste">{{ cellClipboard.length }} copié(s)</q-chip>
<span class="text-grey-7 q-mr-xs">Légende :</span>
<span class="tod-leg"></span><span class="text-grey-7 q-mr-sm">dispo (matin → soir)</span>
<span class="occ-leg"></span><span class="text-grey-7 q-mr-sm">occupation</span>
<span class="leg-absent"></span><span class="text-grey-7 q-mr-sm">absent</span>
<span class="leg-garde"></span><span class="text-grey-7 q-mr-sm">garde</span>
<span class="free q-mr-xs">·</span><span class="text-grey-7 q-mr-sm">libre</span>
<span class="cell-dirty-demo q-mr-xs">J</span><span class="text-grey-7 q-mr-sm">modifié (non publié)</span>
<span class="text-teal-8">· <b>glisser</b> = sélection · shift+clic = bloc · clic en-tête = colonne · clic nom = rangée · ctrl+clic = +1 · <b>ctrl+C/V</b> = copier/coller · <b>Suppr/⌫</b> = vider</span>
</div>
<div class="grid-wrap">
<table class="roster-grid">
<thead>
<tr>
<th class="tech-col">Technicien</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) }">
<td class="tech-col clk" @click="maybeSelectRow(ti)">
<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>
{{ t.name }}
<span v-if="t.group" class="grp">{{ t.group }}</span>
<span v-if="t.efficiency && t.efficiency !== 1" class="eff" :class="t.efficiency < 1 ? 'fast' : 'slow'">{{ effLabel(t.efficiency) }}</span>
<span v-if="hoursOf(t.id)" :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>
</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) }" @mousedown="onDown(ti, di, $event)" @mouseenter="onEnter(ti, di)" @click="onCellClick(t, d, $event, ti, di)">
<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="cellsOf(t.id, d.iso).length">
<div v-if="cellOcc(t.id, d.iso)" 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 cellOcc(t.id, d.iso).blocks" :key="'j' + bi" class="tl-blk" :style="blockStyle(b, cellOcc(t.id, d.iso).pct)"></div>
<q-tooltip class="bg-grey-9">{{ cellInterval(t.id, d.iso) }}<template v-if="cellOcc(t.id, d.iso).bookableH"> · {{ cellOcc(t.id, d.iso).usedH }} h occupé / {{ cellOcc(t.id, d.iso).bookableH }} h offrable ({{ cellOcc(t.id, d.iso).pct }} %)</template></q-tooltip>
</div>
</template>
<span v-else class="free">·</span>
</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 &amp; 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 &amp; 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:620px">
<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>
<div class="text-caption text-grey-7 q-mb-sm">La garde tourne entre les techs d'un département, à la période choisie — <b>indépendante par département</b>. « Appliquer » génère les gardes de la semaine affichée (un tech absent est sauté au profit du suivant dans la rotation).</div>
<q-list v-if="gardeRules.length" dense bordered class="rounded-borders q-mb-md">
<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) }}</q-item-label>
<q-item-label caption>{{ gardeDowLabel(r) }} · toutes les {{ r.periodWeeks }} sem. · rotation : {{ r.techs.map(id => (techs.find(t => t.id === id) || {}).name || id).join(' → ') }}</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 class="text-caption text-weight-medium q-mb-xs">{{ editingGardeId ? 'Modifier la règle' : 'Nouvelle règle' }}</div>
<div class="row q-col-gutter-sm items-end">
<q-select dense outlined v-model="newGardeRule.dept" :options="groupNames" use-input fill-input hide-selected new-value-mode="add-unique" input-debounce="0" label="Département (optionnel)" style="width:180px" hint="existant ou tape un nom" />
<q-select dense outlined v-model="newGardeRule.shift" :options="gardeTemplateOptions" emit-value map-options label="Shift de garde" style="width:190px" />
<q-input dense outlined type="number" min="1" v-model.number="newGardeRule.periodWeeks" label="Sem. consécutives / tech" style="width:160px"><q-tooltip>2 = chaque tech fait 2 semaines de suite avant de passer au suivant</q-tooltip></q-input>
</div>
<div class="q-mt-sm row items-center q-gutter-xs">
<span class="text-caption text-grey-7 q-mr-xs">Jours :</span>
<q-chip v-for="dw in GARDE_DOW" :key="dw.v" clickable dense :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>
<q-btn flat dense size="sm" no-caps label="Tous les soirs" @click="newGardeRule.weekdays = [1,2,3,4,5,6,0]" />
<q-btn flat dense size="sm" no-caps label="Week-ends" @click="newGardeRule.weekdays = [5,6,0]" />
</div>
<div class="row items-end q-gutter-sm q-mt-sm">
<q-select dense outlined v-model="gardePick" :options="techOptions" emit-value map-options label="Ajouter un tech à la suite" style="min-width:210px" />
<q-btn dense unelevated color="grey-7" icon="add" label="Ajouter" :disable="!gardePick" @click="addTechToSeq" />
</div>
<div v-if="newGardeRule.techs.length" class="q-mt-xs">
<div class="text-caption text-grey-7">Séquence de rotation ({{ newGardeRule.techs.length }} positions — doublons permis pour des tours inégaux) :</div>
<div v-for="(id, i) in newGardeRule.techs" :key="i" class="row items-center no-wrap q-gutter-xs q-mt-xs">
<span class="text-caption text-weight-medium" style="min-width:22px">{{ i + 1 }}.</span>
<q-select dense outlined options-dense v-model="newGardeRule.techs[i]" :options="techOptions" emit-value map-options style="min-width:175px" />
<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.techs.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.techs.splice(i, 1)"><q-tooltip>Retirer</q-tooltip></q-btn>
</div>
</div>
<div class="row items-center q-mt-md">
<q-btn dense unelevated color="brown" :icon="editingGardeId ? 'save' : 'add'" :label="editingGardeId ? 'Mettre à jour' : 'Ajouter la règle'" @click="addGardeRule" />
<q-btn v-if="editingGardeId" flat dense class="q-ml-xs" label="Annuler" @click="editingGardeId = null; newGardeRule.techs = []; newGardeRule.weekdays = []" />
<q-space />
<q-btn dense unelevated color="primary" icon="auto_awesome" label="Appliquer à la semaine" @click="applyGardeRules" />
</div>
</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>
<!-- Raccourcis (près du clic) -->
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense unelevated size="sm" color="primary" label="Normal 817" class="col" @click="quickShift(8, 17)" />
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 1620" class="col" @click="quickShift(16, 20)" />
</div>
<!-- Slider d'ajustement (en haut = proche du clic ; Appliquer dans la même rangée) -->
<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-toggle dense v-model="menuOnCall" :true-value="1" :false-value="0" label="Garde" color="brown" />
<q-btn dense unelevated size="sm" color="primary" label="Appliquer" @click="applyMenuRange" />
</div>
</div>
<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>
import { ref, computed, reactive, onMounted, onUnmounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import TechSelect from 'src/components/shared/TechSelect.vue'
import SkillSelect from 'src/components/shared/SkillSelect.vue'
const $q = useQuasar()
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 newGardeRule = reactive({ dept: '', shift: '', weekdays: [], periodWeeks: 1, techs: [] })
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 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'
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 })))
function code (t) { return (t.template_name || t.name || '?').trim()[0].toUpperCase() }
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
// Modèles triés par usage (les plus utilisés en premier) — pour l'assignation rapide
const templatesRanked = computed(() => {
const cnt = {}; for (const a of assignments.value) cnt[a.shift] = (cnt[a.shift] || 0) + 1
return templates.value.slice().sort((x, y) => (cnt[y.name] || 0) - (cnt[x.name] || 0) || (x.template_name || '').localeCompare(y.template_name || ''))
})
// Presets « nommés » seulement (Jour/Soir/…) → barre d'assignation + légende propres, même si des modèles auto existent
const presetTemplates = computed(() => templatesRanked.value.filter(t => /[a-gi-zA-GI-Z]/.test(t.template_name || '')))
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 })) })
const visibleTechs = computed(() => {
const q = search.value.trim().toLowerCase()
return techs.value.filter(t => (!groupFilter.value || t.group === groupFilter.value) && (!q || (t.name || '').toLowerCase().includes(q) || (t.group || '').toLowerCase().includes(q)))
.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) { return statByDate.value[iso] || {} }
const hasOnCall = computed(() => 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' }
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 de la semaine (la garde n'élargit pas).
const axisBounds = computed(() => {
let lo = Infinity; let hi = -Infinity
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; 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) } }
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 = chaque shift. Régulier = dégradé matin→soir ; garde (on_call) = hachuré (réserve).
function cellBands (techId, iso) {
const out = []
for (const a of cellsOf(techId, iso)) {
const t = tplByName.value[a.shift]; if (!t) continue
const oc = !!t.on_call
const s = hToNum(t.start_time); const e = hToNum(t.end_time); if (s == null || e == null) continue
if (e <= s) { out.push({ ...pos(s, 24), oncall: oc, bg: oc ? null : bandGradient(s, 24) }); out.push({ ...pos(0, e), oncall: oc, bg: oc ? null : bandGradient(0, e) }) }
else out.push({ ...pos(s, e), oncall: oc, bg: oc ? null : bandGradient(s, e) }) // chevauche minuit
}
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: [] }
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 || [] }
}
return m
})
function cellOcc (techId, iso) { return occCells.value[techId + '|' + iso] || null }
function cellInterval (techId, iso) {
return cellsOf(techId, iso).map(a => { const t = tplByName.value[a.shift]; const nm = (t && t.template_name) ? t.template_name.split(' ')[0] + ' ' : ''; const tag = (t && t.on_call) ? ' (garde)' : ''; return (t && t.start_time) ? (nm + t.start_time.slice(0, 5) + '' + (t.end_time || '').slice(0, 5) + tag) : (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
function effLabel (e) { const p = Math.round((e - 1) * 100); return (p < 0 ? '' : '+') + Math.abs(p) + '%' }
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 = [] } }
// ── 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 addTechToSeq () { if (gardePick.value) { newGardeRule.techs.push(gardePick.value); gardePick.value = null } } // doublons permis (tours inégaux)
function moveTech (i, dir) { const a = newGardeRule.techs; 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, weekdays: [...r.weekdays], periodWeeks: r.periodWeeks || 1, techs: [...r.techs] }); editingGardeId.value = r.id }
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 weekIndex (iso) { return Math.round((d2ms(mondayISO(iso)) - d2ms(GARDE_EPOCH)) / (7 * 86400000)) }
// Tech de garde pour une date : tourne toutes les periodWeeks ; saute un tech absent au profit du suivant.
function rotationTech (rule, iso) {
if (!rule.techs || !rule.techs.length) return null
const base = Math.floor(weekIndex(iso) / (rule.periodWeeks || 1))
for (let k = 0; k < rule.techs.length; k++) { const id = rule.techs[((base + k) % rule.techs.length + rule.techs.length) % rule.techs.length]; if (!isAbsent(id, iso)) return id }
return rule.techs[((base % rule.techs.length) + rule.techs.length) % rule.techs.length]
}
function toggleGardeDow (v) { const i = newGardeRule.weekdays.indexOf(v); if (i >= 0) newGardeRule.weekdays.splice(i, 1); else newGardeRule.weekdays.push(v) }
function saveGarde () { localStorage.setItem(LS_GARDE, JSON.stringify(gardeRules.value)) }
function addGardeRule () {
if (!newGardeRule.shift || !newGardeRule.techs.length || !newGardeRule.weekdays.length) { $q.notify({ type: 'warning', message: 'Shift, jours et techs requis (département optionnel)' }); return }
const rule = { id: editingGardeId.value || Date.now(), dept: newGardeRule.dept || '—', shift: newGardeRule.shift, weekdays: [...newGardeRule.weekdays], periodWeeks: newGardeRule.periodWeeks || 1, techs: [...newGardeRule.techs] }
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.techs = []; newGardeRule.weekdays = []
$q.notify({ type: 'positive', message: 'Règle de garde enregistrée' })
}
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('') }
// Génère les gardes de la semaine affichée selon les règles (rotation par département)
function applyGardeRules () {
if (!gardeRules.value.length) { $q.notify({ type: 'info', message: 'Aucune règle — ouvre « Garde » pour en créer' }); return }
pushHistory(); let added = 0
for (const d of dayList.value) {
const dow = dowOf(d.iso)
for (const rule of gardeRules.value) {
if (!rule.weekdays.includes(dow)) continue
const tpl = tplByName.value[rule.shift]; if (!tpl) continue
const id = rotationTech(rule, d.iso); if (!id) continue
const t = techs.value.find(x => x.id === id); addShift(id, t ? t.name : id, d.iso, tpl); added++
}
}
$q.notify({ type: 'positive', message: added + ' garde(s) appliquée(s) — Publier pour confirmer' })
}
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 menuOnCall = ref(0)
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); const wg = winOf(t.id, d.iso, true); const w0 = wr || wg
menuRange.value = w0 ? { min: w0.s, max: w0.e } : { min: 8, max: 16 }; menuOnCall.value = wr ? 0 : (wg ? 1 : 0)
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])] }
const menuCellShifts = computed(() => (menu.tech && menu.day) ? cellsOf(menu.tech.id, menu.day.iso) : [])
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.
async function applyWindow (min, max, oncall) {
if (!menu.tech || !menu.day || max <= min) return
const s = numToTime(min); const e = numToTime(max)
const nm = fmtH(min) + 'h' + fmtH(max) + 'h' + (oncall ? ' (garde)' : '')
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: oncall ? '#f9a825' : '#1976d2', default_required: 1, on_call: oncall ? 1 : 0 }); await refreshTemplates(); tpl = templates.value.find(t => t.template_name === nm) } catch (e2) { err(e2); return } }
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, 0) }
async function applyMenuRange () { return applyWindow(menuRange.value.min, menuRange.value.max, menuOnCall.value) }
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 = [] }
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 = []
}
}
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() })
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>
.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: 195px; 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; }
.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; }
.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; }
.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>