From ef7b51399da3816ee9c586229f7820a658200bc5 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 16 Jul 2026 18:38:17 -0400 Subject: [PATCH] =?UTF-8?q?refactor(planif):=20=C2=AB=20Bloquer=20du=20tem?= =?UTF-8?q?ps=20=C2=BB=20=3D=20UN=20job/ticket=20qui=20occupe=20la=20timel?= =?UTF-8?q?ine,=20priorit=C3=A9=20=3D=20curseur=20dur/souple?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite retour Louis : plus de mode soft/hard (toggle du commit précédent retiré). Un seul concept — un job/ticket interne qui OCCUPE la timeline (formation · réunion · projet · entretien) → dé-priorise le tech au dispatch. La PRIORITÉ est le curseur : Moyenne/Basse = les urgences/réparations peuvent quand même prendre le tech ; Urgent (high) = bloc dur, non traversé. Front : dialogue unique « Bloquer du temps » (Intitulé · journée complète · date · début · durée · Priorité [défaut Moyenne] · ticket optionnel), info adaptée à la priorité. Un seul submit `doBlockTime` → /roster/filler-job. Retiré : bascule de mode, branche réserve, `roster.reserveTech`. Les 2 points d'entrée (bouton volet réglages + menu de cellule) → même dialogue, relabellés « Bloquer du temps… ». Hub (dispatch.js suggestSlots) : la traversée urgence passe du flag `job_type === 'Réservation'` à la PRIORITÉ — `isSoftBlock` = bloc interne (Interne/Réservation) de priorité ≤ moyenne → traversé en mode urgent ; high résiste ; les vrais jobs client ne sont JAMAIS traversés (pas de double-booking). Ajout du champ `priority` au fetch. (/roster/reserve laissé en place, inutilisé — retrait ultérieur.) Vérifié : test unitaire pierce (medium/low traversé · high résiste · job client jamais) + aperçu (dialogue unique, priorité Moyenne, info souple, pas de toggle). Build OK. Co-Authored-By: Claude Opus 4.8 --- .../src/components/planif/PlanifCellMenu.vue | 2 +- apps/ops/src/pages/PlanificationPage.vue | 84 +++++++++---------- services/targo-hub/lib/dispatch.js | 8 +- 3 files changed, 45 insertions(+), 49 deletions(-) diff --git a/apps/ops/src/components/planif/PlanifCellMenu.vue b/apps/ops/src/components/planif/PlanifCellMenu.vue index a8e7828..ee7d698 100644 --- a/apps/ops/src/components/planif/PlanifCellMenu.vue +++ b/apps/ops/src/components/planif/PlanifCellMenu.vue @@ -34,7 +34,7 @@ - Réserver / bloquer du temps…Bloc (hors dispatch · ticket) ou réserve souple (urgences OK) + Bloquer du temps…Job/ticket qui occupe la journée → hors dispatch (priorité = curseur dur/souple) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 0e08c9d..eb0cef4 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -743,7 +743,7 @@
Ouvre l'assistant de quarts (modèles 5×8h · 4×10h · 3×12h + heures/jour + N semaines) → publie les quarts. Ce n'est PAS un bouton « enregistrer ». Applique 5×8h à la semaine affichée (à publier) - Réserver ou bloquer du temps (tâche/projet, formation, réunion…). Choix dans le dialogue : bloc dur (hors dispatch) ou réserve souple (urgences OK). + Bloquer du temps (formation · réunion · projet · entretien…) : un job/ticket qui occupe la journée → dé-priorise au dispatch. Priorité Urgent = bloc dur ; sinon les urgences peuvent quand même passer.
🏠 Domicile (départ de tournée si plus proche que le dépôt)
@@ -1359,26 +1359,20 @@ - + - -
Réserver / bloquer du temps
+ +
Bloquer du temps
{{ fillerDlg.tech && fillerDlg.tech.name }}
- - -
- - - -
- +
@@ -1386,17 +1380,18 @@
- +
- -
- - +
+ + +
+ - + @@ -2558,23 +2553,24 @@ const skillDialog = ref(null) // tech dont on édite les compétences const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée) const skillMenuShown = ref(false) function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true; loadTraccarDevices() } // charge les appareils GPS pour la section « Appareil GPS » du volet -// #5 — Réserver du temps d'un tech (bloc « Réservation », priorité moyenne). Occupe l'occupation → dé-priorise au dispatch auto + soustrait des créneaux. -// Dialogue UNIQUE « Réserver / bloquer du temps » (fusion des ex- « Réserver du temps » + « Bloquer / job générique »). -// `mode` = SEUL vrai écart entre les deux : 'block' (dur, hors dispatch, ticket optionnel) · 'reserve' (souple, urgences passent). -const fillerDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 8, fullDay: true, shiftH: 8, mode: 'block', priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false }) +// BLOQUER DU TEMPS = un simple job/ticket qui OCCUPE la timeline (formation, réunion, projet, entretien…). Occupe la +// journée ⇒ dé-priorise au dispatch. La PRIORITÉ est le curseur : Moyenne/Basse = urgences/réparations peuvent quand +// même le prendre (suggestSlots pierce les blocs priorité ≤ moyenne en mode urgent) ; Urgent (high) = bloc dur. +// UN seul concept (fusion des ex- « Réserver du temps » + « Bloquer / job générique » — plus de mode soft/hard). +const fillerDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 8, fullDay: true, shiftH: 8, priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false }) function shiftHoursOf (techId, iso) { // longueur cumulée des quarts de la case (0 si aucun) const occ = cellOcc(techId, iso) if (occ && occ.hasReg && occ.shiftE > occ.shiftS) return Math.round((occ.shiftE - occ.shiftS) * 10) / 10 return 0 } -// Ouverture depuis le volet réglages du tech (bouton « Réserver / bloquer… ») : jour = sélection courante, souple par défaut. +// Ouverture depuis le volet réglages du tech (bouton « Bloquer du temps… ») : jour = sélection courante, 2 h par défaut. function openReserve (t) { if (!t) return skillMenuShown.value = false const iso = selDay.value || mobileSelIso.value || kbSelIso.value || start.value || todayISO() - Object.assign(fillerDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, fullDay: false, shiftH: 0, mode: 'reserve', priority: 'medium', job_type: 'Interne', subject: '', createTicket: false, busy: false }) + Object.assign(fillerDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, fullDay: false, shiftH: 0, priority: 'medium', job_type: 'Interne', subject: '', createTicket: false, busy: false }) } -// Ouverture depuis le menu de cellule (clic case) : bloc dur par défaut, pré-rempli tech+date+durée (= longueur du quart, sinon 8 h). +// Ouverture depuis le menu de cellule (clic case) : journée complète par défaut, pré-rempli tech+date+durée (= longueur du quart, sinon 8 h). function openFillerFromMenu () { if (!menu.tech || !menu.day) return const sh = shiftHoursOf(menu.tech.id, menu.day.iso) @@ -2583,31 +2579,27 @@ function openFillerFromMenu () { Object.assign(fillerDlg, { open: true, tech: { id: menu.tech.id, name: menu.tech.name }, date: menu.day.iso, start: numToTime(startH), dur: sh || 8, fullDay: true, shiftH: sh || 8, - mode: 'block', priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false, + priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false, }) menu.show = false } function onFillerFullDay (v) { if (v) { fillerDlg.dur = fillerDlg.shiftH || 8 } } // journée complète → durée = quart -// Soumission unifiée : réserve souple (/reserve) OU bloc dur + ticket (/filler-job) selon le mode. -async function doBlockOrReserve () { - if (!fillerDlg.tech || !fillerDlg.date || fillerDlg.busy) return - if (fillerDlg.mode === 'block' && !fillerDlg.subject) return +// Soumission UNIQUE : crée le job/ticket interne qui occupe la timeline (priorité = curseur dur/souple). +async function doBlockTime () { + if (!fillerDlg.tech || !fillerDlg.date || !fillerDlg.subject || fillerDlg.busy) return fillerDlg.busy = true - const dur = fillerDlg.fullDay ? (fillerDlg.shiftH || 8) : (Number(fillerDlg.dur) || (fillerDlg.mode === 'reserve' ? 2 : 8)) + const dur = fillerDlg.fullDay ? (fillerDlg.shiftH || 8) : (Number(fillerDlg.dur) || 8) try { - if (fillerDlg.mode === 'reserve') { - const r = await roster.reserveTech({ tech: fillerDlg.tech.id, date: fillerDlg.date, start_time: fillerDlg.start, duration_h: dur, reason: fillerDlg.subject }) - if (r && r.ok) { $q.notify({ type: 'positive', icon: 'event_busy', message: 'Temps réservé — ' + fillerDlg.tech.name + ' · ' + dur + 'h (urgences OK)' }); fillerDlg.open = false; try { await reloadOccupancy() } catch (e) {} } - else $q.notify({ type: 'negative', message: (r && r.error) || 'Réservation impossible' }) - } else { - const r = await roster.createFillerJob({ - tech: fillerDlg.tech.id, date: fillerDlg.date, start_time: fillerDlg.start, duration_h: dur, - priority: fillerDlg.priority, job_type: fillerDlg.job_type || 'Interne', subject: fillerDlg.subject, create_ticket: fillerDlg.createTicket, - }) - if (r && r.ok) { $q.notify({ type: 'positive', icon: 'block', message: fillerDlg.tech.name + ' — ' + dur + 'h bloquées (hors dispatch)' + (r.issue ? ' · ticket ' + r.issue : '') }); fillerDlg.open = false; try { await reloadOccupancy() } catch (e) {} } - else $q.notify({ type: 'negative', message: (r && r.error) || 'Création impossible' }) - } - } catch (e) { $q.notify({ type: 'negative', message: 'Action impossible : ' + (e.message || e) }) } finally { fillerDlg.busy = false } + const r = await roster.createFillerJob({ + tech: fillerDlg.tech.id, date: fillerDlg.date, start_time: fillerDlg.start, duration_h: dur, + priority: fillerDlg.priority, job_type: fillerDlg.job_type || 'Interne', subject: fillerDlg.subject, create_ticket: fillerDlg.createTicket, + }) + if (r && r.ok) { + const soft = fillerDlg.priority !== 'high' + $q.notify({ type: 'positive', icon: 'event_busy', message: fillerDlg.tech.name + ' — ' + dur + 'h bloquées' + (soft ? ' (urgences OK)' : ' (bloc dur)') + (r.issue ? ' · ticket ' + r.issue : '') }) + fillerDlg.open = false; try { await reloadOccupancy() } catch (e) {} + } else $q.notify({ type: 'negative', message: (r && r.error) || 'Blocage impossible' }) + } catch (e) { $q.notify({ type: 'negative', message: 'Blocage impossible : ' + (e.message || e) }) } finally { fillerDlg.busy = false } } // « Voir sa tournée » depuis le volet réglages du tech : bascule en vue Tournées + isole ce tech sur la carte. function viewTechTournee (tech) { diff --git a/services/targo-hub/lib/dispatch.js b/services/targo-hub/lib/dispatch.js index fbfd85f..c070b52 100644 --- a/services/targo-hub/lib/dispatch.js +++ b/services/targo-hub/lib/dispatch.js @@ -157,7 +157,7 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, ['scheduled_date', '<=', dates[dates.length - 1]], ]))}&fields=${encodeURIComponent(JSON.stringify([ 'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', - 'longitude', 'latitude', 'job_type', + 'longitude', 'latitude', 'job_type', 'priority', ]))}&limit_page_length=500`), erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`), erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`), @@ -209,8 +209,12 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date, // Day's pinned jobs (only those with a real start_time) — servent à découper les trous. // Match par technician_id OU docname (les jobs legacy portent parfois l'un ou l'autre). + // BLOC DE TEMPS interne (formation/réunion/projet…) = job_type Interne/Réservation. En mode URGENT (ignoreReserved), + // la PRIORITÉ est le curseur : un bloc de priorité ≤ moyenne est TRAVERSÉ (l'urgence peut prendre le tech) ; un bloc + // « Urgent » (high) RÉSISTE = bloc dur. Les vrais jobs client ne sont JAMAIS traversés (jamais de double-booking). + const isSoftBlock = (j) => (j.job_type === 'Réservation' || j.job_type === 'Interne') && String(j.priority || 'medium').toLowerCase() !== 'high' const belongs = (j) => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) && - j.scheduled_date === dateStr && !(ignoreReserved && j.job_type === 'Réservation') + j.scheduled_date === dateStr && !(ignoreReserved && isSoftBlock(j)) const dayJobs = allJobs .filter(j => belongs(j) && j.start_time) .map(j => {