feat: map markers — workload progress bar + crew group badge

- Progress bar under each tech avatar showing daily load (0-8h)
  Green (<4h) → Yellow (4-6h) → Orange (6-8h) → Red (8h+)
  Includes both primary queue and assistant jobs
- Crew badge: purple circle with count (e.g. "2") when tech has
  assistants on today's jobs — indicates grouped team
- Tooltip shows "Name — X.Xh / 8h"
- Marker now uses gpsCoords || coords for visibility check
  (fixes techs with GPS but no static coords)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-03-27 12:16:56 -04:00
parent af42c6082e
commit 15813e6caf
2 changed files with 56 additions and 6 deletions

View File

@ -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)

View File

@ -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; }