feat(garde): intervalle de garde ÉDITABLE (début → fin) + quart de nuit « +1j »

Remplace les 2 listes déroulantes de modèles (figées, non éditables) par un éditeur d'intervalle
compact début → fin, un pour la semaine (soir/nuit) + un optionnel fin de semaine. Le modèle on_call
est créé/retrouvé au save (ensureGardeTpl). Quart qui chevauche minuit : si fin ≤ début → badge
« 🌙 +1j » automatique, durée = wrap +24h (ex. 19:00 → 07:00 = 12h). La bande de garde de nuit
s'affiche du soir jusqu'à minuit sur la case (cellBands clampé). editGardeRule ré-hydrate les
intervalles depuis les modèles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-23 22:23:31 -04:00
parent ade7f312ec
commit 7d3cfbe658

View File

@ -729,8 +729,29 @@
<div class="row q-col-gutter-sm">
<div class="col-12 col-sm-6"><q-select dense outlined class="full-width" v-model="newGardeRule.dept" :options="groupNames" use-input fill-input hide-selected new-value-mode="add-unique" input-debounce="0" label="Nom de la file / département" hint="ex. Netadmin · Épissure · Urgences terrain" /></div>
<div class="col-12 col-sm-6"><q-input dense outlined class="full-width" type="date" v-model="newGardeRule.anchor" label="Rotation démarre la semaine du" /></div>
<div class="col-12 col-sm-6"><q-select dense outlined class="full-width" v-model="newGardeRule.shift" :options="gardeTemplateOptions" emit-value map-options label="Quart en semaine (soir)" /></div>
<div class="col-12 col-sm-6"><q-select dense outlined class="full-width" clearable v-model="newGardeRule.shiftWeekend" :options="gardeTemplateOptions" emit-value map-options label="Quart fin de semaine" hint="sinon = quart de semaine" /></div>
<!-- Intervalle de garde ÉDITABLE (début fin), au lieu d'une liste de modèles figée. Le modèle on_call est
créé/retrouvé au save. Fin début chevauche minuit badge « +1j » (ex. 19:00 07:00 = 12h). -->
<div class="col-12 col-sm-6">
<div class="text-caption text-grey-7 q-mb-xs">Quart de garde semaine (soir/nuit)</div>
<div class="garde-int">
<input class="garde-time" inputmode="numeric" maxlength="5" v-model="newGardeRule.wkStart" @change="newGardeRule.wkStart = normG(newGardeRule.wkStart)" placeholder="19:00" />
<span class="garde-arrow"></span>
<input class="garde-time" inputmode="numeric" maxlength="5" v-model="newGardeRule.wkEnd" @change="newGardeRule.wkEnd = normG(newGardeRule.wkEnd)" placeholder="07:00" />
<span v-if="gOvernight(newGardeRule.wkStart, newGardeRule.wkEnd)" class="garde-plus1">🌙 +1j</span>
<span class="garde-dur">{{ gDur(newGardeRule.wkStart, newGardeRule.wkEnd) }} h</span>
</div>
</div>
<div class="col-12 col-sm-6">
<div class="text-caption text-grey-7 q-mb-xs">Fin de semaine <span class="text-grey-5">· optionnel (sinon = semaine)</span></div>
<div class="garde-int">
<input class="garde-time" inputmode="numeric" maxlength="5" v-model="newGardeRule.weStart" @change="newGardeRule.weStart = normG(newGardeRule.weStart)" placeholder="—" />
<span class="garde-arrow"></span>
<input class="garde-time" inputmode="numeric" maxlength="5" v-model="newGardeRule.weEnd" @change="newGardeRule.weEnd = normG(newGardeRule.weEnd)" placeholder="—" />
<span v-if="newGardeRule.weStart && newGardeRule.weEnd && gOvernight(newGardeRule.weStart, newGardeRule.weEnd)" class="garde-plus1">🌙 +1j</span>
<span v-if="newGardeRule.weStart && newGardeRule.weEnd" class="garde-dur">{{ gDur(newGardeRule.weStart, newGardeRule.weEnd) }} h</span>
<q-btn v-if="newGardeRule.weStart || newGardeRule.weEnd" flat dense round size="xs" icon="close" color="grey-6" class="q-ml-xs" @click="newGardeRule.weStart = ''; newGardeRule.weEnd = ''"><q-tooltip>Effacer (utiliser le quart de semaine)</q-tooltip></q-btn>
</div>
</div>
</div>
<div class="q-mt-sm">
<div class="text-caption text-grey-7 q-mb-xs">Jours couverts (hors bureau) :</div>
@ -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 (wkStartwkEnd, weStartweEnd) 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 » (=817, 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; }