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:
@@ -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: '© <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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user