Compare commits
2 Commits
e149b252b3
...
9b866ec3b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b866ec3b9 | ||
|
|
1b095ae0e8 |
|
|
@ -9,23 +9,11 @@
|
|||
* 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).
|
||||
*/
|
||||
import { watch, onMounted, onBeforeUnmount, h, render } from 'vue'
|
||||
import { QIcon } from 'quasar'
|
||||
import { watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { MAPBOX_TOKEN } from 'src/config/erpnext'
|
||||
import * as roster from 'src/api/roster'
|
||||
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({
|
||||
routes: { type: Array, default: () => [] },
|
||||
live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }]
|
||||
|
|
@ -82,12 +70,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) ──
|
||||
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 = [] }
|
||||
function clearMarkers () { for (const m of stopMk) 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é) ──
|
||||
// initials → composables/useFormatters (source unique)
|
||||
function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] }
|
||||
function clearPins () { for (const m of pinMk) { if (m.ih) try { render(null, m.ih) } catch (e) {} m.mk.remove() } pinMk = [] }
|
||||
function clearPins () { for (const m of pinMk) m.mk.remove(); pinMk = [] }
|
||||
function renderLive (list) {
|
||||
if (!map || !ready) return
|
||||
clearLive()
|
||||
|
|
@ -115,21 +103,13 @@ function renderPins (list) {
|
|||
for (const p of (list || [])) {
|
||||
if (!okLL(p.lon, p.lat)) continue
|
||||
const color = p.color || '#f97316'
|
||||
// MÊME pastille round-rect que les arrêts assignés (icône de compétence), variante « non assigné » (bordure pointillée) ;
|
||||
// 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 disc = document.createElement('div'); disc.className = 'rm-pill rm-pill-un'; disc.style.background = color
|
||||
const ih = iconInto(disc, p.icon)
|
||||
wrap.appendChild(disc)
|
||||
const wrap = document.createElement('div'); wrap.className = 'rm-pin'
|
||||
wrap.innerHTML = '<span class="rm-pin-drop" style="background:' + color + '"></span>'
|
||||
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 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('mouseleave', () => onLeave(rec))
|
||||
wrap.addEventListener('click', () => emit('pin-click', p))
|
||||
pinMk.push(rec)
|
||||
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'bottom' }).setLngLat([+p.lon, +p.lat]).addTo(map)
|
||||
pinMk.push({ mk, el: wrap })
|
||||
}
|
||||
recluster()
|
||||
}
|
||||
function renderMarkers (routes) {
|
||||
clearMarkers()
|
||||
|
|
@ -146,11 +126,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).
|
||||
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 ih = iconInto(disc, s.icon)
|
||||
const num = document.createElement('span'); num.className = 'rm-num'; num.textContent = s.seq; disc.appendChild(num)
|
||||
const ic = /^[a-z0-9_]+$/.test(String(s.icon || '')) ? s.icon : 'place'
|
||||
disc.innerHTML = '<span class="material-icons rm-ic">' + ic + '</span><span class="rm-num">' + s.seq + '</span>'
|
||||
wrap.appendChild(disc)
|
||||
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+s.lon, +s.lat]).addTo(map)
|
||||
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 }
|
||||
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 }
|
||||
wrap.addEventListener('mouseenter', () => onEnter(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
|
||||
|
|
@ -161,31 +141,29 @@ function renderMarkers (routes) {
|
|||
}
|
||||
// Regroupe les pastilles qui se CHEVAUCHENT à l'écran (distance pixel) → calcule pour chacune un décalage en éventail.
|
||||
function recluster () {
|
||||
// Arrêts assignés ET jobs non assignés partagent le même éclatement en éventail au chevauchement.
|
||||
const all = stopMk.concat(pinMk)
|
||||
if (!map || !all.length) return
|
||||
const px = all.map(s => map.project(s.lngLat))
|
||||
const parent = all.map((_, i) => i)
|
||||
if (!map || !stopMk.length) return
|
||||
const px = stopMk.map(s => map.project(s.lngLat))
|
||||
const parent = stopMk.map((_, i) => i)
|
||||
const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i] } return i }
|
||||
const TH = 24 // pastille ø24 → chevauchement si centres < ~24 px
|
||||
for (let i = 0; i < all.length; i++) for (let j = i + 1; j < all.length; j++) {
|
||||
for (let i = 0; i < stopMk.length; i++) for (let j = i + 1; j < stopMk.length; j++) {
|
||||
const dx = px[i].x - px[j].x, dy = px[i].y - px[j].y
|
||||
if (dx * dx + dy * dy < TH * TH) parent[find(i)] = find(j)
|
||||
}
|
||||
const groups = {}
|
||||
for (let i = 0; i < all.length; i++) { const r = find(i); (groups[r] = groups[r] || []).push(i) }
|
||||
for (const s of all) { s.fan = null; s.members = null }
|
||||
for (let i = 0; i < stopMk.length; i++) { const r = find(i); (groups[r] = groups[r] || []).push(i) }
|
||||
for (const s of stopMk) { s.fan = null; s.members = null }
|
||||
for (const key in groups) {
|
||||
const ids = groups[key]; if (ids.length < 2) continue
|
||||
const cx = ids.reduce((a, i) => a + px[i].x, 0) / ids.length
|
||||
const cy = ids.reduce((a, i) => a + px[i].y, 0) / ids.length
|
||||
const R = 18 + ids.length * 6 // rayon d'éclatement croît avec le nombre
|
||||
const members = ids.map(i => all[i])
|
||||
const members = ids.map(i => stopMk[i])
|
||||
ids.forEach((i, k) => {
|
||||
const ang = (k / ids.length) * 2 * Math.PI - Math.PI / 2
|
||||
const tx = cx + R * Math.cos(ang), ty = cy + R * Math.sin(ang)
|
||||
all[i].fan = [tx - px[i].x, ty - px[i].y] // décalage (px) de la pastille par rapport à son point
|
||||
all[i].members = members
|
||||
stopMk[i].fan = [tx - px[i].x, ty - px[i].y] // décalage (px) de la pastille par rapport à son point
|
||||
stopMk[i].members = members
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -290,10 +268,7 @@ watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // job
|
|||
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-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; }
|
||||
/* 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 .rm-home { width: 15px; height: 15px; border-radius: 50%; box-sizing: border-box; background: #fff; border: 3px solid #888; box-shadow: 0 1px 3px rgba(0,0,0,.4); }
|
||||
.rm-mk.hot { z-index: 7; }
|
||||
.rm-mk.hot .rm-pill { transform: scale(1.15); box-shadow: 0 3px 9px rgba(0,0,0,.55); }
|
||||
|
|
|
|||
|
|
@ -2510,8 +2510,16 @@ function skillIcon (sk) {
|
|||
if (/factur|paiement|compta/.test(s)) return 'receipt_long'
|
||||
return 'bolt'
|
||||
}
|
||||
// (markerIcon retiré : les marqueurs carte rendent désormais skillSym via un vrai q-icon dans RouteMap →
|
||||
// MÊMES icônes custom que le carrousel/blocs, plus de divergence assigné/non-assigné.)
|
||||
// Icône de compétence pour un MARQUEUR carte (DOM, police material-icons) : UNIQUEMENT des ligatures material (pas les SVG
|
||||
// custom de skillSym) — sinon le glyphe ne s'affiche pas dans un <span class="material-icons"> hors q-icon.
|
||||
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) {
|
||||
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
|
||||
|
|
@ -4730,7 +4738,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)
|
||||
const k = (+e.lat).toFixed(4) + ',' + (+e.lon).toFixed(4)
|
||||
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: skillSym(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: markerIcon(e.skill) }; seen.set(k, s); stops.push(s) }
|
||||
}
|
||||
if (!stops.length) continue
|
||||
const home = techOrigin(g.techId) // domicile, sinon bureau TARGO
|
||||
|
|
@ -4778,7 +4786,7 @@ const routeDayUnassignedView = computed(() => {
|
|||
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é
|
||||
const routeUnassignedPins = computed(() => routeDayUnassignedView.value.filter(j => { const la = _jlat(j); const lo = _jlon(j); return la != null && lo != null && isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01 }).map(j => ({ lat: _jlat(j), lon: _jlon(j), subject: j.subject || j.service_type || j.name, name: j.name, color: prioColor(j.priority), city: jobCity(j), icon: skillSym(j.required_skill || j.service_type), job: j })))
|
||||
const routeUnassignedPins = computed(() => routeDayUnassignedView.value.filter(j => { const la = _jlat(j); const lo = _jlon(j); return la != null && lo != null && isFinite(la) && isFinite(lo) && Math.abs(la) > 0.01 }).map(j => ({ lat: _jlat(j), lon: _jlon(j), subject: j.subject || j.service_type || j.name, name: j.name, color: prioColor(j.priority), city: jobCity(j), job: j })))
|
||||
|
||||
// ── Deep-link (?day=YYYY-MM-DD & ?skill=…) + action « planifier des quarts » du slot-finder ──
|
||||
const route = useRoute()
|
||||
|
|
@ -4808,7 +4816,7 @@ const allDayRoutes = computed(() => { // TOUTES les tournées du jour (couleurs
|
|||
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
|
||||
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: skillSym(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: markerIcon(j.skill) })) })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user