+
+
+
+
+
+
+
{{ cellJobsMenu.tech && cellJobsMenu.tech.name }} — {{ cellJobsMenu.day && cellJobsMenu.day.dnum }}
+
{{ cellJobsMenu.list.length }} job(s)
+
+
Aucun job assigné ce jour.
+
+
+
+
+
+
{{ i + 1 }}
+
+
+
{{ j.subject }}
+
{{ j.start || '—' }} · {{ j.dur }}h · {{ j.customer }} · {{ j.skill }}
+
+
+
+
+ Flèches = ordre du tech · menu = priorité
+
+
+
+
@@ -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') {