Planification: rotation de garde par département (récurrence + rotation)

Dialogue « Garde » : règles par département (tech_group) = {shift de garde, jours, période (toutes
les X sem.), techs en rotation ordonnés}. Indépendantes entre départements (non synchronisées).
« Appliquer à la semaine » génère les gardes : pour chaque jour ciblé, le tech de garde = rotation
(index de semaine / période % liste) ; un tech absent est SAUTÉ au profit du suivant. Règles persistées
(localStorage roster-garde-rules-v1). Les gardes s'affichent en pointillé ambre (on_call), hors heures
travaillées/booking déjà en place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-04 20:27:24 -04:00
parent 060ee578c3
commit 8d946daf8d

View File

@ -29,6 +29,7 @@
</q-menu> </q-menu>
</q-btn> </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 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-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-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 :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" @click="doPublish" />
@ -255,6 +256,45 @@
</q-card> </q-card>
</q-dialog> </q-dialog>
<!-- Rotation de garde par département -->
<q-dialog v-model="showGarde">
<q-card style="min-width:620px">
<q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-bold">🛡 Rotation de garde (par département)</div><q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div class="text-caption text-grey-7 q-mb-sm">La garde tourne entre les techs d'un département, à la période choisie <b>indépendante par département</b>. « Appliquer » génère les gardes de la semaine affichée (un tech absent est sauté au profit du suivant dans la rotation).</div>
<q-list v-if="gardeRules.length" dense bordered class="rounded-borders q-mb-md">
<q-item v-for="(r, i) in gardeRules" :key="r.id">
<q-item-section>
<q-item-label class="text-weight-medium">{{ r.dept }} · {{ shiftName(r.shift) }}</q-item-label>
<q-item-label caption>{{ gardeDowLabel(r) }} · toutes les {{ r.periodWeeks }} sem. · rotation : {{ r.techs.map(id => (techs.find(t => t.id === id) || {}).name || id).join(' → ') }}</q-item-label>
</q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="removeGardeRule(i)" /></q-item-section>
</q-item>
</q-list>
<div class="row q-col-gutter-sm items-end">
<q-select dense outlined v-model="newGardeRule.dept" :options="groupOptions" emit-value map-options label="Département" style="width:170px" />
<q-select dense outlined v-model="newGardeRule.shift" :options="gardeTemplateOptions" emit-value map-options label="Shift de garde" style="width:190px" />
<q-input dense outlined type="number" min="1" v-model.number="newGardeRule.periodWeeks" label="Toutes les (sem.)" style="width:130px" />
</div>
<div class="q-mt-sm row items-center q-gutter-xs">
<span class="text-caption text-grey-7 q-mr-xs">Jours :</span>
<q-chip v-for="dw in GARDE_DOW" :key="dw.v" clickable dense :color="newGardeRule.weekdays.includes(dw.v) ? 'brown' : 'grey-4'" :text-color="newGardeRule.weekdays.includes(dw.v) ? 'white' : 'grey-8'" @click="toggleGardeDow(dw.v)">{{ dw.l }}</q-chip>
<q-btn flat dense size="sm" no-caps label="Tous les soirs" @click="newGardeRule.weekdays = [1,2,3,4,5,6,0]" />
<q-btn flat dense size="sm" no-caps label="Week-ends" @click="newGardeRule.weekdays = [5,6,0]" />
</div>
<q-select dense outlined multiple use-chips v-model="newGardeRule.techs" :options="deptTechs" emit-value map-options label="Techs en rotation (dans l'ordre de sélection)" class="q-mt-sm" :disable="!newGardeRule.dept" hint="L'ordre de sélection = l'ordre de la rotation" />
<div class="row items-center q-mt-md">
<q-btn dense unelevated color="brown" icon="add" label="Ajouter la règle" @click="addGardeRule" />
<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-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-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> <q-item-label header class="q-py-xs">{{ menu.tech && menu.tech.name }} {{ menu.day && menu.day.dnum }}</q-item-label>
@ -318,6 +358,9 @@ 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 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 anchor = ref(null)
const demand = ref([]); const holidays = ref([]); const weekTemplates = ref([]) const demand = ref([]); const holidays = ref([]); const weekTemplates = ref([])
const gardeRules = ref([]); const showGarde = ref(false)
const newGardeRule = reactive({ dept: '', shift: '', weekdays: [], periodWeeks: 1, techs: [] })
const GARDE_DOW = [{ v: 1, l: 'L' }, { v: 2, l: 'M' }, { v: 3, l: 'M' }, { v: 4, l: 'J' }, { v: 5, l: 'V' }, { v: 6, l: 'S' }, { v: 0, l: 'D' }]
const history = ref([]); const future = ref([]) const history = ref([]); const future = ref([])
const search = ref(''); const groupFilter = ref(null); const maxHours = ref(40) const search = ref(''); const groupFilter = ref(null); const maxHours = ref(40)
const showShiftEditor = ref(false); const editTpls = ref([]) const showShiftEditor = ref(false); const editTpls = ref([])
@ -330,7 +373,7 @@ function numToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh
// Slider à 2 poignées pour le nouveau modèle (heures custom) newTpl.start/end // 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 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_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 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 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) }
@ -539,7 +582,46 @@ async function doPublish () {
} }
// demande // 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 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 deptTechs = computed(() => techs.value.filter(t => !newGardeRule.dept || t.group === newGardeRule.dept).map(t => ({ label: t.name, value: t.id })))
function d2ms (iso) { const a = iso.split('-').map(Number); return Date.UTC(a[0], a[1] - 1, a[2]) }
function mondayISO (iso) { return addDaysISO(iso, -((dowOf(iso) + 6) % 7)) }
function weekIndex (iso) { return Math.round((d2ms(mondayISO(iso)) - d2ms(GARDE_EPOCH)) / (7 * 86400000)) }
// Tech de garde pour une date : tourne toutes les periodWeeks ; saute un tech absent au profit du suivant.
function rotationTech (rule, iso) {
if (!rule.techs || !rule.techs.length) return null
const base = Math.floor(weekIndex(iso) / (rule.periodWeeks || 1))
for (let k = 0; k < rule.techs.length; k++) { const id = rule.techs[((base + k) % rule.techs.length + rule.techs.length) % rule.techs.length]; if (!isAbsent(id, iso)) return id }
return rule.techs[((base % rule.techs.length) + rule.techs.length) % rule.techs.length]
}
function toggleGardeDow (v) { const i = newGardeRule.weekdays.indexOf(v); if (i >= 0) newGardeRule.weekdays.splice(i, 1); else newGardeRule.weekdays.push(v) }
function saveGarde () { localStorage.setItem(LS_GARDE, JSON.stringify(gardeRules.value)) }
function addGardeRule () {
if (!newGardeRule.dept || !newGardeRule.shift || !newGardeRule.techs.length || !newGardeRule.weekdays.length) { $q.notify({ type: 'warning', message: 'Département, shift, jours et techs requis' }); return }
gardeRules.value = [...gardeRules.value, { id: Date.now(), dept: newGardeRule.dept, shift: newGardeRule.shift, weekdays: [...newGardeRule.weekdays], periodWeeks: newGardeRule.periodWeeks || 1, techs: [...newGardeRule.techs] }]
saveGarde(); newGardeRule.techs = []; $q.notify({ type: 'positive', message: 'Règle de garde ajoutée' })
}
function removeGardeRule (i) { gardeRules.value = gardeRules.value.filter((_, j) => j !== i); saveGarde() }
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 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 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() } function removeDemand (i) { demand.value = demand.value.filter((_, j) => j !== i); saveDemand() }