From c7c9f813642c50e0d6496749fa3c4a61a3f55b7b Mon Sep 17 00:00:00 2001 From: louispaulb Date: Tue, 14 Jul 2026 22:17:12 -0400 Subject: [PATCH] =?UTF-8?q?fix(planif):=20Sugg=C3=A9rer=20=3D=20jobs=20DUS?= =?UTF-8?q?=20le=20jour=20s=C3=A9lectionn=C3=A9=20=C2=B7=20jour=20partag?= =?UTF-8?q?=C3=A9=20entre=20vues=20(session)=20=C2=B7=20quarts=20brouillon?= =?UTF-8?q?=20refl=C3=A9t=C3=A9s=20au=20calendrier=20mois?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Suggérer (retour terrain, ex. ticket 254403 en retard auto-dispatché) : - fenêtre = EXACTEMENT le jour sélectionné ; les chips de date du pool n'élargissent plus jamais le dispatch (elles filtrent l'affichage) - suggestJobs filtré à la SOURCE (couvre solveur VRP + heuristique) : scheduled_date === jour sélectionné ; retards/sans-date exclus — seule exception : sélection manuelle (lasso) - libellés du dialogue mis à jour (« N job(s) DUS ce jour · retards exclus ») 2) Jour sélectionné PARTAGÉ (selDay) persisté en sessionStorage (planif_sel_day) : écrit par bande Tournées / Jour kanban / sélecteur de date / bande mobile / dialogue Suggérer ; repris au changement de vue (Jour ne retombe plus sur « aujourd'hui », Tournées suit, Mois ancre son mois dessus) 3) Quarts créés en Semaine/Jour → calendrier mois par tech + vue Mois : flush du brouillon (debounce 800 ms) avant l'ouverture du dialogue horaire, flush au passage en vue Mois, et refresh auto des calendriers ouverts quand une auto-sauvegarde se termine (techSchedRefresh / monthRefresh ; MonthOverview.refreshKey enfin câblé du parent) Vérifié (aperçu 9001, lecture seule) : pool 44 jobs dont 8 en retard → dialogue n'offre que les 2 DUS le jour choisi ; 22/07 suivi Semaine→Jour→Tournées ; Mois affiche « Août 2026 » pour selDay=2026-08-05 ; build SPA OK. Co-Authored-By: Claude Fable 5 --- apps/ops/src/pages/PlanificationPage.vue | 102 +++++++++++++---------- 1 file changed, 60 insertions(+), 42 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 0415631..e1481a3 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -448,7 +448,8 @@ techs en LANES horizontales à ÉCHELLE D'HEURES (réutilise cellBands + pos + axisTicks). Glisser = assigner (hub). ── --> - + +
@@ -911,20 +912,20 @@ Inclut les {{ assignedNamesForWindow().length }} job(s) déjà assigné(s) du/des jour(s) → le solveur repropose une répartition de TOUT (existant + nouveaux). Force le solveur VRP. Aperçu seulement — « Appliquer » réassigne.
- +
Répartir :
@@ -2019,9 +2020,16 @@ watch(dayMode, (on) => { if (on) showDayJobs.value = true }) // en mode Jour, af // Réutilise tout : occupation (avec équipes via job.assist), pool unassignedJobs, onCellDrop (= roster.assignJob hub). ── const { prefs: planifPrefs, save: savePlanif } = useUserPrefs('planif', { view: 'grid' }) // préférence d'affichage SERVEUR (suit l'usager entre appareils) const boardView = ref(['kanban', 'routes', 'month'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | 'month' | 'routes' -watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = thisMonday(); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } }) +watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = selDay.value || nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = mondayISO(kbSelIso.value); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } else if (v === 'month' && _draftT) { autosaveDraft() } }) // Jour reprend le jour SÉLECTIONNÉ (selDay), plus « aujourd'hui » ; Mois → flush du brouillon (le calendrier lit le serveur) watch(() => planifPrefs.value.view, (v) => { if (v && v !== boardView.value) boardView.value = v }) // sync depuis le serveur (autre appareil) une fois chargé const kbSelIso = ref('') // mode Jour : jour explicitement choisi ('' = auto → aujourd'hui) +// ── Jour SÉLECTIONNÉ partagé entre les vues (Semaine · Jour · Tournées · mobile) — VARIABLE DE SESSION : +// choisi dans une vue → mémorisé (sessionStorage, survit au refresh de l'onglet) → repris par les autres vues +// au lieu de retomber sur « aujourd'hui ». Source unique aussi pour le jour cible de « Suggérer ». ── +const SEL_DAY_KEY = 'planif_sel_day' +const selDay = ref((() => { try { const v = sessionStorage.getItem(SEL_DAY_KEY); return (v && /^\d{4}-\d{2}-\d{2}$/.test(v)) ? v : todayISO() } catch (e) { return todayISO() } })()) +function setSelDay (iso) { if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(String(iso))) return; selDay.value = iso; try { sessionStorage.setItem(SEL_DAY_KEY, iso) } catch (e) {} } +watch(kbSelIso, v => setSelDay(v)) const kanbanDay = computed(() => { // Jour : jour choisi, sinon AUJOURD'HUI (ET) si dans la fenêtre, sinon le 1er jour for (const c of [kbSelIso.value, nowET.value.iso]) { if (!c) continue; const d = dayList.value.find(x => x.iso === c); if (d) return d } return dayList.value[0] || null @@ -2031,6 +2039,7 @@ const boardDate = computed({ get: () => (boardView.value === 'kanban' ? ((kanbanDay.value && kanbanDay.value.iso) || start.value) : start.value), set: (v) => { if (!v) return + setSelDay(v) // le sélecteur de date de la barre choisit AUSSI le jour partagé (repris par Jour/Tournées/Suggérer) if (boardView.value === 'kanban') { kbSelIso.value = v; if (!dayList.value.find(d => d.iso === v)) { start.value = mondayISO(v); loadWeek() } } else if (v !== start.value) { start.value = v; onWeekChange() } }, @@ -2635,7 +2644,11 @@ async function applyWeekPreset (t, dows, min, max) { // ── Écran unique « Horaire » par tech (icône event de la rangée) : pause indéfinie + horaire récurrent + calendrier congés + archivage. ── const techSchedOpen = ref(false); const techSchedTech = ref(null) const techSchedRefresh = ref(0) // ↑ après une écriture de quart → le calendrier par tech recharge son mois -function openTechSchedule (t) { techSchedTech.value = t; techSchedOpen.value = true } +const monthRefresh = ref(0) // ↑ quand un brouillon vient d'être auto-sauvegardé → la vue Mois recharge (les quarts créés en Semaine/Jour s'y reflètent) +// Ouvre le calendrier par tech en FLUSHANT d'abord le brouillon en attente (debounce 800 ms) : le dialogue lit le +// SERVEUR (occupation + assignations Proposé/Publié) → sans flush, un quart tout juste peint n'apparaîtrait pas. +async function openTechSchedule (t) { techSchedTech.value = t; if (_draftT) { try { await autosaveDraft() } catch (e) { /* best-effort */ } } techSchedOpen.value = true } +// (le watcher « autosave terminé → rafraîchir les calendriers » vit à côté de `autosaving` plus bas — TDZ) // ── Calendrier par tech : le menu de cellule PARTAGÉ y émet des intentions ; on les exécute avec les mêmes fonctions que la grille. ── // Quarts = persistés PAR DATE (createShift 'Publié' / deleteAssignment) car le calendrier est mensuel (hors fenêtre de la grille). async function onTechSchedSetShift ({ techId, iso, min, max } = {}) { @@ -4239,6 +4252,7 @@ const todayIso = computed(() => (nowET.value && nowET.value.iso) || new Date().t // heatColor → composables/useHelpers.js (source unique, partagé avec + tableau de bord) const _CAP_H = 8 // capacité indicative par tech-jour (h) pour normaliser les barres const mobileSelIso = ref(null) +watch(mobileSelIso, v => setSelDay(v)) // le jour touché sur mobile devient le jour partagé (repris par les autres vues) // Bande de jours mobile = fenêtre LONGUE (4 semaines) → carrousel horizontal CONTINU (sans flèches). // L'occupation est chargée sur la même fenêtre (voir getOccupancy mobile) pour que tous ces jours aient des données. const MOBILE_STRIP_DAYS = 28 @@ -4619,11 +4633,9 @@ function buildSuggestion () { else if (sched && winSet.has(sched)) cand = [sched] // daté DANS la fenêtre → ce jour else if (sched && sched > winMax) continue // daté APRÈS la fenêtre → PAS dispatché maintenant (on ne planifie pas loin) else { - // En retard (date < fenêtre) ou sans date : NE PLUS balayer automatiquement sur le(s) jour(s) choisi(s). - // Seulement si EXPLICITEMENT demandé : sélection lasso, ou chip « ⏰ en retard » / « Sans date » coché. - // → sélectionner un seul jour ne dispatche QUE les jobs dus ce jour-là (retour terrain : les retards ne doivent pas s'inviter). - const wantBacklog = useSel || (floatingPool.dateFilter.value || []).includes('__overdue__') || (floatingPool.dateFilter.value || []).includes('Sans date') - if (!wantBacklog) continue + // En retard (date < fenêtre) ou sans date : JAMAIS auto-dispatché. UNE seule exception : la sélection + // manuelle explicite (lasso/cases) — les chips du pool n'ouvrent plus cette porte (ticket ex. 254403). + if (!useSel) continue cand = win } if (!cand.length) continue @@ -4706,8 +4718,9 @@ function buildSuggestion () { suggestDlg.plan = plan } function openSuggest () { - // Jour de base = la sélection de la vue active (strip Tournées, Jour kanban, ou bande mobile) → « Suggérer se base sur la journée sélectionnée » - suggestDay.value = (boardView.value === 'routes' && routesDay.value) || (boardView.value === 'kanban' && kbSelIso.value) || mobileSelIso.value || routesDay.value || kbSelIso.value || todayISO() + // Jour de base = le jour SÉLECTIONNÉ PARTAGÉ (selDay — synchronisé depuis la bande Tournées, le Jour kanban, + // le sélecteur de date et la bande mobile) → « Suggérer se base toujours sur la journée sélectionnée ». + suggestDay.value = (boardView.value === 'routes' && routesDay.value) || (boardView.value === 'kanban' && kanbanDay.value && kanbanDay.value.iso) || selDay.value || todayISO() suggestDlg.fromSel = selectedNames.value.length > 0 // Étape « disponibilité » d'abord : défaut = techs AVEC un quart sur un des jours de la fenêtre (= disponibles) ; sinon tous les visibles. const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur @@ -4727,48 +4740,41 @@ function openSuggest () { suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true } const suggestSelCount = computed(() => Object.values(suggestDlg.techSel).filter(Boolean).length) -// Dispatch auto : la FENÊTRE = les CHIPS de date cochées dans le pool (jours à répartir). Sinon (aucun chip) → le jour choisi dans le sélecteur. -// Les chips « ⏰ en retard » / « Sans date » ne sont pas des jours calendaires → les jobs correspondants sont placés AUJOURD'HUI. Non destructif (revue avant d'appliquer). +// Dispatch auto : la FENÊTRE = LE jour sélectionné (selDay partagé entre vues ; modifiable dans le dialogue). +// Changer le jour dans le dialogue met aussi à jour le jour partagé (mémoire de session). Non destructif (revue avant d'appliquer). const suggestDay = ref('') +watch(suggestDay, v => setSelDay(v)) const suggestWindow = computed(() => { - // Vue Tournées : la fenêtre = EXACTEMENT le jour sélectionné (autorité = la bande de dates), jamais les chips de date du pool. - // → « Suggérer » ne balaie PAS les jobs en retard (date < jour choisi) sur ce jour ; seuls les jobs DUS ce jour-là sont répartis. + // RÈGLE DÉFINITIVE (retour terrain, ticket ex. 254403 en retard auto-dispatché) : la fenêtre = EXACTEMENT + // LE JOUR SÉLECTIONNÉ (bande Tournées / Jour kanban / sélecteur du dialogue — tous synchronisés via selDay). + // Les chips de date du pool FILTRENT l'affichage mais n'élargissent JAMAIS le dispatch auto ; les jobs en + // retard ou sans date ne sont dispatchés QUE par sélection manuelle explicite (lasso/cases cochées). if (boardView.value === 'routes' && routesDay.value) return [routesDay.value] - const f = floatingPool.dateFilter.value || [] - if (!f.length) return [suggestDay.value || todayISO()] // aucun chip coché → sélecteur de jour du dialogue - const days = new Set(f.filter(iso => iso !== '__overdue__' && iso !== 'Sans date')) // jours concrets cochés (auj./futur) - if (f.includes('__overdue__') || f.includes('Sans date') || !days.size) days.add(todayISO()) // en retard / sans date → placés aujourd'hui - return [...days].sort() + return [suggestDay.value || selDay.value || todayISO()] }) // Jobs que « Suggérer » doit répartir : la sélection lasso si active, sinon « ce que je vois » = le pool filtré // par les CHIPS de TYPE **et** de DATE (assignJobsFiltered). Ex. décocher « téléphonie » → non réparti aux techs terrain. const suggestJobs = () => { - if (selectedNames.value.length) return (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && selectedJobs[j.name]) // sélection lasso explicite + if (selectedNames.value.length) return (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && selectedJobs[j.name]) // sélection lasso explicite = seule façon de dispatcher un retard/sans-date // Vue Tournées : périmètre = EXACTEMENT la bande filtrée (jour sélectionné + compétence + secteur + priorité) → « ce que je filtre = ce que Suggérer répartit ». if (boardView.value === 'routes') return (routeDayUnassignedView.value || []).filter(j => j.status !== 'On Hold') - let jobs = (floatingPool.filteredJobs.value || []).filter(j => j.status !== 'On Hold') // pool tel qu'affiché (type + date) - // Par DÉFAUT (aucun chip de date coché) : ne répartir QUE les jobs DATÉS sur le(s) jour(s) de la fenêtre — jamais les - // jobs en retard/sans date/d'autres jours (souvent faits mais pas encore fermés) qui pollueraient la simulation. - // (Pour dispatcher les retards, cocher explicitement le chip « ⏰ en retard » ou « Sans date ».) - // Filtre appliqué à la SOURCE (couvre aussi le solveur ⚡ Optimiser qui prend suggestJobs()) : seuls les jobs - // datés DANS la fenêtre passent, SAUF si l'utilisateur demande explicitement les retards/sans-date. - const wantBacklog = (floatingPool.dateFilter.value || []).includes('__overdue__') || (floatingPool.dateFilter.value || []).includes('Sans date') - if (!wantBacklog) { - const win = new Set(suggestWindow.value) - jobs = jobs.filter(j => j.scheduled_date && j.scheduled_date !== 'Sans date' && win.has(j.scheduled_date)) - } + // RÈGLE : seuls les jobs dont la DATE DUE = le jour sélectionné sont auto-dispatchés (jamais les retards, + // jamais les sans-date, jamais les autres jours — ticket ex. 254403). Appliqué à la SOURCE : couvre le + // solveur VRP (job_names) ET l'heuristique locale. Les chips de COMPÉTENCE du pool restreignent encore + // (ex. décocher « téléphonie ») ; les chips de DATE n'influencent plus le dispatch (affichage seulement). + const win = new Set(suggestWindow.value) + let jobs = (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && j.scheduled_date && j.scheduled_date !== 'Sans date' && win.has(j.scheduled_date)) + const sk = floatingPool.skillFilter.value || [] + if (sk.length) jobs = jobs.filter(j => sk.includes(j.required_skill)) return jobs } const suggestJobCount = computed(() => suggestJobs().length) -// Y a-t-il un filtre restreignant le dispatch ? (lasso, chips de date ou de type) -const suggestFiltered = computed(() => !!(selectedNames.value.length || floatingPool.dateFilter.value.length || floatingPool.skillFilter.value.length)) -// Libellé du périmètre : « sélectionnés » (lasso) · « des jours/types cochés » (chips) — décrit ce qui limite le jeu de jobs. +// Y a-t-il un filtre restreignant le dispatch ? (lasso ou chips de TYPE — les chips de date ne comptent plus : le jour vient de selDay) +const suggestFiltered = computed(() => !!(selectedNames.value.length || floatingPool.skillFilter.value.length)) +// Libellé du périmètre : « sélectionnés » (lasso) · « des types cochés » (chips de compétence). const suggestScope = computed(() => { if (selectedNames.value.length) return 'sélectionnés' - const parts = [] - if (floatingPool.dateFilter.value.length) parts.push('jours') - if (floatingPool.skillFilter.value.length) parts.push('types') - return parts.length ? ('des ' + parts.join(' + ') + ' cochés') : 'du pool' + return floatingPool.skillFilter.value.length ? 'des types cochés' : 'du jour' }) const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso)) const suggestWindowLabel = computed(() => { @@ -5208,7 +5214,16 @@ async function loadRouteTrack () { } catch (e) { routeTrack.value = null; routeTrackMsg.value = 'indispo' } } watch([soloRouteTech, routesDay, showRouteTrack, boardView], loadRouteTrack) -watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true }) +watch(boardView, (v) => { + if (v !== 'routes') return + // Reprend le jour SÉLECTIONNÉ partagé (selDay) — même jour que les vues Semaine/Jour. Si le jour est hors de la + // fenêtre chargée, on déplace la fenêtre (comme le fait le mode Jour) plutôt que de retomber sur aujourd'hui. + const sel = selDay.value + if (sel && (dayList.value || []).some(d => d.iso === sel)) { routesDay.value = sel; return } + if (sel && /^\d{4}-\d{2}-\d{2}$/.test(sel)) { routesDay.value = sel; start.value = mondayISO(sel); loadWeek(); return } + if (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value)) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } +}, { immediate: true }) +watch(routesDay, v => setSelDay(v)) // le jour cliqué dans la bande Tournées devient le jour partagé // Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées). const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s)) // Existe-t-il AU MOINS un tech capable ? Sinon (données de compétences incomplètes) on ne bloque personne (évite de piéger une file inassignable). @@ -5829,6 +5844,9 @@ function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.v function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } } // #2 — AUTO-SAVE : chaque édition de la grille est persistée en BROUILLON (statut 'Proposé') après un court debounce. Plus rien n'est « non sauvegardé ». const autosaving = ref(false) +// Auto-save TERMINÉ → rafraîchit les calendriers qui lisent le SERVEUR (dialogue horaire par tech · vue Mois) : +// les quarts créés en Semaine/Jour s'y répliquent sans fermer/rouvrir. (Watcher ICI, après la déclaration — TDZ.) +watch(autosaving, (v, ov) => { if (ov && !v) { if (techSchedOpen.value) techSchedRefresh.value++; if (boardView.value === 'month') monthRefresh.value++ } }) const changeLog = ref([]) // journal de session : { id, at, text, revert?:{tech,date,before}, reverted? } let _draftT = null let _chgSeq = 0