From c267f9a3ddd8a0c9175f32a4456d8965bc416b2c Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 3 Jul 2026 10:59:39 -0400 Subject: [PATCH] =?UTF-8?q?feat(planif):=20functional=20dispatch=20?= =?UTF-8?q?=E2=80=94=20single-day=20suggest,=20one=20overdue=20chip,=20sic?= =?UTF-8?q?k-tech=20redistribute,=20bulk=20shift=20gen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Making the auto-dispatch match the real workflow: - SINGLE selected day: suggestWindow is now one day (suggestDay), not today+tomorrow. openSuggest defaults it to the board's focus day (mobileSelIso/kbSelIso/today); config has a day picker (Aujourd'hui / Demain / date). Overdue + undated jobs are pulled onto that day. Re-simulating today is fine (non-destructive; apply is explicit). - ONE "⏰ en retard" chip: assignDates collapses all past dates into a single overdue chip (was one per date); pool filter handles __overdue__. - SICK/absent tech: a "sick" button per review group deselects the tech and re-optimizes → the solver redistributes their jobs across the rest (verified: removing Anthony reassigned his jobs, Gilles picked up). - BULK shift generation (A): WeeklyScheduleEditor gains multi-tech mode (tech chips, all on by default); "Générer les horaires (lot)" in the Outils + mobile menus applies a template to N techs × N weeks at once. Verified: overdue collapses to "en retard 13"; day selector + single-day plan (13 jobs); sick redistributes; bulk shows 56 techs / "Générer ×56". Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shared/WeeklyScheduleEditor.vue | 36 ++++++-- apps/ops/src/pages/PlanificationPage.vue | 85 ++++++++++++------- 2 files changed, 85 insertions(+), 36 deletions(-) diff --git a/apps/ops/src/components/shared/WeeklyScheduleEditor.vue b/apps/ops/src/components/shared/WeeklyScheduleEditor.vue index 0d73624..6dca3a4 100644 --- a/apps/ops/src/components/shared/WeeklyScheduleEditor.vue +++ b/apps/ops/src/components/shared/WeeklyScheduleEditor.vue @@ -6,24 +6,37 @@ * + « nombre de semaines » pour AUTOMATISER (répéter le patron sur N semaines). * Émet 'apply' { schedule:[{dow,on,start,end}] (Lun..Dim), weeks } → le parent écrit les Shift Assignment. */ -import { reactive, ref, watch } from 'vue' +import { reactive, ref, watch, computed } from 'vue' import { SCHEDULE_PRESETS } from 'src/composables/useHelpers' -const props = defineProps({ modelValue: { type: Boolean, default: false }, techName: { type: String, default: '' } }) +const props = defineProps({ + modelValue: { type: Boolean, default: false }, + techName: { type: String, default: '' }, + techs: { type: Array, default: () => [] }, // [{id,name}] — si >1 : sélection multi (application en LOT) +}) const emit = defineEmits(['update:modelValue', 'apply']) const DAYS = [['mon', 'Lun'], ['tue', 'Mar'], ['wed', 'Mer'], ['thu', 'Jeu'], ['fri', 'Ven'], ['sat', 'Sam'], ['sun', 'Dim']] const sched = reactive({}) const weeks = ref(1) +const selected = ref(new Set()) // techIds cochés (mode lot) +const bulk = computed(() => (props.techs || []).length > 1) +const title = computed(() => bulk.value ? ((props.techs || []).length + ' techniciens') : (props.techName || (props.techs[0] && props.techs[0].name) || '')) function fill (preset) { for (const [k] of DAYS) { const s = preset[k]; sched[k] = s ? { on: true, start: s.start, end: s.end } : { on: false, start: '08:00', end: '16:00' } } } fill(SCHEDULE_PRESETS[0].schedule) -watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = 1 } }) +watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = 1; selected.value = new Set((props.techs || []).map(t => t.id)) } }) +function toggleTech (id) { const s = new Set(selected.value); s.has(id) ? s.delete(id) : s.add(id); selected.value = s } function dayHours (d) { if (!d || !d.on) return 0; const [h1, m1] = d.start.split(':').map(Number); const [h2, m2] = d.end.split(':').map(Number); let mn = (h2 * 60 + m2) - (h1 * 60 + m1); if (mn < 0) mn += 1440; return Math.round(mn / 60 * 10) / 10 } const totalDays = () => DAYS.filter(([k]) => sched[k]?.on).length const totalHours = () => DAYS.reduce((s, [k]) => s + dayHours(sched[k]), 0) -function submit () { emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)) }); emit('update:modelValue', false) } +function submit () { + const techIds = bulk.value ? [...selected.value] : (props.techs[0] ? [props.techs[0].id] : []) + if (!techIds.length) return + emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)), techIds }) + emit('update:modelValue', false) +} @@ -2336,27 +2347,30 @@ async function applyWeekPreset (t, dows, min, max) { $q.notify({ type: 'positive', message: t.name + ' : horaire appliqué (semaine affichée) — pense à Publier', timeout: 2500 }) } // ── Génération de quarts HEBDO par tech (modèles + N semaines) — écrit DIRECTEMENT les Shift Assignment (Publié). ── -const schedGenOpen = ref(false); const schedGenTech = ref(null) -function openSchedGen (t) { schedGenTech.value = { id: t.id, name: t.name }; skillMenuShown.value = false; schedGenOpen.value = true } +const schedGenOpen = ref(false); const schedGenTechs = ref([]) +function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech +function openSchedGenBulk () { schedGenTechs.value = (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name })); schedGenOpen.value = true } // LOT : tous les techs visibles const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 } -async function onScheduleApply ({ schedule, weeks }) { - const t = schedGenTech.value; if (!t) return +async function onScheduleApply ({ schedule, weeks, techIds }) { + const targets = (schedGenTechs.value || []).filter(t => (techIds || []).includes(t.id)); if (!targets.length) return let created = 0, failed = 0; const tplByKey = {} const base = start.value // lundi de la semaine affichée - for (let w = 0; w < weeks; w++) { - for (let i = 0; i < 7; i++) { - const day = schedule[i]; if (!day || !day.on) continue - const sh = _hmNum(day.start), eh = _hmNum(day.end); if (!(eh > sh)) { failed++; continue } - const key = sh + '-' + eh; let tpl = tplByKey[key] - if (!tpl) { tpl = await ensureWindowTpl(sh, eh); tplByKey[key] = tpl } - if (!tpl) { failed++; continue } - const iso = addDaysISO(base, w * 7 + i) - try { const r = await roster.createShift({ tech: t.id, tech_name: t.name, date: iso, shift: tpl.name, hours: tpl.hours || calcHours(day.start, day.end) }); if (r && (r.ok || r.existed)) created++; else failed++ } catch (e) { failed++ } + for (const t of targets) { + for (let w = 0; w < weeks; w++) { + for (let i = 0; i < 7; i++) { + const day = schedule[i]; if (!day || !day.on) continue + const sh = _hmNum(day.start), eh = _hmNum(day.end); if (!(eh > sh)) { failed++; continue } + const key = sh + '-' + eh; let tpl = tplByKey[key] + if (!tpl) { tpl = await ensureWindowTpl(sh, eh); tplByKey[key] = tpl } + if (!tpl) { failed++; continue } + const iso = addDaysISO(base, w * 7 + i) + try { const r = await roster.createShift({ tech: t.id, tech_name: t.name, date: iso, shift: tpl.name, hours: tpl.hours || calcHours(day.start, day.end) }); if (r && (r.ok || r.existed)) created++; else failed++ } catch (e) { failed++ } + } } } try { await loadWeek() } catch (e) {} // rafraîchit la grille (semaine affichée) - $q.notify({ type: created ? 'positive' : 'warning', icon: 'event_available', message: created + ' quart(s) publié(s) pour ' + t.name + (weeks > 1 ? ' (' + weeks + ' sem.)' : '') + (failed ? ' · ' + failed + ' ignoré(s)' : ''), timeout: 3500 }) - schedGenTech.value = null + $q.notify({ type: created ? 'positive' : 'warning', icon: 'event_available', message: created + ' quart(s) publié(s) · ' + targets.length + ' tech' + (weeks > 1 ? ' (' + weeks + ' sem.)' : '') + (failed ? ' · ' + failed + ' ignoré(s)' : ''), timeout: 4000 }) + schedGenTechs.value = [] } function skillEffOf (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? Number(e) : (Number(t.efficiency) || 1) } // facteur (pour le calcul de priorité) // Efficacité saisie en % de PERFORMANCE : + = plus VITE (moins de temps) · − = plus LENT. Conversion ↔ facteur. @@ -2525,7 +2539,15 @@ const assignDateFilter = ref([]) // isos retenus (vide = toutes les dates) const assignDates = computed(() => { const t = todayISO(); const m = {} for (const j of assignPanel.jobs) { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; m[d] = (m[d] || 0) + 1 } - return Object.entries(m).map(([iso, n]) => ({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: iso !== 'Sans date' && iso < t })).sort((a, b) => String(a.iso).localeCompare(String(b.iso))) + // Toutes les dates PASSÉES → UN SEUL chip « ⏰ en retard » (déclutter). Les jours futurs + « Sans date » restent séparés. + const out = []; let overdueN = 0 + for (const [iso, n] of Object.entries(m)) { + if (iso !== 'Sans date' && iso < t) { overdueN += n; continue } + out.push({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: false }) + } + out.sort((a, b) => String(a.iso).localeCompare(String(b.iso))) + if (overdueN) out.unshift({ iso: '__overdue__', n: overdueN, label: '⏰ en retard', overdue: true }) + return out }) function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] } // Depuis l'aperçu « jobs sur la colonne du jour » → ouvre le panneau « À assigner » filtré sur ce jour. @@ -2533,7 +2555,7 @@ async function openDayInPanel (iso) { await openAssignPanel(); assignSort.value const assignJobsFiltered = computed(() => { let arr = assignPanel.jobs const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) } - const df = assignDateFilter.value; if (df.length) { const ds = new Set(df); arr = arr.filter(j => ds.has((j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date')) } + const df = assignDateFilter.value; if (df.length) { const ds = new Set(df); const t = todayISO(); arr = arr.filter(j => { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t) }) } // '__overdue__' = toutes dates passées return arr }) function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) } @@ -4313,23 +4335,19 @@ function buildSuggestion () { suggestDlg.plan = plan } function openSuggest () { + suggestDay.value = mobileSelIso.value || kbSelIso.value || todayISO() // jour ciblé = celui sélectionné au tableau (défaut auj.) suggestDlg.fromSel = selectedNames.value.length > 0 - // Étape « disponibilité » d'abord : défaut = techs AVEC un quart dans la fenêtre (= disponibles) ; sinon tous les visibles. + // Étape « disponibilité » d'abord : défaut = techs AVEC un quart CE JOUR (= disponibles) ; sinon tous les visibles. const techs = visibleTechs.value || [] - const withShift = techs.filter(t => (dayList.value || []).some(d => hasShiftDay(t.id, d.iso))) + const withShift = techs.filter(t => hasShiftDay(t.id, suggestDay.value)) const base = withShift.length ? withShift : techs const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true } const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length) -// Fenêtre du dispatch auto : dates SÉLECTIONNÉES (chips) sinon aujourd'hui + demain (max) — on ne planifie pas loin (techs malades imprévus). -const suggestWindow = computed(() => { - const isos = (dayList.value || []).map(d => d.iso); const t = todayISO(); const tmrw = tomorrowISO() - const sel = (assignDateFilter.value || []).filter(iso => isos.includes(iso)) - let win = sel.length ? [...sel].sort() : isos.filter(iso => iso === t || iso === tmrw) - if (!win.length) win = isos.filter(iso => iso >= t).slice(0, 2) // repli : 2 premiers jours futurs de la grille - return win -}) +// Dispatch auto = UNE SEULE journée (celle sélectionnée). Les jobs en retard / sans date sont tirés sur CE jour. Non destructif : on revoit avant d'appliquer (re-simuler aujourd'hui = OK). +const suggestDay = ref('') +const suggestWindow = computed(() => [suggestDay.value || todayISO()]) const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso)) const suggestWindowLabel = computed(() => { const w = suggestWindow.value; if (!w.length) return '—' @@ -4375,6 +4393,13 @@ async function runSuggestion () { loadLegTimes().catch(() => {}) // temps de route réels entre arrêts (OSRM, non bloquant) } // Ré-optimiser depuis la revue : relance le solveur VRP en tenant compte des contraintes AM/PM/⚡ posées sur les jobs. +// Tech malade/absent : le désélectionne puis RÉ-OPTIMISE → ses jobs sont répartis équitablement (le solveur équilibre la charge) sur les techs restants. +async function markTechSick (g) { + if (!g || isHoldId(g.techId)) return + suggestDlg.techSel[g.techId] = false + $q.notify({ type: 'info', icon: 'sick', message: g.techName + ' retiré (malade/absent) — redistribution de ses ' + g.jobs + ' job(s)…', timeout: 2200 }) + await reoptimize() +} async function reoptimize () { suggestDlg.strategy = 'optimize'; suggestDlg.building = true try { await optimizeSuggestion() } catch (e) { err(e) } finally { suggestDlg.building = false }