From f7fea2b8e51f1bf4ecbef32164c2ce55b70c4525 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 27 Mar 2026 12:22:11 -0400 Subject: [PATCH] feat: dual progress bar on map markers (load + completion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Background bar (faded): planned hours / 8h capacity Color codes: green <4h, yellow 4-6h, orange 6-7h, red 7h+ - Foreground bar (solid green): completed hours / 8h Shows real-time job completion progress - Tooltip: "Name — X.Xh complété / X.Xh planifié" - Both bars stacked with absolute positioning for clean overlay Co-Authored-By: Claude Opus 4.6 (1M context) --- src/composables/useMap.js | 33 ++++++++++++++++++++++----------- src/pages/DispatchV2Page.vue | 5 +++-- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/composables/useMap.js b/src/composables/useMap.js index faf2da5..0f3cc18 100644 --- a/src/composables/useMap.js +++ b/src/composables/useMap.js @@ -181,13 +181,16 @@ export function useMap (deps) { const initials = tech.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) const color = TECH_COLORS[tech.colorIdx] - // Calculate daily workload (hours) + // Calculate daily workload + completion const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr)) const todayAssist = (tech.assistJobs || []).filter(j => jobSpansDate(j, dayStr)) - const totalHours = todayJobs.reduce((s, j) => s + (j.duration || 1), 0) - + todayAssist.reduce((s, j) => s + (j.duration || 1), 0) - const loadPct = Math.min(totalHours / 8, 1) // 0..1, capped at 8h - const barColor = loadPct < 0.5 ? '#10b981' : loadPct < 0.75 ? '#f59e0b' : loadPct < 0.9 ? '#f97316' : '#ef4444' + const allToday = [...todayJobs, ...todayAssist] + const totalHours = allToday.reduce((s, j) => s + (j.duration || 1), 0) + const doneHours = allToday.filter(j => (j.status || '').toLowerCase() === 'completed') + .reduce((s, j) => s + (j.duration || 1), 0) + const loadPct = Math.min(totalHours / 8, 1) + const donePct = totalHours > 0 ? Math.min(doneHours / 8, 1) : 0 + const loadColor = loadPct < 0.5 ? '#10b981' : loadPct < 0.75 ? '#f59e0b' : loadPct < 0.9 ? '#f97316' : '#ef4444' // Outer wrapper const outer = document.createElement('div') @@ -199,7 +202,7 @@ export function useMap (deps) { el.className = 'sb-map-tech-pin' el.style.cssText = `background:${color};border-color:${color};` el.textContent = initials - el.title = `${tech.fullName} — ${totalHours.toFixed(1)}h / 8h` + el.title = `${tech.fullName} — ${doneHours.toFixed(1)}h complété / ${totalHours.toFixed(1)}h planifié` outer.appendChild(el) // Group badge (crew size) @@ -212,14 +215,22 @@ export function useMap (deps) { el.appendChild(badge) } - // Workload progress bar (under avatar) + // Dual progress bar: load (background) + done (foreground) if (totalHours > 0) { const bar = document.createElement('div') bar.className = 'sb-map-load-bar' - const fill = document.createElement('div') - fill.className = 'sb-map-load-fill' - fill.style.cssText = `width:${Math.round(loadPct * 100)}%;background:${barColor};` - bar.appendChild(fill) + // Load bar (planned hours — full width = 8h) + const loadFill = document.createElement('div') + loadFill.className = 'sb-map-load-fill' + loadFill.style.cssText = `width:${Math.round(loadPct * 100)}%;background:${loadColor};opacity:0.35;` + bar.appendChild(loadFill) + // Done bar (completed hours — overlaid on top) + if (doneHours > 0) { + const doneFill = document.createElement('div') + doneFill.className = 'sb-map-done-fill' + doneFill.style.cssText = `width:${Math.round(donePct * 100)}%;background:#10b981;` + bar.appendChild(doneFill) + } outer.appendChild(bar) } diff --git a/src/pages/DispatchV2Page.vue b/src/pages/DispatchV2Page.vue index e5b3c4b..cc5d298 100644 --- a/src/pages/DispatchV2Page.vue +++ b/src/pages/DispatchV2Page.vue @@ -1498,8 +1498,9 @@ html, body { margin:0; padding:0; height:100%; overflow:hidden; } .sb-map-tech-pin { width:36px; height:36px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:0.65rem; font-weight:800; color:#fff; border:2.5px solid; box-shadow:0 2px 10px rgba(0,0,0,0.55); cursor:pointer; transition:transform 0.15s; } .sb-map-tech-pin:hover { transform:scale(1.2); } .sb-map-tech-pin { position:relative; } -.sb-map-load-bar { width:32px; height:4px; background:rgba(255,255,255,0.15); border-radius:2px; margin-top:3px; overflow:hidden; } -.sb-map-load-fill { height:100%; border-radius:2px; transition:width 0.3s; } +.sb-map-load-bar { width:32px; height:5px; background:rgba(255,255,255,0.12); border-radius:3px; margin-top:3px; overflow:hidden; position:relative; } +.sb-map-load-fill { position:absolute; top:0; left:0; height:100%; border-radius:3px; } +.sb-map-done-fill { position:absolute; top:0; left:0; height:100%; border-radius:3px; } .sb-map-crew-badge { position:absolute; top:-4px; right:-6px; min-width:16px; height:16px; border-radius:8px; background:#6366f1; color:#fff; font-size:9px; font-weight:800; display:flex; align-items:center; justify-content:center; border:2px solid #0f172a; line-height:1; padding:0 3px; } .sb-map-gps-active { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); animation:gps-glow 2s infinite; } @keyframes gps-glow { 0%,100% { box-shadow:0 0 0 3px rgba(16,185,129,0.5), 0 0 12px rgba(16,185,129,0.4), 0 2px 10px rgba(0,0,0,0.55); } 50% { box-shadow:0 0 0 6px rgba(16,185,129,0.3), 0 0 20px rgba(16,185,129,0.3), 0 2px 10px rgba(0,0,0,0.55); } }