+
+
+
+
+
+
+
+
+
{{ dayEditor.tech && dayEditor.tech.name }} — {{ dayEditor.day && (dayEditor.day.dow + ' ' + dayEditor.day.dnum) }}
+
{{ dayOcc().usedH }}h occupé / {{ dayOcc().bookableH }}h · {{ dayOcc().pct }}%
+
+
+ Ouvrir le tableau Dispatch complet
+
+
+
+
+
+ Aucun job ce jour.
+
+
+
+
{{ i + 1 }}
+
+
+
{{ j.subject }}
+
{{ j.start || '—' }} · {{ j.dur }}h · {{ j.customer }} · {{ j.skill }}
+
+
+
Retirer du tech (retour au pool)
+
+
+
+ Glisser pour réordonner la tournée · ✕ retire du tech
+
+
+
+
@@ -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') {