feat(planif): Tournées — chips secteur + jobs non assignés sur la carte (gouttes), filtre liste+carte
Batch D — regrouper/situer les non-assignés du jour : - RouteMap : nouvelle prop `pins` (+ renderPins/clearPins/watch, gouttes oranges couleur=priorité, clic → @pin-click) — miroir de `live`, inclus dans le fit bounds - Planif Tournées : chips SECTEUR (par ville, comptés, triés) qui filtrent SIMULTANÉMENT la liste « à répartir » ET les gouttes sur la carte ; routeUnassignedPins (coords robustes latitude/lat), clic goutte → détail ; « Suggérer » agit sur le secteur si filtré Vérifié préview : jour 06/07 → 10 secteurs + 21 gouttes ; clic « STE CLOTILDE » → 4 chips + 4 gouttes (« 4 à répartir / 21 »). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
21724e65f8
commit
c8afa97004
|
|
@ -16,12 +16,13 @@ import * as roster from 'src/api/roster'
|
|||
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 }]
|
||||
height: { type: String, default: '440px' },
|
||||
})
|
||||
const emit = defineEmits(['metrics', 'stop-click'])
|
||||
const emit = defineEmits(['metrics', 'stop-click', 'pin-click'])
|
||||
|
||||
let el = null; let map = null; let ro = null; let ready = false; let drawSeq = 0
|
||||
let stopMk = []; let homeMk = []; let liveMk = [] // marqueurs HTML (arrêts + domiciles + positions live)
|
||||
let stopMk = []; let homeMk = []; let liveMk = []; let pinMk = [] // marqueurs HTML (arrêts + domiciles + positions live + jobs non assignés)
|
||||
const _rmCache = RouteMapCache() // cache module (partagé entre instances) : signature → { geometry, km, mins }
|
||||
function RouteMapCache () { const g = globalThis; if (!g.__opsRouteMapCache) g.__opsRouteMapCache = new Map(); return g.__opsRouteMapCache }
|
||||
function setEl (node) { el = node }
|
||||
|
|
@ -73,6 +74,7 @@ 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é) ──
|
||||
function initials (n) { return String(n || '?').trim().split(/\s+/).map(w => w[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() }
|
||||
function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] }
|
||||
function clearPins () { for (const m of pinMk) m.mk.remove(); pinMk = [] }
|
||||
function renderLive (list) {
|
||||
if (!map || !ready) return
|
||||
clearLive()
|
||||
|
|
@ -92,6 +94,22 @@ function renderLive (list) {
|
|||
liveMk.push({ mk, el: wrap })
|
||||
}
|
||||
}
|
||||
// Jobs NON assignés du jour : gouttes oranges (à situer/répartir) ; clic → la page ouvre le détail.
|
||||
function renderPins (list) {
|
||||
if (!map || !ready) return
|
||||
clearPins()
|
||||
const mapboxgl = window.mapboxgl
|
||||
for (const p of (list || [])) {
|
||||
if (!okLL(p.lon, p.lat)) continue
|
||||
const color = p.color || '#f97316'
|
||||
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é'
|
||||
wrap.addEventListener('click', () => emit('pin-click', p))
|
||||
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'bottom' }).setLngLat([+p.lon, +p.lat]).addTo(map)
|
||||
pinMk.push({ mk, el: wrap })
|
||||
}
|
||||
}
|
||||
function renderMarkers (routes) {
|
||||
clearMarkers()
|
||||
const mapboxgl = window.mapboxgl
|
||||
|
|
@ -174,8 +192,9 @@ async function draw () {
|
|||
ls.setData({ type: 'FeatureCollection', features: lineFeatures(routes) })
|
||||
renderMarkers(routes)
|
||||
renderLive(props.live)
|
||||
const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|')
|
||||
if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} }
|
||||
renderPins(props.pins)
|
||||
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.
|
||||
const results = await Promise.all(routes.map(async r => ({ r, info: await realLine(r) })))
|
||||
if (seq !== drawSeq || !map || !map.getSource('rm-line')) return
|
||||
|
|
@ -224,9 +243,10 @@ onMounted(async () => {
|
|||
draw()
|
||||
})
|
||||
})
|
||||
onBeforeUnmount(() => { clearMarkers(); clearLive(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false })
|
||||
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.pins, () => { renderPins(props.pins) }, { deep: true }) // jobs non assignés (secteur) sans redessiner les tournées
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -261,4 +281,8 @@ watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // raf
|
|||
@keyframes rm-live-pulse { 0% { transform: translate(-50%,-50%) scale(1); opacity: .45; } 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } }
|
||||
.rm-live.stale .rm-live-pulse { display: none; }
|
||||
.rm-live.stale .rm-live-dot { opacity: .55; filter: grayscale(.5); }
|
||||
/* Jobs NON assignés (goutte, couleur = priorité) — à situer/répartir */
|
||||
.rm-pin { position: relative; width: 0; height: 0; z-index: 7; cursor: pointer; pointer-events: auto; }
|
||||
.rm-pin-drop { position: absolute; left: -9px; bottom: 0; width: 18px; height: 18px; border-radius: 50% 50% 50% 0; transform: rotate(-45deg); border: 2px solid #fff; box-shadow: 0 2px 5px rgba(0, 0, 0, .4); }
|
||||
.rm-pin:hover .rm-pin-drop { filter: brightness(1.12); }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -495,18 +495,26 @@
|
|||
<q-tooltip>{{ hiddenRouteTechs.has(r.id) ? 'Cliquer pour RÉAFFICHER cette tournée' : 'Cliquer pour retirer cette tournée de la carte' }}</q-tooltip>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Jobs DUS ce jour, NON assignés (mis en évidence) — clic = détail · « Suggérer » = dispatch auto du jour -->
|
||||
<!-- 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">
|
||||
<span class="rt-ua-lbl"><q-icon name="inbox" size="15px" color="deep-orange-7" /> <b>{{ routeDayUnassigned.length }}</b> à répartir :</span>
|
||||
<span v-for="j in routeDayUnassigned.slice(0, 14)" :key="j.name" class="rt-ua-chip" :style="{ borderLeftColor: prioColor(j.priority) }" @click="openJobDetail(j)">
|
||||
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="12px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" />{{ j.subject || j.service_type || j.name }}<template v-if="jobCity(j)"> · {{ jobCity(j) }}</template>
|
||||
<q-tooltip class="bg-grey-9">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template></q-tooltip>
|
||||
</span>
|
||||
<span v-if="routeDayUnassigned.length > 14" class="text-caption text-grey-6 q-ml-xs">+{{ routeDayUnassigned.length - 14 }}</span>
|
||||
<q-space />
|
||||
<q-btn dense unelevated no-caps size="sm" color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip>Répartir automatiquement les jobs de ce jour</q-tooltip></q-btn>
|
||||
<div class="rt-ua-top">
|
||||
<span class="rt-ua-lbl"><q-icon name="inbox" size="15px" color="deep-orange-7" /> <b>{{ routeDayUnassignedView.length }}</b> à répartir<span v-if="routeSectorSel.size" class="text-grey-6"> / {{ routeDayUnassigned.length }}</span> :</span>
|
||||
<template v-if="routeSectors.length > 1">
|
||||
<button v-for="s in routeSectors" :key="s.sector" type="button" class="rt-sect" :class="{ on: routeSectorSel.has(s.sector) }" @click="toggleRouteSector(s.sector)"><q-icon name="place" size="11px" />{{ s.sector }}<span class="rt-sect-n">{{ s.n }}</span></button>
|
||||
<button v-if="routeSectorSel.size" type="button" class="rt-sect rt-sect-x" @click="routeSectorSel = new Set()">✕ tout</button>
|
||||
</template>
|
||||
<q-space />
|
||||
<q-btn dense unelevated no-caps size="sm" color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip>Répartir automatiquement les jobs {{ routeSectorSel.size ? 'du secteur' : 'de ce jour' }}</q-tooltip></q-btn>
|
||||
</div>
|
||||
<div class="rt-ua-jobs">
|
||||
<span v-for="j in routeDayUnassignedView.slice(0, 16)" :key="j.name" class="rt-ua-chip" :style="{ borderLeftColor: prioColor(j.priority) }" @click="openJobDetail(j)">
|
||||
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="12px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" />{{ j.subject || j.service_type || j.name }}<template v-if="jobCity(j)"> · {{ jobCity(j) }}</template>
|
||||
<q-tooltip class="bg-grey-9">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template></q-tooltip>
|
||||
</span>
|
||||
<span v-if="routeDayUnassignedView.length > 16" class="text-caption text-grey-6 q-ml-xs">+{{ routeDayUnassignedView.length - 16 }} de plus</span>
|
||||
</div>
|
||||
</div>
|
||||
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" />
|
||||
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" :pins="routeUnassignedPins" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" @pin-click="p => openJobDetail(p.job)" />
|
||||
<div class="suggest-legend q-mt-xs">
|
||||
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
|
||||
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }}
|
||||
|
|
@ -4710,6 +4718,16 @@ const routeStripDays = computed(() => {
|
|||
})
|
||||
// Jobs DUS le jour sélectionné (Tournées) mais NON assignés — mis en évidence + « Suggérer ». Respecte le filtre de compétence (unassignedByDay).
|
||||
const routeDayUnassigned = computed(() => unassignedByDay.value[routesDay.value] || [])
|
||||
// Chips SECTEUR (par ville) des non-assignés du jour → filtre la liste ET la carte. Reset au changement de jour.
|
||||
const routeSectorSel = ref(new Set())
|
||||
watch(routesDay, () => { routeSectorSel.value = new Set() })
|
||||
const routeSectors = computed(() => { const m = {}; for (const j of routeDayUnassigned.value) { const c = jobCity(j) || 'Sans secteur'; m[c] = (m[c] || 0) + 1 } return Object.entries(m).map(([sector, n]) => ({ sector, n })).sort((a, b) => b.n - a.n) })
|
||||
function toggleRouteSector (s) { const set = new Set(routeSectorSel.value); if (set.has(s)) set.delete(s); else set.add(s); routeSectorSel.value = set }
|
||||
const routeDayUnassignedView = computed(() => { const sel = routeSectorSel.value; return sel.size ? routeDayUnassigned.value.filter(j => sel.has(jobCity(j) || 'Sans secteur')) : routeDayUnassigned.value })
|
||||
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), job: j })))
|
||||
|
||||
// ── Deep-link (?day=YYYY-MM-DD & ?skill=…) + action « planifier des quarts » du slot-finder ──
|
||||
const route = useRoute()
|
||||
|
|
@ -5655,9 +5673,15 @@ onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return
|
|||
.rt-chip.off { opacity: 0.55; border-style: dashed; }
|
||||
.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 cliquables + Suggérer */
|
||||
.rt-unassigned { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; margin-bottom: 6px; padding: 6px 8px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
|
||||
/* Bande « jobs dus non assignés » (vue Tournées) : chips secteur (filtrent liste+carte) + chips job + Suggérer */
|
||||
.rt-unassigned { margin-bottom: 6px; padding: 6px 8px; background: #fff7ed; border: 1px solid #fed7aa; border-radius: 8px; }
|
||||
.rt-ua-top { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
|
||||
.rt-ua-jobs { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; margin-top: 5px; max-height: 70px; overflow-y: auto; }
|
||||
.rt-ua-lbl { font-size: 12px; color: #9a3412; display: inline-flex; align-items: center; gap: 3px; }
|
||||
.rt-sect { display: inline-flex; align-items: center; gap: 2px; padding: 2px 8px; border: 1px solid #cbd5e1; border-radius: 12px; background: #fff; font-size: 11.5px; font-weight: 600; color: #475569; cursor: pointer; user-select: none; }
|
||||
.rt-sect.on { background: #6366f1; border-color: #6366f1; color: #fff; }
|
||||
.rt-sect-n { font-weight: 800; margin-left: 2px; opacity: .85; }
|
||||
.rt-sect-x { border-style: dashed; color: #94a3b8; }
|
||||
.rt-ua-chip { display: inline-flex; align-items: center; gap: 3px; max-width: 230px; padding: 2px 8px 2px 6px; border: 1px solid #e2e8f0; border-left: 3px solid #9e9e9e; border-radius: 6px; background: #fff; font-size: 11.5px; color: #334155; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rt-ua-chip:hover { border-color: #6366f1; }
|
||||
.jt-chip { display: inline-flex; align-items: center; font-size: 9px; font-weight: 700; padding: 1px 4px; margin-right: 2px; border-radius: 6px; border: 1px solid #cbd5e1; color: #64748b; background: #fff; cursor: pointer; user-select: none; line-height: 1.4; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user