feat(ops): job detail — map collapsed, duration adjust; popup width caps

Retours terrain sur les modales de détail job (carte Tournées) :
- Volet détail 1 job : la CARTE (jd-map + tracé GPS) n'est pas requise pour
  lire le ticket → repliée par défaut derrière « Voir la carte / tracé
  GPS » (init à la demande, pas au chargement). Détails simples d'abord +
  champ « Durée estimée » ajustable (comme « Suggérer ») qui persiste
  duration_h (+ override de session). Titre déjà complet.
- Popups « liste des jobs à cette adresse » débordaient hors écran sans
  scroll : maxWidth 260 + overflow-wrap + max-height 44vh scroll (carte
  jour du panneau À assigner ET popup groupée RouteMap).

Vérifié live : volet 1 job → carte masquée par défaut, bouton déplie +
init canvas OK, champ durée = 2.25h ajustable ; 0 nouvelle erreur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-05 18:06:02 -04:00
parent 3979f79283
commit 98184d0a61
2 changed files with 33 additions and 8 deletions

View File

@ -294,7 +294,7 @@ watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // job
.rm-mk .rm-pill .rm-num { font-size: 12px; font-weight: 800; line-height: 1; }
.rm-mk .rm-pill .rm-time { font-size: 10.5px; font-weight: 700; line-height: 1; padding-left: 2px; white-space: nowrap; }
/* Popup « jobs à cette adresse » (tap mobile) — bloc <style> non-scopé, s'applique au popup mapbox global */
.rm-pop { font-size: 12px; min-width: 170px; }
.rm-pop { font-size: 12px; min-width: 170px; max-width: 240px; max-height: 44vh; overflow-y: auto; }
.rm-pop-hd { font-weight: 700; color: #1e293b; margin-bottom: 4px; }
.rm-pop-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 4px 2px; border-top: 1px solid #eef1f5; cursor: pointer; }
.rm-pop-row:hover { background: #eef2ff; }

View File

@ -1369,14 +1369,27 @@
</q-card-section>
<q-separator />
<q-card-section class="jd-body scroll">
<div ref="jdMapEl" class="jd-map"></div>
<div v-if="kbCanTrack" class="jd-track-row"><q-toggle v-model="jdShowTrack" dense size="sm" color="warning" /><span class="text-caption text-weight-medium">Tracé GPS réel (Traccar)</span><span class="text-caption text-grey-6">{{ jdTrackMsg }}</span></div>
<div v-else class="text-caption text-grey-5 q-mb-sm">Tracé GPS réel : aujourd'hui / hier seulement (Traccar).</div>
<!-- Détails SIMPLES d'abord (carte/tracé GPS repliés : non requis pour lire le ticket) -->
<div class="jd-meta">
<div v-if="jobDetail.time"><q-icon name="schedule" size="16px" /> {{ jobDetail.time }}</div>
<div v-if="jobDetail.skill"><q-icon name="construction" size="16px" /> {{ jobDetail.skill }}</div>
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
</div>
<!-- Ajuster la durée estimée (comme « Suggérer ») persiste duration_h -->
<div class="jd-dur row items-center q-gutter-sm q-my-sm">
<q-icon name="timer" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Durée estimée</span>
<q-input dense outlined type="number" step="0.25" min="0" v-model.number="jobDetail.durH" style="width:96px" suffix="h" @change="jdSetDuration(jobDetail.durH)" @keyup.enter="jdSetDuration(jobDetail.durH)" />
<span class="text-caption text-grey-5"> {{ fmtMin(Math.round((jobDetail.durH || 0) * 60)) }}</span>
</div>
<!-- Carte / tracé GPS : repliés par défaut -->
<div class="q-mb-sm">
<q-btn flat dense no-caps size="sm" color="grey-7" :icon="jdShowMap ? 'expand_less' : 'map'" :label="jdShowMap ? 'Masquer la carte' : 'Voir la carte / tracé GPS'" @click="jdShowMap = !jdShowMap" />
</div>
<div v-show="jdShowMap">
<div ref="jdMapEl" class="jd-map"></div>
<div v-if="kbCanTrack" class="jd-track-row"><q-toggle v-model="jdShowTrack" dense size="sm" color="warning" /><span class="text-caption text-weight-medium">Tracé GPS réel (Traccar)</span><span class="text-caption text-grey-6">{{ jdTrackMsg }}</span></div>
<div v-else class="text-caption text-grey-5 q-mb-sm">Tracé GPS réel : aujourd'hui / hier seulement (Traccar).</div>
</div>
<!-- Suivi terrain (géofencing GPS) : étapes façon suivi de colis En route Arrivé Reparti, avec l'heure atteinte. -->
<div v-if="jobDetail.geofence && jobDetail.geofence.state" class="jd-geo">
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="my_location" size="18px" class="q-mr-xs" color="teal-7" /> Suivi terrain (GPS)</div>
@ -2185,8 +2198,9 @@ const _deEntEl = typeof document !== 'undefined' ? document.createElement('texta
function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value }
async function openJobDetail (b, t) {
if (!b) return
jdShowTrack.value = false
Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || ''), address: deEnt(b.address || ''), skill: b.skill || '', time: (b.start || (b.s != null ? fmtH(b.s) : '')) + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : ''), detail: deEnt(b.detail || ''), lid: b.legacy_id || null, dept: b.dept || '', techId: (t && t.id) || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : null, lon: b.lon != null ? +b.lon : null, loading: !!b.legacy_id, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null })
jdShowTrack.value = false; jdShowMap.value = false
const durH0 = b.dur != null ? +b.dur : (b.est_min ? +b.est_min / 60 : (b.duration_h != null ? +b.duration_h : 1))
Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || ''), address: deEnt(b.address || ''), skill: b.skill || '', time: (b.start || (b.s != null ? fmtH(b.s) : '')) + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : ''), durH: Math.round((durH0 || 1) * 100) / 100, detail: deEnt(b.detail || ''), lid: b.legacy_id || null, dept: b.dept || '', techId: (t && t.id) || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : null, lon: b.lon != null ? +b.lon : null, loading: !!b.legacy_id, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null })
// Géofencing (suivi façon colis) : timeline En route Arrivé Reparti, non bloquant.
jobDetail.geofence = null
if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {})
@ -2292,7 +2306,16 @@ async function applyAbs (remove) {
// Mini-carte du volet détails : zoome sur le job + layer « tracé GPS réel » (Traccar) en option
const jdMapEl = ref(null); let _jdMap = null
const jdShowMap = ref(false) // carte masquée par défaut dans le volet détail (pas requise) dépliable au besoin
const jdShowTrack = ref(false); const jdTrackMsg = ref('')
// Ajuste la durée du job depuis le volet détail (comme « Suggérer ») persiste duration_h + override de session.
async function jdSetDuration (h) {
h = Math.round((Number(h) || 0) * 100) / 100
if (h <= 0 || !jobDetail.name) return
durOverride[jobDetail.name] = h
const pj = (assignPanel.jobs || []).find(x => x.name === jobDetail.name)
await patchJob(pj || { name: jobDetail.name }, { duration_h: h }, 'Durée révisée : ' + h + ' h — Ré-optimiser pour recalculer')
}
// Toggle Traccar disponible UNIQUEMENT aujourd'hui ou hier (ET) sinon Traccar trop lourd/lent.
const kbCanTrack = computed(() => {
const iso = kanbanDay.value && kanbanDay.value.iso; const n = nowET.value.iso; if (!iso || !n) return false
@ -2355,7 +2378,9 @@ async function loadJdTrack () {
} catch (e) { jdTrackMsg.value = 'Tracé indisponible' }
}
watch(jdShowTrack, (on) => { if (!_jdMap) return; if (on) loadJdTrack(); else { const s = _jdMap.getSource('jd-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); jdTrackMsg.value = '' } })
watch(() => jobDetail.open, (open) => { if (open) nextTick(() => setTimeout(initJdMap, 280)); else if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } })
// Carte NON initialisée à l'ouverture (masquée par défaut) ; init seulement quand on déplie « Voir la carte ».
watch(() => jobDetail.open, (open) => { if (!open) { jdShowMap.value = false; if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } } })
watch(jdShowMap, (on) => { if (on && jobDetail.open) nextTick(() => setTimeout(initJdMap, 200)); else if (!on && _jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } })
const tplOptions = computed(() => templates.value.map(t => ({ label: t.template_name, value: t.name })))
const techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id })))
@ -3587,7 +3612,7 @@ function refreshDayMap () {
const pinHtml = (color, icon, num, items, round) => `${seg(items || [])}<div style="display:flex;align-items:center;gap:1px;color:#fff;background:${color};border:2px solid #fff;border-radius:${round ? '50%' : '11px'};padding:${round ? '3px' : '1px 5px 1px 3px'};box-shadow:0 1px 4px rgba(0,0,0,.45)">${iconMarkup(icon)}${num ? `<span style="font-size:10px;font-weight:800;line-height:1;padding:0 1px">${num}</span>` : ''}</div><div style="width:0;height:0;margin:-1px auto 0;border-left:4px solid transparent;border-right:4px solid transparent;border-top:5px solid ${color}"></div>`
const addPin = (lon, lat, html, popup, addr) => {
const el = document.createElement('div'); el.style.cssText = 'display:flex;flex-direction:column;align-items:center;cursor:pointer;line-height:0'; el.innerHTML = html
el.addEventListener('click', (ev) => { ev.stopPropagation(); new window.mapboxgl.Popup({ offset: 16 }).setLngLat([lon, lat]).setHTML(`<div style="font-size:12px;line-height:1.5">${popup}${addr ? '<br><span style="color:#888">📍 ' + _esc(addr) + '</span>' : ''}</div>`).addTo(_dayMap) })
el.addEventListener('click', (ev) => { ev.stopPropagation(); new window.mapboxgl.Popup({ offset: 16, maxWidth: '260px' }).setLngLat([lon, lat]).setHTML(`<div style="font-size:12px;line-height:1.5;max-width:236px;overflow-wrap:anywhere;max-height:44vh;overflow-y:auto">${popup}${addr ? '<br><span style="color:#888">📍 ' + _esc(addr) + '</span>' : ''}</div>`).addTo(_dayMap) })
_dayStopMarkers.push(new window.mapboxgl.Marker({ element: el, anchor: 'bottom' }).setLngLat([lon, lat]).addTo(_dayMap))
}
for (const k of gord) {