feat(ops): saveable tech groups in Suggérer + solo tech route on chip click

1. Groupes de techs enregistrés (Suggérer) : sélection manuelle de techs
   sauvée comme groupe nommé réutilisable (ex. Installateurs, Épisseurs) —
   utile quand la compétence déduite du job est imprécise. Persisté par
   utilisateur (useUserPrefs 'techGroups', suit l'usager entre appareils).
   Chips « Groupes : » (clic = appliquer, ✕ = supprimer) + bouton
   « Enregistrer » (nomme la sélection courante) + chip « Tout
   désélectionner ». Complète le sélecteur par compétence existant.
2. Vue Tournées — clic sur un chip technicien = ISOLER sa tournée sur la
   carte (masquer les autres) pour voir une route précise ; re-clic sur le
   tech déjà seul = tout réafficher ; chip « Tout afficher » si masqués.
   (Avant : le clic masquait/affichait un tech à la fois — inversé.)

Vérifié live : groupes save→apply→delete + « Tout désélectionner »
(13→0) OK ; 0 nouvelle erreur. (Les chips de tournée n'apparaissent
qu'avec des jobs géocodés — comportement solo vérifié par la logique,
à confirmer à l'œil en prod.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-05 12:17:03 -04:00
parent 9b866ec3b9
commit c0695f7ea9

View File

@ -487,13 +487,14 @@
<q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn> <q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn>
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span> <span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
</div> </div>
<!-- Chips techs : enlever/ajouter une tournée de la carte d'un clic pastille = nb de jobs, dans la couleur du tech --> <!-- Chips techs : clic = ISOLER la tournée de ce tech (masquer les autres) ; re-clic = tout réafficher. Pastille = nb de jobs, couleur du tech. -->
<div class="row items-center q-mb-xs" style="gap:5px;flex-wrap:wrap"> <div v-if="allDayRoutes.length" class="row items-center q-mb-xs" style="gap:5px;flex-wrap:wrap">
<span v-for="r in allDayRoutes" :key="'chip' + r.id" class="rt-chip" :class="{ off: hiddenRouteTechs.has(r.id) }" :style="hiddenRouteTechs.has(r.id) ? {} : { borderColor: r.color, background: r.color + '14' }" @click="toggleRouteTech(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)"> <span v-for="r in allDayRoutes" :key="'chip' + r.id" class="rt-chip" :class="{ off: hiddenRouteTechs.has(r.id) }" :style="hiddenRouteTechs.has(r.id) ? {} : { borderColor: r.color, background: r.color + '14' }" @click="toggleRouteTech(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
<span class="rt-dot" :style="{ background: hiddenRouteTechs.has(r.id) ? '#cbd5e1' : r.color }"></span>{{ r.name }} <span class="rt-dot" :style="{ background: hiddenRouteTechs.has(r.id) ? '#cbd5e1' : r.color }"></span>{{ r.name }}
<span class="rt-n" :style="{ background: hiddenRouteTechs.has(r.id) ? '#94a3b8' : r.color }">{{ r.stops.length }}</span> <span class="rt-n" :style="{ background: hiddenRouteTechs.has(r.id) ? '#94a3b8' : r.color }">{{ r.stops.length }}</span>
<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 n\'afficher QUE cette tournée' : 'Cliquer pour isoler cette tournée (masquer les autres)' }}</q-tooltip>
</span> </span>
<button v-if="hiddenRouteTechs.size" type="button" class="rt-chip rt-chip-all" @click="showAllRouteTechs"><q-icon name="done_all" size="13px" />Tout afficher</button>
</div> </div>
<!-- Jobs DUS ce jour, NON assignés (mis en évidence) chips secteur filtrent liste+carte · clic = détail · « Suggérer » = dispatch auto --> <!-- Jobs DUS ce jour, NON assignés (mis en évidence) chips secteur filtrent liste+carte · clic = détail · « Suggérer » = dispatch auto -->
<div v-if="routeDayUnassigned.length" class="rt-unassigned"> <div v-if="routeDayUnassigned.length" class="rt-unassigned">
@ -1071,6 +1072,14 @@
<span class="text-caption text-grey-7 q-mr-xs">Par compétence :</span> <span class="text-caption text-grey-7 q-mr-xs">Par compétence :</span>
<span v-for="s in suggestSkillGroups" :key="s.skill" class="suggest-skill-chip" :class="{ on: suggestSkillOn(s.skill) }" :style="suggestSkillOn(s.skill) ? { background: getTagColor(s.skill), color: '#fff', borderColor: getTagColor(s.skill) } : { borderColor: getTagColor(s.skill), color: getTagColor(s.skill) }" @click="suggestSelectSkill(s.skill)">{{ s.skill }} <b>{{ s.n }}</b></span> <span v-for="s in suggestSkillGroups" :key="s.skill" class="suggest-skill-chip" :class="{ on: suggestSkillOn(s.skill) }" :style="suggestSkillOn(s.skill) ? { background: getTagColor(s.skill), color: '#fff', borderColor: getTagColor(s.skill) } : { borderColor: getTagColor(s.skill), color: getTagColor(s.skill) }" @click="suggestSelectSkill(s.skill)">{{ s.skill }} <b>{{ s.n }}</b></span>
</div> </div>
<!-- Groupes de techs enregistrés (sélection manuelle sauvée : Installateurs, Épisseurs) utile quand la compétence déduite du job est imprécise. -->
<div class="row items-center q-mb-sm" style="gap:5px;flex-wrap:wrap">
<span class="text-caption text-grey-7 q-mr-xs">Groupes :</span>
<span v-for="g in techGroups" :key="g.name" class="suggest-grp-chip" @click="applyTechGroup(g)"><q-icon name="groups" size="13px" />{{ g.name }} <b>{{ (g.ids || []).length }}</b><q-icon name="close" size="12px" class="suggest-grp-x" @click.stop="deleteTechGroup(g)"><q-tooltip>Supprimer ce groupe</q-tooltip></q-icon><q-tooltip>Sélectionner ce groupe de techniciens</q-tooltip></span>
<q-btn dense flat size="sm" no-caps icon="bookmark_add" label="Enregistrer" color="primary" @click="saveTechGroup"><q-tooltip>Enregistrer la sélection actuelle comme groupe réutilisable</q-tooltip></q-btn>
<span v-if="suggestSelCount" class="suggest-grp-chip suggest-grp-clear" @click="suggestSelectTechs('none')"><q-icon name="close" size="13px" />Tout désélectionner</span>
<span v-if="!techGroups.length" class="text-caption text-grey-5"> enregistre une sélection (ex. Installateurs, Épisseurs)</span>
</div>
<div class="suggest-tech-grid"> <div class="suggest-tech-grid">
<div v-for="t in suggestTechsRanked" :key="t.id" class="suggest-tech-chip" :class="{ on: !!suggestDlg.techSel[t.id] }" @click="suggestDlg.techSel[t.id] = !suggestDlg.techSel[t.id]"> <div v-for="t in suggestTechsRanked" :key="t.id" class="suggest-tech-chip" :class="{ on: !!suggestDlg.techSel[t.id] }" @click="suggestDlg.techSel[t.id] = !suggestDlg.techSel[t.id]">
<q-icon :name="suggestDlg.techSel[t.id] ? 'check_circle' : 'radio_button_unchecked'" size="16px" :color="suggestDlg.techSel[t.id] ? 'primary' : 'grey-5'" /> <q-icon :name="suggestDlg.techSel[t.id] ? 'check_circle' : 'radio_button_unchecked'" size="16px" :color="suggestDlg.techSel[t.id] ? 'primary' : 'grey-5'" />
@ -4527,6 +4536,29 @@ function suggestSelectSkill (skill) { // (dé)sélectionne TOUT le groupe de tec
techs.forEach(t => { if (allOn) delete sel[t.id]; else sel[t.id] = true }) techs.forEach(t => { if (allOn) delete sel[t.id]; else sel[t.id] = true })
suggestDlg.techSel = sel suggestDlg.techSel = sel
} }
// Groupes de techs ENREGISTRÉS (ex. « Installateurs », « Épisseurs ») sélection manuelle sauvée par utilisateur,
// utile quand la compétence déduite du job est imprécise. Persisté serveur (useUserPrefs, suit l'usager).
const { prefs: techGroupPrefs, save: saveTechGroupPrefs } = useUserPrefs('techGroups', { groups: [] })
const techGroups = computed(() => techGroupPrefs.value.groups || [])
function applyTechGroup (g) { // remplace la sélection par les techs du groupe (présents/visibles seulement)
const ids = new Set(g.ids || []); const sel = {}
for (const t of (visibleTechs.value || [])) if (ids.has(t.id)) sel[t.id] = true
suggestDlg.techSel = sel
}
function saveTechGroup () {
const ids = Object.keys(suggestDlg.techSel).filter(id => suggestDlg.techSel[id])
if (!ids.length) { $q.notify({ type: 'warning', message: 'Sélectionne au moins un technicien avant denregistrer un groupe' }); return }
$q.dialog({ title: 'Enregistrer le groupe', message: 'Nom du groupe (ex. Installateurs, Épisseurs)', prompt: { model: '', type: 'text' }, cancel: true }).onOk(name => {
name = String(name || '').trim(); if (!name) return
const groups = [...(techGroupPrefs.value.groups || [])].filter(g => g.name.toLowerCase() !== name.toLowerCase())
groups.push({ name, ids }); groups.sort((a, b) => a.name.localeCompare(b.name))
saveTechGroupPrefs({ groups })
$q.notify({ type: 'positive', message: `Groupe « ${name} » enregistré (${ids.length} tech)` })
})
}
function deleteTechGroup (g) {
saveTechGroupPrefs({ groups: (techGroupPrefs.value.groups || []).filter(x => x.name !== g.name) })
}
// Tri des techs par QUALITÉ (maîtrise · vitesse · coût ) « les meilleurs en premier » dans la liste de dispo. // Tri des techs par QUALITÉ (maîtrise · vitesse · coût ) « les meilleurs en premier » dans la liste de dispo.
function techQuality (t) { function techQuality (t) {
const lvls = Object.values(t.skill_levels || {}); const avgLvl = lvls.length ? lvls.reduce((a, b) => a + b, 0) / lvls.length : 0 const lvls = Object.values(t.skill_levels || {}); const avgLvl = lvls.length ? lvls.reduce((a, b) => a + b, 0) / lvls.length : 0
@ -4806,7 +4838,14 @@ function onPlanShifts ({ skill, date } = {}) {
const routesMapRef = ref(null) const routesMapRef = ref(null)
const dayRouteMetrics = reactive({}) const dayRouteMetrics = reactive({})
const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic) const hiddenRouteTechs = ref(new Set()) // chips : techs RETIRÉS de la carte (couleur/badge conservés, réactivables d'un clic)
function toggleRouteTech (id) { const s = new Set(hiddenRouteTechs.value); if (s.has(id)) s.delete(id); else s.add(id); hiddenRouteTechs.value = s } function toggleRouteTech (id) {
// Clic sur un tech = ISOLER sa tournée sur la carte (masquer toutes les autres) voir une tournée précise.
// Re-clic sur le tech déjà seul = tout réafficher.
const others = allDayRoutes.value.map(r => r.id).filter(x => x !== id)
const soloed = others.length > 0 && !hiddenRouteTechs.value.has(id) && others.every(x => hiddenRouteTechs.value.has(x))
hiddenRouteTechs.value = soloed ? new Set() : new Set(others)
}
function showAllRouteTechs () { hiddenRouteTechs.value = new Set() }
const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs STABLES par index, même quand on masque) const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs STABLES par index, même quand on masque)
const iso = routesDay.value; if (!iso) return [] const iso = routesDay.value; if (!iso) return []
const out = []; let ci = 0 const out = []; let ci = 0
@ -5730,6 +5769,8 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.assign-sla.sla-at_risk { background: #ffedd5; color: #c2410c; } .assign-sla.sla-at_risk { background: #ffedd5; color: #c2410c; }
.rt-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12px; font-weight: 600; color: #334155; cursor: pointer; user-select: none; background: #fff; } .rt-chip { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12px; font-weight: 600; color: #334155; cursor: pointer; user-select: none; background: #fff; }
.rt-chip.off { opacity: 0.55; border-style: dashed; } .rt-chip.off { opacity: 0.55; border-style: dashed; }
.rt-chip-all { border-color: #94a3b8; color: #475569; gap: 3px; }
.rt-chip-all:hover { border-color: #64748b; background: #f1f5f9; }
.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 secteur (filtrent liste+carte) + chips job + Suggérer */ /* Bande « jobs dus non assignés » (vue Tournées) : chips secteur (filtrent liste+carte) + chips job + Suggérer */
@ -5772,6 +5813,11 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
.suggest-tech-chip:hover { background: #f8fafc; } .suggest-tech-chip:hover { background: #f8fafc; }
.suggest-tech-chip.on { border-color: #6366f1; background: #eef2ff; } .suggest-tech-chip.on { border-color: #6366f1; background: #eef2ff; }
.suggest-skill-chip { border: 1px solid; border-radius: 12px; padding: 1px 9px; font-size: 11px; cursor: pointer; user-select: none; background: #fff; } .suggest-skill-chip { border: 1px solid; border-radius: 12px; padding: 1px 9px; font-size: 11px; cursor: pointer; user-select: none; background: #fff; }
.suggest-grp-chip { display: inline-flex; align-items: center; gap: 3px; border: 1px solid #c7d2fe; background: #eef2ff; color: #4338ca; border-radius: 12px; padding: 1px 8px; font-size: 11px; font-weight: 600; cursor: pointer; user-select: none; }
.suggest-grp-chip:hover { border-color: #6366f1; }
.suggest-grp-x { opacity: .55; margin-left: 1px; }
.suggest-grp-x:hover { opacity: 1; color: #dc2626; }
.suggest-grp-clear { border-color: #cbd5e1; background: #f8fafc; color: #64748b; }
.suggest-step { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; border-radius: 50%; background: #e0e7ff; color: #4338ca; font-size: 10px; font-weight: 700; margin-right: 2px; flex-shrink: 0; } .suggest-step { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; border-radius: 50%; background: #e0e7ff; color: #4338ca; font-size: 10px; font-weight: 700; margin-right: 2px; flex-shrink: 0; }
.suggest-mlvl { font-size: 10px; color: #b45309; font-weight: 700; white-space: nowrap; margin-right: 3px; } .suggest-mlvl { font-size: 10px; color: #b45309; font-weight: 700; white-space: nowrap; margin-right: 3px; }
.assign-foot { border-top: 1px solid #e0e0e0; padding: 6px 9px; font-size: 11px; color: #555; line-height: 1.45; background: #fafafa; border-radius: 0 0 8px 8px; } .assign-foot { border-top: 1px solid #e0e0e0; padding: 6px 9px; font-size: 11px; color: #555; line-height: 1.45; background: #fafafa; border-radius: 0 0 8px 8px; }