From c0695f7ea957f7f2d29d7d3d32996f69ae1d5618 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sun, 5 Jul 2026 12:17:03 -0400 Subject: [PATCH] =?UTF-8?q?feat(ops):=20saveable=20tech=20groups=20in=20Su?= =?UTF-8?q?gg=C3=A9rer=20+=20solo=20tech=20route=20on=20chip=20click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/ops/src/pages/PlanificationPage.vue | 54 ++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 77a3b98..cb2f925 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -487,13 +487,14 @@ Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}. {{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail - -
+ +
{{ r.name }} {{ r.stops.length }} - {{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }} + {{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour n\'afficher QUE cette tournée' : 'Cliquer pour isoler cette tournée (masquer les autres)' }} +
@@ -1071,6 +1072,14 @@ Par compétence : {{ s.skill }} {{ s.n }}
+ +
+ Groupes : + {{ g.name }} {{ (g.ids || []).length }}Supprimer ce groupeSélectionner ce groupe de techniciens + Enregistrer la sélection actuelle comme groupe réutilisable + Tout désélectionner + — enregistre une sélection (ex. Installateurs, Épisseurs) +
@@ -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 }) 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 d’enregistrer 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. function techQuality (t) { 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 dayRouteMetrics = reactive({}) 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 iso = routesDay.value; if (!iso) return [] 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; } .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-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-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 */ @@ -5772,6 +5813,11 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return .suggest-tech-chip:hover { background: #f8fafc; } .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-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-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; }