From 368e22d57e83cf815b465c3fe2ab0e35dcde1332 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sat, 6 Jun 2026 12:44:22 -0400 Subject: [PATCH] =?UTF-8?q?feat(planif):=20blocs=20cellule=20color=C3=A9s?= =?UTF-8?q?=20par=20comp=C3=A9tence=20+=20menu=20r=C3=A9ordonner/re-priori?= =?UTF-8?q?ser=20au=20clic=20sur=20le=20progressbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Blocs d'occupation = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable), au lieu du dégradé vert→rouge par taux. Hub occupancyByTechDay attache skill+job à chaque bloc (skillForJob||deptToSkill) ; blockStyle → getTagColor(blk.skill). - Clic sur le progressbar (.tl, @click.stop+@mousedown.stop) → menu : liste des jobs du tech×jour avec réordonnancement (flèches → route_order) + re-priorisation (select) + Enregistrer. Hub POST /roster/reorder-jobs (route_order/priority, séquentiel) ; tri occupancy par route_order. - Clic HORS du progressbar dans la cellule → menu shift inchangé (créer/modifier). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/api/roster.js | 2 + apps/ops/src/pages/PlanificationPage.vue | 55 +++++++++++++++++++++++- services/targo-hub/lib/roster.js | 26 +++++++++-- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 7fc4477..c4e5afa 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -95,5 +95,7 @@ export const unassignedJobs = () => jget('/roster/unassigned-jobs') export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, tech, date }) // Fil complet d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only export const legacyTicketThread = (id) => jget('/dispatch/legacy-sync/ticket-thread?id=' + encodeURIComponent(id)) +// Réordonner / re-prioriser les jobs d'un tech×jour : updates = [{ job, route_order, priority? }] +export const reorderJobs = (updates) => jpost('/roster/reorder-jobs', { updates }) // Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? } export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 2bbf670..d1ff8e6 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -183,7 +183,7 @@
Absent · {{ absenceLabel(t.id, d.iso) }}
@@ -925,6 +955,20 @@ function panelUp () { _panelDrag = null; document.removeEventListener('mousemove // Réutilise les helpers de cellule (cellBands/cellBlocks/cellJobs/cellPct) → 0 nouvel appel réseau. const timelineDlg = reactive({ open: false, tech: null }) function openTimeline (t) { timelineDlg.tech = t; timelineDlg.open = true } +// ── Menu « jobs de la cellule » : détail + réordonner / re-prioriser (clic sur le progressbar) ── +const cellJobsMenu = reactive({ show: false, target: null, tech: null, day: null, list: [], saving: false }) +function openCellJobs (t, d, ev) { + cellJobsMenu.tech = t; cellJobsMenu.day = d + cellJobsMenu.list = cellJobs(t.id, d.iso).map(j => ({ ...j })) // copie ordonnée (route_order → priorité → heure) + cellJobsMenu.target = (ev && ev.currentTarget) || null + cellJobsMenu.show = true +} +function moveCellJob (i, dir) { const l = cellJobsMenu.list; const j = i + dir; if (j < 0 || j >= l.length) return; const [x] = l.splice(i, 1); l.splice(j, 0, x) } +async function saveCellOrder () { + const updates = cellJobsMenu.list.map((j, i) => ({ job: j.name, route_order: i + 1, priority: j.priority })) + cellJobsMenu.saving = true + try { const r = await roster.reorderJobs(updates); cellJobsMenu.show = false; await loadWeek(); $q.notify({ type: 'positive', message: (r.updated || 0) + ' job(s) réordonné(s)', timeout: 2000 }) } catch (e) { err(e) } finally { cellJobsMenu.saving = false } +} // Deep-link vers le tableau Dispatch focalisé sur la ressource + son 1er jour avec jobs (sinon début de semaine). function gotoDispatch (t) { const q = {} @@ -1152,7 +1196,8 @@ function cellBands (techId, iso) { } // Barre de statut OPAQUE selon l'occupation : vert (peu) → orange (plein) → rouge (surbooké). function occColor (pct) { if (pct == null) return '#9e9e9e'; if (pct >= 100) return '#e53935'; const t = Math.max(0, Math.min(1, pct / 100)); return 'hsl(' + Math.round(122 - t * 90) + ',68%,44%)' } -function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: occColor(pct) } } +// Bloc = 1 job, coloré par la COULEUR DE SA COMPÉTENCE (palette skills éditable). Repli : couleur d'occupation. +function blockStyle (blk, pct) { return { ...pos(blk.s, Math.min(blk.e, 24)), background: blk && blk.skill ? getTagColor(blk.skill) : occColor(pct) } } // Fenêtre des shifts (garde=true → seulement les quarts de garde ; garde=false → réguliers) function winOf (techId, iso, garde) { let s = Infinity; let e = -Infinity; for (const a of cellsOf(techId, iso)) { const t = tplByName.value[a.shift]; if (!t || (!!t.on_call) !== garde) continue; const st = hToNum(t.start_time); const en = hToNum(t.end_time); if (st != null) s = Math.min(s, st); if (en != null) e = Math.max(e, en) } return isFinite(s) ? { s, e } : null } const occCells = computed(() => { @@ -1653,6 +1698,12 @@ tr.res-hidden .hide-eye { opacity: 1; } .hdr-ruler .tick { position: absolute; top: 2px; transform: translateX(-50%); font-size: 8px; color: #aab; line-height: 1; font-weight: 400; } .hdr-ruler .tick::before { content: ''; position: absolute; top: -3px; left: 50%; width: 1px; height: 2px; background: #d0d0d8; } .tl { position: relative; height: 11px; min-width: 64px; background: #f1f3f5; border-radius: 2px; margin: 2px 0; overflow: hidden; } +.tl-click { cursor: pointer; } /* clic sur le progressbar → menu jobs (détail + réordonner) */ +.tl-click:hover { outline: 1px solid #1976d2; outline-offset: 1px; } +.cjm-row { display: flex; align-items: center; gap: 6px; padding: 3px 2px; border-bottom: 1px solid #f0f0f0; } +.cjm-ord { font-size: 11px; font-weight: 700; color: #607d8b; min-width: 14px; text-align: center; } +.cjm-dot { width: 9px; height: 9px; border-radius: 2px; flex: 0 0 auto; } +.cjm-prio { font-size: 10px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 1px 2px; background: #fff; max-width: 92px; } .tl-shift { position: absolute; top: 0; bottom: 0; background: #ccd2d8; border-radius: 2px; border: 1px solid rgba(55,65,120,.5); box-sizing: border-box; } /* fenêtre dispo (contour foncé pour la distinguer du fond) */ .tl-shift.oncall { background: rgba(255,179,0,.14); border: 1px dashed #f9a825; } /* garde = sur appel hors heures (pointillé ambre) */ .tl-absent { position: absolute; inset: 0; border-radius: 2px; box-sizing: border-box; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg, #cfcfcf 0, #cfcfcf 3px, #f0f0f0 3px, #f0f0f0 6px); } /* absent = hachuré gris */ diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 71fadbd..e5a7d37 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -525,7 +525,7 @@ async function occupancyByTechDay (start, days) { const dates = rangeDates(start, days) const jobs = await erp.list('Dispatch Job', { filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress']]], - fields: ['name', 'subject', 'customer_name', 'service_type', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority'], limit: 5000, + fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order'], limit: 5000, }) const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage const m = {} @@ -535,11 +535,13 @@ async function occupancyByTechDay (start, days) { const o = m[k] || (m[k] = { h: 0, blocks: [], jobs: [] }) const dur = Number(j.duration_h) || 0 o.h += dur + const skill = skillForJob(j) || deptToSkill(j.legacy_dept || j.job_type || j.subject) // compétence → couleur du bloc (palette skills) const s = j.start_time ? timeToH(j.start_time) : null - if (s != null) o.blocks.push({ s, e: s + dur }) - o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low' }) + if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name }) // 1 bloc = 1 job, coloré par sa compétence + o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0 }) } - for (const k in m) m[k].jobs.sort((a, b) => (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // priorité puis heure + // ordre = route_order manuel s'il existe, sinon priorité puis heure + for (const k in m) m[k].jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || (PRIO[a.priority] ?? 3) - (PRIO[b.priority] ?? 3) || ((a.start_h ?? 99) - (b.start_h ?? 99))) return m } @@ -771,6 +773,22 @@ async function handle (req, res, method, path, url) { const r = await retryWrite(() => erp.update('Dispatch Job', b.job, patch)) return json(res, r.ok ? 200 : 500, { ...r, job: b.job, tech: b.tech, start_time: placed, duration_h: dur }) } + // Réordonner / re-prioriser les jobs d'un tech×jour (depuis le menu de la cellule). + // body.updates = [{ job, route_order, priority? }] — SÉQUENTIEL (frappe_pg). + if (path === '/roster/reorder-jobs' && method === 'POST') { + const b = await parseBody(req); const ups = b.updates || [] + let ok = 0; let errors = 0 + for (const u of ups) { + if (!u.job) continue + const patch = {} + if (u.route_order != null) patch.route_order = Number(u.route_order) || 0 + if (u.priority) patch.priority = u.priority + if (!Object.keys(patch).length) continue + const r = await retryWrite(() => erp.update('Dispatch Job', u.job, patch)) + if (r.ok) ok++; else errors++ + } + return json(res, 200, { ok: true, updated: ok, errors }) + } // Backfill : pose un start_time (premier trou libre) sur les jobs DÉJÀ assignés mais SANS heure // → leurs blocs d'occupation apparaissent enfin sur la grille. Idempotent (ne touche que start_time vide). if (path === '/roster/backfill-start-times' && method === 'POST') {