From c9fbbdbe9e72d2ee186a9bd80649d68bffc956fc Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sat, 6 Jun 2026 13:29:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(planif):=20=C3=A9diteur=20de=20JOURN=C3=89?= =?UTF-8?q?E=20contextuel=20au=20clic=20sur=20le=20progressbar=20(drag-dro?= =?UTF-8?q?p=20r=C3=A9ordonner=20+=20retirer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - clic sur le progressbar → q-dialog ciblé sur le tech×jour (garde le contexte de la grille derrière) : timeline visuelle (blocs colorés par compétence) + liste éditable des jobs - réordonnancement par DRAG-DROP (dragstart/dragover/dragend → route_order) + sélecteur de priorité + Enregistrer - retrait d'un job (✕ → hub POST /roster/unassign-job : assigned_tech null, status open → retour au pool) - bouton « Dispatch » comme échappatoire vers le tableau complet (gotoDispatch) - réutilise occupancy/cellBands/cellBlocks/blockStyle + reorderJobs ; best-practice détail-drawer (pas de navigation pleine page) Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/api/roster.js | 2 + apps/ops/src/pages/PlanificationPage.vue | 75 ++++++++++++++++++++++-- services/targo-hub/lib/roster.js | 6 ++ 3 files changed, 78 insertions(+), 5 deletions(-) diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index c4e5afa..519ee67 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -97,5 +97,7 @@ export const assignJob = (job, tech, date) => jpost('/roster/assign-job', { job, 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 }) +// Retirer un job d'un tech (retour au pool non assigné) +export const unassignJobRoster = (job) => jpost('/roster/unassign-job', { job }) // 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 aeb247c..97fa39f 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) }}
@@ -965,6 +1009,24 @@ function gotoDispatch (t, dateIso) { q.date = dateIso || (timelineDays.value[0] && timelineDays.value[0].iso) || start.value router.push({ path: '/dispatch', query: q }) } +// ── Éditeur de JOURNÉE (fenêtre contextuelle ciblée — clic sur le progressbar) ── +// Garde le contexte de la grille derrière. Timeline + réordonnancement DRAG-DROP + retrait d'un job. +const dayEditor = reactive({ open: false, tech: null, day: null, list: [], saving: false, dragIdx: null }) +function openDayEditor (t, d) { dayEditor.tech = t; dayEditor.day = d; dayEditor.list = cellJobs(t.id, d.iso).map(j => ({ ...j })); dayEditor.dragIdx = null; dayEditor.open = true } +const dayOcc = () => (dayEditor.tech && dayEditor.day) ? cellOcc(dayEditor.tech.id, dayEditor.day.iso) : null +const dayBands = () => (dayEditor.tech && dayEditor.day) ? cellBands(dayEditor.tech.id, dayEditor.day.iso) : [] +const dayBlocks = () => (dayEditor.tech && dayEditor.day) ? cellBlocks(dayEditor.tech.id, dayEditor.day.iso) : [] +function dayDragStart (i, ev) { dayEditor.dragIdx = i; try { ev.dataTransfer.effectAllowed = 'move' } catch (e) {} } +function dayDragOver (i) { const from = dayEditor.dragIdx; if (from == null || from === i) return; const l = dayEditor.list; const [x] = l.splice(from, 1); l.splice(i, 0, x); dayEditor.dragIdx = i } +function dayDragEnd () { dayEditor.dragIdx = null } +async function removeFromDay (j) { + try { await roster.unassignJobRoster(j.name); dayEditor.list = dayEditor.list.filter(x => x.name !== j.name); await loadWeek(); $q.notify({ type: 'info', message: 'Retiré du tech (retour au pool « à assigner »)', timeout: 2200 }) } catch (e) { err(e) } +} +async function saveDayOrder () { + dayEditor.saving = true + const updates = dayEditor.list.map((j, i) => ({ job: j.name, route_order: i + 1, priority: j.priority })) + try { const r = await roster.reorderJobs(updates); dayEditor.open = false; await loadWeek(); $q.notify({ type: 'positive', message: 'Ordre/priorités enregistrés (' + (r.updated || 0) + ')', timeout: 2000 }) } catch (e) { err(e) } finally { dayEditor.saving = false } +} const timelineDays = computed(() => { const t = timelineDlg.tech; if (!t) return [] const out = [] @@ -1692,10 +1754,13 @@ tr.res-hidden .hide-eye { opacity: 1; } .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; } +/* Éditeur de journée (clic progressbar) — lignes draggables */ +.de-row { display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-bottom: 1px solid #eee; background: #fff; cursor: default; } +.de-row.de-drag { opacity: .5; background: #ede7f6; } +.de-row:hover { background: #f7f5fc; } +.de-ord { font-size: 12px; font-weight: 700; color: #607d8b; min-width: 16px; text-align: center; } +.de-dot { width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto; } +.de-prio { font-size: 11px; border: 1px solid #ccc; border-left-width: 4px; border-radius: 4px; padding: 2px 4px; background: #fff; } .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 e5a7d37..d8c34df 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -789,6 +789,12 @@ async function handle (req, res, method, path, url) { } return json(res, 200, { ok: true, updated: ok, errors }) } + // Retirer un job d'un tech (depuis l'éditeur de journée) → retour au pool (non assigné). + if (path === '/roster/unassign-job' && method === 'POST') { + const b = await parseBody(req); if (!b.job) return json(res, 400, { error: 'job requis' }) + const r = await retryWrite(() => erp.update('Dispatch Job', b.job, { assigned_tech: null, status: 'open', start_time: null })) + return json(res, r.ok ? 200 : 500, { ...r, job: b.job }) + } // 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') {