diff --git a/apps/ops/src/components/planif/JobPool.vue b/apps/ops/src/components/planif/JobPool.vue new file mode 100644 index 0000000..ac11ae9 --- /dev/null +++ b/apps/ops/src/components/planif/JobPool.vue @@ -0,0 +1,312 @@ + + + + + + diff --git a/apps/ops/src/components/shared/RouteMap.vue b/apps/ops/src/components/shared/RouteMap.vue index 172249c..d8501e9 100644 --- a/apps/ops/src/components/shared/RouteMap.vue +++ b/apps/ops/src/components/shared/RouteMap.vue @@ -18,6 +18,7 @@ const props = defineProps({ routes: { type: Array, default: () => [] }, live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }] pins: { type: Array, default: () => [] }, // jobs NON assignés à situer : [{ lat, lon, subject, name, color(priorité), city }] + track: { type: Object, default: null }, // tracé GPS RÉEL (Traccar) d'UN tech sur la journée : { coords:[[lon,lat]…] } — couche orange au 1er plan (vs tournées prévues) height: { type: String, default: '440px' }, }) const emit = defineEmits(['metrics', 'stop-click', 'pin-click']) @@ -66,6 +67,12 @@ function clearMarkers () { for (const m of stopMk) m.mk.remove(); for (const m o // ── 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 renderTrack (tk) { // tracé GPS réel (couche GL orange) — vidé si absent + if (!map || !ready) return + const src = map.getSource('rm-track'); if (!src) return + const coords = (tk && Array.isArray(tk.coords)) ? tk.coords : [] + src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] }) +} function clearPins () { for (const m of pinMk) m.mk.remove(); pinMk = [] } function renderLive (list) { if (!map || !ready) return @@ -77,14 +84,23 @@ function renderLive (list) { const stale = ageMin != null && ageMin > 10 // > 10 min sans fix = position vieillie (pas de halo, grisée) const color = p.color || '#1e88e5' const wrap = document.createElement('div'); wrap.className = 'rm-live' + (stale ? ' stale' : '') - wrap.innerHTML = '' + + // Élément INTERNE fannable : Mapbox pose sa transform de positionnement sur `wrap` (racine du marqueur) → on translate `inner`, + // pas `wrap` (comme les arrêts translatent .rm-pill, enfant de .rm-mk), sinon on casserait le placement GPS. + const inner = document.createElement('div'); inner.className = 'rm-live-in' + inner.innerHTML = '' + '' + initials(p.name || p.techName) + '' + wrap.appendChild(inner) const spd = (p.speed != null && p.speed > 1) ? Math.round(p.speed * 1.852) + ' km/h' : 'arrêté' // Traccar speed = nœuds → km/h const when = ageMin == null ? 'position' : (ageMin <= 1 ? "à l'instant" : 'il y a ' + ageMin + ' min') wrap.title = (p.name || p.techName || '') + ' — ' + when + ' · ' + spd const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map) - liveMk.push({ mk, el: wrap }) + // Rejoint l'ÉCLATEMENT en éventail : quand une position GPS chevauche des arrêts/gouttes, le groupe se sépare au survol. + const rec = { mk, el: wrap, disc: inner, rid: p.techId, lngLat: [+p.lon, +p.lat], fan: null, members: null, live: true } + wrap.addEventListener('mouseenter', () => onEnter(rec)) + wrap.addEventListener('mouseleave', () => onLeave(rec)) + liveMk.push(rec) } + recluster() } function esc (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) } // Jobs NON assignés du jour, GROUPÉS PAR ADRESSE : pastille round-rect avec les icônes des types de jobs + le temps @@ -132,7 +148,11 @@ function renderMarkers (routes) { const d = document.createElement('div'); d.className = 'rm-home'; d.style.borderColor = r.color; wrap.appendChild(d) const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+r.home.lon, +r.home.lat]).addTo(map) wrap.title = r.name + ' — départ' - homeMk.push({ mk, el: wrap, rid: r.id }) + // Le point de DÉPART (domicile/dépôt) rejoint aussi l'éclatement : disc = pastille interne (mapbox transforme `wrap`) + survol → fan. + const rec = { mk, el: wrap, disc: d, rid: r.id, lngLat: [+r.home.lon, +r.home.lat], fan: null, members: null, home: true } + wrap.addEventListener('mouseenter', () => onEnter(rec)) + wrap.addEventListener('mouseleave', () => onLeave(rec)) + homeMk.push(rec) } for (const s of (r.stops || [])) { // Pastille = ROUND-RECT (pill) : icône du type de job + numéro d'ordre (best practice quand 2 infos ; cf. outils de tournée). @@ -153,8 +173,8 @@ 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 gouttes non assignées (par adresse) partagent l'éclatement en éventail au chevauchement. - const all = stopMk.concat(pinMk) + // TOUS les types d'icônes partagent l'éclatement au chevauchement : arrêts, gouttes (non assignés), positions GPS live ET départs (domicile/dépôt). + const all = stopMk.concat(pinMk, liveMk, homeMk) if (!map || !all.length) return const px = all.map(s => map.project(s.lngLat)) const parent = all.map((_, i) => i) @@ -208,6 +228,7 @@ async function draw () { renderMarkers(routes) renderLive(props.live) renderPins(props.pins) + renderTrack(props.track) const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|') + '#' + (props.pins || []).length if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); for (const p of (props.pins || [])) if (okLL(p.lon, p.lat)) b.extend([+p.lon, +p.lat]); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} } // Routes RÉELLES en asynchrone : remplace les segments droits + émet km/min réels. Ignoré si un draw plus récent est parti. @@ -250,6 +271,10 @@ onMounted(async () => { map.addSource('rm-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) map.addLayer({ id: 'rm-line', type: 'line', source: 'rm-line', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.7 } }) map.addLayer({ id: 'rm-line-a', type: 'line', source: 'rm-line', filter: ['==', ['get', 'rid'], '___aucun___'], layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 5.5, 'line-opacity': 0.95 } }) + // Tracé GPS RÉEL (Traccar) d'un seul tech — orange, AU-DESSUS des tournées prévues (halo + trait) → « où il est réellement passé ». + map.addSource('rm-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }) + map.addLayer({ id: 'rm-track-halo', type: 'line', source: 'rm-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 9, 'line-opacity': 0.22 } }) + map.addLayer({ id: 'rm-track-l', type: 'line', source: 'rm-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3.5, 'line-opacity': 0.85 } }) map.on('mousemove', 'rm-line', (e) => { const f = e.features && e.features[0]; if (f) setActive(f.properties.rid) }) map.on('mouseleave', 'rm-line', () => setActive(null)) map.on('movestart', () => { if (openMembers) { fan(openMembers, false); openMembers = null } }) // décalages px périmés au déplacement @@ -260,8 +285,9 @@ onMounted(async () => { }) onBeforeUnmount(() => { clearMarkers(); clearLive(); clearPins(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false }) watch(() => props.routes, () => { draw() }, { deep: true }) -watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live sans redessiner les tournées +watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live (+ recluster interne) sans redessiner les tournées watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // jobs non assignés (secteur) sans redessiner les tournées +watch(() => props.track, () => { renderTrack(props.track) }, { deep: true }) // tracé GPS réel (1 tech) sans redessiner les tournées // Satellite : bouton local + préférence PARTAGÉE (satellitePref) → toutes les cartes suivent. function toggleSat () { setSatellitePref(!satellitePref.value); if (map) setSatelliteVisible(map, satellitePref.value) } watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) }) @@ -299,7 +325,7 @@ watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) }) .rm-pop-row:hover { background: #eef2ff; } .rm-pop-s { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rm-pop-est { color: #64748b; font-variant-numeric: tabular-nums; flex-shrink: 0; } -.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 .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); transition: transform .18s cubic-bezier(.2,.8,.3,1); } .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); } .rm-mk.route-hot { z-index: 5; } @@ -307,6 +333,8 @@ watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) }) .rm-mk.fanned .rm-pill { box-shadow: 0 3px 10px rgba(0,0,0,.55); } /* Position GPS LIVE d'un tech : pastille ronde à initiales + halo pulsé (distincte des pastilles d'arrêt). */ .rm-live { position: relative; width: 0; height: 0; z-index: 8; pointer-events: auto; } +.rm-live.fanned { z-index: 9; } /* éclatée au chevauchement → passe devant arrêts/gouttes */ +.rm-live-in { position: absolute; width: 0; height: 0; transition: transform .18s cubic-bezier(.2,.8,.3,1); } /* élément translaté par l'éventail */ .rm-live .rm-live-dot { position: absolute; transform: translate(-50%, -50%); width: 28px; height: 28px; border-radius: 50%; border: 2.5px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,.5); color: #fff; font-size: 10.5px; font-weight: 800; display: flex; align-items: center; justify-content: center; z-index: 2; cursor: default; } .rm-live .rm-live-pulse { position: absolute; width: 28px; height: 28px; border-radius: 50%; opacity: .45; z-index: 1; animation: rm-live-pulse 1.8s ease-out infinite; } @keyframes rm-live-pulse { 0% { transform: translate(-50%,-50%) scale(1); opacity: .45; } 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } } diff --git a/apps/ops/src/composables/useJobPool.js b/apps/ops/src/composables/useJobPool.js new file mode 100644 index 0000000..81811e2 --- /dev/null +++ b/apps/ops/src/composables/useJobPool.js @@ -0,0 +1,147 @@ +import { ref, computed } from 'vue' + +/** + * useJobPool — cerveau UNIQUE des pools « Jobs à assigner » (panneau flottant, colonne Jour/kanban, liste mobile). + * + * Historique : trois réimplémentations divergentes (assignJobsFiltered/assignGroups, kanbanPoolView, poolJobs) filtraient + * et triaient le MÊME tableau (assignPanel.jobs). Ce composable les fusionne : une instance PAR surface (créée et détenue + * par la page → la carte du panneau flottant garde sa source de vérité en lisant `filteredJobs`). Le composant JobPool.vue + * ne fait que rendre `groups`/`filteredJobs` + les badges. Toute la logique d'assignation/drag reste dans la page. + * + * @param {Function} jobsGetter () => Array — source réactive (ex. () => assignPanel.jobs) + * @param {Object} deps { jobCity, jobLocKey, todayISO, fmtDueLabel, distOf? } — helpers PURS de la page (source unique) + * @param {Object} opts { variant:'floating'|'docked'|'mobile', defaultSort?, selectedDay?:()=>iso } + */ +export function useJobPool (jobsGetter, deps, opts = {}) { + const { jobCity, jobLocKey, todayISO, fmtDueLabel } = deps + const distOf = deps.distOf || null // (j) => km depuis le dépôt — seulement le variant « docked » l'utilise + const variant = opts.variant || 'floating' + const selectedDay = opts.selectedDay || (() => null) + + const PRIO_RANK = { urgent: 0, high: 1, 'élevée': 1, elevee: 1, medium: 2, moyenne: 2, normal: 2, low: 3, basse: 3 } + const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } + const pr = j => (PRIO_RANK[String(j.priority || '').toLowerCase()] ?? 2) + const durMin = j => (+j.est_min || (+j.duration_h || 1) * 60) + + // État de vue (PAR instance, vit dans la page → survit au démontage du composant lors d'un changement de vue). + const skillFilter = ref([]) // compétences cochées (vide = toutes) + const dateFilter = ref([]) // isos de date DUE cochés ('__overdue__' = toutes passées) + const sort = ref(opts.defaultSort || (variant === 'mobile' ? 'smart' : variant === 'docked' ? 'prio' : 'date')) + const sortDir = ref('asc') + const search = ref('') + const collapsed = ref(new Set()) + + const jobs = computed(() => jobsGetter() || []) + + // Chips compétence : {k,n} par required_skill (le plus fréquent d'abord). + const skillChips = computed(() => { + const m = {} + for (const j of jobs.value) { const k = j.required_skill || 'autre'; m[k] = (m[k] || 0) + 1 } + return Object.entries(m).map(([k, n]) => ({ k, n })).sort((a, b) => b.n - a.n) + }) + + // Chips date DUE : dates passées regroupées en « ⏰ en retard », futures + « Sans date » séparées. + const dateChips = computed(() => { + const t = todayISO(); const m = {} + for (const j of jobs.value) { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; m[d] = (m[d] || 0) + 1 } + const out = []; let overdueN = 0 + for (const [iso, n] of Object.entries(m)) { + if (iso !== 'Sans date' && iso < t) { overdueN += n; continue } + out.push({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: false }) + } + out.sort((a, b) => String(a.iso).localeCompare(String(b.iso))) + if (overdueN) out.unshift({ iso: '__overdue__', n: overdueN, label: '⏰ en retard', overdue: true }) + return out + }) + + function jobMatchesDateChips (j) { + const df = dateFilter.value; if (!df.length) return true + const ds = new Set(df); const t = todayISO() + const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date' + return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t) + } + + // Filtre : date d'abord (borne le périmètre) → compétence PAR ADRESSE (garde les jobs co-localisés d'un job filtré) → recherche texte. + const filteredJobs = computed(() => { + let arr = jobs.value + if (dateFilter.value.length) arr = arr.filter(jobMatchesDateChips) + const f = skillFilter.value + if (f.length) { + const set = new Set(f); const matchAddr = new Set() + for (const j of arr) if (set.has(j.required_skill || 'autre')) { const k = jobLocKey(j); if (k) matchAddr.add(k) } + arr = arr.filter(j => set.has(j.required_skill || 'autre') || matchAddr.has(jobLocKey(j))) + } + const q = search.value.trim().toLowerCase() + if (q) arr = arr.filter(j => [j.subject, j.service_type, j.customer_name, j.address, j.required_skill].some(x => String(x || '').toLowerCase().includes(q))) + return arr + }) + + // Info d'adresse (périmètre du jour, AVANT filtre compétence) : nb de jobs + compétences distinctes → badges. + const addrInfo = computed(() => { + const base = dateFilter.value.length ? jobs.value.filter(jobMatchesDateChips) : jobs.value + const m = {} + for (const j of base) { const k = jobLocKey(j); if (!k) continue; const g = m[k] || (m[k] = { n: 0, skills: new Set() }); g.n++; if (j.required_skill) g.skills.add(j.required_skill) } + return m + }) + function jobAddrBadge (j) { const k = jobLocKey(j); const g = k && addrInfo.value[k]; if (!g || g.n < 2) return null; const skills = [...g.skills]; return { n: g.n, skills, multi: skills.length > 1 } } + function jobAssocOnly (j) { const f = skillFilter.value; return f.length ? !new Set(f).has(j.required_skill || 'autre') : false } + + // Modes de tri qui PRODUISENT des en-têtes de groupe (par variant) ; les autres = une seule liste à plat. + const GROUPING = new Set(opts.groupingModes || (variant === 'mobile' ? ['sector'] : variant === 'docked' ? ['city', 'skill'] : ['group', 'skill', 'date', 'city', 'sector', 'priority'])) + const secOf = j => jobCity(j) || 'Sans secteur' + const byDate = (a, b) => { const da = a.scheduled_date && a.scheduled_date !== 'Sans date' ? a.scheduled_date : '9999-99-99'; const db = b.scheduled_date && b.scheduled_date !== 'Sans date' ? b.scheduled_date : '9999-99-99'; return String(da).localeCompare(String(db)) } + const dueRank = j => { const t = todayISO(); const sel = selectedDay(); const d = j.scheduled_date; if (d && d === sel) return 0; if (!d || d === 'Sans date') return 4; if (d < t) return 1; if (d === t) return 2; return 3 } + + const groups = computed(() => { + const list = filteredJobs.value; const mode = sort.value + // Groupe parent-enfant (installation → activation…), ordonné par step_order. + if (mode === 'group') { + const g = {}; for (const j of list) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) } + return Object.keys(g).map(k => ({ key: k, label: null, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) })) + } + // Tris À PLAT (une seule liste, pas d'en-tête) : smart / prio / dist / dur. + if (!GROUPING.has(mode)) { + const cmp = mode === 'prio' ? (a, b) => pr(a) - pr(b) || byDate(a, b) + : mode === 'dist' && distOf ? (a, b) => (distOf(a) ?? 9e9) - (distOf(b) ?? 9e9) + : mode === 'dur' ? (a, b) => durMin(b) - durMin(a) || dueRank(a) - dueRank(b) + : (a, b) => dueRank(a) - dueRank(b) || pr(a) - pr(b) || byDate(a, b) // 'smart' + return [{ key: 'all', label: null, jobs: [...list].sort(cmp) }] + } + // Tris GROUPÉS : skill / date / city / sector / priority. + const keyOf = j => mode === 'skill' ? (j.required_skill || 'Sans compétence') + : (mode === 'city' || mode === 'sector') ? secOf(j) + : mode === 'priority' ? (j.priority || 'low') + : (j.scheduled_date || 'Sans date') + const labelOf = k => mode === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k) + : mode === 'date' ? '📅 ' + fmtDueLabel(k) : k + const g = {}; for (const j of list) { const k = keyOf(j); (g[k] = g[k] || []).push(j) } + const dir = sortDir.value === 'desc' ? -1 : 1 + const keys = Object.keys(g).sort((a, b) => (mode === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b)) * dir) + return keys.map(k => { + const gj = g[k] + const mins = gj.reduce((s, j) => s + durMin(j), 0) + return { key: k, label: labelOf(k), jobs: gj, n: gj.length, mins } + }) + }) + + const allCollapsed = computed(() => { const gs = groups.value; return gs.length > 0 && gs.every(g => g.label != null && collapsed.value.has(g.key)) }) + + // Actions sur l'état de vue + function toggleSkill (k) { const i = skillFilter.value.indexOf(k); if (i >= 0) skillFilter.value.splice(i, 1); else skillFilter.value.push(k) } + function clearSkills () { skillFilter.value = [] } + function toggleDate (iso) { const s = new Set(dateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); dateFilter.value = [...s] } + function clearDates () { dateFilter.value = [] } + function toggleCollapse (key) { const s = new Set(collapsed.value); s.has(key) ? s.delete(key) : s.add(key); collapsed.value = s } + function toggleCollapseAll () { collapsed.value = allCollapsed.value ? new Set() : new Set(groups.value.filter(g => g.label != null).map(g => g.key)) } + + return { + variant, + // état (writable) + skillFilter, dateFilter, sort, sortDir, search, collapsed, + // dérivés + jobs, skillChips, dateChips, filteredJobs, groups, addrInfo, allCollapsed, + // helpers + jobMatchesDateChips, jobAddrBadge, jobAssocOnly, + toggleSkill, clearSkills, toggleDate, clearDates, toggleCollapse, toggleCollapseAll, + } +}