- {{ pubSummary.added }} quart(s) · {{ pubSummary.removed }} retiré(s) · {{ pubSummary.absAdded }} congé(s) · {{ pubSummary.absRemoved }} congé(s) levé(s) · {{ notifySms ? pubSummary.added + ' SMS aux techs' : 'sans SMS' }}
+ {{ pubSummary.added }} quart(s) · {{ pubSummary.removed }} retiré(s) · {{ pubSummary.paused }} pause(s) · {{ pubSummary.absAdded }} congé(s) · {{ pubSummary.absRemoved }} congé(s) levé(s) · {{ notifySms ? pubSummary.added + ' SMS aux techs' : 'sans SMS' }}
{{ g.name }}
+ Pause indéfinie · {{ g.pause.reason }} · {{ g.rem.length }} quart(s) futur(s) retiré(s)
{{ a.shift }} · {{ fmtDueLabel(a.date) }}
{{ r.shift }} · {{ fmtDueLabel(r.date) }}
{{ ab.op === 'set' ? (ab.type || 'Congé') : 'congé levé' }} · {{ fmtDueLabel(ab.date) }}
@@ -1754,8 +1756,8 @@
-
@@ -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) */