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 @@
-
-
+ +
+
Quart de garde — semaine (soir/nuit)
+
+ + + + 🌙 +1j + {{ gDur(newGardeRule.wkStart, newGardeRule.wkEnd) }} h +
+
+
+
Fin de semaine · optionnel (sinon = semaine)
+
+ + + + 🌙 +1j + {{ gDur(newGardeRule.weStart, newGardeRule.weEnd) }} h + Effacer (utiliser le quart de semaine) +
+
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; }