From 3489576212134af70bc875642f339f68d27dda75 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 9 Jul 2026 11:32:54 -0400 Subject: [PATCH] =?UTF-8?q?Planif:=20cong=C3=A9s=20publish-required,=20men?= =?UTF-8?q?u=20de=20cellule=20partag=C3=A9,=20vue=20Mois?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Absences (congé/pause) passent par le modèle local -> Publier (journal, undo, dirty badge) au lieu d'écrire directement au serveur. Applique à la grille ET au calendrier par tech (TechScheduleDialog). - Fix capacité "48h" : l'estimation ignorait l'absence et le patron hebdo. - Menu de cellule (Jour/Soir/Garde/Absent/heures) extrait en composant partagé PlanifCellMenu, réutilisé par la grille et le calendrier par tech. - Raccourci "Jour" = 8-16 (pas 8-17) ; saisie rapide accepte "816". - Nouvelle vue Mois (MonthOverview) : qui est en quart/absent par jour + couverture par compétence (heures requises vs heures en quart). Remplace l'ancien module "Demande - effectif requis par créneau". --- .../src/components/planif/MonthOverview.vue | 252 ++++++++++++ .../src/components/planif/PlanifCellMenu.vue | 79 ++++ .../components/planif/TechScheduleDialog.vue | 96 +++-- apps/ops/src/pages/PlanificationPage.vue | 377 ++++++++---------- 4 files changed, 573 insertions(+), 231 deletions(-) create mode 100644 apps/ops/src/components/planif/MonthOverview.vue create mode 100644 apps/ops/src/components/planif/PlanifCellMenu.vue diff --git a/apps/ops/src/components/planif/MonthOverview.vue b/apps/ops/src/components/planif/MonthOverview.vue new file mode 100644 index 0000000..3964b62 --- /dev/null +++ b/apps/ops/src/components/planif/MonthOverview.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/apps/ops/src/components/planif/PlanifCellMenu.vue b/apps/ops/src/components/planif/PlanifCellMenu.vue new file mode 100644 index 0000000..e0158d1 --- /dev/null +++ b/apps/ops/src/components/planif/PlanifCellMenu.vue @@ -0,0 +1,79 @@ + + + diff --git a/apps/ops/src/components/planif/TechScheduleDialog.vue b/apps/ops/src/components/planif/TechScheduleDialog.vue index 8f55adf..6630536 100644 --- a/apps/ops/src/components/planif/TechScheduleDialog.vue +++ b/apps/ops/src/components/planif/TechScheduleDialog.vue @@ -53,14 +53,15 @@ {{ b.label }} - glisse sur les jours pour appliquer + glisse pour marquer · re-clique pour annuler · à publier - -
+ +
{{ d }}
+ +
+ + +
{{ b.label }} aujourd'hui @@ -100,12 +111,16 @@ import { ref, reactive, computed, watch } from 'vue' import { useQuasar } from 'quasar' import * as roster from 'src/api/roster' import { techOccupancy } from 'src/api/dispatch' // occupation/quart par jour du tech → grise les jours sans quart, barre d'occupation les jours avec quart +import PlanifCellMenu from 'src/components/planif/PlanifCellMenu.vue' // menu de cellule PARTAGÉ (identique à la grille semaine/jour) 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 + 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']) +const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed', 'stage-abs', 'set-shift', 'remove-shift', 'clear-shifts', 'toggle-garde']) const $q = useQuasar() const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) }) @@ -145,10 +160,24 @@ const month = ref('') // 'YYYY-MM' const calLoading = ref(false) const absMap = ref({}) // 'YYYY-MM-DD' → type d'absence const shiftMap = ref({}) // 'YYYY-MM-DD' → { shift:bool, occupancy:0..1 } (quart réel du tech ce jour) → gris = pas de quart +const asgByDate = ref({}) // 'YYYY-MM-DD' → [{ name, shift, shift_name, hours }] (quarts réguliers du tech ce jour) → menu « shifts en place » const holiSet = ref(new Set()) -const drag = reactive({ on: false, sel: new Set() }) +const drag = reactive({ on: false, sel: new Set(), moved: false }) const selCount = computed(() => drag.sel.size) +// Menu de cellule (clic simple) — ancré au curseur (comme la grille). +const cellAnchor = ref(null) +const cellMenu = reactive({ show: false, iso: '', x: 0, y: 0 }) +const menuShifts = computed(() => asgByDate.value[cellMenu.iso] || []) +const menuIsGarde = computed(() => !!(props.tech && props.gardeMap && props.gardeMap[props.tech.id + '|' + cellMenu.iso])) +const menuIsAbsent = computed(() => !!effAbsType(cellMenu.iso)) +function fmtDayLabel (iso) { if (!iso) return ''; return iso.slice(8) + '/' + iso.slice(5, 7) } +// Type d'absence EFFECTIF pour un jour = serveur (absMap) écrasé par le delta EN ATTENTE du parent. null = pas absent. +function effAbsType (iso) { + const p = props.pendingAbs && props.tech && props.pendingAbs[props.tech.id + '|' + iso] + if (p) return p.op === 'set' ? (p.type || 'Congé') : null + return absMap.value[iso] || null +} const MO = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'] const monthLabel = computed(() => { const [y, m] = month.value.split('-').map(Number); return MO[m - 1] + ' ' + y }) function todayISO () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) } @@ -167,7 +196,7 @@ const monthCells = computed(() => { const iso = y + '-' + pad(m) + '-' + pad(day) const dow = new Date(iso + 'T12:00:00').getUTCDay() const sh = shiftMap.value[iso] - cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, isToday: iso === t, absType: absMap.value[iso] || null, holiday: holiSet.value.has(iso), sel: drag.sel.has(iso), hasShift: !!(sh && sh.shift), occupancy: sh ? sh.occupancy : null }) + cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, isToday: iso === t, absType: effAbsType(iso), holiday: holiSet.value.has(iso), sel: drag.sel.has(iso), hasShift: !!(sh && sh.shift), occupancy: sh ? sh.occupancy : null }) } while (cells.length % 7) cells.push({ inMonth: false }) return cells @@ -195,10 +224,11 @@ async function loadCal () { const [y, m] = month.value.split('-').map(Number) const start = y + '-' + pad(m) + '-01' const days = new Date(Date.UTC(y, m, 0)).getUTCDate() - const [absRes, holiRes, occRes] = await Promise.all([ + const [absRes, holiRes, occRes, asgRes] = await Promise.all([ roster.getAbsences(start, days), roster.holidays(start, y + '-' + pad(m) + '-' + pad(days)), techOccupancy({ after_date: start, days, skill: '' }).catch(() => null), // quart + occupation par jour (tous techs) → on garde le nôtre + roster.listAssignments(start, days).catch(() => null), // quarts réguliers du mois → menu « shifts en place » / retrait ]) const out = {} const abs = (absRes && absRes.absences) || {} @@ -213,29 +243,41 @@ async function loadCal () { const mine = occRes && (occRes.techs || []).find(x => x.tech_id === props.tech.id || x.tech_name === props.tech.name) if (mine) for (const d of (mine.days || [])) sh[d.date] = { shift: !d.off, occupancy: d.occupancy } shiftMap.value = sh + // Quarts réguliers du mois pour CE tech → menu « shifts en place » + retrait par docname. + const abd = {} + for (const a of ((asgRes && asgRes.assignments) || [])) { + if (a.tech !== props.tech.id && a.tech !== props.tech.name) continue + ;(abd[a.date] || (abd[a.date] = [])).push({ name: a.name, shift: a.shift, shift_name: a.shift_name || a.shift, hours: a.hours }) + } + asgByDate.value = abd } catch (e) { err(e) } finally { calLoading.value = false } } -// Glisser-sélectionner -function startDrag (c) { drag.on = true; drag.sel = new Set([c.iso]) } -function overDrag (c) { if (drag.on) drag.sel.add(c.iso) } -async function endDrag () { +// Interaction : CLIC SIMPLE (sans glisser) = menu de cellule partagé (Jour/Soir/Garde/Absent/heures) ; GLISSER = pinceau +// rapide de congés (multi-jours, vacances). Le congé reste STAGÉ (publish-required) : on émet le delta au parent, 0 écriture ici. +function startDrag (c, ev) { drag.on = true; drag.moved = false; drag.sel = new Set([c.iso]); if (ev) { cellMenu.x = ev.clientX; cellMenu.y = ev.clientY } } +function overDrag (c) { if (!drag.on) return; if (!drag.sel.has(c.iso)) drag.moved = true; drag.sel.add(c.iso) } +function cancelDrag () { drag.on = false; drag.sel = new Set(); drag.moved = false } +function endDrag () { if (!drag.on) return drag.on = false - const days = [...drag.sel] - drag.sel = new Set() - if (!days.length) return - const remove = brush.value === 'clear' - const type = remove ? '' : brush.value - calLoading.value = true - let ok = 0 - try { - for (const iso of days) { - try { await roster.setAbsence(props.tech.id, iso, type, remove); ok++; if (remove) delete absMap.value[iso]; else absMap.value[iso] = type } catch (e) { err(e) } - } - if (ok) { $q.notify({ type: 'positive', message: (remove ? 'Effacé' : 'Marqué « ' + type + ' »') + ' — ' + ok + ' jour(s)', timeout: 1800 }); emit('changed', { id: props.tech.id }) } - } finally { calLoading.value = false; absMap.value = { ...absMap.value } } + const days = [...drag.sel]; drag.sel = new Set() + if (!days.length || !props.tech) return + if (!drag.moved) { openCellMenu(days[0]); return } // clic simple → menu (pas d'application du pinceau) + const changes = days.map(iso => { + if (brush.value === 'clear') return { iso, type: '' } + const cur = effAbsType(iso) + return { iso, type: cur === brush.value ? '' : brush.value } // même type → toggle off + }) + emit('stage-abs', { techId: props.tech.id, changes }) } +// ── Menu de cellule (clic simple) — émet des intentions au parent (single source of logic). ── +function openCellMenu (iso) { cellMenu.iso = iso; cellMenu.show = true } +function onMenuWindow (r) { if (props.tech && r) emit('set-shift', { techId: props.tech.id, iso: cellMenu.iso, min: r.min, max: r.max }); cellMenu.show = false } +function onMenuGarde () { if (props.tech) emit('toggle-garde', { techId: props.tech.id, iso: cellMenu.iso }); cellMenu.show = false } +function onMenuAbsent () { if (props.tech) emit('stage-abs', { techId: props.tech.id, changes: [{ iso: cellMenu.iso, type: menuIsAbsent.value ? '' : 'Congé' }] }); cellMenu.show = false } +function onMenuRemoveShift (a) { if (a && a.name) emit('remove-shift', { name: a.name }); cellMenu.show = false } +function onMenuClear () { if (props.tech) emit('clear-shifts', { techId: props.tech.id, iso: cellMenu.iso, names: (asgByDate.value[cellMenu.iso] || []).map(a => a.name) }); cellMenu.show = false } // ── Archivage (réversible) ─────────────────────────────────────────────────── const archiveBusy = ref(false) @@ -262,9 +304,11 @@ watch(() => props.modelValue, (o) => { pauseNote.value = '' const t = todayISO() month.value = t.slice(0, 7) - absMap.value = {}; shiftMap.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set() + absMap.value = {}; shiftMap.value = {}; asgByDate.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set(); cellMenu.show = false loadCal() }) +// Le parent a persisté un quart (createShift/deleteAssignment) → recharger le mois pour refléter les shifts. +watch(() => props.refreshKey, () => { if (props.modelValue) loadCal() })