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