diff --git a/src/composables/useMap.js b/src/composables/useMap.js index 992794e..faf2da5 100644 --- a/src/composables/useMap.js +++ b/src/composables/useMap.js @@ -165,18 +165,65 @@ export function useMap (deps) { // Tech avatar markers mapMarkers.value.forEach(m => m.remove()) mapMarkers.value = [] + + // Pre-compute: which techs are assistants on which lead tech's jobs today + const groupCounts = {} // leadTechId → total crew size (1 + assistants) + store.technicians.forEach(tech => { + const todayJobs = tech.queue.filter(j => jobSpansDate(j, dayStr)) + const assistIds = new Set() + todayJobs.forEach(j => (j.assistants || []).forEach(a => assistIds.add(a.techId))) + if (assistIds.size > 0) groupCounts[tech.id] = 1 + assistIds.size + }) + filteredResources.value.forEach(tech => { - if (!tech.coords || (tech.coords[0] === 0 && tech.coords[1] === 0)) return + const pos = tech.gpsCoords || tech.coords + if (!pos || (pos[0] === 0 && pos[1] === 0)) return const initials = tech.fullName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) const color = TECH_COLORS[tech.colorIdx] + + // Calculate daily workload (hours) + 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' + + // Outer wrapper const outer = document.createElement('div') - outer.style.cssText = 'cursor:pointer;' + outer.style.cssText = 'cursor:pointer;display:flex;flex-direction:column;align-items:center;' outer.dataset.techId = tech.id + + // Avatar circle const el = document.createElement('div') el.className = 'sb-map-tech-pin' el.style.cssText = `background:${color};border-color:${color};` - el.textContent = initials; el.title = tech.fullName + el.textContent = initials + el.title = `${tech.fullName} — ${totalHours.toFixed(1)}h / 8h` outer.appendChild(el) + + // Group badge (crew size) + const crew = groupCounts[tech.id] + if (crew && crew > 1) { + const badge = document.createElement('div') + badge.className = 'sb-map-crew-badge' + badge.textContent = String(crew) + badge.title = `Équipe de ${crew}` + el.appendChild(badge) + } + + // Workload progress bar (under avatar) + 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) + outer.appendChild(bar) + } + + // Drag & drop handlers outer.addEventListener('dragover', e => { e.preventDefault(); el.style.transform = 'scale(1.25)' }) outer.addEventListener('dragleave', () => { el.style.transform = '' }) outer.addEventListener('drop', e => { @@ -191,11 +238,10 @@ export function useMap (deps) { }) outer.addEventListener('mouseenter', () => { if (mapDragJob.value) el.style.transform = 'scale(1.3)' }) outer.addEventListener('mouseleave', () => { el.style.transform = '' }) - // Use GPS position if available, else static coords - const pos = tech.gpsCoords || tech.coords + if (tech.gpsCoords) { el.classList.add('sb-map-gps-active') - el.title = tech.fullName + ' (GPS)' + el.title += ' (GPS)' } const m = new mbgl.Marker({ element: outer, anchor: 'bottom' }).setLngLat(pos).addTo(map) mapMarkers.value.push(m) diff --git a/src/pages/DispatchV2Page.vue b/src/pages/DispatchV2Page.vue index c145cdd..e5b3c4b 100644 --- a/src/pages/DispatchV2Page.vue +++ b/src/pages/DispatchV2Page.vue @@ -1497,6 +1497,10 @@ html, body { margin:0; padding:0; height:100%; overflow:hidden; } .sb-map { flex:1; min-height:0; } .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-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); } } .sb-map-drag-ghost { padding:4px 8px; border-radius:6px; background:rgba(99,102,241,0.9); color:#fff; font-size:0.68rem; font-weight:700; box-shadow:0 4px 16px rgba(0,0,0,0.55); max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; user-select:none; }