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é.
231 lines
8.3 KiB
Vue
231 lines
8.3 KiB
Vue
<template>
|
|
<div data-testid="tier-address-map">
|
|
<!-- Carte d'ensemble : un marqueur par adresse geolocalisee du Tiers,
|
|
cadree sur l'ensemble (fitBounds). Pin ajustable par drag (M6.6,
|
|
spec § 6.2) : au drag, PATCH direct des coordonnees + geoManual=true
|
|
(RG-6.08), sans passer par le formulaire d'edition. -->
|
|
<div
|
|
v-if="located.length > 0"
|
|
ref="mapEl"
|
|
class="h-96 w-full rounded border border-gray-200"
|
|
data-testid="tier-map"
|
|
/>
|
|
<p v-else class="rounded border border-dashed border-gray-300 bg-gray-50 py-8 text-center text-sm text-gray-500">
|
|
{{ t('commercial.geo.map.noLocated') }}
|
|
</p>
|
|
|
|
<p v-if="located.length > 0 && editable" class="mt-1 text-xs text-gray-500">
|
|
{{ t('commercial.geo.dragHint') }}
|
|
</p>
|
|
|
|
<!-- Adresses sans coordonnees : listees a part (« a geolocaliser »),
|
|
exclues de la carte et du calcul de tournee (RG-6.05). -->
|
|
<div v-if="missing.length > 0" class="mt-6" data-testid="tier-map-missing-list">
|
|
<h3 class="mb-2 flex items-center gap-2 text-sm font-medium text-gray-700">
|
|
{{ t('commercial.geo.map.missingTitle') }}
|
|
<span class="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">
|
|
{{ missing.length }}
|
|
</span>
|
|
</h3>
|
|
<ul class="flex flex-col gap-2">
|
|
<li
|
|
v-for="address in missing"
|
|
:key="address.id"
|
|
class="rounded border border-gray-200 bg-white px-3 py-2 text-sm"
|
|
data-testid="tier-map-missing"
|
|
>
|
|
<span class="font-medium text-gray-800">{{ address.title }}</span>
|
|
<span v-if="address.typeLabel" class="ml-2 text-xs text-gray-500">{{ address.typeLabel }}</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import type { Map as LeafletMap, Marker } from 'leaflet'
|
|
|
|
/**
|
|
* Adresse normalisee pour la carte d'ensemble d'un Tiers. La page parente
|
|
* (fiche Client / Fournisseur) construit la liste : elle resout le libelle, le
|
|
* type traduit et l'endpoint PATCH des coordonnees (les modeles d'adresse
|
|
* client/fournisseur different — drapeaux vs enum).
|
|
*/
|
|
export interface TierMapAddress {
|
|
/** Id serveur de l'adresse. */
|
|
id: number
|
|
/** Latitude WGS84 (chaine decimale) ou null si non geolocalisee. */
|
|
latitude: string | null
|
|
/** Longitude WGS84 (chaine decimale) ou null si non geolocalisee. */
|
|
longitude: string | null
|
|
/** RG-6.08 : pin deja corrige a la main. */
|
|
geoManual: boolean
|
|
/** Libelle principal (rue + code postal ville). */
|
|
title: string
|
|
/** Type d'adresse traduit (Prospect / Livraison / Depart...). */
|
|
typeLabel: string
|
|
/** Endpoint PATCH des coordonnees (ex: /client_addresses/12). */
|
|
patchPath: string
|
|
}
|
|
</script>
|
|
|
|
<script setup lang="ts">
|
|
const props = withDefaults(defineProps<{
|
|
/** Toutes les adresses du Tiers (geolocalisees ou non). */
|
|
addresses: TierMapAddress[]
|
|
/** Drag du pin actif (PATCH des coordonnees) — exige le droit d'edition. */
|
|
editable?: boolean
|
|
}>(), {
|
|
editable: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
/** Coordonnees d'une adresse mises a jour par drag (PATCH reussi). */
|
|
updated: [value: { id: number, latitude: string, longitude: string }]
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const api = useApi()
|
|
|
|
const mapEl = ref<HTMLElement | null>(null)
|
|
|
|
/** Vrai si l'adresse porte des coordonnees exploitables. */
|
|
function hasCoords(address: TierMapAddress): boolean {
|
|
return address.latitude !== null && address.latitude !== ''
|
|
&& address.longitude !== null && address.longitude !== ''
|
|
}
|
|
|
|
const located = computed(() => props.addresses.filter(hasCoords))
|
|
const missing = computed(() => props.addresses.filter(a => !hasCoords(a)))
|
|
|
|
// Instances Leaflet (hors reactivite Vue : un proxy casse l'API Leaflet).
|
|
let L: typeof import('leaflet') | null = null
|
|
let map: LeafletMap | null = null
|
|
let markers: Marker[] = []
|
|
|
|
/** Zoom max applique par fitBounds (evite un zoom excessif sur un seul pin). */
|
|
const MAX_FIT_ZOOM = 16
|
|
|
|
/** Monte la carte Leaflet (import dynamique : chargee seulement si besoin). */
|
|
async function ensureMap(): Promise<void> {
|
|
if (map !== null || mapEl.value === null || located.value.length === 0) {
|
|
return
|
|
}
|
|
|
|
const mod = await import('leaflet')
|
|
L = mod.default ?? mod
|
|
await import('leaflet/dist/leaflet.css')
|
|
|
|
// Le conteneur peut avoir disparu pendant le chargement async (v-if).
|
|
if (mapEl.value === null) {
|
|
return
|
|
}
|
|
|
|
map = L.map(mapEl.value, { scrollWheelZoom: false })
|
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
|
maxZoom: 19,
|
|
}).addTo(map)
|
|
|
|
renderMarkers()
|
|
}
|
|
|
|
/** Pin SVG inline (evite les assets PNG Leaflet casses par Vite). */
|
|
function pinIcon() {
|
|
return L!.divIcon({
|
|
className: '',
|
|
html: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="28" height="40" fill="#2563eb" stroke="#1e40af" stroke-width="0.5"><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: [28, 40],
|
|
iconAnchor: [14, 40],
|
|
popupAnchor: [0, -36],
|
|
})
|
|
}
|
|
|
|
/** Contenu HTML du popup : libelle + type de l'adresse. */
|
|
function popupHtml(address: TierMapAddress): string {
|
|
const title = escapeHtml(address.title)
|
|
const type = address.typeLabel ? `<div class="text-gray-600">${escapeHtml(address.typeLabel)}</div>` : ''
|
|
return `<div class="text-sm"><div class="font-semibold">${title}</div>${type}</div>`
|
|
}
|
|
|
|
/** (Re)pose un marqueur par adresse geolocalisee et cadre la carte dessus. */
|
|
function renderMarkers(): void {
|
|
if (map === null || L === null) {
|
|
return
|
|
}
|
|
|
|
markers.forEach(m => m.remove())
|
|
markers = []
|
|
|
|
const points: [number, number][] = []
|
|
for (const address of located.value) {
|
|
const position: [number, number] = [Number(address.latitude), Number(address.longitude)]
|
|
const marker = L.marker(position, { icon: pinIcon(), draggable: props.editable }).addTo(map)
|
|
marker.bindPopup(popupHtml(address))
|
|
if (props.editable) {
|
|
marker.on('dragend', () => onMarkerDragEnd(address, marker))
|
|
}
|
|
markers.push(marker)
|
|
points.push(position)
|
|
}
|
|
|
|
// Cadre sur l'ensemble des marqueurs (fitBounds), borne pour un pin isole.
|
|
if (points.length > 0) {
|
|
map.fitBounds(L.latLngBounds(points), { padding: [40, 40], maxZoom: MAX_FIT_ZOOM })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Drag d'un pin -> PATCH direct des coordonnees + geoManual=true (RG-6.08).
|
|
* Contrairement au formulaire d'edition (persistance differee au submit), la
|
|
* carte d'ensemble enregistre immediatement le nouveau positionnement.
|
|
*/
|
|
async function onMarkerDragEnd(address: TierMapAddress, marker: Marker): Promise<void> {
|
|
const position = marker.getLatLng()
|
|
const latitude = position.lat.toFixed(7)
|
|
const longitude = position.lng.toFixed(7)
|
|
try {
|
|
await api.patch(address.patchPath, { latitude, longitude, geoManual: true }, { toast: false })
|
|
address.geoManual = true
|
|
address.latitude = latitude
|
|
address.longitude = longitude
|
|
emit('updated', { id: address.id, latitude, longitude })
|
|
}
|
|
catch {
|
|
// Echec d'enregistrement : on remet le pin a sa derniere position connue.
|
|
marker.setLatLng([Number(address.latitude), Number(address.longitude)])
|
|
}
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
// (Re)monte ou rafraichit la carte quand la liste des adresses geolocalisees
|
|
// change (chargement async de la fiche, ajout de coordonnees).
|
|
watch(located, async () => {
|
|
if (located.value.length === 0) {
|
|
return
|
|
}
|
|
if (map === null) {
|
|
await nextTick()
|
|
await ensureMap()
|
|
return
|
|
}
|
|
renderMarkers()
|
|
}, { deep: true })
|
|
|
|
onMounted(ensureMap)
|
|
|
|
onBeforeUnmount(() => {
|
|
map?.remove()
|
|
map = null
|
|
L = null
|
|
markers = []
|
|
})
|
|
</script>
|