f8793ab359
- API visitable_tiers (provider DBAL bbox/q/type, paginé) pour les pins de la carte
- POST /tours/{id}/reorder (drag & drop) : renumérotation atomique + recompute
- Layer front field-sales : TourMap (pins, popup, polyline, sélection rectangle),
liste d'étapes draggable (vuedraggable), composable de planification + Vitest
- Pages /tours, /tours/new, /tours/[id]/plan (split responsive, point custom géocodé)
- i18n FR, deep links Waze/Google/Apple, état 100% local
354 lines
11 KiB
Vue
354 lines
11 KiB
Vue
<template>
|
|
<!-- Carte Leaflet (exception documentee a @malio/layer-ui : carte interactive,
|
|
type non couvert par la lib — cf. frontend.md § Composants formulaires).
|
|
TODO : migrer si la lib couvre un jour les cartes. -->
|
|
<div class="relative h-full w-full">
|
|
<div ref="mapEl" class="h-full w-full" data-testid="tour-map" />
|
|
<!-- Aide a la selection rectangle (lasso facon Badger Maps). -->
|
|
<div class="pointer-events-none absolute bottom-2 left-2 z-[400] rounded bg-white/90 px-2 py-1 text-xs text-gray-600 shadow">
|
|
{{ t('field_sales.plan.map.lassoHint') }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Map as LeafletMap, Marker, Polyline, Rectangle } from 'leaflet'
|
|
import type { PlanningStop } from '~/modules/field-sales/composables/useTourPlanning'
|
|
import type { VisitableTier } from '~/modules/field-sales/types/tour'
|
|
|
|
/**
|
|
* Carte interactive de planification de tournee (M6.5, spec § 6.1).
|
|
*
|
|
* - Charge les pins des Tiers geolocalises de la zone visible
|
|
* (GET /api/visitable_tiers?bbox=...), colores par type (client/fournisseur),
|
|
* filtrables (types + recherche). Recharge au deplacement/zoom (debounce).
|
|
* - Popup au clic : nom, adresse, bouton « + Ajouter » (emet `add-tier`).
|
|
* - Selection rectangle (Maj + glisser) : ajoute tous les Tiers entoures
|
|
* (emet `add-tiers`).
|
|
* - Trace la tournee par-dessus : polyline + marqueurs numerotes suivant l'ordre
|
|
* des etapes geolocalisees.
|
|
*
|
|
* Instances Leaflet hors reactivite Vue (un proxy casse l'API Leaflet).
|
|
*/
|
|
const props = withDefaults(defineProps<{
|
|
/** Etapes geolocalisees a tracer (polyline numerotee). */
|
|
stops: PlanningStop[]
|
|
/** Types de pins affiches. */
|
|
types: Array<'client' | 'supplier'>
|
|
/** Recherche raison sociale / ville. */
|
|
search: string
|
|
/** Centre initial (defaut : Nantes). */
|
|
center?: [number, number]
|
|
}>(), {
|
|
center: () => [47.218, -1.553],
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
/** Ajout d'un seul Tiers (popup « + Ajouter »). */
|
|
'add-tier': [tier: VisitableTier]
|
|
/** Ajout d'un lot de Tiers (selection rectangle). */
|
|
'add-tiers': [tiers: VisitableTier[]]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const api = useApi()
|
|
|
|
const mapEl = ref<HTMLElement | null>(null)
|
|
|
|
// Instances Leaflet (hors reactivite).
|
|
let L: typeof import('leaflet') | null = null
|
|
let map: LeafletMap | null = null
|
|
let pinLayer: Marker[] = []
|
|
let pinTiers: Array<{ tier: VisitableTier, marker: Marker }> = []
|
|
let routeLine: Polyline | null = null
|
|
let stopMarkers: Marker[] = []
|
|
let selectionRect: Rectangle | null = null
|
|
|
|
/** Zoom initial (niveau agglomeration). */
|
|
const INITIAL_ZOOM = 12
|
|
|
|
/** Couleur du pin par type de Tiers. */
|
|
const PIN_COLORS: Record<string, string> = {
|
|
client: '#2563eb', // bleu
|
|
supplier: '#16a34a', // vert
|
|
}
|
|
|
|
/** Debounce du rechargement des pins au deplacement de la carte. */
|
|
let fetchTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
async function ensureMap(): Promise<void> {
|
|
if (map !== null || mapEl.value === null) {
|
|
return
|
|
}
|
|
|
|
const mod = await import('leaflet')
|
|
L = mod.default ?? mod
|
|
await import('leaflet/dist/leaflet.css')
|
|
|
|
if (mapEl.value === null) {
|
|
return
|
|
}
|
|
|
|
map = L.map(mapEl.value).setView(props.center, INITIAL_ZOOM)
|
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
maxZoom: 19,
|
|
}).addTo(map)
|
|
|
|
// Selection rectangle a la place du box-zoom natif (Maj + glisser).
|
|
map.boxZoom.disable()
|
|
map.on('mousedown', onMouseDown)
|
|
|
|
// Rechargement des pins quand la zone visible change.
|
|
map.on('moveend', scheduleFetch)
|
|
|
|
drawRoute()
|
|
await fetchPins()
|
|
}
|
|
|
|
/** bbox de la zone visible au format Leaflet (minLng,minLat,maxLng,maxLat). */
|
|
function currentBbox(): string | null {
|
|
if (map === null) {
|
|
return null
|
|
}
|
|
|
|
return map.getBounds().toBBoxString()
|
|
}
|
|
|
|
function scheduleFetch(): void {
|
|
if (fetchTimer !== null) {
|
|
clearTimeout(fetchTimer)
|
|
}
|
|
fetchTimer = setTimeout(() => {
|
|
void fetchPins()
|
|
}, 300)
|
|
}
|
|
|
|
/**
|
|
* Charge les pins de la zone visible. `?pagination=false` : la carte affiche
|
|
* TOUS les pins de la bbox (le volume est borne par la zone, pas par la page).
|
|
*/
|
|
async function fetchPins(): Promise<void> {
|
|
if (map === null || L === null) {
|
|
return
|
|
}
|
|
if (props.types.length === 0) {
|
|
clearPins()
|
|
return
|
|
}
|
|
|
|
const bbox = currentBbox()
|
|
if (bbox === null) {
|
|
return
|
|
}
|
|
|
|
const query: Record<string, string> = {
|
|
bbox,
|
|
type: props.types.join(','),
|
|
pagination: 'false',
|
|
}
|
|
if (props.search.trim() !== '') {
|
|
query.q = props.search.trim()
|
|
}
|
|
|
|
try {
|
|
const response = await api.get<{ member?: VisitableTier[] }>(
|
|
'/visitable_tiers',
|
|
query,
|
|
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
|
)
|
|
renderPins(response.member ?? [])
|
|
}
|
|
catch {
|
|
// Echec non bloquant : la carte reste utilisable, les pins ne se mettent
|
|
// simplement pas a jour.
|
|
}
|
|
}
|
|
|
|
function clearPins(): void {
|
|
pinLayer.forEach(m => m.remove())
|
|
pinLayer = []
|
|
pinTiers = []
|
|
}
|
|
|
|
function renderPins(tiers: VisitableTier[]): void {
|
|
if (map === null || L === null) {
|
|
return
|
|
}
|
|
clearPins()
|
|
|
|
for (const tier of tiers) {
|
|
const marker = L.marker([tier.latitude, tier.longitude], {
|
|
icon: pinIcon(PIN_COLORS[tier.tierType] ?? '#6b7280'),
|
|
}).addTo(map)
|
|
|
|
marker.bindPopup(popupHtml(tier))
|
|
marker.on('popupopen', () => bindPopupButton(tier))
|
|
|
|
pinLayer.push(marker)
|
|
pinTiers.push({ tier, marker })
|
|
}
|
|
}
|
|
|
|
/** divIcon SVG inline colore (evite les assets PNG Leaflet casses par Vite). */
|
|
function pinIcon(color: string) {
|
|
return L!.divIcon({
|
|
className: '',
|
|
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="26" height="38" fill="${color}" stroke="#ffffff" stroke-width="1"><path d="M12 0C7 0 3 4 3 9c0 6.6 9 15 9 15s9-8.4 9-15c0-5-4-9-9-9zm0 12.5A3.5 3.5 0 1 1 12 5.5a3.5 3.5 0 0 1 0 7z"/></svg>`,
|
|
iconSize: [26, 38],
|
|
iconAnchor: [13, 38],
|
|
popupAnchor: [0, -34],
|
|
})
|
|
}
|
|
|
|
/** Contenu HTML du popup (le bouton est cable a l'ouverture, cf. bindPopupButton). */
|
|
function popupHtml(tier: VisitableTier): string {
|
|
const name = escapeHtml(tier.displayName)
|
|
const address = escapeHtml(tier.address)
|
|
|
|
return `<div class="text-sm">
|
|
<div class="font-semibold">${name}</div>
|
|
<div class="text-gray-600">${address}</div>
|
|
<button type="button" data-add-tier class="mt-2 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white">${t('field_sales.plan.map.add')}</button>
|
|
</div>`
|
|
}
|
|
|
|
/** Cable le bouton « + Ajouter » du popup ouvert vers l'emit `add-tier`. */
|
|
function bindPopupButton(tier: VisitableTier): void {
|
|
const el = map?.getContainer().querySelector('[data-add-tier]')
|
|
el?.addEventListener('click', () => {
|
|
emit('add-tier', tier)
|
|
map?.closePopup()
|
|
}, { once: true })
|
|
}
|
|
|
|
// ── Selection rectangle (lasso) ─────────────────────────────────────────────
|
|
let selectStart: import('leaflet').LatLng | null = null
|
|
|
|
function onMouseDown(e: import('leaflet').LeafletMouseEvent): void {
|
|
if (map === null || L === null || !e.originalEvent.shiftKey) {
|
|
return
|
|
}
|
|
// Empeche le drag de la carte pendant la selection.
|
|
map.dragging.disable()
|
|
selectStart = e.latlng
|
|
selectionRect = L.rectangle(L.latLngBounds(e.latlng, e.latlng), {
|
|
color: '#2563eb',
|
|
weight: 1,
|
|
fillOpacity: 0.1,
|
|
}).addTo(map)
|
|
|
|
map.on('mousemove', onMouseMove)
|
|
map.on('mouseup', onMouseUp)
|
|
}
|
|
|
|
function onMouseMove(e: import('leaflet').LeafletMouseEvent): void {
|
|
if (selectStart === null || selectionRect === null || L === null) {
|
|
return
|
|
}
|
|
selectionRect.setBounds(L.latLngBounds(selectStart, e.latlng))
|
|
}
|
|
|
|
function onMouseUp(): void {
|
|
if (map === null) {
|
|
return
|
|
}
|
|
const bounds = selectionRect?.getBounds() ?? null
|
|
cleanupSelection()
|
|
|
|
if (bounds === null) {
|
|
return
|
|
}
|
|
const selected = pinTiers
|
|
.filter(({ marker }) => bounds.contains(marker.getLatLng()))
|
|
.map(({ tier }) => tier)
|
|
|
|
if (selected.length > 0) {
|
|
emit('add-tiers', selected)
|
|
}
|
|
}
|
|
|
|
function cleanupSelection(): void {
|
|
selectionRect?.remove()
|
|
selectionRect = null
|
|
selectStart = null
|
|
map?.off('mousemove', onMouseMove)
|
|
map?.off('mouseup', onMouseUp)
|
|
map?.dragging.enable()
|
|
}
|
|
|
|
// ── Trace de la tournee ──────────────────────────────────────────────────────
|
|
function drawRoute(): void {
|
|
if (map === null || L === null) {
|
|
return
|
|
}
|
|
routeLine?.remove()
|
|
routeLine = null
|
|
stopMarkers.forEach(m => m.remove())
|
|
stopMarkers = []
|
|
|
|
const located = props.stops.filter(s => s.latitude != null && s.longitude != null)
|
|
const points = located.map(s => [s.latitude as number, s.longitude as number] as [number, number])
|
|
|
|
if (points.length >= 2) {
|
|
routeLine = L.polyline(points, { color: '#1e40af', weight: 3, opacity: 0.7 }).addTo(map)
|
|
}
|
|
|
|
located.forEach((stop, index) => {
|
|
const marker = L!.marker([stop.latitude as number, stop.longitude as number], {
|
|
icon: numberedIcon(index + 1),
|
|
zIndexOffset: 1000,
|
|
}).addTo(map!)
|
|
marker.bindTooltip(stop.label, { direction: 'top' })
|
|
stopMarkers.push(marker)
|
|
})
|
|
}
|
|
|
|
/** Pastille numerotee pour une etape de la tournee. */
|
|
function numberedIcon(n: number) {
|
|
return L!.divIcon({
|
|
className: '',
|
|
html: `<div style="display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:9999px;background:#1e40af;color:#fff;font-size:12px;font-weight:700;border:2px solid #fff;box-shadow:0 1px 2px rgba(0,0,0,.4)">${n}</div>`,
|
|
iconSize: [24, 24],
|
|
iconAnchor: [12, 12],
|
|
})
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
// Recharge les pins quand les filtres changent.
|
|
watch(() => [props.types, props.search], scheduleFetch, { deep: true })
|
|
|
|
// Redessine le trace quand les etapes changent.
|
|
watch(() => props.stops, drawRoute, { deep: true })
|
|
|
|
onMounted(ensureMap)
|
|
|
|
onBeforeUnmount(() => {
|
|
if (fetchTimer !== null) {
|
|
clearTimeout(fetchTimer)
|
|
}
|
|
map?.remove()
|
|
map = null
|
|
L = null
|
|
pinLayer = []
|
|
pinTiers = []
|
|
stopMarkers = []
|
|
routeLine = null
|
|
selectionRect = null
|
|
})
|
|
|
|
defineExpose({
|
|
/** Recentre la carte sur une cible (ex: depuis le panneau). */
|
|
panTo(target: { latitude: number, longitude: number }) {
|
|
map?.panTo([target.latitude, target.longitude])
|
|
},
|
|
})
|
|
</script>
|