feat(ops): filter chips scope Suggérer + skill-matched techs (Tournées)

Suite au carrousel : les chips de filtre de la bande « à répartir »
pilotent maintenant AUSSI « Suggérer » (« ce que je filtre = ce que je
répartis »).

- Chips PRIORITÉ (normalisée high/medium/low, via POOL_PRIOS) ajoutés à
  côté des secteurs ; filtrent la liste + la carte (routeDayUnassignedView
  = jour + compétence + secteur + priorité). Affichés seulement si >1
  priorité présente (comme les secteurs).
- suggestJobs() : en vue Tournées, le périmètre = routeDayUnassignedView
  EXACTEMENT (corrige l'incohérence : avant, les chips secteur étaient
  ignorés par Suggérer alors que l'infobulle disait « du secteur »).
- openSuggest() : pré-sélection des techs par COMPÉTENCE — parmi les
  disponibles (quart ce jour), ceux dont t.skills couvre un required_skill
  des jobs du périmètre ; repli sur tous les dispos si aucun match.
  Toujours modifiable (le dialogue liste les 56 techs, cochables).

Vérifié live (06/07) : secteur Huntingdon → vue 4 jobs → Suggérer affiche
« 4 job » (avant : ignorait le secteur) + 13/56 techs pré-cochés
(compétents+dispos, reste modifiable). 0 nouvelle erreur console.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-04 23:11:02 -04:00
parent b7b1647ece
commit cef6507f5f

View File

@ -503,6 +503,12 @@
<button v-for="s in routeSectors" :key="s.sector" type="button" class="rt-sect" :class="{ on: routeSectorSel.has(s.sector) }" @click="toggleRouteSector(s.sector)"><q-icon name="place" size="11px" />{{ s.sector }}<span class="rt-sect-n">{{ s.n }}</span></button>
<button v-if="routeSectorSel.size" type="button" class="rt-sect rt-sect-x" @click="routeSectorSel = new Set()"> tout</button>
</template>
<template v-if="routePrios.length > 1">
<span class="rt-chip-sep" />
<button v-for="p in routePrios" :key="p.value" type="button" class="rt-sect rt-prio" :class="{ on: routePrioSel.has(p.value) }"
:style="routePrioSel.has(p.value) ? { background: p.color, borderColor: p.color, color: '#fff' } : { borderColor: p.color, color: p.color }"
@click="toggleRoutePrio(p.value)"><q-icon name="flag" size="11px" />{{ p.label }}<span class="rt-sect-n">{{ p.n }}</span></button>
</template>
<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 {{ routeSectorSel.size ? 'du secteur' : 'de ce jour' }}</q-tooltip></q-btn>
</div>
@ -4424,7 +4430,13 @@ function openSuggest () {
const win = suggestWindow.value // fenêtre = chips de date cochés, sinon le jour du sélecteur
const techs = visibleTechs.value || []
const withShift = techs.filter(t => win.some(iso => hasShiftDay(t.id, iso)))
const base = withShift.length ? withShift : techs
const availPool = withShift.length ? withShift : techs
// Pré-sélection par COMPÉTENCE : parmi les dispos, ceux dont les compétences couvrent au moins un skill requis
// par les jobs du périmètre (jour + filtres). Toujours modifiable (le dialogue liste tous les techs, cochables) ;
// repli sur tous les dispos si aucun skill requis connu ou aucun match.
const needed = new Set((suggestJobs() || []).map(j => j.required_skill).filter(Boolean))
const skilled = needed.size ? availPool.filter(t => (t.skills || []).some(s => needed.has(s))) : []
const base = skilled.length ? skilled : availPool
const sel = {}; base.forEach(t => { sel[t.id] = true }); suggestDlg.techSel = sel
suggestDlg.plan = []; suggestDlg.mode = 'config'; suggestDlg.open = true
}
@ -4443,6 +4455,8 @@ const suggestWindow = computed(() => {
// par les CHIPS de TYPE **et** de DATE (assignJobsFiltered). Ex. décocher « téléphonie » non réparti aux techs terrain.
const suggestJobs = () => {
if (selectedNames.value.length) return (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && selectedJobs[j.name]) // sélection lasso explicite
// Vue Tournées : périmètre = EXACTEMENT la bande filtrée (jour sélectionné + compétence + secteur + priorité) « ce que je filtre = ce que Suggérer répartit ».
if (boardView.value === 'routes') return (routeDayUnassignedView.value || []).filter(j => j.status !== 'On Hold')
let jobs = (assignJobsFiltered.value || []).filter(j => j.status !== 'On Hold') // pool tel qu'affiché (type + date)
// Par DÉFAUT (aucun chip de date coché) : ne répartir QUE les jobs DATÉS sur le(s) jour(s) de la fenêtre jamais les
// jobs en retard/sans date/d'autres jours (souvent faits mais pas encore fermés) qui pollueraient la simulation.
@ -4729,10 +4743,20 @@ const routeStripDays = computed(() => {
const routeDayUnassigned = computed(() => unassignedByDay.value[routesDay.value] || [])
// Chips SECTEUR (par ville) des non-assignés du jour filtre la liste ET la carte. Reset au changement de jour.
const routeSectorSel = ref(new Set())
watch(routesDay, () => { routeSectorSel.value = new Set() })
const routePrioSel = ref(new Set())
watch(routesDay, () => { routeSectorSel.value = new Set(); routePrioSel.value = new Set() })
const routeSectors = computed(() => { const m = {}; for (const j of routeDayUnassigned.value) { const c = jobCity(j) || 'Sans secteur'; m[c] = (m[c] || 0) + 1 } return Object.entries(m).map(([sector, n]) => ({ sector, n })).sort((a, b) => b.n - a.n) })
function toggleRouteSector (s) { const set = new Set(routeSectorSel.value); if (set.has(s)) set.delete(s); else set.add(s); routeSectorSel.value = set }
const routeDayUnassignedView = computed(() => { const sel = routeSectorSel.value; return sel.size ? routeDayUnassigned.value.filter(j => sel.has(jobCity(j) || 'Sans secteur')) : routeDayUnassigned.value })
// Chips PRIORITÉ (normalisée high/medium/low) filtrent la liste + la carte comme les secteurs, et (avec eux) le périmètre de « Suggérer ».
const NORM_PRIO = { high: 'high', urgent: 'high', 'élevée': 'high', elevee: 'high', medium: 'medium', moyenne: 'medium', normal: 'medium', low: 'low', basse: 'low' }
const normPrio = j => NORM_PRIO[String(j.priority || 'medium').toLowerCase()] || 'medium'
const routePrios = computed(() => { const m = {}; for (const j of routeDayUnassigned.value) { const p = normPrio(j); m[p] = (m[p] || 0) + 1 } return POOL_PRIOS.filter(pp => m[pp.value]).map(pp => ({ ...pp, n: m[pp.value] })) })
function toggleRoutePrio (p) { const set = new Set(routePrioSel.value); if (set.has(p)) set.delete(p); else set.add(p); routePrioSel.value = set }
const routeDayUnassignedView = computed(() => {
const sel = routeSectorSel.value; const ps = routePrioSel.value
if (!sel.size && !ps.size) return routeDayUnassigned.value
return routeDayUnassigned.value.filter(j => (!sel.size || sel.has(jobCity(j) || 'Sans secteur')) && (!ps.size || ps.has(normPrio(j))))
})
const _jlat = j => (j.latitude != null ? +j.latitude : (j.lat != null ? +j.lat : null))
const _jlon = j => (j.longitude != null ? +j.longitude : (j.lon != null ? +j.lon : null))
// Gouttes carte = non-assignés du jour (filtrés secteur) qui ont des coordonnées ; couleur = priorité
@ -5695,6 +5719,7 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.rt-sect.on { background: #6366f1; border-color: #6366f1; color: #fff; }
.rt-sect-n { font-weight: 800; margin-left: 2px; opacity: .85; }
.rt-sect-x { border-style: dashed; color: #94a3b8; }
.rt-chip-sep { width: 1px; align-self: stretch; min-height: 16px; background: #fed7aa; margin: 0 2px; }
.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; }