From d16c7e5541053cad8da6e63a5f23fd0627c854f7 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 12 Jun 2026 08:56:37 +0200 Subject: [PATCH] =?UTF-8?q?feat(field=5Fsales)=20:=20onglet=20Carte=20fich?= =?UTF-8?q?es=20Client/Fournisseur=20+=20point=20de=20d=C3=A9part=20par=20?= =?UTF-8?q?site=20ou=20adresse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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é. --- frontend/i18n/locales/fr.json | 28 +- .../commercial/components/TierAddressMap.vue | 230 ++++++++++++++++ .../__tests__/TierAddressMap.spec.ts | 158 +++++++++++ .../commercial/pages/clients/[id]/index.vue | 60 ++++- .../commercial/pages/suppliers/[id]/index.vue | 50 +++- .../field-sales/components/TourMap.vue | 98 ++++++- .../field-sales/pages/tours/[id]/plan.vue | 247 ++++++++++++------ .../utils/__tests__/startPoint.spec.ts | 35 +++ .../modules/field-sales/utils/startPoint.ts | 37 +++ 9 files changed, 841 insertions(+), 102 deletions(-) create mode 100644 frontend/modules/commercial/components/TierAddressMap.vue create mode 100644 frontend/modules/commercial/components/__tests__/TierAddressMap.spec.ts create mode 100644 frontend/modules/field-sales/utils/__tests__/startPoint.spec.ts create mode 100644 frontend/modules/field-sales/utils/startPoint.ts diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index ddd16c2..201e4d0 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -87,6 +87,11 @@ "date": "Date", "departureTime": "Heure de départ", "startLabel": "Point de départ", + "startModeSite": "Mes sites", + "startModeCustom": "Adresse libre", + "startSitePrefix": "Site de {name}", + "startSitePlaceholder": "Choisir un site…", + "startNoResults": "Adresse introuvable — saisie conservée.", "defaultVisitMinutes": "Durée de visite (min)", "stops": "Étapes", "noStops": "Aucune étape. Sélectionnez des Tiers sur la carte ou ajoutez un point libre.", @@ -112,21 +117,12 @@ "google": "Google Maps", "apple": "Plan (Apple)" }, - "custom": { - "add": "Ajouter un point libre", - "title": "Point libre", - "label": "Libellé", - "address": "Adresse", - "confirm": "Ajouter le point", - "cancel": "Annuler", - "geocodeFailed": "Adresse introuvable — ajustez le pin sur la carte.", - "hint": "Saisissez une adresse, elle sera géolocalisée automatiquement." - }, "map": { "typeClient": "Clients", "typeSupplier": "Fournisseurs", "search": "Rechercher un Tiers", "add": "Ajouter", + "startPoint": "Point de départ", "lassoHint": "Maintenez Maj et dessinez un rectangle pour sélectionner plusieurs Tiers." }, "duplicateModal": { @@ -155,7 +151,11 @@ "manualPin": "Pin ajusté manuellement", "dragHint": "Déplacez le marqueur pour ajuster la position exacte (lieu-dit, entrée de site...).", "regeocode": "Re-géocoder depuis l'adresse", - "regeocodeFailed": "Adresse introuvable — position inchangée." + "regeocodeFailed": "Adresse introuvable — position inchangée.", + "map": { + "noLocated": "Aucune adresse géolocalisée à afficher sur la carte.", + "missingTitle": "Adresses à géolocaliser" + } }, "suppliers": { "title": "Répertoire fournisseurs", @@ -197,7 +197,8 @@ "accounting": "Comptabilité", "statistics": "Statistiques", "reports": "Rapports", - "exchanges": "Échanges" + "exchanges": "Échanges", + "carte": "Carte" }, "action": { "edit": "Modifier", @@ -330,7 +331,8 @@ "accounting": "Comptabilité", "statistics": "Statistiques", "reports": "Rapports", - "exchanges": "Échanges" + "exchanges": "Échanges", + "carte": "Carte" }, "action": { "edit": "Modifier", diff --git a/frontend/modules/commercial/components/TierAddressMap.vue b/frontend/modules/commercial/components/TierAddressMap.vue new file mode 100644 index 0000000..9d64f86 --- /dev/null +++ b/frontend/modules/commercial/components/TierAddressMap.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/frontend/modules/commercial/components/__tests__/TierAddressMap.spec.ts b/frontend/modules/commercial/components/__tests__/TierAddressMap.spec.ts new file mode 100644 index 0000000..9963e5b --- /dev/null +++ b/frontend/modules/commercial/components/__tests__/TierAddressMap.spec.ts @@ -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 + }>, + 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 { + 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() + }) +}) diff --git a/frontend/modules/commercial/pages/clients/[id]/index.vue b/frontend/modules/commercial/pages/clients/[id]/index.vue index cfa09ce..57f071f 100644 --- a/frontend/modules/commercial/pages/clients/[id]/index.vue +++ b/frontend/modules/commercial/pages/clients/[id]/index.vue @@ -242,6 +242,13 @@ + + + @@ -280,7 +287,8 @@