gigafibre-fsm/apps/ops/src/pages/PlanificationPage.vue
louispaulb d7867e2a62 Planification: cellule = barre de dispo seule sur une échelle de temps (règle horaire en-tête)
- En-tête de chaque jour: règle horaire (graduations alignées sur l'axe adaptatif, ex 8/12/16/20).
- Cellule réduite à LA barre de dispo (dégradé matin→soir, occupé assombri, garde hachuré) —
  plus de texte d'intervalle : on lit la position contre la règle, détails au survol.
- Barre un peu plus haute (11px). Retrait cellLabel/cell-int/cell-chips.

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

696 lines
61 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>{{ 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 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">matin → soir</span>
<span class="tod-garde"></span><span class="text-grey-7 q-mr-sm">garde</span>
<span class="code-chip" style="background:#e0e0e0;color:#777">P</span><span class="text-grey-7 q-mr-sm">pause</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 une case</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="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-if="isPaused(t)" class="code-chip" style="background:#eee;color:#999">P</span>
<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-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>
<q-menu v-model="menu.show" :target="menu.target" anchor="bottom left" self="top left">
<q-list dense style="min-width:170px">
<q-item-label header>{{ menu.tech && menu.tech.name }} — {{ menu.day && menu.day.dnum }}</q-item-label>
<q-item v-for="a in menuCellShifts" :key="'c' + a.shift">
<q-item-section avatar><span class="code-chip" :style="chip(cellColor(a))">{{ cellCode(a) }}</span></q-item-section>
<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>
<q-separator />
<q-item-label header>Ajouter un shift</q-item-label>
<q-item v-for="t in templates" :key="t.name" clickable v-close-popup @click="addFromMenu(t)"><q-item-section avatar><span class="code-chip" :style="chip(t.color)">{{ code(t) }}</span></q-item-section><q-item-section>{{ t.template_name }}</q-item-section></q-item>
<q-item v-if="menuCellShifts.length" clickable v-close-popup @click="clearOne"><q-item-section class="text-grey-7">Libérer tout</q-item-section></q-item>
<q-separator />
<q-item clickable v-close-popup @click="copyFromMenu"><q-item-section avatar><q-icon name="content_copy" size="18px" /></q-item-section><q-item-section>Copier cette case</q-item-section></q-item>
<q-item clickable v-close-popup @click="pasteFromMenu" :disable="!cellClipboard.length"><q-item-section avatar><q-icon name="content_paste" size="18px" /></q-item-section><q-item-section>Coller{{ cellClipboard.length ? ' (' + cellClipboard.length + ')' : '' }}</q-item-section></q-item>
<q-separator />
<q-item-label header>Ajuster l'horaire (glisser)</q-item-label>
<div class="q-px-md q-pb-sm" style="min-width:230px" @click.stop>
<q-range v-model="menuRange" :min="0" :max="24" :step="0.5" snap label :left-label-value="fmtH(menuRange.min) + 'h'" :right-label-value="fmtH(menuRange.max) + 'h'" color="primary" />
<div class="row items-center no-wrap q-gutter-sm q-mt-xs">
<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-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 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: '' })
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'
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 codeByShift = computed(() => Object.fromEntries(templates.value.map(t => [t.name, code(t)])))
const colorByShift = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t.color || '#1976d2'])))
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 cellCode (a) { return codeByShift.value[a.shift] || (a.shift_name || a.shift || '?')[0].toUpperCase() }
function cellColor (a) { return a.color || colorByShift.value[a.shift] || '#1976d2' }
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({})
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) + '%' } }
// Couleur selon l'heure : bleu pâle le matin → violet le soir
function todColor (h) { const t = Math.max(0, Math.min(1, (h - 6) / 15)); return 'hsl(' + Math.round(210 + t * 60) + ',' + Math.round(62 - t * 6) + '%,' + Math.round(83 - t * 22) + '%)' }
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
}
// Occupé = assombrit la dispo (clair = libre/offrable) ; rouge si surbooké.
function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: (pct != null && pct >= 100) ? 'rgba(229,57,53,.6)' : 'rgba(0,0,0,.28)' } }
// 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 }); $q.notify({ type: 'positive', message: 'Demande créée' }); newLeave.from_date = ''; newLeave.to_date = ''; newLeave.reason = ''; 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 */ }
}
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 = [] } }
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)) }
function applyTemplate (tm) {
pushHistory()
for (const d of dayList.value) { const map = tm.byDow[dowOf(d.iso)]; if (!map) continue
for (const techId in map) { 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) } }
$q.notify({ type: 'info', message: 'Modèle « ' + tm.name + ' » appliqué — Publier pour confirmer' })
}
// é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 addFromMenu (tpl) { if (menu.tech && menu.day) { pushHistory(); addShift(menu.tech.id, menu.tech.name, menu.day.iso, tpl) } }
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 }
async function applyMenuRange () {
if (!menu.tech || !menu.day) return
const s = numToTime(menuRange.value.min); const e = numToTime(menuRange.value.max)
const nm = fmtH(menuRange.value.min) + 'h' + fmtH(menuRange.value.max) + 'h' + (menuOnCall.value ? ' (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: menuOnCall.value ? '#6d4c41' : '#1976d2', default_required: 1, on_call: menuOnCall.value ? 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 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 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() }
}
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: 1px; } /* fenêtre dispo = neutre */
.tl-shift.oncall { background: repeating-linear-gradient(45deg, #d7ccc8 0, #d7ccc8 2px, transparent 2px, transparent 4px); box-shadow: inset 0 0 0 1px #a1887f; } /* garde = hachuré (réserve, non offrable) */
.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 1px; } /* occupé = assombrit la dispo */
.tod-leg { display: inline-block; width: 46px; height: 9px; border-radius: 2px; vertical-align: middle; background: linear-gradient(to right, hsl(210,62%,83%), hsl(270,56%,61%)); }
.tod-garde { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; background: repeating-linear-gradient(45deg,#d7ccc8 0,#d7ccc8 2px,transparent 2px,transparent 4px); box-shadow: inset 0 0 0 1px #a1887f; }
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>