feat(planif): pause indéfinie STAGÉE → visible tout de suite, commise au Publier (retire quarts futurs + relève jobs)

Une mise en pause indéfinie ne s'applique plus immédiatement : elle est mise en
attente (pendingPause, localStorage — comme les congés), prend effet VISUELLEMENT
(nom barré + puce « pause à publier » sur la rangée, compteur « non publié »), et
n'est COMMISE qu'au Publier — au cas où on se trompe.

- TechScheduleDialog : le toggle « Pause indéfinie » émet `stage-pause` (plus
  d'écriture roster directe) ; l'état reflète serveur OU en attente (prop pendingPaused).
- Parent onStagePause : stage/dé-stage ; réactivation d'un tech DÉJÀ en pause reste
  immédiate (Disponible + re-matérialisation du patron).
- Résumé de Publier (groupé par tech) : ligne « Pause indéfinie · motif · N quart(s)
  futur(s) retiré(s) » + chaque quart futur listé en retrait ; en-tête « · N pause(s) ».
- doPublishConfirmed : exclut les quarts futurs (aujourd'hui→) des techs en pause de la
  charge → publish-week les SUPPRIME ; applique le statut En pause ; puis relève des jobs
  via l'impact IROPS existant (checkAbsenceImpact). Quarts passés conservés ; patron
  récurrent intact (réactivation re-matérialise).

Vérifié en aperçu (client-only, RIEN écrit en prod) : Philippe Bourdon → puce +
nom barré ; résumé « Pause indéfinie · Arrêt maladie · 14 quart(s) futur(s) retiré(s) »
+ 14 retraits, à côté du quart de Nathan Morrisseau ; annulé + nettoyé. Build OK.
⚠️ Chemin de COMMIT au Publier (suppression quarts + pause + relève jobs) non exercé en
prod (écriture réelle) — réutilise publishWeek/pauseTechnician/checkAbsenceImpact éprouvés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-16 13:33:08 -04:00
parent e98b21ba0a
commit e8fd69c6f8
2 changed files with 84 additions and 24 deletions

View File

@ -117,10 +117,11 @@ const props = defineProps({
modelValue: { type: Boolean, default: false },
tech: { type: Object, default: null }, // { id, name, status, ... }
pendingAbs: { type: Object, default: () => ({}) }, // congés EN ATTENTE (delta local du parent, publish-required) superposés à l'état serveur
pendingPaused: { type: Boolean, default: false }, // pause indéfinie EN ATTENTE (staged par le parent, publish-required)
gardeMap: { type: Object, default: () => ({}) }, // garde EFFECTIVE du parent (gardeEffective) : clé tech|iso shift
refreshKey: { type: Number, default: 0 }, // par le parent après une écriture de quart recharge le mois
})
const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed', 'stage-abs', 'set-shift', 'remove-shift', 'clear-shifts', 'toggle-garde'])
const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed', 'stage-abs', 'stage-pause', 'set-shift', 'remove-shift', 'clear-shifts', 'toggle-garde'])
const $q = useQuasar()
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
@ -139,19 +140,16 @@ const pauseReason = ref('Arrêt maladie')
const pauseNote = ref('')
const pauseBusy = ref(false)
function reasonPayload () { return [pauseReason.value, pauseNote.value].filter(Boolean).join(' — ') }
async function onTogglePause (v) {
// La pause est STAGÉE par le parent (publish-required) : on émet l'intention, le parent l'applique au Publier
// (retire les quarts futurs + relève les jobs). Réactivation d'un tech déjà en pause = gérée immédiatement côté parent.
function onTogglePause (v) {
if (!props.tech) return
pauseBusy.value = true
try {
await roster.pauseTechnician(props.tech.id, v, v ? reasonPayload() : '')
paused.value = v
$q.notify({ type: 'info', message: props.tech.name + (v ? ' en pause' : ' réactivé') })
emit('changed', { id: props.tech.id, status: v ? 'En pause' : 'Disponible' })
} catch (e) { err(e) } finally { pauseBusy.value = false }
paused.value = v // reflet optimiste dans le dialogue
emit('stage-pause', { techId: props.tech.id, want: v, reason: v ? reasonPayload() : '' })
}
async function savePauseReason () {
function savePauseReason () {
if (!props.tech || !paused.value) return
try { await roster.pauseTechnician(props.tech.id, true, reasonPayload()) } catch (e) { /* best-effort */ }
emit('stage-pause', { techId: props.tech.id, want: true, reason: reasonPayload() })
}
// Calendrier du mois
@ -300,7 +298,7 @@ function confirmArchive () {
// Ouverture : synchronise l'état pause + charge le mois courant.
watch(() => props.modelValue, (o) => {
if (!o) return
paused.value = props.tech ? props.tech.status === 'En pause' : false
paused.value = (props.tech && props.tech.status === 'En pause') || props.pendingPaused // serveur OU en attente (staged)
pauseNote.value = ''
const t = todayISO()
month.value = t.slice(0, 7)

View File

@ -400,7 +400,8 @@
<q-btn flat round dense size="9px" icon="event" :color="isPaused(t) ? 'orange-8' : 'primary'" @click.stop="openTechSchedule(t)"><q-tooltip>Horaire de {{ t.name }} récurrent · pause · congés{{ isPaused(t) ? ' (en pause)' : '' }}</q-tooltip></q-btn>
<q-badge v-if="techRank(t)" color="teal">{{ techRank(t) }}<q-tooltip>Priorité {{ techRank(t) }} combine maîtrise (niveau) + vitesse (efficacité) + coût.</q-tooltip></q-badge>
<!-- Clic sur la RESSOURCE (nom ou chips) propriétés de l'employé (compétences, horaire) -->
<span class="tech-name clk" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages de {{ t.name }} compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip>{{ t.name }}</span>
<span class="tech-name clk" :class="{ 'tech-paused-pending': !!pendingPause[t.id] }" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages de {{ t.name }} compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip>{{ t.name }}</span>
<q-chip v-if="pendingPause[t.id]" dense size="sm" color="orange-9" text-color="white" icon="pause_circle" class="q-ml-xs">pause à publier<q-tooltip class="bg-grey-9">Pause indéfinie en attente Publier pour appliquer (retire ses quarts futurs + relève ses jobs)</q-tooltip></q-chip>
<span v-if="t.group" class="grp">{{ t.group }}</span>
<span v-if="hoursOf(t.id)" class="th text-grey-6">{{ hoursOf(t.id) }}h<q-tooltip class="bg-grey-9" style="font-size:11px">Heures planifiées sur l'étendue affichée ({{ days }} j) total, PAS une limite hebdomadaire</q-tooltip></span>
<q-btn flat dense round size="9px" icon="more_vert" color="grey-6" @click.stop><q-tooltip>Actions {{ t.name }}</q-tooltip>
@ -1338,12 +1339,13 @@
<q-space /><q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-pt-sm">
<div class="text-body2 q-mb-sm"><b class="text-positive">{{ pubSummary.added }}</b> quart(s)<span v-if="pubSummary.removed"> · <b class="text-negative">{{ pubSummary.removed }}</b> retiré(s)</span><span v-if="pubSummary.absAdded"> · <b class="text-orange-8">{{ pubSummary.absAdded }}</b> congé(s)</span><span v-if="pubSummary.absRemoved"> · <b class="text-teal-7">{{ pubSummary.absRemoved }}</b> congé(s) levé(s)</span> · <b>{{ notifySms ? pubSummary.added + ' SMS aux techs' : 'sans SMS' }}</b></div>
<div class="text-body2 q-mb-sm"><b class="text-positive">{{ pubSummary.added }}</b> quart(s)<span v-if="pubSummary.removed"> · <b class="text-negative">{{ pubSummary.removed }}</b> retiré(s)</span><span v-if="pubSummary.paused"> · <b class="text-orange-9">{{ pubSummary.paused }}</b> pause(s)</span><span v-if="pubSummary.absAdded"> · <b class="text-orange-8">{{ pubSummary.absAdded }}</b> congé(s)</span><span v-if="pubSummary.absRemoved"> · <b class="text-teal-7">{{ pubSummary.absRemoved }}</b> congé(s) levé(s)</span> · <b>{{ notifySms ? pubSummary.added + ' SMS aux techs' : 'sans SMS' }}</b></div>
<q-list dense bordered style="border-radius:8px;max-height:44vh;overflow:auto">
<template v-for="g in pubSummary.techs" :key="g.tech">
<q-item-label header class="q-py-xs text-weight-bold row items-center">
<UserAvatar :tech-id="g.tech" :name="g.name" :size="18" card class="q-mr-xs" style="flex:0 0 auto" />{{ g.name }}
</q-item-label>
<q-item v-if="g.pause" dense><q-item-section avatar style="min-width:24px"><q-icon name="pause_circle" color="orange-9" size="16px" /></q-item-section><q-item-section class="text-orange-9 text-weight-medium">Pause indéfinie<span class="text-grey-6" v-if="g.pause.reason"> · {{ g.pause.reason }}</span><span class="text-grey-6" v-if="g.rem.length"> · {{ g.rem.length }} quart(s) futur(s) retiré(s)</span></q-item-section></q-item>
<q-item v-for="(a, i) in g.add" :key="'a' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="add" color="positive" size="16px" /></q-item-section><q-item-section>{{ a.shift }}<span class="text-grey-6"> · {{ fmtDueLabel(a.date) }}</span></q-item-section></q-item>
<q-item v-for="(r, i) in g.rem" :key="'r' + i" dense><q-item-section avatar style="min-width:24px"><q-icon name="remove" color="negative" size="16px" /></q-item-section><q-item-section><span style="text-decoration:line-through">{{ r.shift }}</span><span class="text-grey-6"> · {{ fmtDueLabel(r.date) }}</span></q-item-section></q-item>
<q-item v-for="(ab, i) in g.abs" :key="'ab' + i" dense><q-item-section avatar style="min-width:24px"><q-icon :name="ab.op === 'set' ? 'event_busy' : 'event_available'" :color="ab.op === 'set' ? 'orange-8' : 'teal-7'" size="16px" /></q-item-section><q-item-section>{{ ab.op === 'set' ? (ab.type || 'Congé') : 'congé levé' }}<span class="text-grey-6"> · {{ fmtDueLabel(ab.date) }}</span></q-item-section></q-item>
@ -1754,8 +1756,8 @@
<ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" :initial-tier="quoteTier" @created="onQuoteCreated" />
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment -->
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
<TechScheduleDialog v-model="techSchedOpen" :tech="techSchedTech" :pending-abs="pendingAbs" :garde-map="gardeEffective" :refresh-key="techSchedRefresh"
@edit-schedule="onTechSchedEdit" @changed="onTechSchedChanged" @stage-abs="onStageAbs"
<TechScheduleDialog v-model="techSchedOpen" :tech="techSchedTech" :pending-abs="pendingAbs" :pending-paused="!!(techSchedTech && pendingPause[techSchedTech.id])" :garde-map="gardeEffective" :refresh-key="techSchedRefresh"
@edit-schedule="onTechSchedEdit" @changed="onTechSchedChanged" @stage-abs="onStageAbs" @stage-pause="onStagePause"
@set-shift="onTechSchedSetShift" @remove-shift="onTechSchedRemoveShift" @clear-shifts="onTechSchedClearShifts" @toggle-garde="onTechSchedToggleGarde" />
</q-page>
</template>
@ -2683,6 +2685,32 @@ function onStageAbs ({ techId, changes } = {}) {
}
if (n) { $q.notify({ type: 'info', message: n + ' changement(s) de congé en attente — Publier pour appliquer', timeout: 2200 }); scheduleDraftSave() }
}
// PAUSE INDÉFINIE (émise par TechScheduleDialog) : on STAGE (want=true) prend effet visuellement, commise au Publier.
// Dé-stager (want=false) une pause en attente = simple retrait. Réactiver un tech DÉJÀ en pause (serveur) reste immédiat
// (statut Disponible + re-matérialisation du patron) hors du flux « brouillon ».
async function onStagePause ({ techId, want, reason } = {}) {
if (!techId) return
const t = (techs.value || []).find(x => x.id === techId); const nm = (t && t.name) || techId
if (want) {
if (isServerPaused(t) && !pendingPause.value[techId]) { // déjà en pause côté serveur simple maj du motif (immédiat)
try { await roster.pauseTechnician(techId, true, reason || '') } catch (e) { err(e) }
return
}
const already = !!pendingPause.value[techId]
pendingPause.value = { ...pendingPause.value, [techId]: { reason: reason || '' } }; savePendingPause()
if (!already) { // 1re mise en attente journal + notif (les re-émissions « motif » sur blur restent silencieuses)
logChange('⏸ Pause indéfinie à publier · ' + nm)
$q.notify({ type: 'info', message: nm + ' — pause en attente. Publier pour appliquer (retire ses quarts + relève ses jobs).', timeout: 3000 })
}
} else {
if (pendingPause.value[techId]) { // annule une pause en attente (jamais écrite)
const m = { ...pendingPause.value }; delete m[techId]; pendingPause.value = m; savePendingPause()
logChange('Pause annulée (non publiée) · ' + nm)
} else if (isServerPaused(t)) { // réactivation immédiate d'un tech en pause
try { await roster.pauseTechnician(techId, false, ''); if (t) t.status = 'Disponible'; await roster.materializeShifts({ weeks: 6, tech: techId }).catch(() => {}); $q.notify({ type: 'positive', message: nm + ' réactivé — horaire récurrent re-matérialisé' }); await loadWeek() } catch (e) { err(e) }
}
}
}
async function onTechSchedChanged (ev) {
// pause : reflète le statut localement ; archivage : recharge la base (le tech disparaît). Congés : recharge la semaine (hachures).
if (ev && ev.status) { const tt = techs.value.find(x => x.id === ev.id); if (tt) tt.status = ev.status }
@ -3995,8 +4023,8 @@ const diffKeys = computed(() => { const cur = currentSet.value; const srv = serv
// #2/#3 « dirty » = quarts NON PUBLIÉS (statut Publié). Tout est AUTO-SAUVÉ en brouillon plus d'alerte « modifications non publiées » à la navigation.
const unpublished = computed(() => assignments.value.filter(a => a.status && a.status !== 'Publié'))
// « dirty » = quarts NON PUBLIÉS + congés en attente (publish-required) tout doit passer par « Publier ».
const dirty = computed(() => unpublished.value.length > 0 || pendingAbsCount.value > 0)
const dirtyCount = computed(() => unpublished.value.length + pendingAbsCount.value)
const dirty = computed(() => unpublished.value.length > 0 || pendingAbsCount.value > 0 || pendingPauseCount.value > 0)
const dirtyCount = computed(() => unpublished.value.length + pendingAbsCount.value + pendingPauseCount.value)
const dirtyCells = computed(() => { const s = new Set(unpublished.value.map(a => a.tech + '|' + a.date)); for (const k of Object.keys(pendingAbs.value)) s.add(k); return s })
function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' + iso) }
// Statut agrégé de la semaine (pour l'affichage #3) : le « plus bas » statut non publié présent.
@ -4011,7 +4039,7 @@ const pubSummary = computed(() => {
const nameOf = id => { const t = (techs.value || []).find(x => x.id === id); return (t && t.name) || id }
const shiftLabel = s => { const t = tplByName.value[s]; return (t && t.template_name) || s }
const byTech = {}
const bucket = tid => (byTech[tid] = byTech[tid] || { tech: tid, name: nameOf(tid), add: [], rem: [], abs: [] })
const bucket = tid => (byTech[tid] = byTech[tid] || { tech: tid, name: nameOf(tid), add: [], rem: [], abs: [], pause: null })
// Quarts à publier = état courant non Publié (Proposé/Soumis/Approuvé deviendront Publié)
for (const a of unpublished.value) bucket(a.tech).add.push({ date: a.date, shift: a.shift_name || shiftLabel(a.shift) })
// Quarts RETIRÉS = présents au chargement mais plus dans l'état courant seront supprimés au Publier
@ -4030,11 +4058,18 @@ const pubSummary = computed(() => {
bucket(key.slice(0, i1)).abs.push({ date: key.slice(i1 + 1), type: set ? (v.type || 'Congé') : null, op: set ? 'set' : 'remove' })
if (set) absAdded++; else absRemoved++
}
// PAUSES INDÉFINIES en attente : marque le tech + liste ses quarts FUTURS (aujourd'hui) qui seront RETIRÉS au Publier.
// (Ces quarts sont encore dans assignments donc pas déjà comptés en « rem » ci-dessus ; on les ajoute ici.)
const todayIso = todayISO(); let paused = 0
for (const [tid, v] of Object.entries(pendingPause.value || {})) {
const g = bucket(tid); g.pause = { reason: (v && v.reason) || '' }; paused++
for (const a of assignments.value) { if (a.tech === tid && a.date >= todayIso) g.rem.push({ date: a.date, shift: a.shift_name || shiftLabel(a.shift), pause: true }) }
}
const list = Object.values(byTech)
const byDate = (x, y) => String(x.date).localeCompare(String(y.date))
for (const g of list) { g.add.sort(byDate); g.rem.sort(byDate); g.abs.sort(byDate) }
list.sort((a, b) => String(a.name).localeCompare(String(b.name)))
return { added: unpublished.value.length, removed, absAdded, absRemoved, techs: list }
return { added: unpublished.value.length, removed, absAdded, absRemoved, paused, techs: list }
})
const holSet = computed(() => new Set(holidays.value))
@ -4074,6 +4109,15 @@ const LS_PENDING_ABS = 'roster-pending-abs-v1'
const pendingAbs = ref({})
function savePendingAbs () { try { localStorage.setItem(LS_PENDING_ABS, JSON.stringify(pendingAbs.value)) } catch (e) {} }
const pendingAbsCount = computed(() => Object.keys(pendingAbs.value).length)
// PAUSE INDÉFINIE en attente (publish-required, comme les congés) : { [techId]: { reason } }. Prend effet VISUELLEMENT
// (badge « pause à publier » + quarts futurs listés « à retirer » dans le résumé), mais n'est COMMISE qu'au Publier
// (status En pause + suppression des quarts futurs + relève des jobs). Persistée (survit au refresh, fait partie du brouillon).
const LS_PENDING_PAUSE = 'roster-pending-pause-v1'
const pendingPause = ref({})
function savePendingPause () { try { localStorage.setItem(LS_PENDING_PAUSE, JSON.stringify(pendingPause.value)) } catch (e) {} }
const pendingPauseCount = computed(() => Object.keys(pendingPause.value).length)
const isServerPaused = (t) => !!(t && t.status === 'En pause')
const isEffectivelyPaused = (id) => !!pendingPause.value[id] || isServerPaused((techs.value || []).find(t => t.id === id))
// Type d'absence EFFECTIF = état serveur écrasé par le delta local en attente. null = pas absent.
function effAbsType (techId, iso) { const k = techId + '|' + iso; const p = pendingAbs.value[k]; if (p) return p.op === 'set' ? (p.type || 'Congé') : null; return absByTechDay.value[k] || null }
function isAbsent (techId, iso) { return !!effAbsType(techId, iso) }
@ -5696,14 +5740,31 @@ async function flushPendingAbsences () {
async function doPublishConfirmed () {
publishing.value = true
try {
// PAUSES en attente : la charge publiée EXCLUT les quarts futurs (aujourd'hui) des techs mis en pause publish-week
// les SUPPRIME côté serveur (diff). Les quarts passés restent (historique). Rien retiré si aucune pause.
const pausedIds = Object.keys(pendingPause.value || {})
const todayIso = todayISO()
const payload = pausedIds.length
? assignments.value.filter(a => !(pausedIds.includes(a.tech) && a.date >= todayIso))
: assignments.value
// Mode 'publish' : promeut les brouillons (Proposé/Soumis/Approuvé) Publié + SMS. Les éditions étaient déjà auto-sauvées.
const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value, 'publish')
const r = await roster.publishWeek(start.value, days.value, payload, notifySms.value, 'publish')
const done = (r.created || 0) + (r.promoted || 0)
const abs = await flushPendingAbsences() // congés en attente serveur
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${done} quart(s)` + (abs.n ? ` · ${abs.n} congé(s)` : '') + (r.deleted ? ` (${r.deleted} retirés)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
// PAUSES : applique le statut « En pause » (retire du dispatch/solveur + stoppe la matérialisation du patron).
const pauseKeys = [] // tech|date futurs des techs mis en pause relève des jobs (IROPS)
let pausedDone = 0
for (const tid of pausedIds) {
try { const pr = await roster.pauseTechnician(tid, true, (pendingPause.value[tid] || {}).reason || ''); if (!(pr && pr.ok === false)) pausedDone++ } catch (e) { err(e) }
for (const a of assignments.value) if (a.tech === tid && a.date >= todayIso) pauseKeys.push(tid + '|' + a.date)
}
if (pausedIds.length) { pendingPause.value = {}; savePendingPause() }
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${done} quart(s)` + (abs.n ? ` · ${abs.n} congé(s)` : '') + (pausedDone ? ` · ${pausedDone} pause(s)` : '') + (r.deleted ? ` (${r.deleted} retirés)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
pubConfirm.value = false
await loadWeek()
if (abs.newlyAbsent.length) await checkAbsenceImpact(abs.newlyAbsent) // IROPS : redistribue les jobs des jours désormais en congé
// IROPS : jobs des jours désormais indisponibles (congés + pauses) dialogue de redistribution.
const impact = [...new Set([...(abs.newlyAbsent || []), ...pauseKeys])]
if (impact.length) await checkAbsenceImpact(impact)
} catch (e) { err(e) } finally { publishing.value = false }
}
// #3 étape d'approbation FACULTATIVE : Soumettre ( Soumis) / Approuver ( Approuvé), sans SMS. « Publier » reste l'étape finale (SMS).
@ -5718,7 +5779,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 = {} } }
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 = {} } }
// 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
@ -6305,6 +6366,7 @@ th.clk, td.clk { cursor: pointer; }
tr.res-hidden { opacity: .5; background: repeating-linear-gradient(45deg, #fafafa, #fafafa 6px, #f0f0f0 6px, #f0f0f0 12px); } /* ressource masquée affichée en grisé */
tr.res-hidden .hide-eye { opacity: 1; }
.tech-name.clk:hover { text-decoration: underline; }
.tech-name.tech-paused-pending { color: var(--ops-text-muted, #94a3b8); text-decoration: line-through; }
.hol-toggle { font-size: 9px; color: #ccc; cursor: pointer; border: 1px solid #eee; border-radius: 3px; width: 14px; margin: 1px auto 0; line-height: 12px; }
.hol-toggle.on { background: #ff9800; color: #fff; border-color: #ff9800; }
.cell { cursor: pointer; min-height: 24px; min-width: 132px; position: relative; background: #eef1f4; } /* gris pâle = INDISPONIBLE par défaut ; un quart crée une zone blanche (.tl-shift) */