d16c7e5541
Onglet « Carte » sur les fiches Client et Fournisseur (visible sous field_sales.tours.view et module actif) : tous les points géolocalisés du Tiers sur une carte Leaflet, pin ajustable (PATCH lat/lng + geoManual), adresses non géolocalisées listées à part. Composant réutilisable TierAddressMap. Écran de planification : point de départ choisi parmi les sites de l'utilisateur (MalioSelect) ou en adresse libre (autocomplete BAN), marqueur « maison » sur la carte et trace partant du départ. Le mode n'est dérivé du back qu'au premier chargement (les sauvegardes ne réécrasent plus le choix). Suppression de l'ajout d'étape « point libre » (bouton + modale) ; l'affichage des étapes custom existantes est conservé.
442 lines
15 KiB
Vue
442 lines
15 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]
|
|
/** Point de depart de la tournee (marqueur « maison »), si geolocalise. */
|
|
start?: { latitude: number, longitude: number, label?: string } | null
|
|
}>(), {
|
|
center: () => [47.218, -1.553],
|
|
start: null,
|
|
})
|
|
|
|
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 startMarker: Marker | null = null
|
|
let selectionRect: Rectangle | null = null
|
|
// Signature du dernier cadrage automatique (ensemble des points geolocalises).
|
|
// Evite de re-cadrer la carte a chaque recompute (memes points, ETA mises a jour)
|
|
// ou reorder (memes points, ordre different) : on ne recadre qu'a l'ajout/retrait.
|
|
let lastFitSignature = ''
|
|
// Observe les changements de taille du conteneur (layout flex/responsive) pour
|
|
// reparer le rendu des tuiles (invalidateSize).
|
|
let resizeObserver: ResizeObserver | 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, {
|
|
// Conserve les tuiles hors-cadre un court instant : panning plus fluide.
|
|
preferCanvas: true,
|
|
}).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,
|
|
// Garde les tuiles deja chargees pendant le zoom (moins de gris/clignotement).
|
|
keepBuffer: 4,
|
|
}).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)
|
|
|
|
// Le conteneur est dans un layout flex (lg:flex-1) : sa taille n'est pas
|
|
// toujours stabilisee a la creation de la map → tuiles partielles/grises et
|
|
// panning saccade. On force un recalcul de taille apres le 1er rendu, puis a
|
|
// chaque resize du conteneur (passage responsive, ouverture panneau, etc.).
|
|
requestAnimationFrame(() => map?.invalidateSize())
|
|
resizeObserver = new ResizeObserver(() => map?.invalidateSize())
|
|
resizeObserver.observe(mapEl.value)
|
|
|
|
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 = []
|
|
startMarker?.remove()
|
|
startMarker = null
|
|
|
|
// Point de depart : marqueur « maison » distinctif, en tete du trace.
|
|
const start = props.start
|
|
if (start != null && start.latitude != null && start.longitude != null) {
|
|
startMarker = L.marker([start.latitude, start.longitude], {
|
|
icon: startIcon(),
|
|
zIndexOffset: 1100,
|
|
}).addTo(map)
|
|
startMarker.bindTooltip(start.label && start.label.trim() !== '' ? start.label : t('field_sales.plan.map.startPoint'), { direction: 'top' })
|
|
}
|
|
|
|
const located = props.stops.filter(s => s.latitude != null && s.longitude != null)
|
|
const stopPoints = located.map(s => [s.latitude as number, s.longitude as number] as [number, number])
|
|
|
|
// La polyline part du point de depart (si geolocalise) puis enchaine les etapes.
|
|
const linePoints: Array<[number, number]> = start != null && start.latitude != null && start.longitude != null
|
|
? [[start.latitude, start.longitude], ...stopPoints]
|
|
: stopPoints
|
|
|
|
if (linePoints.length >= 2) {
|
|
routeLine = L.polyline(linePoints, { 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)
|
|
})
|
|
|
|
fitToRoute(linePoints)
|
|
}
|
|
|
|
/**
|
|
* Cadre la carte sur l'ensemble des points de la tournee (depart + etapes).
|
|
* Ne recadre que si l'ensemble des points a change (ajout/retrait d'etape ou de
|
|
* depart) : un recompute (memes points) ou un reorder ne doit pas faire sauter la
|
|
* vue. Signature triee → independante de l'ordre des etapes.
|
|
*/
|
|
function fitToRoute(points: Array<[number, number]>): void {
|
|
if (map === null || L === null || points.length === 0) {
|
|
return
|
|
}
|
|
const signature = points
|
|
.map(([lat, lng]) => `${lat.toFixed(5)},${lng.toFixed(5)}`)
|
|
.sort()
|
|
.join('|')
|
|
if (signature === lastFitSignature) {
|
|
return
|
|
}
|
|
lastFitSignature = signature
|
|
|
|
if (points.length === 1) {
|
|
map.setView(points[0]!, Math.max(map.getZoom(), 13))
|
|
return
|
|
}
|
|
map.fitBounds(L.latLngBounds(points), { padding: [40, 40], maxZoom: 15 })
|
|
}
|
|
|
|
/** 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],
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Marqueur du point de depart : pastille « maison » ambre, visuellement distincte
|
|
* des pins de Tiers (goutte) et des etapes numerotees (rond bleu).
|
|
*/
|
|
function startIcon() {
|
|
return L!.divIcon({
|
|
className: '',
|
|
html: `<div style="display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:9999px;background:#f59e0b;border:2px solid #fff;box-shadow:0 1px 3px rgba(0,0,0,.5)">
|
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" fill="#ffffff"><path d="M12 3 2 12h3v8h6v-6h2v6h6v-8h3z"/></svg>
|
|
</div>`,
|
|
iconSize: [30, 30],
|
|
iconAnchor: [15, 15],
|
|
})
|
|
}
|
|
|
|
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 ou le point de depart changent.
|
|
watch(() => props.stops, drawRoute, { deep: true })
|
|
watch(() => props.start, drawRoute, { deep: true })
|
|
|
|
onMounted(ensureMap)
|
|
|
|
onBeforeUnmount(() => {
|
|
if (fetchTimer !== null) {
|
|
clearTimeout(fetchTimer)
|
|
}
|
|
resizeObserver?.disconnect()
|
|
resizeObserver = null
|
|
map?.remove()
|
|
map = null
|
|
L = null
|
|
pinLayer = []
|
|
pinTiers = []
|
|
stopMarkers = []
|
|
startMarker = null
|
|
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>
|