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
+15 -13
View File
@@ -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",
@@ -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()
})
})
@@ -242,6 +242,13 @@
</div>
</template>
<!-- Onglet Carte (M6.6) : vue d'ensemble des implantations du client. -->
<template v-if="showMapTab" #carte>
<div class="mt-12 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<TierAddressMap :addresses="mapAddresses" :editable="canEditAddresses" />
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
@@ -280,7 +287,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useClient } from '~/modules/commercial/composables/useClient'
import { buildClientFormTabKeys, isRibRequiredForPaymentType } from '~/modules/commercial/utils/clientFormRules'
import { addressTypeFromFlags, buildClientFormTabKeys, isRibRequiredForPaymentType, type AddressType } from '~/modules/commercial/utils/clientFormRules'
import type { TierMapAddress } from '~/modules/commercial/components/TierAddressMap.vue'
import { readHistoryTab } from '~/shared/utils/historyTab'
import {
canEditClient,
@@ -308,6 +316,7 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const { isModuleActive } = useModules()
const authStore = useAuthStore()
// Gating de la route : la consultation exige `view`. Usine (sans view) est
@@ -411,10 +420,54 @@ const paymentDelayOptions = computed(() => referentialOptionOf(client.value?.pay
const paymentTypeOptions = computed(() => referentialOptionOf(client.value?.paymentType))
const bankOptions = computed(() => referentialOptionOf(client.value?.bank))
// ── Onglet « Carte » (M6.6, module field_sales) ────────────────────────────
// Visible uniquement si le module field_sales est actif ET que l'utilisateur a
// la permission de consultation des tournees. Le drag du pin (PATCH direct) est
// reserve aux roles pouvant editer un client.
const showMapTab = computed(() => isModuleActive('field_sales') && can('field_sales.tours.view'))
const canEditAddresses = computed(() => can('commercial.clients.manage'))
// Cles i18n du type d'adresse (RG-1.06/07/08) pour le libelle du popup carte.
const CLIENT_ADDRESS_TYPE_I18N: Record<AddressType, string> = {
prospect: 'addressTypeProspect',
delivery: 'addressTypeDelivery',
billing: 'addressTypeBilling',
delivery_billing: 'addressTypeDeliveryBilling',
broker: 'addressTypeBroker',
distributor: 'addressTypeDistributor',
}
/** Adresses du client normalisees pour la carte d'ensemble (M6.6). */
const mapAddresses = computed<TierMapAddress[]>(() =>
(client.value?.addresses ?? []).map((a) => {
const type = addressTypeFromFlags({
isProspect: a.isProspect ?? false,
isDelivery: a.isDelivery ?? false,
isBilling: a.isBilling ?? false,
isBroker: a.isBroker ?? false,
isDistributor: a.isDistributor ?? false,
})
const cityLine = [a.postalCode, a.city].filter(Boolean).join(' ')
return {
id: a.id,
latitude: a.latitude ?? null,
longitude: a.longitude ?? null,
geoManual: a.geoManual === true,
title: [a.street, cityLine].filter(Boolean).join(', ') || t('commercial.clients.form.address.title', { n: a.id }),
typeLabel: type ? t(`commercial.clients.form.address.${CLIENT_ADDRESS_TYPE_I18N[type]}`) : '',
patchPath: `/client_addresses/${a.id}`,
}
}),
)
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// 4 onglets actifs (Information, Contact, Adresse, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
// 4 coquilles (Transport, Statistiques, Rapports, Echanges) ; + Carte si M6.6.
const tabKeys = computed(() => {
const keys = buildClientFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true })
if (showMapTab.value) keys.push('carte')
return keys
})
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
@@ -425,6 +478,7 @@ const TAB_ICONS: Record<string, string> = {
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
carte: 'mdi:map-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
@@ -225,6 +225,13 @@
</div>
</template>
<!-- Onglet Carte (M6.6) : vue d'ensemble des implantations du fournisseur. -->
<template v-if="showMapTab" #carte>
<div class="mt-12 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
<TierAddressMap :addresses="mapAddresses" :editable="canEditAddresses" />
</div>
</template>
<!-- Onglets non encore implementes : frame vide (navigation libre). -->
<template #transport><ComingSoonPlaceholder /></template>
<template #statistics><ComingSoonPlaceholder /></template>
@@ -281,7 +288,8 @@ import {
type SelectOption,
type SupplierDetail,
} from '~/modules/commercial/utils/supplierConsultation'
import { emptyContact } from '~/modules/commercial/types/supplierForm'
import { emptyContact, type SupplierAddressType } from '~/modules/commercial/types/supplierForm'
import type { TierMapAddress } from '~/modules/commercial/components/TierAddressMap.vue'
// Masque d'affichage (purement visuel, la donnee reste celle du serveur).
const SIREN_MASK = '#########'
@@ -291,6 +299,7 @@ const route = useRoute()
const router = useRouter()
const toast = useToast()
const { can, canAny } = usePermissions()
const { isModuleActive } = useModules()
const authStore = useAuthStore()
// Gating de la route : la consultation exige `view`. Usine (sans view) est
@@ -386,10 +395,44 @@ const paymentDelayOptions = computed(() => referentialOptionOf(supplier.value?.p
const paymentTypeOptions = computed(() => referentialOptionOf(supplier.value?.paymentType))
const bankOptions = computed(() => referentialOptionOf(supplier.value?.bank))
// ── Onglet « Carte » (M6.6, module field_sales) ────────────────────────────
// Visible uniquement si le module field_sales est actif ET que l'utilisateur a
// la permission de consultation des tournees. Le drag du pin (PATCH direct) est
// reserve aux roles pouvant editer un fournisseur.
const showMapTab = computed(() => isModuleActive('field_sales') && can('field_sales.tours.view'))
const canEditAddresses = computed(() => can('commercial.suppliers.manage'))
// Cles i18n du type d'adresse fournisseur (enum PROSPECT/DEPART/RENDU, RG-2.09).
const SUPPLIER_ADDRESS_TYPE_I18N: Record<SupplierAddressType, string> = {
PROSPECT: 'addressTypeProspect',
DEPART: 'addressTypeDepart',
RENDU: 'addressTypeRendu',
}
/** Adresses du fournisseur normalisees pour la carte d'ensemble (M6.6). */
const mapAddresses = computed<TierMapAddress[]>(() =>
(supplier.value?.addresses ?? []).map((a) => {
const cityLine = [a.postalCode, a.city].filter(Boolean).join(' ')
return {
id: a.id,
latitude: a.latitude ?? null,
longitude: a.longitude ?? null,
geoManual: a.geoManual === true,
title: [a.street, cityLine].filter(Boolean).join(', ') || t('commercial.suppliers.form.address.title', { n: a.id }),
typeLabel: a.addressType ? t(`commercial.suppliers.form.address.${SUPPLIER_ADDRESS_TYPE_I18N[a.addressType]}`) : '',
patchPath: `/supplier_addresses/${a.id}`,
}
}),
)
// ── Onglets : navigation LIBRE (pas de sequence forcee en consultation) ────
// 3 onglets actifs (Information, Contacts, Adresses, + Comptabilite si droit) et
// 4 coquilles (Transport, Statistiques, Rapports, Echanges).
const tabKeys = computed(() => buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true }))
// 4 coquilles (Transport, Statistiques, Rapports, Echanges) ; + Carte si M6.6.
const tabKeys = computed(() => {
const keys = buildSupplierFormTabKeys(canAccountingView.value, { includeEditOnlyTabs: true })
if (showMapTab.value) keys.push('carte')
return keys
})
const TAB_ICONS: Record<string, string> = {
information: 'mdi:account-outline',
@@ -400,6 +443,7 @@ const TAB_ICONS: Record<string, string> = {
statistics: 'mdi:finance',
reports: 'mdi:file-document-edit-outline',
exchanges: 'mdi:account-group-outline',
carte: 'mdi:map-outline',
}
const tabs = computed(() => tabKeys.value.map(key => ({
@@ -39,8 +39,11 @@ const props = withDefaults(defineProps<{
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<{
@@ -62,7 +65,15 @@ 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
@@ -89,10 +100,15 @@ async function ensureMap(): Promise<void> {
return
}
map = L.map(mapEl.value).setView(props.center, INITIAL_ZOOM)
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: '&copy; <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).
@@ -102,6 +118,14 @@ async function ensureMap(): Promise<void> {
// 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()
}
@@ -286,12 +310,29 @@ function drawRoute(): void {
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 points = located.map(s => [s.latitude as number, s.longitude as number] as [number, number])
const stopPoints = located.map(s => [s.latitude as number, s.longitude as number] as [number, number])
if (points.length >= 2) {
routeLine = L.polyline(points, { color: '#1e40af', weight: 3, opacity: 0.7 }).addTo(map)
// 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) => {
@@ -302,6 +343,34 @@ function drawRoute(): void {
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. */
@@ -314,6 +383,21 @@ function numberedIcon(n: number) {
})
}
/**
* 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, '&amp;')
@@ -325,8 +409,9 @@ function escapeHtml(value: string): string {
// Recharge les pins quand les filtres changent.
watch(() => [props.types, props.search], scheduleFetch, { deep: true })
// Redessine le trace quand les etapes changent.
// 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)
@@ -334,12 +419,15 @@ 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
})
@@ -19,6 +19,7 @@
:types="activeTypes"
:search="mapSearch"
:center="mapCenter"
:start="mapStart"
@add-tier="addTier"
@add-tiers="addTiers"
/>
@@ -55,11 +56,46 @@
<MalioDate v-model="panel.tourDate" :label="t('field_sales.plan.panel.date')" class="flex-1" @update:model-value="saveDate" />
<MalioTime v-model="panel.departureTime" :label="t('field_sales.plan.panel.departureTime')" class="flex-1" @update:model-value="saveDepartureTime" />
</div>
<MalioInputText
v-model="panel.startLabel"
:label="t('field_sales.plan.panel.startLabel')"
@update:model-value="debouncedSaveStart"
/>
<!-- Point de départ : un de mes sites OU une adresse libre (BAN). -->
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-gray-700">{{ t('field_sales.plan.panel.startLabel') }}</span>
<div class="flex gap-4">
<MalioRadioButton
v-model="startMode"
name="start-mode"
value="site"
:label="t('field_sales.plan.panel.startModeSite')"
:disabled="userSites.length === 0"
group-class="mt-0"
/>
<MalioRadioButton
v-model="startMode"
name="start-mode"
value="custom"
:label="t('field_sales.plan.panel.startModeCustom')"
group-class="mt-0"
/>
</div>
<MalioSelect
v-if="startMode === 'site'"
:model-value="selectedSiteId"
:options="siteOptions"
:empty-option-label="t('field_sales.plan.panel.startSitePlaceholder')"
@update:model-value="onSiteSelect"
/>
<MalioInputAutocomplete
v-else
:model-value="panel.startLabel"
:options="startAddressOptions"
:loading="startAddressLoading"
:min-search-length="3"
:allow-create="true"
:no-results-text="t('field_sales.plan.panel.startNoResults')"
@update:model-value="onStartLabelInput"
@search="onStartAddressSearch"
@select="onStartAddressSelect"
/>
</div>
<MalioInputNumber
v-model="panel.defaultVisitMinutes"
:label="t('field_sales.plan.panel.defaultVisitMinutes')"
@@ -96,13 +132,6 @@
<div>
<div class="mb-2 flex items-center justify-between">
<h2 class="font-semibold text-gray-800">{{ t('field_sales.plan.panel.stops') }}</h2>
<MalioButton
variant="tertiary"
:label="t('field_sales.plan.custom.add')"
icon-name="mdi:map-marker-plus"
icon-position="left"
@click="customOpen = true"
/>
</div>
<TourStopList
:stops="stops"
@@ -114,22 +143,6 @@
</div>
</div>
<!-- Modale : point libre custom. -->
<MalioModal v-model="customOpen" modal-class="max-w-md">
<template #header>
<h2 class="text-[22px] font-bold">{{ t('field_sales.plan.custom.title') }}</h2>
</template>
<div class="flex flex-col gap-3">
<MalioInputText v-model="customForm.label" :label="t('field_sales.plan.custom.label')" required :error="customErrors.label" />
<MalioInputText v-model="customForm.address" :label="t('field_sales.plan.custom.address')" required :error="customErrors.address" :hint="t('field_sales.plan.custom.hint')" />
<p v-if="customGeocodeFailed" class="text-xs text-red-600">{{ t('field_sales.plan.custom.geocodeFailed') }}</p>
</div>
<template #footer>
<MalioButton variant="secondary" :label="t('field_sales.plan.custom.cancel')" button-class="flex-1" @click="customOpen = false" />
<MalioButton variant="primary" :label="t('field_sales.plan.custom.confirm')" button-class="flex-1" :disabled="busy" @click="confirmCustom" />
</template>
</MalioModal>
<!-- Modale : duplication. -->
<MalioModal v-model="duplicateOpen" modal-class="max-w-md">
<template #header>
@@ -155,8 +168,10 @@ import {
formatTime,
type PlanningStop,
} from '~/modules/field-sales/composables/useTourPlanning'
import { useAddressAutocomplete } from '~/shared/composables/useAddressAutocomplete'
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
import { siteFullAddress, siteOptionLabel } from '~/modules/field-sales/utils/startPoint'
import type { Tour, TourStop, VisitableTier } from '~/modules/field-sales/types/tour'
import type { Site } from '~/shared/types/sites'
/**
* Ecran de planification d'une tournee (M6.5, spec § 6.1).
@@ -175,9 +190,29 @@ const router = useRouter()
const route = useRoute()
const toast = useToast()
const autocomplete = useAddressAutocomplete()
const authStore = useAuthStore()
const tourId = computed(() => Number(route.params.id))
// ── Point de départ : choix entre « mes sites » et « adresse libre » ─────────
// Les sites de l'utilisateur (embarqués dans /api/me) servent de départs
// pré-enregistrés ; sinon une adresse libre géocodée via la BAN.
const userSites = computed<Site[]>(() => authStore.user?.sites ?? [])
const startMode = ref<'site' | 'custom'>('custom')
const selectedSiteId = ref<number | null>(null)
// Le mode de départ n'est dérivé du back qu'au premier chargement (cf. applyTour).
const startModeInitialized = ref(false)
const siteOptions = computed(() => userSites.value.map(s => ({ value: s.id, label: siteOptionLabel(s) })))
// Suggestions BAN du champ « adresse libre » (mode custom).
const startAddressOptions = ref<Array<{ value: string, label: string }>>([])
const startAddressLoading = ref(false)
let startAddressSuggestions: AddressSuggestion[] = []
/** Libellé stocké quand on choisit un site : « Site de {nom} — {adresse} ». */
function composeSiteStartLabel(site: Site): string {
return `${t('field_sales.plan.panel.startSitePrefix', { name: site.name })} — ${siteFullAddress(site)}`
}
const tour = ref<Tour | null>(null)
const stops = ref<PlanningStop[]>([])
const busy = ref(false)
@@ -237,15 +272,27 @@ const activeTypes = computed<Array<'client' | 'supplier'>>(() => {
})
const mapCenter = ref<[number, number]>([47.218, -1.553])
// Point de départ géolocalisé à afficher sur la carte (marqueur « maison »).
// Le back stocke lat/lng en chaînes ; null tant que la BAN n'a rien géocodé.
const mapStart = computed<{ latitude: number, longitude: number, label?: string } | null>(() => {
const lat = tour.value?.startLatitude
const lng = tour.value?.startLongitude
if (lat == null || lng == null) {
return null
}
const latNum = Number(lat)
const lngNum = Number(lng)
if (!Number.isFinite(latNum) || !Number.isFinite(lngNum)) {
return null
}
return { latitude: latNum, longitude: lngNum, label: tour.value?.startLabel ?? undefined }
})
// ── Totaux recalcules localement (feedback instantane) ──────────────────────
const totals = computed(() => computeTotals(stops.value, Number(panel.defaultVisitMinutes) || 0))
// ── Modales ──────────────────────────────────────────────────────────────────
const customOpen = ref(false)
const customForm = reactive({ label: '', address: '' })
const customErrors = reactive<{ label: string, address: string }>({ label: '', address: '' })
const customGeocodeFailed = ref(false)
const duplicateOpen = ref(false)
const duplicateDate = ref<string | null>(null)
const duplicateError = ref('')
@@ -278,6 +325,24 @@ async function applyTour(raw: Tour, withStops: boolean): Promise<void> {
panel.startLabel = raw.startLabel ?? ''
panel.defaultVisitMinutes = String(raw.defaultVisitMinutes)
// Mode du point de départ : dérivé UNE SEULE FOIS, au premier chargement de
// la tournée. Les `patchTour` suivants (sauvegardes) rappellent `applyTour`
// mais ne doivent pas réécraser le choix explicite de l'utilisateur (sinon
// sélectionner un site, qui déclenche un PATCH, ré-évalue le mode et peut
// retomber en « adresse libre » sur la moindre divergence de libellé).
if (!startModeInitialized.value) {
const matchedSite = userSites.value.find(s => composeSiteStartLabel(s) === panel.startLabel)
if (matchedSite) {
startMode.value = 'site'
selectedSiteId.value = matchedSite.id
}
else {
startMode.value = panel.startLabel === '' && userSites.value.length > 0 ? 'site' : 'custom'
selectedSiteId.value = null
}
startModeInitialized.value = true
}
if (withStops) {
stops.value = await enrichStops(raw.stops ?? [])
recenterOnFirstStop()
@@ -416,43 +481,6 @@ async function postStop(payload: Record<string, unknown>): Promise<void> {
}
}
async function confirmCustom(): Promise<void> {
customErrors.label = customForm.label.trim() === '' ? t('field_sales.plan.custom.label') : ''
customErrors.address = customForm.address.trim() === '' ? t('field_sales.plan.custom.address') : ''
if (customErrors.label || customErrors.address) {
return
}
customGeocodeFailed.value = false
// Geocodage de l'adresse libre (BAN). Echec -> point non geolocalise (exclu
// du calcul, RG-6.05) mais l'etape est tout de meme creee.
let coords: { latitude: string, longitude: string } | null = null
try {
coords = await autocomplete.geocode(customForm.address.trim())
}
catch {
coords = null
}
if (coords === null) {
customGeocodeFailed.value = true
}
await postStop({
tierType: 'custom',
customLabel: customForm.label.trim(),
customAddress: customForm.address.trim(),
customLatitude: coords?.latitude ?? null,
customLongitude: coords?.longitude ?? null,
position: stops.value.length,
})
if (!customGeocodeFailed.value) {
customOpen.value = false
customForm.label = ''
customForm.address = ''
}
}
// =============================================================================
// Reordonnancement / suppression / navigation
// =============================================================================
@@ -589,28 +617,91 @@ async function saveVisitMinutes(): Promise<void> {
await patchTour({ defaultVisitMinutes: minutes }, true)
}
/** Geocode le point de depart (ou le vide) puis recompute. */
async function saveStart(): Promise<void> {
const label = panel.startLabel.trim()
if (label === '' && (tour.value?.startLabel ?? '') === '') {
/**
* Persiste le point de départ : `label` est ce qui est stocké/affiché (ex.
* « Site de Châtellerault — … » ou l'adresse libre), `geocodeQuery` l'adresse
* postale réellement géocodée via la BAN. Coords nulles si la BAN ne trouve rien
* (le badge « à géolocaliser » s'affiche, la tournée reste sauvegardable).
*/
async function persistStart(label: string, geocodeQuery: string): Promise<void> {
const trimmed = label.trim()
if (trimmed === '' && (tour.value?.startLabel ?? '') === '') {
return
}
let coords: { latitude: string, longitude: string } | null = null
if (label !== '') {
if (geocodeQuery.trim() !== '') {
try {
coords = await autocomplete.geocode(label)
coords = await autocomplete.geocode(geocodeQuery.trim())
}
catch {
coords = null
}
}
await patchTour({
startLabel: label === '' ? null : label,
startLabel: trimmed === '' ? null : trimmed,
startLatitude: coords?.latitude ?? null,
startLongitude: coords?.longitude ?? null,
}, true)
}
/** Mode « adresse libre » : saisie au clavier → géocode le texte tel quel. */
async function saveStart(): Promise<void> {
await persistStart(panel.startLabel, panel.startLabel)
}
/** Met à jour le texte du champ « adresse libre » (puis save débouncé). */
function onStartLabelInput(value: string | number | null): void {
panel.startLabel = value === null ? '' : String(value)
debouncedSaveStart()
}
/** Mode « mes sites » : choix d'un site → libellé « Site de … » + géocodage de son adresse. */
async function onSiteSelect(value: string | number | null): Promise<void> {
const id = value === null || value === '' ? null : Number(value)
selectedSiteId.value = id
const site = userSites.value.find(s => s.id === id)
if (!site) {
panel.startLabel = ''
await persistStart('', '')
return
}
const label = composeSiteStartLabel(site)
panel.startLabel = label
await persistStart(label, siteFullAddress(site))
}
/** Recherche d'adresse assistée (event de MalioInputAutocomplete, mode libre). */
async function onStartAddressSearch(query: string): Promise<void> {
if (query.trim().length < 3) {
startAddressOptions.value = []
return
}
startAddressLoading.value = true
try {
const suggestions = await autocomplete.searchAddress(query)
startAddressSuggestions = suggestions
startAddressOptions.value = suggestions.map(s => ({ value: s.label, label: s.label }))
}
catch {
// Erreur transitoire : on vide les suggestions (la frappe suivante réessaie).
startAddressOptions.value = []
}
finally {
startAddressLoading.value = false
}
}
/** Sélection d'une suggestion d'adresse libre → libellé = adresse, puis géocodage. */
async function onStartAddressSelect(option: { label: string, value: string | number } | null): Promise<void> {
if (option === null) {
return
}
const suggestion = startAddressSuggestions.find(s => s.label === option.value)
const label = suggestion?.label ?? String(option.value)
panel.startLabel = label
await persistStart(label, label)
}
/** PATCH /tours/{id}. `recompute` enchaine /compute (ETA impactee). */
async function patchTour(partial: Record<string, unknown>, recompute: boolean): Promise<void> {
busy.value = true
@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { siteFullAddress, siteOptionLabel, type StartSite } from '../startPoint'
function site(over: Partial<StartSite> = {}): StartSite {
return {
name: 'Châtellerault',
street: "14 allée d'Argenson",
postalCode: '86100',
city: 'Châtellerault',
...over,
}
}
describe('startPoint — siteFullAddress', () => {
it('utilise fullAddress du backend quand il est présent', () => {
expect(siteFullAddress(site({ fullAddress: "14 allée d'Argenson, 86100 Châtellerault" })))
.toBe("14 allée d'Argenson, 86100 Châtellerault")
})
it('recompose « rue, CP ville » quand fullAddress est absent', () => {
expect(siteFullAddress(site({ fullAddress: undefined })))
.toBe("14 allée d'Argenson, 86100 Châtellerault")
})
it('ignore les segments vides à la recomposition', () => {
expect(siteFullAddress({ name: 'X', street: '', postalCode: '79000', city: 'Niort' }))
.toBe('79000 Niort')
})
})
describe('startPoint — siteOptionLabel', () => {
it('formate « nom — code postal »', () => {
expect(siteOptionLabel(site())).toBe('Châtellerault — 86100')
})
})
@@ -0,0 +1,37 @@
/**
* Helpers purs du « Point de départ » d'une tournée (M6.5+).
*
* Le point de départ peut être choisi parmi les sites de l'utilisateur ou saisi
* en adresse libre (autocomplete BAN). Ces helpers ne touchent ni à l'API ni à
* l'état réactif : ils formatent des libellés à partir d'un site, donc testables
* unitairement (cf. startPoint.spec.ts). La composition du libellé stocké
* (`startLabel`) reste dans le composant car elle dépend d'i18n (préfixe
* « Site de … »).
*/
/** Sous-ensemble d'un site nécessaire au formatage du point de départ. */
export interface StartSite {
name: string
street: string
postalCode: string
city: string
/** Adresse complète reconstituée côté backend (peut être absente). */
fullAddress?: string
}
/**
* Adresse postale complète d'un site (« rue, CP ville »), à géocoder via la BAN.
* Utilise `fullAddress` du backend si présent, sinon recompose depuis les champs.
*/
export function siteFullAddress(site: StartSite): string {
if (site.fullAddress && site.fullAddress.trim() !== '') {
return site.fullAddress.trim()
}
const cityLine = [site.postalCode, site.city].filter(Boolean).join(' ')
return [site.street, cityLine].filter(Boolean).join(', ')
}
/** Libellé d'une option du select de sites : « {nom} — {code postal} ». */
export function siteOptionLabel(site: Pick<StartSite, 'name' | 'postalCode'>): string {
return `${site.name}${site.postalCode}`
}