diff --git a/apps/ops/src/components/planif/TechScheduleDialog.vue b/apps/ops/src/components/planif/TechScheduleDialog.vue index 6630536..3bcc832 100644 --- a/apps/ops/src/components/planif/TechScheduleDialog.vue +++ b/apps/ops/src/components/planif/TechScheduleDialog.vue @@ -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) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 3df7da5..09ebaab 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -400,7 +400,8 @@ Horaire de {{ t.name }} — récurrent · pause · congés{{ isPaused(t) ? ' (en pause)' : '' }} {{ techRank(t) }}Priorité {{ techRank(t) }} — combine maîtrise (niveau) + vitesse (efficacité) + coût. - Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée{{ t.name }} + Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée{{ t.name }} + pause à publierPause indéfinie en attente — Publier pour appliquer (retire ses quarts futurs + relève ses jobs) {{ t.group }} {{ hoursOf(t.id) }}hHeures planifiées sur l'étendue affichée ({{ days }} j) — total, PAS une limite hebdomadaire Actions — {{ t.name }} @@ -1338,12 +1339,13 @@ -
{{ 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' }}
@@ -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) */