- Plages combinables: « Soirs de semaine » (L-V) + « Fin de semaine » (S-D) en toggles (+ chips fins). - Rotation par défaut = PAR SEMAINE (garde = bloc hebdo) → corrige la séquence brisée (par jour, un week-end pouvait avoir 2 personnes). Index de semaine continu dans le temps → ordre respecté en naviguant. Séquence [A,A,B] = A 2 semaines consécutives, puis B. (Sélecteur jour/semaine conservé.) - APERÇU live : qui est de garde sur les 8 prochaines semaines, reflète la file en cours d'édition → on voit l'ordre respecté + l'effet des modifs avant d'appliquer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
888 lines
78 KiB
Vue
888 lines
78 KiB
Vue
<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> > 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 & disponibilités</div><q-space />
|
||
<q-select dense outlined v-model="leaveFilter" :options="['Demandé', 'Approuvé', 'Refusé', '']" style="width:130px" @update:model-value="loadLeave" />
|
||
<q-btn flat round dense icon="close" v-close-popup class="q-ml-sm" />
|
||
</q-card-section>
|
||
<q-card-section>
|
||
<table class="demand-tbl" style="width:100%">
|
||
<thead><tr><th>Technicien</th><th>Type</th><th>Du</th><th>Au</th><th>Motif</th><th>Statut</th><th></th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="l in leaveRows" :key="l.name">
|
||
<td>{{ l.technician_name || l.technician }}</td><td>{{ l.availability_type }}</td><td>{{ l.from_date }}</td><td>{{ l.to_date }}</td><td>{{ l.reason }}</td>
|
||
<td><q-badge :color="l.status === 'Approuvé' ? 'green' : l.status === 'Refusé' ? 'red' : 'orange'">{{ l.status }}</q-badge></td>
|
||
<td><template v-if="l.status === 'Demandé'"><q-btn flat dense round size="sm" icon="check" color="green" @click="approveLeave(l, false)" /><q-btn flat dense round size="sm" icon="close" color="red" @click="approveLeave(l, true)" /></template></td>
|
||
</tr>
|
||
<tr v-if="!leaveRows.length"><td colspan="7" class="text-grey-6 q-pa-sm">Aucune demande.</td></tr>
|
||
</tbody>
|
||
</table>
|
||
<q-separator class="q-my-md" />
|
||
<div class="text-caption text-weight-bold q-mb-xs">Nouvelle demande</div>
|
||
<div class="row items-center q-gutter-sm">
|
||
<TechSelect v-model="newLeave.technician" :options="techOptions" label="Technicien" style="width:200px" />
|
||
<q-select dense outlined v-model="newLeave.availability_type" :options="['Congé', 'Pause', 'Indisponible', 'Maladie']" label="Type" style="width:130px" />
|
||
<q-input dense outlined type="date" v-model="newLeave.from_date" label="Du" style="width:140px" />
|
||
<q-input dense outlined type="date" v-model="newLeave.to_date" label="Au" style="width:140px" />
|
||
<q-input dense outlined v-model="newLeave.reason" label="Motif" style="width:150px" />
|
||
<q-toggle dense v-model="newLeave.long_term" :true-value="1" :false-value="0" label="Longue durée"><q-tooltip>Maternité, invalidité… → à remplacer (pas juste sauter comme des vacances)</q-tooltip></q-toggle>
|
||
<q-btn dense unelevated color="primary" icon="add" label="Créer" @click="createLeave" />
|
||
</div>
|
||
<div class="text-caption text-grey-7 q-mt-sm">Une demande <b>approuvée</b> rend le tech indisponible pour le solveur sur ces dates.</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<q-dialog v-model="showTeamEditor">
|
||
<q-card style="min-width:900px">
|
||
<q-card-section class="row items-center q-pb-none"><div class="text-subtitle1 text-weight-bold">Équipe — cadence & coût</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
|
||
<q-card-section>
|
||
<div class="text-caption text-grey-7 q-mb-sm">Cadence : 1.00 normal · 1.10 = +10 % (plus lent) · 0.90 = −10 % (plus rapide). Coût chargé/h = salaire × (1 + charges %) + autres (véhicule, outils, frais). Le solveur préfère les techs rapides et moins coûteux.</div>
|
||
<div style="max-height:55vh;overflow:auto">
|
||
<table class="demand-tbl" style="width:100%">
|
||
<thead><tr><th>Technicien</th><th>Compétences</th><th>Cadence</th><th>Salaire/h</th><th>Charges %</th><th>Autres/h</th><th>Coût chargé/h</th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="t in editTechs" :key="t.id">
|
||
<td>{{ t.name }}<span v-if="t.group" class="grp">{{ t.group }}</span></td>
|
||
<td><SkillSelect v-model="t.skills" style="min-width:160px" @update:model-value="saveSkills(t)" /></td>
|
||
<td><q-input dense outlined type="number" step="0.05" v-model.number="t.efficiency" style="width:80px" @blur="saveEff(t)" /></td>
|
||
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.salary" style="width:80px" @blur="saveCost(t)" /></td>
|
||
<td><q-input dense outlined type="number" step="1" v-model.number="t.charges" style="width:80px" @blur="saveCost(t)" /></td>
|
||
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.other" style="width:80px" @blur="saveCost(t)" /></td>
|
||
<td class="text-weight-bold text-center">{{ loadedCost(t) }} $</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</q-dialog>
|
||
|
||
<!-- Rotation de garde par département -->
|
||
<q-dialog v-model="showGarde">
|
||
<q-card style="min-width: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) }} · change tous les {{ r.periodWeeks }} {{ (r.unit || 'occ') === 'week' ? 'sem.' : 'jour(s) de garde' }} · 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-select dense outlined v-model="newGardeRule.unit" :options="[{ label: 'par jour de garde', value: 'occ' }, { label: 'par semaine', value: 'week' }]" emit-value map-options label="Rotation" style="width:150px" />
|
||
<q-input dense outlined type="number" min="1" v-model.number="newGardeRule.periodWeeks" :label="newGardeRule.unit === 'week' ? 'Semaines / tech' : 'Jours de garde / tech'" style="width:150px"><q-tooltip>Occurrences consécutives avant de passer au tech suivant (1 = change à chaque {{ newGardeRule.unit === 'week' ? 'semaine' : 'jour de garde' }})</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">Plages (hors bureau) :</span>
|
||
<q-btn :outline="!isSetActive(WD_SEMAINE)" :unelevated="isSetActive(WD_SEMAINE)" dense size="sm" color="brown" no-caps label="Soirs de semaine" @click="toggleWeekdaysSet(WD_SEMAINE)" />
|
||
<q-btn :outline="!isSetActive(WD_FINSEM)" :unelevated="isSetActive(WD_FINSEM)" dense size="sm" color="brown" no-caps label="Fin de semaine" @click="toggleWeekdaysSet(WD_FINSEM)" />
|
||
<span class="text-grey-5 q-mx-xs">·</span>
|
||
<q-chip v-for="dw in GARDE_DOW" :key="dw.v" clickable dense size="sm" :color="newGardeRule.weekdays.includes(dw.v) ? 'brown' : 'grey-4'" :text-color="newGardeRule.weekdays.includes(dw.v) ? 'white' : 'grey-8'" @click="toggleGardeDow(dw.v)">{{ dw.l }}</q-chip>
|
||
</div>
|
||
<div class="text-caption text-grey-6 q-mt-xs">Combinables. La garde couvre les heures du shift choisi (hors bureau) ; pour des heures différentes semaine/week-end, crée 2 règles.</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 v-if="gardePreview.length" class="q-mt-sm bg-grey-1 rounded-borders q-pa-sm">
|
||
<div class="text-caption text-weight-medium">Aperçu (qui est de garde, prochaines semaines) :</div>
|
||
<div class="row q-gutter-xs q-mt-xs">
|
||
<q-chip v-for="(p, i) in gardePreview" :key="i" dense size="sm" color="brown-1" text-color="brown-9">sem. {{ p.week.slice(8) }}/{{ p.week.slice(5, 7) }} → {{ p.name }}</q-chip>
|
||
</div>
|
||
</div>
|
||
<div class="row items-center q-mt-md">
|
||
<q-btn dense unelevated color="brown" :icon="editingGardeId ? 'save' : 'add'" :label="editingGardeId ? 'Mettre à jour' : '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 8–17" class="col" @click="quickShift(8, 17)" />
|
||
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 16–20" class="col" @click="quickShift(16, 20)" />
|
||
</div>
|
||
<!-- 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, unit: 'week', 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, unit: r.unit || 'week', techs: [...r.techs] }); editingGardeId.value = r.id }
|
||
const WD_SEMAINE = [1, 2, 3, 4, 5]; const WD_FINSEM = [6, 0]
|
||
function isSetActive (set) { return set.length && set.every(v => newGardeRule.weekdays.includes(v)) }
|
||
function toggleWeekdaysSet (set) { if (isSetActive(set)) newGardeRule.weekdays = newGardeRule.weekdays.filter(v => !set.includes(v)); else newGardeRule.weekdays = [...new Set([...newGardeRule.weekdays, ...set])] }
|
||
function 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)) }
|
||
// Nb de jours de garde (matching weekdays) depuis l'époque, AVANT cette date → fait avancer la séquence jour par jour.
|
||
function occurrenceIndex (rule, iso) {
|
||
const days = Math.round((d2ms(iso) - d2ms(GARDE_EPOCH)) / 86400000); if (days <= 0) return 0
|
||
const wd = rule.weekdays || []; let cnt = Math.floor(days / 7) * wd.length
|
||
const epochDow = dowOf(GARDE_EPOCH)
|
||
for (let i = 0; i < days % 7; i++) if (wd.includes((epochDow + i) % 7)) cnt++
|
||
return cnt
|
||
}
|
||
// Index de rotation : par OCCURRENCE de garde (défaut → la séquence continue chaque jour de garde) ou par SEMAINE (bloc).
|
||
function gardeIndex (rule, iso) { return Math.floor(((rule.unit || 'week') === 'week' ? weekIndex(iso) : occurrenceIndex(rule, iso)) / (rule.periodWeeks || 1)) }
|
||
// Aperçu : qui est de garde sur les prochaines semaines (depuis la semaine affichée) — reflète la file en cours d'édition
|
||
const gardePreview = computed(() => {
|
||
const rule = newGardeRule; if (!rule.techs.length || !rule.weekdays.length) return []
|
||
const out = []; const wk0 = mondayISO(start.value)
|
||
for (let i = 0; i < 8; i++) {
|
||
const ws = addDaysISO(wk0, i * 7); let iso = null
|
||
for (let k = 0; k < 7; k++) { const d = addDaysISO(ws, k); if (rule.weekdays.includes(dowOf(d))) { iso = d; break } }
|
||
if (!iso) continue
|
||
const base = gardeIndex(rule, iso); const id = rule.techs[((base % rule.techs.length) + rule.techs.length) % rule.techs.length]
|
||
out.push({ week: ws, name: (techs.value.find(t => t.id === id) || {}).name || id })
|
||
}
|
||
return out
|
||
})
|
||
// Tech de garde pour une date ; saute un tech absent au profit du suivant.
|
||
function rotationTech (rule, iso) {
|
||
if (!rule.techs || !rule.techs.length) return null
|
||
const base = gardeIndex(rule, iso)
|
||
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, unit: newGardeRule.unit || 'occ', 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>
|