fix(ops): unify job icons — custom skillSym SVGs on map markers too

Les jobs ASSIGNÉS (marqueurs carte, HTML brut) utilisaient markerIcon =
ligatures material génériques (install→construction, monteur→camion
générique), alors que les jobs NON assignés (carrousel, blocs, gouttes)
utilisent skillSym = icônes SVG custom (échelle+outils, camion-nacelle,
casque). Résultat : même compétence, icône différente selon jour passé
(assigné) vs à venir (non assigné). Unifié sur les SVG custom :

- RouteMap : les marqueurs (arrêts assignés ET gouttes non assignées)
  rendent l'icône via un VRAI <q-icon> (h+render) → gère les SVG custom
  skillSym comme le reste de l'app ; repli material-icons si échec ;
  démontage propre dans clearMarkers/clearPins (pas de fuite).
- PlanificationPage : les arrêts (Tournées + revue Suggérer) passent
  skillSym(skill) au lieu de markerIcon ; markerIcon retiré (mort).

⚠️ Rendu carte NON vérifiable en préview (jeu dev sans coordonnées → 0
marqueur). Compile OK, carrousel (25 chips) inchangé, 0 nouvelle erreur.
À confirmer à l'œil en prod (jobs géocodés). Reste : unifier la FORME des
puces (chip/pill/carte) selon contexte — passe design dédiée.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-05 07:25:56 -04:00
parent 4c8242eccb
commit e149b252b3
2 changed files with 25 additions and 21 deletions

View File

@ -9,11 +9,23 @@
* chevauchent, le groupe s'ÉCLATE en éventail pour les rendre toutes lisibles/cliquables. * chevauchent, le groupe s'ÉCLATE en éventail pour les rendre toutes lisibles/cliquables.
* - émet 'metrics' { id:{km,mins} } (routes réelles) ; expose fitTo(id) / fitAll() / setActive(id). * - émet 'metrics' { id:{km,mins} } (routes réelles) ; expose fitTo(id) / fitAll() / setActive(id).
*/ */
import { watch, onMounted, onBeforeUnmount } from 'vue' import { watch, onMounted, onBeforeUnmount, h, render } from 'vue'
import { QIcon } from 'quasar'
import { MAPBOX_TOKEN } from 'src/config/erpnext' import { MAPBOX_TOKEN } from 'src/config/erpnext'
import * as roster from 'src/api/roster' import * as roster from 'src/api/roster'
import { initials } from 'src/composables/useFormatters' // initiales (source unique, ex-locale dé-dupliquée) import { initials } from 'src/composables/useFormatters' // initiales (source unique, ex-locale dé-dupliquée)
// Rend l'icône d'un marqueur via un vrai q-icon (gère ligatures material ET SVG custom skillSym
// pastille assignée et goutte non assignée ont ainsi le MÊME jeu d'icônes). Repli material-icons si échec.
function iconInto (parent, name) {
const nm = (name && String(name)) || 'place'
const holder = document.createElement('span'); holder.className = 'rm-ic-h'
try { render(h(QIcon, { name: nm, size: '14px' }), holder) }
catch (e) { holder.className = 'material-icons rm-ic'; holder.textContent = /^[a-z0-9_]+$/.test(nm) ? nm : 'place' }
parent.appendChild(holder)
return holder
}
const props = defineProps({ const props = defineProps({
routes: { type: Array, default: () => [] }, routes: { type: Array, default: () => [] },
live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }] live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }]
@ -70,12 +82,12 @@ async function realLine (r) { // géométrie routière réelle d'une tournée (O
} }
// Marqueurs HTML : le numéro est DANS la pastille (même élément, même niveau) // Marqueurs HTML : le numéro est DANS la pastille (même élément, même niveau)
function clearMarkers () { for (const m of stopMk) m.mk.remove(); for (const m of homeMk) m.mk.remove(); stopMk = []; homeMk = [] } function clearMarkers () { for (const m of stopMk) { if (m.ih) try { render(null, m.ih) } catch (e) {} m.mk.remove() } for (const m of homeMk) m.mk.remove(); stopMk = []; homeMk = [] }
// Positions GPS LIVE des techs (marqueur distinct des arrêts : pastille ronde à initiales + halo pulsé) // Positions GPS LIVE des techs (marqueur distinct des arrêts : pastille ronde à initiales + halo pulsé)
// initials composables/useFormatters (source unique) // initials composables/useFormatters (source unique)
function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] } function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] }
function clearPins () { for (const m of pinMk) m.mk.remove(); pinMk = [] } function clearPins () { for (const m of pinMk) { if (m.ih) try { render(null, m.ih) } catch (e) {} m.mk.remove() } pinMk = [] }
function renderLive (list) { function renderLive (list) {
if (!map || !ready) return if (!map || !ready) return
clearLive() clearLive()
@ -107,12 +119,11 @@ function renderPins (list) {
// participe au même éclatement en éventail au chevauchement (recluster/fan) que les arrêts. // participe au même éclatement en éventail au chevauchement (recluster/fan) que les arrêts.
const wrap = document.createElement('div'); wrap.className = 'rm-mk rm-mk-un' const wrap = document.createElement('div'); wrap.className = 'rm-mk rm-mk-un'
const disc = document.createElement('div'); disc.className = 'rm-pill rm-pill-un'; disc.style.background = color const disc = document.createElement('div'); disc.className = 'rm-pill rm-pill-un'; disc.style.background = color
const ic = /^[a-z0-9_]+$/.test(String(p.icon || '')) ? p.icon : 'place' const ih = iconInto(disc, p.icon)
disc.innerHTML = '<span class="material-icons rm-ic">' + ic + '</span>'
wrap.appendChild(disc) wrap.appendChild(disc)
wrap.title = (p.subject || 'Job') + (p.city ? ' · ' + p.city : '') + ' — non assigné' wrap.title = (p.subject || 'Job') + (p.city ? ' · ' + p.city : '') + ' — non assigné'
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map) const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map)
const rec = { mk, el: wrap, disc, rid: null, lngLat: [+p.lon, +p.lat], fan: null, members: null, pin: p } const rec = { mk, el: wrap, disc, ih, rid: null, lngLat: [+p.lon, +p.lat], fan: null, members: null, pin: p }
wrap.addEventListener('mouseenter', () => onEnter(rec)) wrap.addEventListener('mouseenter', () => onEnter(rec))
wrap.addEventListener('mouseleave', () => onLeave(rec)) wrap.addEventListener('mouseleave', () => onLeave(rec))
wrap.addEventListener('click', () => emit('pin-click', p)) wrap.addEventListener('click', () => emit('pin-click', p))
@ -135,11 +146,11 @@ function renderMarkers (routes) {
// Pastille = ROUND-RECT (pill) : icône du type de job + numéro d'ordre (best practice quand 2 infos ; cf. outils de tournée). // Pastille = ROUND-RECT (pill) : icône du type de job + numéro d'ordre (best practice quand 2 infos ; cf. outils de tournée).
const wrap = document.createElement('div'); wrap.className = 'rm-mk'; wrap.dataset.rid = String(r.id) const wrap = document.createElement('div'); wrap.className = 'rm-mk'; wrap.dataset.rid = String(r.id)
const disc = document.createElement('div'); disc.className = 'rm-pill'; disc.style.background = r.color const disc = document.createElement('div'); disc.className = 'rm-pill'; disc.style.background = r.color
const ic = /^[a-z0-9_]+$/.test(String(s.icon || '')) ? s.icon : 'place' const ih = iconInto(disc, s.icon)
disc.innerHTML = '<span class="material-icons rm-ic">' + ic + '</span><span class="rm-num">' + s.seq + '</span>' const num = document.createElement('span'); num.className = 'rm-num'; num.textContent = s.seq; disc.appendChild(num)
wrap.appendChild(disc) wrap.appendChild(disc)
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+s.lon, +s.lat]).addTo(map) const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+s.lon, +s.lat]).addTo(map)
const rec = { mk, el: wrap, disc, rid: r.id, lngLat: [+s.lon, +s.lat], seq: s.seq, tech: r.name, subject: s.subject || '', stop: s, fan: null, members: null } const rec = { mk, el: wrap, disc, ih, rid: r.id, lngLat: [+s.lon, +s.lat], seq: s.seq, tech: r.name, subject: s.subject || '', stop: s, fan: null, members: null }
wrap.addEventListener('mouseenter', () => onEnter(rec)) wrap.addEventListener('mouseenter', () => onEnter(rec))
wrap.addEventListener('mouseleave', () => onLeave(rec)) wrap.addEventListener('mouseleave', () => onLeave(rec))
wrap.addEventListener('click', () => emit('stop-click', rec.stop, rec.rid)) // la page ouvre la fiche détail complète du ticket wrap.addEventListener('click', () => emit('stop-click', rec.stop, rec.rid)) // la page ouvre la fiche détail complète du ticket
@ -279,6 +290,7 @@ watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // job
transition: transform .18s cubic-bezier(.2,.8,.3,1), box-shadow .15s; transition: transform .18s cubic-bezier(.2,.8,.3,1), box-shadow .15s;
} }
.rm-mk .rm-pill .rm-ic { font-size: 14px; line-height: 1; opacity: .95; } .rm-mk .rm-pill .rm-ic { font-size: 14px; line-height: 1; opacity: .95; }
.rm-mk .rm-pill .rm-ic-h { display: inline-flex; align-items: center; line-height: 1; opacity: .95; }
.rm-mk .rm-pill .rm-num { font-size: 12px; font-weight: 800; line-height: 1; } .rm-mk .rm-pill .rm-num { font-size: 12px; font-weight: 800; line-height: 1; }
/* Non assigné : même pastille, bordure pointillée + halo pour se distinguer des arrêts planifiés. */ /* Non assigné : même pastille, bordure pointillée + halo pour se distinguer des arrêts planifiés. */
.rm-mk-un .rm-pill { border-style: dashed; box-shadow: 0 0 0 2px rgba(249,115,22,.25), 0 1px 3px rgba(0,0,0,.45); } .rm-mk-un .rm-pill { border-style: dashed; box-shadow: 0 0 0 2px rgba(249,115,22,.25), 0 1px 3px rgba(0,0,0,.45); }

View File

@ -2510,16 +2510,8 @@ function skillIcon (sk) {
if (/factur|paiement|compta/.test(s)) return 'receipt_long' if (/factur|paiement|compta/.test(s)) return 'receipt_long'
return 'bolt' return 'bolt'
} }
// Icône de compétence pour un MARQUEUR carte (DOM, police material-icons) : UNIQUEMENT des ligatures material (pas les SVG // (markerIcon retiré : les marqueurs carte rendent désormais skillSym via un vrai q-icon dans RouteMap
// custom de skillSym) sinon le glyphe ne s'affiche pas dans un <span class="material-icons"> hors q-icon. // MÊMES icônes custom que le carrousel/blocs, plus de divergence assigné/non-assigné.)
function markerIcon (skill) {
const s = String(skill || '').toLowerCase()
if (/t[ée]l[ée]vis|\btv\b|iptv/.test(s)) return 'live_tv'
if (/install/.test(s)) return 'construction'
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return 'local_shipping'
const ic = skillIcon(skill)
return (typeof ic === 'string' && /^[a-z0-9_]+$/.test(ic)) ? ic : 'build'
}
function onTagsChange (t, items) { function onTagsChange (t, items) {
const newLabels = (items || []).map(x => typeof x === 'string' ? x : x.tag).filter(Boolean) const newLabels = (items || []).map(x => typeof x === 'string' ? x : x.tag).filter(Boolean)
const removed = (t.skills || []).filter(s => !newLabels.includes(s)) // compétences retirées vérifier l'impact sur les jobs assignés const removed = (t.skills || []).filter(s => !newLabels.includes(s)) // compétences retirées vérifier l'impact sur les jobs assignés
@ -4738,7 +4730,7 @@ const suggestRoutes = computed(() => {
if (e.lat == null || e.lon == null || !isFinite(+e.lat) || !isFinite(+e.lon) || Math.abs(+e.lat) < 0.01) continue // 0,0 / invalides exclus (sinon la carte dézoome jusqu'au golfe de Guinée) if (e.lat == null || e.lon == null || !isFinite(+e.lat) || !isFinite(+e.lon) || Math.abs(+e.lat) < 0.01) continue // 0,0 / invalides exclus (sinon la carte dézoome jusqu'au golfe de Guinée)
const k = (+e.lat).toFixed(4) + ',' + (+e.lon).toFixed(4) const k = (+e.lat).toFixed(4) + ',' + (+e.lon).toFixed(4)
const ex = seen.get(k) const ex = seen.get(k)
if (ex) { ex.n++; ex.subject = ex.n + ' jobs — ' + ex.first } else { const s = { lat: +e.lat, lon: +e.lon, seq: stops.length + 1, subject: e.subject, first: e.subject, n: 1, name: e.jobName, entry: e, icon: markerIcon(e.skill) }; seen.set(k, s); stops.push(s) } if (ex) { ex.n++; ex.subject = ex.n + ' jobs — ' + ex.first } else { const s = { lat: +e.lat, lon: +e.lon, seq: stops.length + 1, subject: e.subject, first: e.subject, n: 1, name: e.jobName, entry: e, icon: skillSym(e.skill) }; seen.set(k, s); stops.push(s) }
} }
if (!stops.length) continue if (!stops.length) continue
const home = techOrigin(g.techId) // domicile, sinon bureau TARGO const home = techOrigin(g.techId) // domicile, sinon bureau TARGO
@ -4816,7 +4808,7 @@ const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs
if (!jobs.length) continue if (!jobs.length) continue
jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel jobs.sort((a, b) => (a.route_order || 9999) - (b.route_order || 9999) || ((a.start_h ?? 99) - (b.start_h ?? 99))) // ordre de tournée réel
const h = techOrigin(t.id) // domicile, sinon bureau TARGO const h = techOrigin(t.id) // domicile, sinon bureau TARGO
out.push({ id: t.id, name: t.name, color: routeColor(ci++), home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject, name: j.name, job: j, icon: markerIcon(j.skill) })) }) out.push({ id: t.id, name: t.name, color: routeColor(ci++), home: h ? { lat: h.lat, lon: h.lon } : null, stops: jobs.map((j, i) => ({ lat: +j.lat, lon: +j.lon, seq: i + 1, subject: j.subject, name: j.name, job: j, icon: skillSym(j.skill) })) })
} }
return out return out
}) })