From 8d946daf8d3319f1d619a02d8cb48efa82491377 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 4 Jun 2026 20:27:24 -0400 Subject: [PATCH] =?UTF-8?q?Planification:=20rotation=20de=20garde=20par=20?= =?UTF-8?q?d=C3=A9partement=20(r=C3=A9currence=20+=20rotation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/ops/src/pages/PlanificationPage.vue | 86 +++++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 55026b8..2373307 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -29,6 +29,7 @@ Appliquer le modèle par défaut (consciente des absences) + Rotation de garde par département Notifier les techs par SMS à la publication @@ -255,6 +256,45 @@ + + + + +
🛡️ Rotation de garde (par département)
+ +
+ +
La garde tourne entre les techs d'un département, à la période choisie — indépendante par département. « Appliquer » génère les gardes de la semaine affichée (un tech absent est sauté au profit du suivant dans la rotation).
+ + + + {{ r.dept }} · {{ shiftName(r.shift) }} + {{ gardeDowLabel(r) }} · toutes les {{ r.periodWeeks }} sem. · rotation : {{ r.techs.map(id => (techs.find(t => t.id === id) || {}).name || id).join(' → ') }} + + + + +
+ + + +
+
+ Jours : + {{ dw.l }} + + +
+ +
+ + + +
+
+
+
+ {{ menu.tech && menu.tech.name }} — {{ menu.day && menu.day.dnum }} @@ -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 anchor = ref(null) const demand = ref([]); const holidays = ref([]); const weekTemplates = ref([]) +const gardeRules = ref([]); const showGarde = ref(false) +const newGardeRule = reactive({ dept: '', shift: '', weekdays: [], periodWeeks: 1, techs: [] }) +const GARDE_DOW = [{ v: 1, l: 'L' }, { v: 2, l: 'M' }, { v: 3, l: 'M' }, { v: 4, l: 'J' }, { v: 5, l: 'V' }, { v: 6, l: 'S' }, { v: 0, l: 'D' }] const history = ref([]); const future = ref([]) const search = ref(''); const groupFilter = ref(null); const maxHours = ref(40) const showShiftEditor = ref(false); const editTpls = ref([]) @@ -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 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 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 -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 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() }