Avant: 'J8' ne distinguait pas 7-15 de 9-17 → mêmes créneaux apparents, dispo réelle différente.
Maintenant chaque cellule affiche: chip (lettre) + intervalle '7–15', et une mini-timeline sur un
axe de journée (06:00→21:00) où la fenêtre du shift est positionnée (donc 7-15 à gauche, 9-17 à
droite = visuellement distinctes) avec les blocs de jobs pris (couleur selon charge) → les TROUS
restants = créneaux offrables. Infobulle = intervalle + h occupées/h (%).
- hub occupancyByTechDay renvoie {h, blocks:[{s,e}]} (heures de début réelles des jobs).
- ops: cellWindow/axisPos/shiftStyle/blockStyle, rendu .tl/.tl-shift/.tl-blk + tick midi.
- démo 8 juin: modèles Matinal 7-15 + Décalé 9-17, techs alignés (7→13.8, 9→18.6 surbooké).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
604 lines
50 KiB
Vue
604 lines
50 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>{{ 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> > 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 templates" :key="t.name" dense unelevated size="sm" class="q-mx-xs" :style="chip(t.color)" :label="code(t)" @click="assignBulk(t)" />
|
||
<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">
|
||
<span class="text-grey-7 q-mr-xs">Légende :</span>
|
||
<template v-for="t in templates" :key="t.name"><span class="code-chip" :style="chip(t.color)">{{ code(t) }}</span><span class="text-grey-7 q-mr-sm">{{ t.template_name }}</span></template>
|
||
<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</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>
|
||
</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 class="cell-chips">
|
||
<span v-for="(a, ai) in cellsOf(t.id, d.iso)" :key="ai" class="code-chip" :style="chip(cellColor(a))">{{ cellCode(a) }}</span>
|
||
<span class="cell-int">{{ cellWindowLabel(t.id, d.iso) }}</span>
|
||
</div>
|
||
<div v-if="cellOcc(t.id, d.iso)" class="tl">
|
||
<div class="tl-shift" :style="shiftStyle(t.id, d.iso)"></div>
|
||
<div v-for="(b, bi) in cellOcc(t.id, d.iso).blocks" :key="bi" class="tl-blk" :style="blockStyle(b, cellOcc(t.id, d.iso).pct)"></div>
|
||
<div class="tl-noon"></div>
|
||
<q-tooltip class="bg-grey-9">{{ cellInterval(t.id, d.iso) }} · {{ cellOcc(t.id, d.iso).usedH }} h / {{ cellOcc(t.id, d.iso).shiftH }} h occupé ({{ cellOcc(t.id, d.iso).pct }} %)</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 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></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><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="Nouveau type" style="width:170px" />
|
||
<q-input dense outlined type="time" v-model="newTpl.start" label="Début" style="width:110px" />
|
||
<q-input dense outlined type="time" v-model="newTpl.end" label="Fin" style="width:110px" />
|
||
<span class="text-caption text-grey-7">{{ calcHours(newTpl.start, newTpl.end) }} h</span>
|
||
<input type="color" v-model="newTpl.color" style="width:36px;height:26px;border:none;background:none" />
|
||
<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-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>
|
||
|
||
<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">{{ cellHours(a) }}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-separator v-if="menuCellShifts.length" />
|
||
<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-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 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' })
|
||
|
||
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])))
|
||
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) h += Number(a.hours) || 0; return h }
|
||
|
||
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] || {} }
|
||
|
||
// Occupation + fenêtre par cellule, visualisées sur un axe de journée (06:00 → 21:00).
|
||
const occByTechDay = ref({})
|
||
const AXIS_START = 6; const AXIS_SPAN = 15 // 06:00 → 21:00
|
||
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) }
|
||
// Fenêtre de travail de la cellule = min(début) → max(fin) des shifts du jour
|
||
function cellWindow (techId, iso) {
|
||
let s = Infinity; let e = -Infinity
|
||
for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t) 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) && isFinite(e) && e > s) ? { s, e } : null
|
||
}
|
||
function cellWindowLabel (techId, iso) { const w = cellWindow(techId, iso); return w ? (fmtH(w.s) + '–' + fmtH(w.e)) : '' }
|
||
const occCells = computed(() => {
|
||
const m = {}; const ct = cellsByTechDay.value
|
||
for (const techId in ct) for (const iso in ct[techId]) {
|
||
const shiftH = ct[techId][iso].reduce((s, a) => s + (Number(a.hours) || 0), 0)
|
||
if (shiftH <= 0) continue
|
||
const o = occByTechDay.value[techId + '|' + iso] || { h: 0, blocks: [] }
|
||
m[techId + '|' + iso] = { shiftH, usedH: Math.round((o.h || 0) * 10) / 10, pct: Math.round((o.h || 0) / shiftH * 100), blocks: o.blocks || [] }
|
||
}
|
||
return m
|
||
})
|
||
function cellOcc (techId, iso) { return occCells.value[techId + '|' + iso] || null }
|
||
function occColor (pct) { return pct >= 100 ? '#e53935' : pct >= 70 ? '#fb8c00' : '#43a047' }
|
||
// Positionnement sur l'axe (en %)
|
||
function axisPos (s, e) { const left = Math.max(0, (s - AXIS_START) / AXIS_SPAN * 100); const width = Math.max(2, Math.min(100 - left, (e - s) / AXIS_SPAN * 100)); return { left: left + '%', width: width + '%' } }
|
||
function shiftStyle (techId, iso) { const w = cellWindow(techId, iso); return w ? axisPos(w.s, w.e) : { display: 'none' } }
|
||
function blockStyle (blk, pct) { return { ...axisPos(blk.s, blk.e), background: occColor(pct) } }
|
||
function cellInterval (techId, iso) {
|
||
return cellsOf(techId, iso).map(a => { const t = tplByName.value[a.shift]; return t && t.start_time ? (t.start_time.slice(0, 5) + '–' + (t.end_time || '').slice(0, 5)) : (a.shift_name || a.shift) }).join(' + ')
|
||
}
|
||
|
||
// coût de main-d'œuvre (coût chargé × heures)
|
||
const costByTech = computed(() => Object.fromEntries(techs.value.map(t => [t.id, t.cost_h || 0])))
|
||
const costByDate = computed(() => { const m = {}; for (const a of assignments.value) m[a.date] = (m[a.date] || 0) + (Number(a.hours) || 0) * (costByTech.value[a.tech] || 0); return m })
|
||
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' })); 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 }); await refreshTemplates(); $q.notify({ type: 'positive', message: t.template_name + ' enregistré (' + calcHours(t.start, t.end) + ' h)' }) } catch (e) { err(e) } }
|
||
async function addShiftTpl () { if (!newTpl.template_name) { $q.notify({ type: 'warning', message: 'Nom requis' }); return } try { await roster.createTemplate({ template_name: newTpl.template_name, start_time: newTpl.start + ':00', end_time: newTpl.end + ':00', hours: calcHours(newTpl.start, newTpl.end), color: newTpl.color, default_required: 1 }); newTpl.template_name = ''; await refreshTemplates(); openShiftEditor(); $q.notify({ type: 'positive', message: 'Type 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 })
|
||
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 }
|
||
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; 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 } }
|
||
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 = [] }
|
||
|
||
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 z = e.key.toLowerCase() === 'z'; if ((e.ctrlKey || e.metaKey) && z) { e.preventDefault(); if (e.shiftKey) redo(); else undo() } }
|
||
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; }
|
||
.cell-chips { line-height: 1; white-space: nowrap; }
|
||
.cell-int { font-size: 9px; color: #555; font-weight: 600; margin-left: 3px; }
|
||
.tl { position: relative; height: 7px; background: #eef0f2; border-radius: 2px; margin-top: 3px; overflow: hidden; }
|
||
.tl-shift { position: absolute; top: 0; bottom: 0; background: #cfd8dc; border-radius: 2px; }
|
||
.tl-blk { position: absolute; top: 0; bottom: 0; border-radius: 2px; opacity: .92; }
|
||
.tl-noon { position: absolute; top: 0; bottom: 0; left: 40%; width: 1px; background: rgba(0, 0, 0, .12); }
|
||
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>
|