feat(planif): Tournées — filtre compétence sur la carte + jour ciblé + jobs non assignés en évidence

Batch B — le filtre de compétence pilote la carte : dayLivePositions (GPS techs) filtré par visibleTechs
(les tournées l'étaient déjà via visibleTechs) → seuls les techs capables + leurs jobs sur la carte.

Batch C — mise en évidence des non-assignés du jour :
- Badge rouge « dus non assignés » par jour sur la bande (OccupancyStrip.os-unassigned), depuis unassignedByDay
- routeDayUnassigned = jobs dus le jour sélectionné mais non assignés → bande « N à répartir » (chips cliquables → détail) + bouton « Suggérer » ; se met à jour au clic sur la strip

Vérifié préview : positions filtrées par compétence ; badges [1,21,16,15,3,8] sur la bande ; clic 06/07 → bande « 21 à répartir » (14 chips réels).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-04 09:58:58 -04:00
parent 012f924683
commit 21724e65f8
2 changed files with 25 additions and 3 deletions

View File

@ -10,7 +10,8 @@
@click="$emit('select', d.iso)" @click="$emit('select', d.iso)"
> >
<span v-if="d.holiday" class="os-hol"></span> <span v-if="d.holiday" class="os-hol"></span>
<span v-if="d.noShift" class="os-warn" title="Aucun quart planifié — capacité estimée"></span> <span v-if="d.noShift && !d.unassigned" class="os-warn" title="Aucun quart planifié — capacité estimée"></span>
<span v-if="d.unassigned" class="os-unassigned" :title="d.unassigned + ' job(s) dû(s) ce jour, non assigné(s) — à répartir'">{{ d.unassigned }}</span>
<span class="os-dow">{{ d.dow }}</span> <span class="os-dow">{{ d.dow }}</span>
<span class="os-num">{{ d.dnum }}</span> <span class="os-num">{{ d.dnum }}</span>
<span class="os-bar"><span class="os-fill" :style="{ height: fillH(d) + '%', background: heatColor(d.ratio) }"></span></span> <span class="os-bar"><span class="os-fill" :style="{ height: fillH(d) + '%', background: heatColor(d.ratio) }"></span></span>
@ -57,6 +58,8 @@ function dayTitle (d) {
.os-day.holiday { background: #fff7ed; border-color: #fdba74; } .os-day.holiday { background: #fff7ed; border-color: #fdba74; }
.os-hol { position: absolute; top: 1px; left: 3px; color: #ea580c; font-size: 9px; line-height: 1; } .os-hol { position: absolute; top: 1px; left: 3px; color: #ea580c; font-size: 9px; line-height: 1; }
.os-warn { position: absolute; top: 2px; right: 3px; color: #ef4444; font-size: 9px; line-height: 1; pointer-events: none; } .os-warn { position: absolute; top: 2px; right: 3px; color: #ef4444; font-size: 9px; line-height: 1; pointer-events: none; }
/* Badge « dus non assignés ce jour » (flottant coin haut-droit) */
.os-unassigned { position: absolute; top: -5px; right: -5px; min-width: 16px; height: 16px; padding: 0 3px; border-radius: 8px; background: #ef4444; color: #fff; font-size: 10px; font-weight: 700; line-height: 16px; text-align: center; box-shadow: 0 1px 3px rgba(2, 6, 23, .3); pointer-events: none; }
.os-dow { font-size: 10px; color: var(--ops-text-secondary, #64748b); text-transform: capitalize; } .os-dow { font-size: 10px; color: var(--ops-text-secondary, #64748b); text-transform: capitalize; }
.os-num { font-size: 13px; font-weight: 600; color: var(--ops-text, #1e293b); } .os-num { font-size: 13px; font-weight: 600; color: var(--ops-text, #1e293b); }
.os-bar { width: 10px; height: 44px; background: #eef2f7; border-radius: 5px; overflow: hidden; display: flex; align-items: flex-end; margin: 3px 0; } .os-bar { width: 10px; height: 44px; background: #eef2f7; border-radius: 5px; overflow: hidden; display: flex; align-items: flex-end; margin: 3px 0; }

View File

@ -495,6 +495,17 @@
<q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }}</q-tooltip> <q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }}</q-tooltip>
</span> </span>
</div> </div>
<!-- Jobs DUS ce jour, NON assignés (mis en évidence) clic = détail · « Suggérer » = dispatch auto du jour -->
<div v-if="routeDayUnassigned.length" class="rt-unassigned">
<span class="rt-ua-lbl"><q-icon name="inbox" size="15px" color="deep-orange-7" /> <b>{{ routeDayUnassigned.length }}</b> à répartir :</span>
<span v-for="j in routeDayUnassigned.slice(0, 14)" :key="j.name" class="rt-ua-chip" :style="{ borderLeftColor: prioColor(j.priority) }" @click="openJobDetail(j)">
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="12px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" />{{ j.subject || j.service_type || j.name }}<template v-if="jobCity(j)"> · {{ jobCity(j) }}</template>
<q-tooltip class="bg-grey-9">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template></q-tooltip>
</span>
<span v-if="routeDayUnassigned.length > 14" class="text-caption text-grey-6 q-ml-xs">+{{ routeDayUnassigned.length - 14 }}</span>
<q-space />
<q-btn dense unelevated no-caps size="sm" color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip>Répartir automatiquement les jobs de ce jour</q-tooltip></q-btn>
</div>
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" /> <RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" />
<div class="suggest-legend q-mt-xs"> <div class="suggest-legend q-mt-xs">
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)"> <span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
@ -4693,10 +4704,12 @@ const routeStripDays = computed(() => {
for (let i = 0; i < 14; i++) { for (let i = 0; i < 14; i++) {
const iso = addDaysISO(start.value, i); const c = routeCapacity.value[iso] || {} const iso = addDaysISO(start.value, i); const c = routeCapacity.value[iso] || {}
const h = +c.due_h || 0; const cap = +c.cap_h || 0; const dw = dowOf(iso) const h = +c.due_h || 0; const cap = +c.cap_h || 0; const dw = dowOf(iso)
out.push({ iso, dow: FR_DOW[dw], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dw === 0 || dw === 6, holiday: isHoliday(iso), h, cap, ratio: cap > 0 ? h / cap : (h > 0 ? 1 : 0), over: !!c.overbooked, today: iso === todayISO() }) out.push({ iso, dow: FR_DOW[dw], dnum: iso.slice(8) + '/' + iso.slice(5, 7), weekend: dw === 0 || dw === 6, holiday: isHoliday(iso), h, cap, ratio: cap > 0 ? h / cap : (h > 0 ? 1 : 0), over: !!c.overbooked, today: iso === todayISO(), unassigned: (unassignedByDay.value[iso] || []).length })
} }
return out return out
}) })
// Jobs DUS le jour sélectionné (Tournées) mais NON assignés mis en évidence + « Suggérer ». Respecte le filtre de compétence (unassignedByDay).
const routeDayUnassigned = computed(() => unassignedByDay.value[routesDay.value] || [])
// Deep-link (?day=YYYY-MM-DD & ?skill=) + action « planifier des quarts » du slot-finder // Deep-link (?day=YYYY-MM-DD & ?skill=) + action « planifier des quarts » du slot-finder
const route = useRoute() const route = useRoute()
@ -4742,8 +4755,9 @@ function stopLivePositions () { if (_livePosTimer) { clearInterval(_livePosTimer
// Positions pour la carte : masque les techs cachés (chips), colore selon leur tournée du jour (sinon gris neutre). // Positions pour la carte : masque les techs cachés (chips), colore selon leur tournée du jour (sinon gris neutre).
const dayLivePositions = computed(() => { const dayLivePositions = computed(() => {
const colorByTech = {}; for (const r of (allDayRoutes.value || [])) colorByTech[r.id] = r.color const colorByTech = {}; for (const r of (allDayRoutes.value || [])) colorByTech[r.id] = r.color
const visIds = new Set((visibleTechs.value || []).map(t => t.id)) // filtre de compétence : seules les positions des techs capables (+ non masqués) sur la carte
return (livePosRaw.value || []) return (livePosRaw.value || [])
.filter(p => !hiddenRouteTechs.value.has(p.techId)) .filter(p => visIds.has(p.techId) && !hiddenRouteTechs.value.has(p.techId))
.map(p => ({ techId: p.techId, name: p.techName, lat: p.lat, lon: p.lon, time: p.time, speed: p.speed, color: colorByTech[p.techId] || '#455a64' })) .map(p => ({ techId: p.techId, name: p.techName, lat: p.lat, lon: p.lon, time: p.time, speed: p.speed, color: colorByTech[p.techId] || '#455a64' }))
}) })
watch(boardView, (v) => { if (v === 'routes') startLivePositions(); else stopLivePositions() }, { immediate: true }) watch(boardView, (v) => { if (v === 'routes') startLivePositions(); else stopLivePositions() }, { immediate: true })
@ -5641,6 +5655,11 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.rt-chip.off { opacity: 0.55; border-style: dashed; } .rt-chip.off { opacity: 0.55; border-style: dashed; }
.rt-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .rt-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.rt-n { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; border-radius: 9px; padding: 0 4px; color: #fff; font-size: 10px; font-weight: 800; } .rt-n { display: inline-flex; align-items: center; justify-content: center; min-width: 17px; height: 17px; border-radius: 9px; padding: 0 4px; color: #fff; font-size: 10px; font-weight: 800; }
/* Bande « jobs dus non assignés » (vue Tournées) : chips cliquables + Suggérer */
.rt-unassigned { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; margin-bottom: 6px; padding: 6px 8px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
.rt-ua-lbl { font-size: 12px; color: #9a3412; display: inline-flex; align-items: center; gap: 3px; }
.rt-ua-chip { display: inline-flex; align-items: center; gap: 3px; max-width: 230px; padding: 2px 8px 2px 6px; border: 1px solid #e2e8f0; border-left: 3px solid #9e9e9e; border-radius: 6px; background: #fff; font-size: 11.5px; color: #334155; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rt-ua-chip:hover { border-color: #6366f1; }
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; } .jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
.jt-chip:hover { border-color: #6366f1; } .jt-chip:hover { border-color: #6366f1; }
.jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; } .jt-chip.on { background: #6366f1; color: #fff; border-color: #6366f1; }