diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue
index 6d615e3..a7d1502 100644
--- a/apps/ops/src/pages/PlanificationPage.vue
+++ b/apps/ops/src/pages/PlanificationPage.vue
@@ -729,8 +729,29 @@
Jours couverts (hors bureau) :
@@ -2162,7 +2183,8 @@ const holidays = ref([]); const weekTemplates = ref([])
const statHolidays = ref([]) // fériés QC DÉTERMINISTES (calendrier hub /roster/holidays) — fusionnés dans isHoliday, tous navigateurs
const gardeRules = ref([]); const showGarde = ref(false)
const manualGarde = ref({}) // overrides manuels de garde : 'techId|iso' → 'on' | 'off' (touche « G »)
-const newGardeRule = reactive({ dept: '', shift: '', shiftWeekend: '', weekdays: [], anchor: '', steps: [] })
+// shift/shiftWeekend = noms de modèles on_call RÉSOLUS depuis les intervalles éditables (wkStart→wkEnd, weStart→weEnd) au save.
+const newGardeRule = reactive({ dept: '', shift: '', shiftWeekend: '', wkStart: '19:00', wkEnd: '07:00', weStart: '', weEnd: '', 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 skillFilter = ref([]) // (maxHours retiré — champ orphelin jamais consommé)
@@ -4652,7 +4674,7 @@ function cellBands (techId, iso) {
pushBand(out, s, e, { oncall: false, bg: bandGradient(s, e <= s ? 24 : e) })
}
const gShift = gardeEffective.value[techId + '|' + iso] // garde EFFECTIVE (règles + overrides manuels « G »)
- if (gShift) { const t = tplByName.value[gShift]; if (t) pushBand(out, hToNum(t.start_time), hToNum(t.end_time), { oncall: true }) }
+ if (gShift) { const t = tplByName.value[gShift]; if (t) { const gs = hToNum(t.start_time); const ge = hToNum(t.end_time); pushBand(out, gs, (gs != null && ge != null && ge <= gs) ? 24 : ge, { oncall: true }) } } // garde de NUIT (fin ≤ début) → bande du soir jusqu'à minuit sur la case
return out
}
// Barre de statut OPAQUE selon l'occupation : vert (peu) → orange (plein) → rouge (surbooké).
@@ -6289,7 +6311,13 @@ function openGarde () { if (!newGardeRule.anchor) newGardeRule.anchor = mondayIS
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) {
- Object.assign(newGardeRule, { dept: r.dept || '', shift: r.shift, shiftWeekend: r.shiftWeekend || '', weekdays: [...(r.weekdays || [])], anchor: r.anchor || mondayISO(start.value), steps: ruleSteps(r) })
+ const wt = tplByName.value[r.shift]; const wet = r.shiftWeekend && tplByName.value[r.shiftWeekend] // retrouve les intervalles depuis les modèles
+ Object.assign(newGardeRule, {
+ dept: r.dept || '', shift: r.shift, shiftWeekend: r.shiftWeekend || '',
+ wkStart: wt ? (wt.start_time || '').slice(0, 5) : '19:00', wkEnd: wt ? (wt.end_time || '').slice(0, 5) : '07:00',
+ weStart: wet ? (wet.start_time || '').slice(0, 5) : '', weEnd: wet ? (wet.end_time || '').slice(0, 5) : '',
+ weekdays: [...(r.weekdays || [])], anchor: r.anchor || mondayISO(start.value), steps: ruleSteps(r),
+ })
editingGardeId.value = r.id
}
const WD_SEMAINE = [1, 2, 3, 4, 5]; const WD_FINSEM = [6, 0]
@@ -6340,13 +6368,17 @@ async function loadGardeQueues () {
}
} catch (e) { /* hub indispo → on garde le cache localStorage déjà chargé par loadLS */ }
}
-function addGardeRule () {
- 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 || ('q' + Date.now()), dept: newGardeRule.dept || '—', shift: newGardeRule.shift, shiftWeekend: newGardeRule.shiftWeekend || '', weekdays: [...newGardeRule.weekdays], anchor: newGardeRule.anchor || mondayISO(start.value), steps: newGardeRule.steps.map(s => ({ tech: s.tech, weeks: Number(s.weeks) || 1 })) }
+async function addGardeRule () {
+ if (!newGardeRule.steps.length || !newGardeRule.weekdays.length) { $q.notify({ type: 'warning', message: 'Jours et au moins un tech requis' }); return }
+ const wk = await ensureGardeTpl(newGardeRule.wkStart, newGardeRule.wkEnd) // intervalle semaine → modèle on_call (créé au besoin)
+ if (!wk) { $q.notify({ type: 'warning', message: 'Intervalle de garde (semaine) invalide — ex. 19:00 → 07:00' }); return }
+ let weName = ''
+ if (newGardeRule.weStart && newGardeRule.weEnd) { const we = await ensureGardeTpl(newGardeRule.weStart, newGardeRule.weEnd); if (!we) { $q.notify({ type: 'warning', message: 'Intervalle fin de semaine invalide' }); return } weName = we.name }
+ const rule = { id: editingGardeId.value || ('q' + Date.now()), dept: newGardeRule.dept || '—', shift: wk.name, shiftWeekend: weName, 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.steps = []; newGardeRule.weekdays = []
- $q.notify({ type: 'positive', message: 'Règle enregistrée — clique « Générer la garde » pour l\'appliquer' })
+ saveGarde(); editingGardeId.value = null; newGardeRule.steps = []; newGardeRule.weekdays = []; newGardeRule.weStart = ''; newGardeRule.weEnd = ''
+ $q.notify({ type: 'positive', message: 'Règle enregistrée — clique « Publier 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('') }
@@ -6576,6 +6608,18 @@ async function ensureWindowTpl (min, max) {
if (!tpl) { try { await roster.createTemplate({ template_name: nm, start_time: s + ':00', end_time: e + ':00', hours: calcHours(s, e), color: '#1976d2', default_required: 1, on_call: 0 }); await refreshTemplates(); tpl = templates.value.find(t => t.template_name === nm) } catch (e2) { err(e2); return null } }
return tpl
}
+// ── Garde : intervalle ÉDITABLE (début → fin). Fin ≤ début ⇒ chevauche minuit (finit le LENDEMAIN), durée = wrap +24h. ──
+function normG (s) { const v = parseHM(s); return v == null ? '' : numToTime(Math.max(0, Math.min(24, v))) } // saisie souple → 'HH:MM'
+function gOvernight (s, e) { const a = parseHM(s); const b = parseHM(e); return a != null && b != null && b <= a } // finit le lendemain
+function gDur (s, e) { const a = parseHM(s); const b = parseHM(e); if (a == null || b == null) return 0; let d = b - a; if (d <= 0) d += 24; return Math.round(d * 10) / 10 }
+function gardeTplName (s, e) { const a = parseHM(s); const b = parseHM(e); return '🛡 ' + fmtH(a) + 'h–' + fmtH(b) + 'h' + (b <= a ? ' +1j' : '') } // nom lisible (marque +1j si nuit)
+// Modèle on_call pour un intervalle de garde (créé/retrouvé). Retourne le doc ou null si intervalle invalide.
+async function ensureGardeTpl (startStr, endStr) {
+ const s = normG(startStr); const e = normG(endStr); if (!s || !e || s === e) return null
+ let tpl = templates.value.find(t => t.on_call && (t.start_time || '').slice(0, 5) === s && (t.end_time || '').slice(0, 5) === e)
+ if (!tpl) { try { await roster.createTemplate({ template_name: gardeTplName(s, e), start_time: s + ':00', end_time: e + ':00', hours: calcHours(s, e), color: '#8d6e63', default_required: 1, on_call: 1, required_skills: '' }); await refreshTemplates(); tpl = templates.value.find(t => t.on_call && (t.start_time || '').slice(0, 5) === s && (t.end_time || '').slice(0, 5) === e) } catch (e2) { err(e2); return null } }
+ return tpl
+}
// Saisie rapide d'heures : « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=8→17, dernier chiffre en pm si ≤ début).
function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) }
function parseQuickShift (str) {
@@ -6688,6 +6732,12 @@ onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le bro
/* Barre d'actions de sélection : flottante (fixed) → hors flux, aucune incidence sur la hauteur de la grille. */
.sel-actions { position: fixed; left: 50%; transform: translateX(-50%); bottom: 18px; z-index: 4000; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; max-width: 94vw; padding: 6px 12px; background: #e0f2f1; color: #00695c; border: 1px solid #4db6ac; border-radius: 9px; box-shadow: 0 6px 22px rgba(0,0,0,.20); }
.garde-editor { background: #faf8f6; border: 1px solid #e8e0d8; } /* sous-panneau éditeur de garde */
+/* Intervalle de garde éditable (début → fin) — compact, comme le menu de cellule. */
+.garde-int { display: flex; align-items: center; gap: 6px; padding: 5px 8px; background: #fff; border: 1px solid #cbd5e1; border-radius: 8px; }
+.garde-int .garde-time { width: 58px; box-sizing: border-box; border: 1px solid #cbd5e1; border-radius: 6px; padding: 3px 6px; font-size: 13px; font-family: inherit; text-align: center; }
+.garde-int .garde-arrow { color: #94a3b8; }
+.garde-int .garde-plus1 { font-size: 10.5px; font-weight: 700; color: #5e35b1; background: #ede7f6; border: 1px solid #b39ddb; border-radius: 10px; padding: 1px 6px; white-space: nowrap; }
+.garde-int .garde-dur { margin-left: auto; font-size: 11.5px; font-weight: 600; color: #64748b; white-space: nowrap; }
/* Chips du roster de file de garde (ordre de rotation ; l'actuel surligné ; cliquable = contact) */
.garde-q-chip { display: inline-flex; align-items: center; gap: 3px; padding: 1px 8px 1px 3px; border: 1px solid #e2ddd6; border-radius: 12px; background: #fff; font-size: 12px; font-weight: 600; color: #5b4636; cursor: pointer; user-select: none; }
.garde-q-chip:hover { border-color: #b98a5e; background: #fbf5ee; }