From bae6771b34e9c11b63a8d439be37184e91acd90b Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sat, 6 Jun 2026 13:41:28 -0400 Subject: [PATCH] =?UTF-8?q?feat(planif):=20=C3=A9diteur=20journ=C3=A9e=20?= =?UTF-8?q?=E2=80=94=20r=C3=A9ordonnancement=20fiable=20(fl=C3=A8ches+drop?= =?UTF-8?q?)=20+=20dur=C3=A9e=20=C3=A9ditable=20(min)=20+=20temps=20de=20t?= =?UTF-8?q?ransport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FIX réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (au lieu du splice live jittery) - durée éditable par job en MINUTES (pas de 5, best practice précision) → persistée via reorder-jobs (duration_h) - temps de transport estimé entre 2 jobs (haversine sur coords Service Location, 40km/h + 5min) affiché entre les lignes → en attendant la géoloc live (Capacitor background-geolocation, noté pour plus tard) - hub : occupancyByTechDay renvoie lat/lon par job ; reorder-jobs accepte duration_h ; total h en pied Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/pages/PlanificationPage.vue | 64 ++++++++++++++++-------- services/targo-hub/lib/roster.js | 5 +- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 97fa39f..f424ec9 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -680,25 +680,37 @@ {{ tk.h }}
Aucun job ce jour.
- -
- - {{ i + 1 }} - -
-
{{ j.subject }}
-
{{ j.start || '—' }} · {{ j.dur }}h · {{ j.customer }} · {{ j.skill }}
+ + - Glisser pour réordonner la tournée · ✕ retire du tech - + Flèches/glisser = ordre · durée en min · ✕ retire · total {{ dayTotalH() }}h + @@ -1016,16 +1028,25 @@ function openDayEditor (t, d) { dayEditor.tech = t; dayEditor.day = d; dayEditor 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 } +// Réordonnancement : flèches ↑↓ (fiable) + drag-drop basé sur le DROP (robuste, pas de splice live jittery) +function moveDayJob (i, dir) { const j = i + dir; const l = dayEditor.list; if (j < 0 || j >= l.length) return; const [x] = l.splice(i, 1); l.splice(j, 0, x) } +function dayDragStart (i, ev) { dayEditor.dragIdx = i; try { ev.dataTransfer.effectAllowed = 'move'; ev.dataTransfer.setData('text/plain', String(i)) } catch (e) {} } +function dayDropOn (i) { const from = dayEditor.dragIdx; if (from == null || from === i) { dayEditor.dragIdx = null; return } const l = dayEditor.list; const [x] = l.splice(from, 1); l.splice(i, 0, x); dayEditor.dragIdx = null } function dayDragEnd () { dayEditor.dragIdx = null } +// Durée éditable en MINUTES (pas de 5) — best practice de précision +function jobMinutes (j) { return Math.round((Number(j.dur) || 0) * 60) } +function setJobMinutes (j, min) { const m = Math.max(5, Math.round((Number(min) || 0) / 5) * 5); j.dur = Math.round(m / 60 * 100) / 100 } +// Temps de transport estimé entre 2 jobs (haversine via coords Service Location) — provisoire, en attendant la géoloc live (Capacitor) +function haversineKm (la1, lo1, la2, lo2) { if ([la1, lo1, la2, lo2].some(v => v == null)) return null; const R = 6371; const r = x => x * Math.PI / 180; const dLa = r(la2 - la1); const dLo = r(lo2 - lo1); const s = Math.sin(dLa / 2) ** 2 + Math.cos(r(la1)) * Math.cos(r(la2)) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(s)) } +function travelBetween (a, b) { const km = haversineKm(a && a.lat, a && a.lon, b && b.lat, b && b.lon); if (km == null) return null; return { km: Math.round(km * 10) / 10, min: Math.max(5, Math.round(km / 40 * 60) + 5) } } // 40 km/h + 5 min tampon +const dayTotalH = () => Math.round(dayEditor.list.reduce((s, j) => s + (Number(j.dur) || 0), 0) * 10) / 10 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 updates = dayEditor.list.map((j, i) => ({ job: j.name, route_order: i + 1, priority: j.priority, duration_h: Number(j.dur) || 1 })) + try { const r = await roster.reorderJobs(updates); dayEditor.open = false; await loadWeek(); $q.notify({ type: 'positive', message: 'Ordre · priorités · durées enregistrés (' + (r.updated || 0) + ')', timeout: 2200 }) } catch (e) { err(e) } finally { dayEditor.saving = false } } const timelineDays = computed(() => { const t = timelineDlg.tech; if (!t) return [] @@ -1761,6 +1782,9 @@ tr.res-hidden .hide-eye { opacity: 1; } .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; } +.de-dur { display: flex; align-items: center; gap: 2px; font-size: 10px; color: #888; } +.de-dur input { width: 46px; font-size: 11px; text-align: right; border: 1px solid #cfc4e8; border-radius: 4px; padding: 2px 3px; } +.de-travel { font-size: 10px; color: #8a6d3b; padding: 1px 0 1px 40px; opacity: .85; } /* espace entre 2 jobs = transport */ .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 d8c34df..2f91f8b 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', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order'], limit: 5000, + fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude'], limit: 5000, }) const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage const m = {} @@ -538,7 +538,7 @@ async function occupancyByTechDay (start, days) { 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, 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 }) + 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, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null }) } // 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))) @@ -783,6 +783,7 @@ async function handle (req, res, method, path, url) { const patch = {} if (u.route_order != null) patch.route_order = Number(u.route_order) || 0 if (u.priority) patch.priority = u.priority + if (u.duration_h != null && Number(u.duration_h) > 0) patch.duration_h = Number(u.duration_h) // durée éditée dans le timeline if (!Object.keys(patch).length) continue const r = await retryWrite(() => erp.update('Dispatch Job', u.job, patch)) if (r.ok) ok++; else errors++