feat(field_sales) : onglet Carte fiches Client/Fournisseur + point de départ par site ou adresse

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é.
This commit is contained in:
Matthieu
2026-06-12 08:56:37 +02:00
parent 2424cc7c55
commit d16c7e5541
9 changed files with 841 additions and 102 deletions
@@ -0,0 +1,230 @@
<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: '&copy; <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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// (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>
@@ -0,0 +1,158 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import TierAddressMap, { type TierMapAddress } from '../TierAddressMap.vue'
// Mock Leaflet (hoisted) : capture les marqueurs crees (un par adresse
// geolocalisee) et leur handler `dragend`, et trace l'appel a fitBounds.
const leafletState = vi.hoisted(() => ({
markers: [] as Array<{
_latlng: { lat: number, lng: number }
dragend: (() => void) | null
setLatLng: ReturnType<typeof vi.fn>
}>,
fitBoundsCalled: false,
}))
vi.mock('leaflet', () => {
function makeMarker(lat: number, lng: number) {
const marker = {
_latlng: { lat, lng },
dragend: null as (() => void) | null,
addTo: vi.fn().mockReturnThis(),
bindPopup: vi.fn().mockReturnThis(),
on: vi.fn((event: string, handler: () => void) => {
if (event === 'dragend') marker.dragend = handler
}),
getLatLng: vi.fn(() => marker._latlng),
setLatLng: vi.fn(),
remove: vi.fn(),
}
return marker
}
const map = {
fitBounds: vi.fn(() => { leafletState.fitBoundsCalled = true }),
setView: vi.fn().mockReturnThis(),
remove: vi.fn(),
}
const L = {
map: vi.fn(() => map),
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
divIcon: vi.fn(() => ({})),
latLngBounds: vi.fn((points: unknown) => points),
marker: vi.fn((pos: [number, number]) => {
const marker = makeMarker(pos[0], pos[1])
leafletState.markers.push(marker)
return marker
}),
}
return { default: L, ...L }
})
vi.mock('leaflet/dist/leaflet.css', () => ({ default: {} }))
// Mock controlable de l'API (PATCH des coordonnees au drag).
const { patchMock } = vi.hoisted(() => ({ patchMock: vi.fn() }))
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('useApi', () => ({ patch: patchMock }))
vi.stubGlobal('ref', ref)
vi.stubGlobal('computed', computed)
vi.stubGlobal('watch', watch)
vi.stubGlobal('nextTick', nextTick)
vi.stubGlobal('onMounted', onMounted)
vi.stubGlobal('onBeforeUnmount', onBeforeUnmount)
function address(over: Partial<TierMapAddress> = {}): TierMapAddress {
return {
id: 1,
latitude: '47.218',
longitude: '-1.553',
geoManual: false,
title: '1 rue du Test, 44000 Nantes',
typeLabel: 'Livraison',
patchPath: '/client_addresses/1',
...over,
}
}
beforeEach(() => {
leafletState.markers = []
leafletState.fitBoundsCalled = false
patchMock.mockReset()
patchMock.mockResolvedValue({})
})
describe('TierAddressMap — marqueurs', () => {
it('pose un marqueur par adresse geolocalisee et liste a part celles sans coordonnees', async () => {
const wrapper = mount(TierAddressMap, {
props: {
addresses: [
address({ id: 1, patchPath: '/client_addresses/1' }),
address({ id: 2, latitude: '48.85', longitude: '2.35', patchPath: '/client_addresses/2' }),
address({ id: 3, latitude: null, longitude: null, patchPath: '/client_addresses/3', title: '5 rue Sans Geo' }),
],
},
})
await flushPromises() // import dynamique de Leaflet + montage carte
// Deux adresses geolocalisees -> deux marqueurs ; la troisieme (sans
// coords) n'est pas posee sur la carte mais listee a part.
expect(leafletState.markers).toHaveLength(2)
expect(leafletState.fitBoundsCalled).toBe(true)
const missing = wrapper.findAll('[data-testid="tier-map-missing"]')
expect(missing).toHaveLength(1)
expect(missing[0]?.text()).toContain('5 rue Sans Geo')
expect(wrapper.find('[data-testid="tier-map"]').exists()).toBe(true)
})
it('affiche un etat vide quand aucune adresse n\'est geolocalisee', async () => {
const wrapper = mount(TierAddressMap, {
props: { addresses: [address({ latitude: null, longitude: null })] },
})
await flushPromises()
expect(leafletState.markers).toHaveLength(0)
expect(wrapper.find('[data-testid="tier-map"]').exists()).toBe(false)
expect(wrapper.findAll('[data-testid="tier-map-missing"]')).toHaveLength(1)
})
})
describe('TierAddressMap — pin ajustable (RG-6.08)', () => {
it('PATCH les coordonnees + geoManual=true au drag quand editable', async () => {
const wrapper = mount(TierAddressMap, {
props: { addresses: [address({ id: 7, patchPath: '/client_addresses/7' })], editable: true },
})
await flushPromises()
const marker = leafletState.markers[0]
expect(marker?.dragend).not.toBeNull()
// L'utilisateur depose le pin ailleurs (entree de site mal geocodee).
marker!._latlng = { lat: 48.1234567, lng: -1.6543217 }
marker!.dragend?.()
await flushPromises()
expect(patchMock).toHaveBeenCalledWith(
'/client_addresses/7',
{ latitude: '48.1234567', longitude: '-1.6543217', geoManual: true },
{ toast: false },
)
expect(wrapper.emitted('updated')?.[0]?.[0]).toEqual({
id: 7,
latitude: '48.1234567',
longitude: '-1.6543217',
})
})
it('ne rend pas les marqueurs draggables (pas de PATCH) en lecture seule', async () => {
mount(TierAddressMap, {
props: { addresses: [address()], editable: false },
})
await flushPromises()
// Aucun handler dragend cable -> pas de drag possible.
expect(leafletState.markers[0]?.dragend).toBeNull()
})
})