Séquence de rotation ({{ newGardeRule.techs.length }} positions — doublons permis pour des tours inégaux) :
+
+
Séquence ({{ newGardeRule.steps.length }} étapes) — « sem. » = nb de semaines consécutives du tech avant de passer au suivant :
+
{{ i + 1 }}.
-
+
+
Monter
- Descendre
- Retirer
+ Descendre
+ Retirer
@@ -315,7 +315,7 @@
-
+
@@ -395,7 +395,7 @@ const activeCell = ref(null) // dernière case cliquée {id, name, iso} — pour
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 newGardeRule = reactive({ dept: '', shift: '', weekdays: [], anchor: '', steps: [] })
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)
@@ -634,57 +634,55 @@ 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 }
+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 weekNo (iso) { return Math.round((d2ms(mondayISO(iso)) - d2ms(GARDE_EPOCH)) / (7 * 86400000)) } // n° de semaine absolu (réf. lundi)
+function openGarde () { if (!newGardeRule.anchor) newGardeRule.anchor = mondayISO(start.value); showGarde.value = true }
+// Séquence = étapes {tech, weeks}. Ajouter à la suite (doublons OK), réordonner, retirer.
+function addTechToSeq () { if (gardePick.value) { newGardeRule.steps.push({ tech: gardePick.value, weeks: 1 }); gardePick.value = null } }
+function moveTech (i, dir) { const a = newGardeRule.steps; 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) {
+ const steps = (r.steps && r.steps.length) ? r.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 })) : (r.techs || []).map(t => ({ tech: t, weeks: r.periodWeeks || 1 }))
+ Object.assign(newGardeRule, { dept: r.dept || '', shift: r.shift, weekdays: [...(r.weekdays || [])], anchor: r.anchor || mondayISO(start.value), steps })
+ 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
+function toggleGardeDow (v) { const i = newGardeRule.weekdays.indexOf(v); if (i >= 0) newGardeRule.weekdays.splice(i, 1); else newGardeRule.weekdays.push(v) }
+// ── Moteur de rotation : on PARCOURT la séquence semaine par semaine depuis l'ANCRAGE ──
+function cycleWeeks (steps) { return (steps || []).reduce((s, x) => s + (Number(x.weeks) || 1), 0) }
+function stepTechAt (steps, w) { for (const s of steps) { const n = Number(s.weeks) || 1; if (w < n) return s.tech; w -= n } return steps[0] && steps[0].tech }
+function rotationTech (rule, iso) {
+ const steps = rule.steps || []; const cyc = cycleWeeks(steps); if (!cyc) return null
+ const anchor = rule.anchor || mondayISO(iso)
+ const w0 = (((weekNo(iso) - weekNo(anchor)) % cyc) + cyc) % cyc
+ for (let k = 0; k < cyc; k++) { const id = stepTechAt(steps, (w0 + k) % cyc); if (id && !isAbsent(id, iso)) return id } // saut d'absent
+ return stepTechAt(steps, w0)
}
-// 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
+// Aperçu : qui est de garde, semaine par semaine, depuis l'ancrage — reflète la file en cours d'édition (ignore absences)
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]
+ const rule = newGardeRule; const cyc = cycleWeeks(rule.steps); if (!cyc || !rule.weekdays.length) return []
+ const anchor = rule.anchor || mondayISO(start.value); const out = []
+ for (let i = 0; i < Math.min(14, cyc + 4); i++) {
+ const ws = addDaysISO(mondayISO(anchor), i * 7); const w0 = (((weekNo(ws) - weekNo(anchor)) % cyc) + cyc) % cyc
+ const id = stepTechAt(rule.steps, w0)
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 (!newGardeRule.shift || !newGardeRule.steps.length || !newGardeRule.weekdays.length) { $q.notify({ type: 'warning', message: 'Shift, jours et au moins un tech requis' }); return }
+ const rule = { id: editingGardeId.value || Date.now(), dept: newGardeRule.dept || '—', shift: newGardeRule.shift, weekdays: [...newGardeRule.weekdays], anchor: newGardeRule.anchor || mondayISO(start.value), steps: newGardeRule.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 })) }
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' })
+ saveGarde(); editingGardeId.value = null; newGardeRule.steps = []; newGardeRule.weekdays = []
+ $q.notify({ type: 'positive', message: 'Règle enregistrée — clique « Générer la garde » pour l\'appliquer' })
}
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('') }
+function gardeDowLabel (r) { return (r.weekdays || []).map(w => (GARDE_DOW.find(x => x.v === w) || {}).l).join('') }
+function gardeSeqLabel (r) { return (r.steps || []).map(s => ((techs.value.find(t => t.id === s.tech) || {}).name || s.tech) + (s.weeks > 1 ? ' ×' + s.weeks : '')).join(' → ') }
// Génère les gardes de la semaine affichée selon les règles (rotation par département)
const gardeHorizon = ref(8) // nb de semaines à matérialiser (évènement récurrent)
// Génère la garde sur un HORIZON (plusieurs semaines) et l'écrit directement (publié) → navigable semaine par semaine.