diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index d90c7e0..43bc81e 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -2175,27 +2175,44 @@ const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).lengt // personnalisés depuis le menu de cellule (ex. Philip 7:30→17:30) → réutilisés à chaque clic « Jour »/« Soir ». const LS_SHIFT_PRESETS = 'roster-shift-presets-v1' const techShiftPresets = ref({}) // { [techId]: { day:{start,end}, eve:{start,end} } } -// Préréglage Jour = perso enregistré, SINON la fenêtre USUELLE du tech (« gabarit le plus probable »), SINON 8–16. +// Fenêtre USUELLE APPRISE + PERSISTÉE par tech : { techId: {start,end} }. Alimentée depuis les VRAIS quarts chaque fois +// qu'une semaine est chargée (refreshUsualCache) → survit aux semaines VIDES/futures (là où le scan direct ne verrait rien) +// ET aux rechargements. C'est le « quart normal le plus probable » demandé (ex. Houssam 8–18 même sur un jour sans quart). +const LS_USUAL = 'roster-usual-win-v1' +const usualCache = ref({}) +// Préréglage Jour = perso enregistré, SINON la fenêtre USUELLE du tech, SINON 8–16. function presetDayFor (techId) { const p = techId && techShiftPresets.value[techId]; return (p && p.day) || usualWindowFor(techId) || { start: 8, end: 16 } } function presetEveFor (techId) { const p = techId && techShiftPresets.value[techId]; return (p && p.eve) || { start: 16, end: 20 } } -// Fenêtre USUELLE d'un tech : la plus fréquente de son patron hebdo (weekly_schedule), sinon de ses quarts réguliers -// déjà assignés (hors garde). Pré-remplit le préréglage Jour (ex. Houssam 8–18) → l'utilisateur n'a plus à ressaisir. +// Recalcule la fenêtre la plus fréquente de CHAQUE tech depuis les quarts réguliers chargés → met à jour le cache persistant +// (les techs sans quart chargé gardent leur valeur connue). Appelé après chaque loadWeek. +function refreshUsualCache () { + const byTech = {} + for (const a of (assignments.value || [])) { + const tp = tplByName.value[a.shift]; if (!tp || tp.on_call) continue + const k = (tp.start_time || '').slice(0, 5) + '-' + (tp.end_time || '').slice(0, 5); if (k === '-') continue + ;(byTech[a.tech] || (byTech[a.tech] = {}))[k] = (byTech[a.tech][k] || 0) + 1 + } + let changed = false; const next = { ...usualCache.value } + for (const tid of Object.keys(byTech)) { + const best = Object.keys(byTech[tid]).sort((x, y) => byTech[tid][y] - byTech[tid][x])[0] + const [s, e] = best.split('-'); const sd = hToNum(s); const ed = hToNum(e) + if (sd != null && ed != null && ed > sd && (!next[tid] || next[tid].start !== sd || next[tid].end !== ed)) { next[tid] = { start: sd, end: ed }; changed = true } + } + if (changed) { usualCache.value = next; try { localStorage.setItem(LS_USUAL, JSON.stringify(next)) } catch (e) {} } +} +// Fenêtre USUELLE d'un tech : d'abord la fenêtre APPRISE (cache, = ses vrais quarts, persistée), sinon son patron hebdo +// (weekly_schedule). null si vraiment rien de connu → l'appelant retombe sur 8–16. function usualWindowFor (techId) { if (!techId) return null - const counts = {} - const add = (s, e) => { if (s && e) { const k = String(s).slice(0, 5) + '-' + String(e).slice(0, 5); counts[k] = (counts[k] || 0) + 1 } } - const t = (techs.value || []).find(x => x.id === techId) - const ws = t && t.weekly_schedule + const c = usualCache.value[techId]; if (c && c.start != null && c.end != null) return c + const t = (techs.value || []).find(x => x.id === techId); const ws = t && t.weekly_schedule if (ws && typeof ws === 'object') { + const counts = {}; const add = (s, e) => { if (s && e) { const k = String(s).slice(0, 5) + '-' + String(e).slice(0, 5); counts[k] = (counts[k] || 0) + 1 } } for (const k of Object.keys(ws)) { const day = ws[k]; if (Array.isArray(day)) day.forEach(w => w && add(w.start, w.end)); else if (day && day.start && day.end) add(day.start, day.end) } + const best = Object.keys(counts).sort((x, y) => counts[y] - counts[x])[0] + if (best) { const [s, e] = best.split('-'); const sd = hToNum(s); const ed = hToNum(e); if (sd != null && ed != null && ed > sd) return { start: sd, end: ed } } } - if (!Object.keys(counts).length) { // repli : quarts réguliers (hors garde) déjà assignés au tech - for (const a of (assignments.value || [])) { if (a.tech !== techId) continue; const tp = tplByName.value[a.shift]; if (!tp || tp.on_call) continue; add(tp.start_time, tp.end_time) } - } - const best = Object.keys(counts).sort((x, y) => counts[y] - counts[x])[0] - if (!best) return null - const [s, e] = best.split('-'); const sd = hToNum(s); const ed = hToNum(e) - return (sd != null && ed != null && ed > sd) ? { start: sd, end: ed } : null + return null } function onSetPreset (techId, { kind, start, end } = {}) { if (!techId || start == null || end == null) return @@ -6149,6 +6166,7 @@ async function loadWeek () { const a = await roster.listAssignments(start.value, days.value); assignments.value = a.assignments || [] snapshotServer(assignments.value); history.value = []; future.value = []; solverStats.value = null lastWeek.start = start.value; lastWeek.days = days.value + refreshUsualCache() // apprend/persiste la fenêtre usuelle par tech depuis les quarts chargés (sert les semaines vides) await loadStats() } catch (e) { err(e) } finally { loading.value = false } } @@ -6256,7 +6274,7 @@ async function doWeekStatus (mode) { } // demande -function loadLS () { 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 = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } try { pendingAbs.value = JSON.parse(localStorage.getItem(LS_PENDING_ABS) || '{}') } catch { pendingAbs.value = {} } try { pendingPause.value = JSON.parse(localStorage.getItem(LS_PENDING_PAUSE) || '{}') } catch { pendingPause.value = {} } try { techShiftPresets.value = JSON.parse(localStorage.getItem(LS_SHIFT_PRESETS) || '{}') || {} } catch { techShiftPresets.value = {} } } +function loadLS () { 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 = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } try { pendingAbs.value = JSON.parse(localStorage.getItem(LS_PENDING_ABS) || '{}') } catch { pendingAbs.value = {} } try { pendingPause.value = JSON.parse(localStorage.getItem(LS_PENDING_PAUSE) || '{}') } catch { pendingPause.value = {} } try { techShiftPresets.value = JSON.parse(localStorage.getItem(LS_SHIFT_PRESETS) || '{}') || {} } catch { techShiftPresets.value = {} } try { usualCache.value = JSON.parse(localStorage.getItem(LS_USUAL) || '{}') || {} } catch { usualCache.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