feat(field_sales) : carte interactive Leaflet + écran de planification de tournée (ERP-127)
- API visitable_tiers (provider DBAL bbox/q/type, paginé) pour les pins de la carte
- POST /tours/{id}/reorder (drag & drop) : renumérotation atomique + recompute
- Layer front field-sales : TourMap (pins, popup, polyline, sélection rectangle),
liste d'étapes draggable (vuedraggable), composable de planification + Vitest
- Pages /tours, /tours/new, /tours/[id]/plan (split responsive, point custom géocodé)
- i18n FR, deep links Waze/Google/Apple, état 100% local
This commit is contained in:
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<!-- Carte Leaflet (exception documentee a @malio/layer-ui : carte interactive,
|
||||
type non couvert par la lib — cf. frontend.md § Composants formulaires).
|
||||
TODO : migrer si la lib couvre un jour les cartes. -->
|
||||
<div class="relative h-full w-full">
|
||||
<div ref="mapEl" class="h-full w-full" data-testid="tour-map" />
|
||||
<!-- Aide a la selection rectangle (lasso facon Badger Maps). -->
|
||||
<div class="pointer-events-none absolute bottom-2 left-2 z-[400] rounded bg-white/90 px-2 py-1 text-xs text-gray-600 shadow">
|
||||
{{ t('field_sales.plan.map.lassoHint') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Map as LeafletMap, Marker, Polyline, Rectangle } from 'leaflet'
|
||||
import type { PlanningStop } from '~/modules/field-sales/composables/useTourPlanning'
|
||||
import type { VisitableTier } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Carte interactive de planification de tournee (M6.5, spec § 6.1).
|
||||
*
|
||||
* - Charge les pins des Tiers geolocalises de la zone visible
|
||||
* (GET /api/visitable_tiers?bbox=...), colores par type (client/fournisseur),
|
||||
* filtrables (types + recherche). Recharge au deplacement/zoom (debounce).
|
||||
* - Popup au clic : nom, adresse, bouton « + Ajouter » (emet `add-tier`).
|
||||
* - Selection rectangle (Maj + glisser) : ajoute tous les Tiers entoures
|
||||
* (emet `add-tiers`).
|
||||
* - Trace la tournee par-dessus : polyline + marqueurs numerotes suivant l'ordre
|
||||
* des etapes geolocalisees.
|
||||
*
|
||||
* Instances Leaflet hors reactivite Vue (un proxy casse l'API Leaflet).
|
||||
*/
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Etapes geolocalisees a tracer (polyline numerotee). */
|
||||
stops: PlanningStop[]
|
||||
/** Types de pins affiches. */
|
||||
types: Array<'client' | 'supplier'>
|
||||
/** Recherche raison sociale / ville. */
|
||||
search: string
|
||||
/** Centre initial (defaut : Nantes). */
|
||||
center?: [number, number]
|
||||
}>(), {
|
||||
center: () => [47.218, -1.553],
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Ajout d'un seul Tiers (popup « + Ajouter »). */
|
||||
'add-tier': [tier: VisitableTier]
|
||||
/** Ajout d'un lot de Tiers (selection rectangle). */
|
||||
'add-tiers': [tiers: VisitableTier[]]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
|
||||
const mapEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// Instances Leaflet (hors reactivite).
|
||||
let L: typeof import('leaflet') | null = null
|
||||
let map: LeafletMap | null = null
|
||||
let pinLayer: Marker[] = []
|
||||
let pinTiers: Array<{ tier: VisitableTier, marker: Marker }> = []
|
||||
let routeLine: Polyline | null = null
|
||||
let stopMarkers: Marker[] = []
|
||||
let selectionRect: Rectangle | null = null
|
||||
|
||||
/** Zoom initial (niveau agglomeration). */
|
||||
const INITIAL_ZOOM = 12
|
||||
|
||||
/** Couleur du pin par type de Tiers. */
|
||||
const PIN_COLORS: Record<string, string> = {
|
||||
client: '#2563eb', // bleu
|
||||
supplier: '#16a34a', // vert
|
||||
}
|
||||
|
||||
/** Debounce du rechargement des pins au deplacement de la carte. */
|
||||
let fetchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function ensureMap(): Promise<void> {
|
||||
if (map !== null || mapEl.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const mod = await import('leaflet')
|
||||
L = mod.default ?? mod
|
||||
await import('leaflet/dist/leaflet.css')
|
||||
|
||||
if (mapEl.value === null) {
|
||||
return
|
||||
}
|
||||
|
||||
map = L.map(mapEl.value).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,
|
||||
}).addTo(map)
|
||||
|
||||
// Selection rectangle a la place du box-zoom natif (Maj + glisser).
|
||||
map.boxZoom.disable()
|
||||
map.on('mousedown', onMouseDown)
|
||||
|
||||
// Rechargement des pins quand la zone visible change.
|
||||
map.on('moveend', scheduleFetch)
|
||||
|
||||
drawRoute()
|
||||
await fetchPins()
|
||||
}
|
||||
|
||||
/** bbox de la zone visible au format Leaflet (minLng,minLat,maxLng,maxLat). */
|
||||
function currentBbox(): string | null {
|
||||
if (map === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return map.getBounds().toBBoxString()
|
||||
}
|
||||
|
||||
function scheduleFetch(): void {
|
||||
if (fetchTimer !== null) {
|
||||
clearTimeout(fetchTimer)
|
||||
}
|
||||
fetchTimer = setTimeout(() => {
|
||||
void fetchPins()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Charge les pins de la zone visible. `?pagination=false` : la carte affiche
|
||||
* TOUS les pins de la bbox (le volume est borne par la zone, pas par la page).
|
||||
*/
|
||||
async function fetchPins(): Promise<void> {
|
||||
if (map === null || L === null) {
|
||||
return
|
||||
}
|
||||
if (props.types.length === 0) {
|
||||
clearPins()
|
||||
return
|
||||
}
|
||||
|
||||
const bbox = currentBbox()
|
||||
if (bbox === null) {
|
||||
return
|
||||
}
|
||||
|
||||
const query: Record<string, string> = {
|
||||
bbox,
|
||||
type: props.types.join(','),
|
||||
pagination: 'false',
|
||||
}
|
||||
if (props.search.trim() !== '') {
|
||||
query.q = props.search.trim()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get<{ member?: VisitableTier[] }>(
|
||||
'/visitable_tiers',
|
||||
query,
|
||||
{ headers: { Accept: 'application/ld+json' }, toast: false },
|
||||
)
|
||||
renderPins(response.member ?? [])
|
||||
}
|
||||
catch {
|
||||
// Echec non bloquant : la carte reste utilisable, les pins ne se mettent
|
||||
// simplement pas a jour.
|
||||
}
|
||||
}
|
||||
|
||||
function clearPins(): void {
|
||||
pinLayer.forEach(m => m.remove())
|
||||
pinLayer = []
|
||||
pinTiers = []
|
||||
}
|
||||
|
||||
function renderPins(tiers: VisitableTier[]): void {
|
||||
if (map === null || L === null) {
|
||||
return
|
||||
}
|
||||
clearPins()
|
||||
|
||||
for (const tier of tiers) {
|
||||
const marker = L.marker([tier.latitude, tier.longitude], {
|
||||
icon: pinIcon(PIN_COLORS[tier.tierType] ?? '#6b7280'),
|
||||
}).addTo(map)
|
||||
|
||||
marker.bindPopup(popupHtml(tier))
|
||||
marker.on('popupopen', () => bindPopupButton(tier))
|
||||
|
||||
pinLayer.push(marker)
|
||||
pinTiers.push({ tier, marker })
|
||||
}
|
||||
}
|
||||
|
||||
/** divIcon SVG inline colore (evite les assets PNG Leaflet casses par Vite). */
|
||||
function pinIcon(color: string) {
|
||||
return L!.divIcon({
|
||||
className: '',
|
||||
html: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="26" height="38" fill="${color}" stroke="#ffffff" stroke-width="1"><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: [26, 38],
|
||||
iconAnchor: [13, 38],
|
||||
popupAnchor: [0, -34],
|
||||
})
|
||||
}
|
||||
|
||||
/** Contenu HTML du popup (le bouton est cable a l'ouverture, cf. bindPopupButton). */
|
||||
function popupHtml(tier: VisitableTier): string {
|
||||
const name = escapeHtml(tier.displayName)
|
||||
const address = escapeHtml(tier.address)
|
||||
|
||||
return `<div class="text-sm">
|
||||
<div class="font-semibold">${name}</div>
|
||||
<div class="text-gray-600">${address}</div>
|
||||
<button type="button" data-add-tier class="mt-2 rounded bg-blue-600 px-2 py-1 text-xs font-medium text-white">${t('field_sales.plan.map.add')}</button>
|
||||
</div>`
|
||||
}
|
||||
|
||||
/** Cable le bouton « + Ajouter » du popup ouvert vers l'emit `add-tier`. */
|
||||
function bindPopupButton(tier: VisitableTier): void {
|
||||
const el = map?.getContainer().querySelector('[data-add-tier]')
|
||||
el?.addEventListener('click', () => {
|
||||
emit('add-tier', tier)
|
||||
map?.closePopup()
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
// ── Selection rectangle (lasso) ─────────────────────────────────────────────
|
||||
let selectStart: import('leaflet').LatLng | null = null
|
||||
|
||||
function onMouseDown(e: import('leaflet').LeafletMouseEvent): void {
|
||||
if (map === null || L === null || !e.originalEvent.shiftKey) {
|
||||
return
|
||||
}
|
||||
// Empeche le drag de la carte pendant la selection.
|
||||
map.dragging.disable()
|
||||
selectStart = e.latlng
|
||||
selectionRect = L.rectangle(L.latLngBounds(e.latlng, e.latlng), {
|
||||
color: '#2563eb',
|
||||
weight: 1,
|
||||
fillOpacity: 0.1,
|
||||
}).addTo(map)
|
||||
|
||||
map.on('mousemove', onMouseMove)
|
||||
map.on('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
function onMouseMove(e: import('leaflet').LeafletMouseEvent): void {
|
||||
if (selectStart === null || selectionRect === null || L === null) {
|
||||
return
|
||||
}
|
||||
selectionRect.setBounds(L.latLngBounds(selectStart, e.latlng))
|
||||
}
|
||||
|
||||
function onMouseUp(): void {
|
||||
if (map === null) {
|
||||
return
|
||||
}
|
||||
const bounds = selectionRect?.getBounds() ?? null
|
||||
cleanupSelection()
|
||||
|
||||
if (bounds === null) {
|
||||
return
|
||||
}
|
||||
const selected = pinTiers
|
||||
.filter(({ marker }) => bounds.contains(marker.getLatLng()))
|
||||
.map(({ tier }) => tier)
|
||||
|
||||
if (selected.length > 0) {
|
||||
emit('add-tiers', selected)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupSelection(): void {
|
||||
selectionRect?.remove()
|
||||
selectionRect = null
|
||||
selectStart = null
|
||||
map?.off('mousemove', onMouseMove)
|
||||
map?.off('mouseup', onMouseUp)
|
||||
map?.dragging.enable()
|
||||
}
|
||||
|
||||
// ── Trace de la tournee ──────────────────────────────────────────────────────
|
||||
function drawRoute(): void {
|
||||
if (map === null || L === null) {
|
||||
return
|
||||
}
|
||||
routeLine?.remove()
|
||||
routeLine = null
|
||||
stopMarkers.forEach(m => m.remove())
|
||||
stopMarkers = []
|
||||
|
||||
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])
|
||||
|
||||
if (points.length >= 2) {
|
||||
routeLine = L.polyline(points, { color: '#1e40af', weight: 3, opacity: 0.7 }).addTo(map)
|
||||
}
|
||||
|
||||
located.forEach((stop, index) => {
|
||||
const marker = L!.marker([stop.latitude as number, stop.longitude as number], {
|
||||
icon: numberedIcon(index + 1),
|
||||
zIndexOffset: 1000,
|
||||
}).addTo(map!)
|
||||
marker.bindTooltip(stop.label, { direction: 'top' })
|
||||
stopMarkers.push(marker)
|
||||
})
|
||||
}
|
||||
|
||||
/** Pastille numerotee pour une etape de la tournee. */
|
||||
function numberedIcon(n: number) {
|
||||
return L!.divIcon({
|
||||
className: '',
|
||||
html: `<div style="display:flex;align-items:center;justify-content:center;width:24px;height:24px;border-radius:9999px;background:#1e40af;color:#fff;font-size:12px;font-weight:700;border:2px solid #fff;box-shadow:0 1px 2px rgba(0,0,0,.4)">${n}</div>`,
|
||||
iconSize: [24, 24],
|
||||
iconAnchor: [12, 12],
|
||||
})
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
// Recharge les pins quand les filtres changent.
|
||||
watch(() => [props.types, props.search], scheduleFetch, { deep: true })
|
||||
|
||||
// Redessine le trace quand les etapes changent.
|
||||
watch(() => props.stops, drawRoute, { deep: true })
|
||||
|
||||
onMounted(ensureMap)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (fetchTimer !== null) {
|
||||
clearTimeout(fetchTimer)
|
||||
}
|
||||
map?.remove()
|
||||
map = null
|
||||
L = null
|
||||
pinLayer = []
|
||||
pinTiers = []
|
||||
stopMarkers = []
|
||||
routeLine = null
|
||||
selectionRect = null
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
/** Recentre la carte sur une cible (ex: depuis le panneau). */
|
||||
panTo(target: { latitude: number, longitude: number }) {
|
||||
map?.panTo([target.latitude, target.longitude])
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div>
|
||||
<p v-if="stops.length === 0" class="py-6 text-center text-sm text-gray-500" data-testid="stops-empty">
|
||||
{{ t('field_sales.plan.panel.noStops') }}
|
||||
</p>
|
||||
|
||||
<!-- Liste draggable (vuedraggable / SortableJS) : au drop, on emet le
|
||||
nouvel ordre. La poignee limite le drag a l'icone (le reste de la
|
||||
ligne reste cliquable). Etat 100 % local cote parent. -->
|
||||
<draggable
|
||||
v-else
|
||||
:model-value="stops"
|
||||
item-key="id"
|
||||
handle=".drag-handle"
|
||||
ghost-class="opacity-50"
|
||||
class="flex flex-col gap-2"
|
||||
@update:model-value="onReorder"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<div
|
||||
class="flex items-start gap-2 rounded border border-gray-200 bg-white p-2"
|
||||
:data-testid="`stop-${element.id}`"
|
||||
>
|
||||
<!-- Poignee de drag + numero d'ordre. -->
|
||||
<button
|
||||
type="button"
|
||||
class="drag-handle mt-0.5 flex h-7 w-7 shrink-0 cursor-grab items-center justify-center rounded-full bg-blue-800 text-xs font-bold text-white"
|
||||
:aria-label="t('field_sales.plan.panel.stops')"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</button>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="truncate font-medium text-gray-900">{{ element.label }}</span>
|
||||
<span
|
||||
v-if="!isStopLocated(element)"
|
||||
class="shrink-0 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800"
|
||||
>
|
||||
{{ t('field_sales.plan.stop.toGeolocate') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="truncate text-xs text-gray-500">{{ element.displayAddress }}</p>
|
||||
|
||||
<!-- ETA + temps depuis l'etape precedente. -->
|
||||
<p v-if="isStopLocated(element)" class="mt-0.5 text-xs text-gray-600">
|
||||
<span class="font-medium">{{ t('field_sales.plan.stop.eta') }}</span>
|
||||
{{ formatTime(element.eta) }}
|
||||
<span v-if="index > 0" class="text-gray-400">
|
||||
· {{ formatDuration(element.legDurationS) }} / {{ formatDistance(element.legDistanceM) }}
|
||||
{{ t('field_sales.plan.stop.fromPrevious') }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<!-- Actions : Y aller (deep links) · Voir le Tiers. -->
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="font-medium text-blue-700 hover:underline disabled:text-gray-300"
|
||||
:disabled="navLinks(element) === null"
|
||||
@click="toggleMenu(element.id)"
|
||||
>
|
||||
{{ t('field_sales.plan.stop.goThere') }}
|
||||
</button>
|
||||
<div
|
||||
v-if="openMenuId === element.id && navLinks(element) !== null"
|
||||
class="absolute z-10 mt-1 flex flex-col rounded border border-gray-200 bg-white py-1 shadow-lg"
|
||||
>
|
||||
<a :href="navLinks(element)!.waze" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.waze') }}</a>
|
||||
<a :href="navLinks(element)!.google" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.google') }}</a>
|
||||
<a :href="navLinks(element)!.apple" target="_blank" rel="noopener" class="px-3 py-1 hover:bg-gray-100" @click="openMenuId = null">{{ t('field_sales.plan.stop.apple') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="element.tierType !== 'custom'"
|
||||
type="button"
|
||||
class="text-gray-600 hover:underline"
|
||||
@click="emit('view-tier', element)"
|
||||
>
|
||||
{{ t('field_sales.plan.stop.viewTier') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suppression de l'etape. -->
|
||||
<button
|
||||
type="button"
|
||||
class="mt-0.5 shrink-0 text-gray-400 hover:text-red-600"
|
||||
:aria-label="t('field_sales.plan.stop.remove')"
|
||||
@click="emit('remove', element)"
|
||||
>
|
||||
<Icon name="mdi:close" size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import {
|
||||
buildNavigationLinks,
|
||||
isStopLocated,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
type PlanningStop,
|
||||
} from '~/modules/field-sales/composables/useTourPlanning'
|
||||
import type { NavigationLinks } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Liste ordonnee et draggable des etapes d'une tournee (panneau de
|
||||
* planification, M6.5). Le reordonnancement (drag & drop) emet le nouvel ordre ;
|
||||
* la persistance (POST /reorder) est a la charge de la page.
|
||||
*/
|
||||
defineProps<{
|
||||
stops: PlanningStop[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** Nouvel ordre des etapes apres drop. */
|
||||
'reorder': [stops: PlanningStop[]]
|
||||
/** Retrait d'une etape. */
|
||||
'remove': [stop: PlanningStop]
|
||||
/** « Voir le Tiers » (etape sur Tiers referentiel). */
|
||||
'view-tier': [stop: PlanningStop]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Menu « Y aller » ouvert (id de l'etape) ou null. */
|
||||
const openMenuId = ref<number | null>(null)
|
||||
|
||||
function toggleMenu(id: number): void {
|
||||
openMenuId.value = openMenuId.value === id ? null : id
|
||||
}
|
||||
|
||||
function navLinks(stop: PlanningStop): NavigationLinks | null {
|
||||
return buildNavigationLinks(stop)
|
||||
}
|
||||
|
||||
function onReorder(next: PlanningStop[]): void {
|
||||
emit('reorder', next)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
reorderStops,
|
||||
computeTotals,
|
||||
buildNavigationLinks,
|
||||
isStopLocated,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
type PlanningStop,
|
||||
} from '../useTourPlanning'
|
||||
|
||||
/** Fabrique une etape de planification minimale pour les tests. */
|
||||
function makeStop(overrides: Partial<PlanningStop> = {}): PlanningStop {
|
||||
return {
|
||||
id: overrides.id ?? 1,
|
||||
tierType: overrides.tierType ?? 'client',
|
||||
tierId: overrides.tierId ?? null,
|
||||
addressId: overrides.addressId ?? null,
|
||||
customLabel: null,
|
||||
customAddress: null,
|
||||
customLatitude: null,
|
||||
customLongitude: null,
|
||||
position: overrides.position ?? 0,
|
||||
visitMinutes: overrides.visitMinutes ?? null,
|
||||
legDistanceM: overrides.legDistanceM ?? null,
|
||||
legDurationS: overrides.legDurationS ?? null,
|
||||
eta: overrides.eta ?? null,
|
||||
label: overrides.label ?? 'Étape',
|
||||
displayAddress: overrides.displayAddress ?? '',
|
||||
latitude: overrides.latitude ?? null,
|
||||
longitude: overrides.longitude ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('reorderStops', () => {
|
||||
it('deplace une etape et renumerote les positions de maniere contigue', () => {
|
||||
const stops = [
|
||||
makeStop({ id: 1, position: 0, label: 'A' }),
|
||||
makeStop({ id: 2, position: 1, label: 'B' }),
|
||||
makeStop({ id: 3, position: 2, label: 'C' }),
|
||||
]
|
||||
|
||||
// Deplace C (index 2) en tete (index 0).
|
||||
const result = reorderStops(stops, 2, 0)
|
||||
|
||||
expect(result.map(s => s.label)).toEqual(['C', 'A', 'B'])
|
||||
expect(result.map(s => s.position)).toEqual([0, 1, 2])
|
||||
})
|
||||
|
||||
it('ne mute pas le tableau source', () => {
|
||||
const stops = [makeStop({ id: 1, position: 0 }), makeStop({ id: 2, position: 1 })]
|
||||
reorderStops(stops, 0, 1)
|
||||
expect(stops.map(s => s.id)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('retourne une copie inchangee si un index est hors borne', () => {
|
||||
const stops = [makeStop({ id: 1, position: 0 })]
|
||||
const result = reorderStops(stops, 0, 5)
|
||||
expect(result.map(s => s.id)).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeTotals', () => {
|
||||
it('somme distances/trajets et ajoute les visites (defaut + specifique)', () => {
|
||||
const stops = [
|
||||
// 1re etape : pas de leg (point de depart). Visite = defaut 30 min.
|
||||
makeStop({ id: 1, legDistanceM: null, legDurationS: null }),
|
||||
// 2e : 10 km / 12 min de trajet, visite specifique 15 min.
|
||||
makeStop({ id: 2, legDistanceM: 10_000, legDurationS: 720, visitMinutes: 15 }),
|
||||
// 3e : 5 km / 6 min, visite par defaut.
|
||||
makeStop({ id: 3, legDistanceM: 5_000, legDurationS: 360, visitMinutes: null }),
|
||||
]
|
||||
|
||||
const totals = computeTotals(stops, 30)
|
||||
|
||||
expect(totals.totalDistanceM).toBe(15_000)
|
||||
expect(totals.travelDurationS).toBe(1_080)
|
||||
// Visites : 30 + 15 + 30 = 75 min = 4500 s.
|
||||
expect(totals.visitDurationS).toBe(4_500)
|
||||
expect(totals.totalDurationS).toBe(1_080 + 4_500)
|
||||
expect(totals.visitCount).toBe(3)
|
||||
})
|
||||
|
||||
it('renvoie des totaux nuls pour une tournee vide', () => {
|
||||
const totals = computeTotals([], 30)
|
||||
expect(totals.totalDistanceM).toBe(0)
|
||||
expect(totals.totalDurationS).toBe(0)
|
||||
expect(totals.visitCount).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildNavigationLinks', () => {
|
||||
it('construit les trois deep links Waze/Google/Apple', () => {
|
||||
const links = buildNavigationLinks({ latitude: 47.218, longitude: -1.553 })
|
||||
|
||||
expect(links).not.toBeNull()
|
||||
expect(links!.waze).toBe('https://waze.com/ul?ll=47.218,-1.553&navigate=yes')
|
||||
expect(links!.google).toBe('https://www.google.com/maps/dir/?api=1&destination=47.218,-1.553')
|
||||
expect(links!.apple).toBe('https://maps.apple.com/?daddr=47.218,-1.553')
|
||||
})
|
||||
|
||||
it('retourne null sans coordonnees (etape a geolocaliser)', () => {
|
||||
expect(buildNavigationLinks(null)).toBeNull()
|
||||
expect(buildNavigationLinks({ latitude: 47.2 })).toBeNull()
|
||||
expect(buildNavigationLinks({ latitude: null, longitude: null })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isStopLocated', () => {
|
||||
it('distingue une etape geolocalisee d\'une etape sans coordonnees', () => {
|
||||
expect(isStopLocated({ latitude: 47.2, longitude: -1.5 })).toBe(true)
|
||||
expect(isStopLocated({ latitude: null, longitude: null })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatteurs', () => {
|
||||
it('formate distances et durees', () => {
|
||||
expect(formatDistance(850)).toBe('850 m')
|
||||
expect(formatDistance(12_340)).toBe('12,3 km')
|
||||
expect(formatDistance(null)).toBe('—')
|
||||
|
||||
expect(formatDuration(1_500)).toBe('25 min')
|
||||
expect(formatDuration(5_100)).toBe('1 h 25')
|
||||
expect(formatDuration(null)).toBe('—')
|
||||
})
|
||||
|
||||
it('extrait l\'heure HH:MM d\'une chaine ISO', () => {
|
||||
expect(formatTime('1970-01-01T08:30:00+00:00')).toBe('08:30')
|
||||
expect(formatTime(null)).toBe('—')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { NavigationLinks, TierType, TourStop, TourTotals } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Composable de planification de tournee (M6.5).
|
||||
*
|
||||
* Porte la logique PURE de l'ecran de planification, isolee de Vue/Nuxt pour
|
||||
* etre testable directement (Vitest) :
|
||||
* - reordonnancement des etapes (drag & drop) + renumerotation des positions ;
|
||||
* - recalcul instantane des totaux (trajets + visites) pour le feedback UI,
|
||||
* avant le retour serveur du /compute ;
|
||||
* - construction des deep links de navigation « Y aller » (Waze/Google/Apple).
|
||||
*
|
||||
* Les coordonnees et libelles des etapes sur Tiers referentiel ne sont pas
|
||||
* portes par tour_stop:read : l'ecran les resout via GET /visitable_tiers/{id}
|
||||
* et alimente un `PlanningStop` enrichi, sur lequel operent ces fonctions.
|
||||
*/
|
||||
|
||||
/** Coordonnees WGS84 minimales d'une cible. */
|
||||
export interface LatLng {
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Etape « enrichie » manipulee par l'ecran : l'etape API + le libelle, l'adresse
|
||||
* et les coordonnees resolus (depuis le Tiers pour une etape referentiel, depuis
|
||||
* les colonnes custom_* pour un point libre).
|
||||
*/
|
||||
export interface PlanningStop extends TourStop {
|
||||
/** Nom affichable (raison sociale du Tiers ou libelle du point libre). */
|
||||
label: string
|
||||
/** Adresse formatee sur une ligne. */
|
||||
displayAddress: string
|
||||
/** Coordonnees resolues, ou null si l'etape n'est pas geolocalisee (RG-6.05). */
|
||||
latitude: number | null
|
||||
longitude: number | null
|
||||
}
|
||||
|
||||
/** Vitesse moyenne par defaut (km/h) — alignee sur HaversineRouteEngine (back). */
|
||||
const DEFAULT_SPEED_KMH = 50
|
||||
|
||||
/**
|
||||
* Deplace l'etape `fromIndex` vers `toIndex` et renumerote toutes les positions
|
||||
* (0-indexees, contigues). Retourne un NOUVEAU tableau (pas de mutation).
|
||||
*/
|
||||
export function reorderStops<T extends { position: number }>(stops: readonly T[], fromIndex: number, toIndex: number): T[] {
|
||||
const next = [...stops]
|
||||
if (fromIndex < 0 || fromIndex >= next.length || toIndex < 0 || toIndex >= next.length) {
|
||||
return next
|
||||
}
|
||||
const [moved] = next.splice(fromIndex, 1)
|
||||
if (moved === undefined) {
|
||||
return next
|
||||
}
|
||||
next.splice(toIndex, 0, moved)
|
||||
|
||||
return next.map((stop, index) => ({ ...stop, position: index }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalcule les totaux d'une tournee a partir des legs deja calcules et des
|
||||
* durees de visite (RG-6.11). Duree totale = trajets + visites.
|
||||
*/
|
||||
export function computeTotals(stops: readonly PlanningStop[], defaultVisitMinutes: number): TourTotals {
|
||||
let totalDistanceM = 0
|
||||
let travelDurationS = 0
|
||||
let visitDurationS = 0
|
||||
|
||||
for (const stop of stops) {
|
||||
totalDistanceM += stop.legDistanceM ?? 0
|
||||
travelDurationS += stop.legDurationS ?? 0
|
||||
visitDurationS += (stop.visitMinutes ?? defaultVisitMinutes) * 60
|
||||
}
|
||||
|
||||
return {
|
||||
totalDistanceM,
|
||||
travelDurationS,
|
||||
visitDurationS,
|
||||
totalDurationS: travelDurationS + visitDurationS,
|
||||
visitCount: stops.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep links de navigation vers une cible geolocalisee (spec M6 § 6.1).
|
||||
* Waze/Google Maps ne prennent qu'UNE destination -> navigation etape par etape
|
||||
* (HP-M6-7 assume). Retourne null si la cible n'a pas de coordonnees.
|
||||
*/
|
||||
export function buildNavigationLinks(target: { latitude?: number | null, longitude?: number | null } | null): NavigationLinks | null {
|
||||
if (target == null || target.latitude == null || target.longitude == null) {
|
||||
return null
|
||||
}
|
||||
const lat = target.latitude
|
||||
const lng = target.longitude
|
||||
|
||||
return {
|
||||
waze: `https://waze.com/ul?ll=${lat},${lng}&navigate=yes`,
|
||||
google: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`,
|
||||
apple: `https://maps.apple.com/?daddr=${lat},${lng}`,
|
||||
}
|
||||
}
|
||||
|
||||
/** Vrai si l'etape est geolocalisee (entre dans le calcul de trajet, RG-6.05). */
|
||||
export function isStopLocated(stop: Pick<PlanningStop, 'latitude' | 'longitude'>): boolean {
|
||||
return stop.latitude != null && stop.longitude != null
|
||||
}
|
||||
|
||||
/** Estime une duree de trajet (s) a partir d'une distance (m) et la vitesse moyenne. */
|
||||
export function estimateDurationSeconds(distanceMeters: number, speedKmh: number = DEFAULT_SPEED_KMH): number {
|
||||
if (speedKmh <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return Math.round((distanceMeters / 1000) / speedKmh * 3600)
|
||||
}
|
||||
|
||||
/** Formate une distance (m) en « 12,3 km » ou « 850 m ». */
|
||||
export function formatDistance(meters: number | null): string {
|
||||
if (meters == null) {
|
||||
return '—'
|
||||
}
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
|
||||
return `${(meters / 1000).toFixed(1).replace('.', ',')} km`
|
||||
}
|
||||
|
||||
/** Formate une duree (s) en « 1 h 25 » ou « 25 min ». */
|
||||
export function formatDuration(seconds: number | null): string {
|
||||
if (seconds == null) {
|
||||
return '—'
|
||||
}
|
||||
const totalMinutes = Math.round(seconds / 60)
|
||||
const hours = Math.floor(totalMinutes / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
if (hours === 0) {
|
||||
return `${minutes} min`
|
||||
}
|
||||
|
||||
return `${hours} h ${String(minutes).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Extrait l'heure « HH:MM » d'une chaine ISO (eta / departureTime). */
|
||||
export function formatTime(iso: string | null): string {
|
||||
if (iso == null || iso === '') {
|
||||
return '—'
|
||||
}
|
||||
const match = iso.match(/(\d{2}):(\d{2})/)
|
||||
|
||||
return match ? `${match[1]}:${match[2]}` : '—'
|
||||
}
|
||||
|
||||
/** Libelle FR court d'un type de Tiers (pour la couleur/le badge du pin). */
|
||||
export function tierTypeLabel(type: TierType): string {
|
||||
switch (type) {
|
||||
case 'client':
|
||||
return 'Client'
|
||||
case 'supplier':
|
||||
return 'Fournisseur'
|
||||
default:
|
||||
return 'Point libre'
|
||||
}
|
||||
}
|
||||
|
||||
export function useTourPlanning() {
|
||||
return {
|
||||
reorderStops,
|
||||
computeTotals,
|
||||
buildNavigationLinks,
|
||||
isStopLocated,
|
||||
estimateDurationSeconds,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
tierTypeLabel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { usePaginatedList } from '~/shared/composables/usePaginatedList'
|
||||
import type { Tour } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Liste paginee des tournees (GET /api/tours), branchee sur usePaginatedList
|
||||
* (regle ABSOLUE n°13 : toute collection est paginee). Tri par date decroissante
|
||||
* par defaut. Le filtre `owner` est applique cote back (RG-6.01) — rien a passer
|
||||
* ici.
|
||||
*/
|
||||
export function useToursRepository() {
|
||||
return usePaginatedList<Tour>({
|
||||
url: '/tours',
|
||||
defaultSort: { field: 'tourDate', direction: 'desc' },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Layer Nuxt du module « Tournées » (field_sales, M6). Auto-detecte par le
|
||||
// shell via le scan de frontend/modules/*/. Config minimale : pages,
|
||||
// composants et composables sont decouverts par convention.
|
||||
export default defineNuxtConfig({})
|
||||
@@ -0,0 +1,648 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Entete : retour + nom de la tournee. -->
|
||||
<div class="flex items-center gap-3 pt-6 pb-4">
|
||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="t('field_sales.plan.back')" @click="goBack" />
|
||||
<h1 class="truncate text-[24px] font-semibold text-primary-500">
|
||||
{{ tour?.label ?? t('field_sales.plan.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Layout split responsive : carte + panneau cote a cote en desktop,
|
||||
empile en mobile (carte au-dessus). Etat 100 % local. -->
|
||||
<div class="flex flex-col gap-4 lg:h-[calc(100vh-180px)] lg:flex-row">
|
||||
<!-- Carte interactive. -->
|
||||
<div class="relative h-[45vh] overflow-hidden rounded border border-gray-200 lg:h-auto lg:flex-1">
|
||||
<TourMap
|
||||
ref="mapRef"
|
||||
:stops="stops"
|
||||
:types="activeTypes"
|
||||
:search="mapSearch"
|
||||
:center="mapCenter"
|
||||
@add-tier="addTier"
|
||||
@add-tiers="addTiers"
|
||||
/>
|
||||
<!-- Filtres de la carte (types + recherche). -->
|
||||
<div class="absolute right-2 top-2 z-[400] flex flex-col gap-2 rounded bg-white/95 p-2 shadow">
|
||||
<MalioInputText
|
||||
v-model="mapSearch"
|
||||
:placeholder="t('field_sales.plan.map.search')"
|
||||
icon-name="mdi:magnify"
|
||||
:reserve-message-space="false"
|
||||
input-class="w-44"
|
||||
/>
|
||||
<div class="flex gap-3 text-xs">
|
||||
<label class="flex items-center gap-1">
|
||||
<input v-model="showClients" type="checkbox" class="accent-blue-600"> {{ t('field_sales.plan.map.typeClient') }}
|
||||
</label>
|
||||
<label class="flex items-center gap-1">
|
||||
<input v-model="showSuppliers" type="checkbox" class="accent-green-600"> {{ t('field_sales.plan.map.typeSupplier') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Panneau tournee. -->
|
||||
<div class="flex flex-col gap-4 overflow-y-auto rounded border border-gray-200 p-4 lg:w-[420px]">
|
||||
<!-- Parametres de la tournee. -->
|
||||
<div class="flex flex-col gap-3">
|
||||
<MalioInputText
|
||||
v-model="panel.label"
|
||||
:label="t('field_sales.plan.panel.label')"
|
||||
@update:model-value="debouncedSaveLabel"
|
||||
/>
|
||||
<div class="flex gap-3">
|
||||
<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"
|
||||
/>
|
||||
<MalioInputNumber
|
||||
v-model="panel.defaultVisitMinutes"
|
||||
:label="t('field_sales.plan.panel.defaultVisitMinutes')"
|
||||
:min="0"
|
||||
@update:model-value="debouncedSaveVisitMinutes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Totaux. -->
|
||||
<div class="grid grid-cols-3 gap-2 rounded bg-gray-50 p-3 text-center">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.distance') }}</p>
|
||||
<p class="font-semibold">{{ formatDistance(totals.totalDistanceM) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.duration') }}</p>
|
||||
<p class="font-semibold">{{ formatDuration(totals.totalDurationS) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">{{ t('field_sales.plan.panel.visits') }}</p>
|
||||
<p class="font-semibold">{{ totals.visitCount }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions tournee. -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<MalioButton variant="primary" :label="t('field_sales.plan.actions.compute')" :disabled="busy" @click="runCompute" />
|
||||
<MalioButton variant="secondary" :label="t('field_sales.plan.actions.optimize')" :disabled="busy" @click="runOptimize" />
|
||||
<MalioButton variant="tertiary" :label="t('field_sales.plan.actions.duplicate')" :disabled="busy" @click="duplicateOpen = true" />
|
||||
<MalioButton variant="tertiary" :label="t('field_sales.plan.actions.pdf')" @click="openPdf" />
|
||||
</div>
|
||||
|
||||
<!-- Etapes draggables. -->
|
||||
<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"
|
||||
@reorder="onReorder"
|
||||
@remove="removeStop"
|
||||
@view-tier="viewTier"
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<h2 class="text-[22px] font-bold">{{ t('field_sales.plan.duplicateModal.title') }}</h2>
|
||||
</template>
|
||||
<MalioDate v-model="duplicateDate" :label="t('field_sales.plan.duplicateModal.date')" required :error="duplicateError" />
|
||||
<template #footer>
|
||||
<MalioButton variant="secondary" :label="t('field_sales.plan.duplicateModal.cancel')" button-class="flex-1" @click="duplicateOpen = false" />
|
||||
<MalioButton variant="primary" :label="t('field_sales.plan.duplicateModal.confirm')" button-class="flex-1" :disabled="busy" @click="confirmDuplicate" />
|
||||
</template>
|
||||
</MalioModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import TourMap from '~/modules/field-sales/components/TourMap.vue'
|
||||
import TourStopList from '~/modules/field-sales/components/TourStopList.vue'
|
||||
import {
|
||||
computeTotals,
|
||||
formatDistance,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
type PlanningStop,
|
||||
} from '~/modules/field-sales/composables/useTourPlanning'
|
||||
import { useAddressAutocomplete } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { Tour, TourStop, VisitableTier } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Ecran de planification d'une tournee (M6.5, spec § 6.1).
|
||||
*
|
||||
* Carte interactive (pins + lasso + trace) a gauche, panneau (parametres,
|
||||
* totaux, actions, etapes draggables) a droite ; empile en mobile. Etat 100 %
|
||||
* LOCAL (jamais dans l'URL, regle ABSOLUE n°6).
|
||||
*
|
||||
* Les etapes sur Tiers referentiel ne portent pas leurs coordonnees/nom dans
|
||||
* tour_stop:read : on les resout via GET /visitable_tiers/{type}-{addressId}
|
||||
* (cache local) pour alimenter des `PlanningStop` enrichis.
|
||||
*/
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const tourId = computed(() => Number(route.params.id))
|
||||
|
||||
const tour = ref<Tour | null>(null)
|
||||
const stops = ref<PlanningStop[]>([])
|
||||
const busy = ref(false)
|
||||
const mapRef = ref<InstanceType<typeof TourMap> | null>(null)
|
||||
|
||||
useHead({ title: () => tour.value?.label ?? t('field_sales.plan.title') })
|
||||
|
||||
// Cache des infos Tiers (nom/adresse/coords) par cle « type-addressId » : evite
|
||||
// de refetcher /visitable_tiers/{id} a chaque recompute.
|
||||
const tierCache = new Map<string, { label: string, displayAddress: string, latitude: number, longitude: number }>()
|
||||
|
||||
// ── Panneau (formulaire local synchronise avec la tournee) ──────────────────
|
||||
// defaultVisitMinutes est une chaine (MalioInputNumber est un v-model string).
|
||||
const panel = reactive<{
|
||||
label: string
|
||||
tourDate: string | null
|
||||
departureTime: string | null
|
||||
startLabel: string
|
||||
defaultVisitMinutes: string
|
||||
}>({
|
||||
label: '',
|
||||
tourDate: null,
|
||||
departureTime: null,
|
||||
startLabel: '',
|
||||
defaultVisitMinutes: '30',
|
||||
})
|
||||
|
||||
/** Debounce simple (les saves lisent l'etat `panel`, donc sans argument). */
|
||||
function debounce(fn: () => void, ms: number): () => void {
|
||||
let handle: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
return () => {
|
||||
if (handle !== null) {
|
||||
clearTimeout(handle)
|
||||
}
|
||||
handle = setTimeout(fn, ms)
|
||||
}
|
||||
}
|
||||
const debouncedSaveLabel = debounce(() => { void saveLabel() }, 600)
|
||||
const debouncedSaveStart = debounce(() => { void saveStart() }, 800)
|
||||
const debouncedSaveVisitMinutes = debounce(() => { void saveVisitMinutes() }, 600)
|
||||
|
||||
// ── Carte : filtres ──────────────────────────────────────────────────────────
|
||||
const showClients = ref(true)
|
||||
const showSuppliers = ref(true)
|
||||
const mapSearch = ref('')
|
||||
const activeTypes = computed<Array<'client' | 'supplier'>>(() => {
|
||||
const types: Array<'client' | 'supplier'> = []
|
||||
if (showClients.value) {
|
||||
types.push('client')
|
||||
}
|
||||
if (showSuppliers.value) {
|
||||
types.push('supplier')
|
||||
}
|
||||
|
||||
return types
|
||||
})
|
||||
const mapCenter = ref<[number, number]>([47.218, -1.553])
|
||||
|
||||
// ── 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('')
|
||||
|
||||
// =============================================================================
|
||||
// Chargement + enrichissement
|
||||
// =============================================================================
|
||||
onMounted(loadTour)
|
||||
|
||||
async function loadTour(): Promise<void> {
|
||||
try {
|
||||
const raw = await api.get<Tour>(`/tours/${tourId.value}`, {}, {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
await applyTour(raw, true)
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.loadError') })
|
||||
router.push('/tours')
|
||||
}
|
||||
}
|
||||
|
||||
/** Applique une reponse Tour au state local. `withStops` re-enrichit les etapes. */
|
||||
async function applyTour(raw: Tour, withStops: boolean): Promise<void> {
|
||||
tour.value = raw
|
||||
panel.label = raw.label
|
||||
panel.tourDate = raw.tourDate ? raw.tourDate.slice(0, 10) : null
|
||||
panel.departureTime = extractTime(raw.departureTime)
|
||||
panel.startLabel = raw.startLabel ?? ''
|
||||
panel.defaultVisitMinutes = String(raw.defaultVisitMinutes)
|
||||
|
||||
if (withStops) {
|
||||
stops.value = await enrichStops(raw.stops ?? [])
|
||||
recenterOnFirstStop()
|
||||
}
|
||||
}
|
||||
|
||||
/** Resout nom/adresse/coords de chaque etape en `PlanningStop`. */
|
||||
async function enrichStops(rawStops: TourStop[]): Promise<PlanningStop[]> {
|
||||
const ordered = [...rawStops].sort((a, b) => a.position - b.position)
|
||||
|
||||
return Promise.all(ordered.map(async (stop): Promise<PlanningStop> => {
|
||||
if (stop.tierType === 'custom') {
|
||||
return {
|
||||
...stop,
|
||||
label: stop.customLabel ?? '',
|
||||
displayAddress: stop.customAddress ?? '',
|
||||
latitude: stop.customLatitude != null ? Number(stop.customLatitude) : null,
|
||||
longitude: stop.customLongitude != null ? Number(stop.customLongitude) : null,
|
||||
}
|
||||
}
|
||||
|
||||
const info = await resolveTier(stop.tierType, stop.addressId)
|
||||
|
||||
return {
|
||||
...stop,
|
||||
label: info?.label ?? `#${stop.tierId}`,
|
||||
displayAddress: info?.displayAddress ?? '',
|
||||
latitude: info?.latitude ?? null,
|
||||
longitude: info?.longitude ?? null,
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/** Infos d'un Tiers (cache + GET /visitable_tiers/{type-addressId}). */
|
||||
async function resolveTier(tierType: string, addressId: number | null) {
|
||||
if (addressId === null) {
|
||||
return null
|
||||
}
|
||||
const key = `${tierType}-${addressId}`
|
||||
const cached = tierCache.get(key)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
try {
|
||||
const tier = await api.get<VisitableTier>(`/visitable_tiers/${key}`, {}, {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
const info = {
|
||||
label: tier.displayName,
|
||||
displayAddress: tier.address,
|
||||
latitude: tier.latitude,
|
||||
longitude: tier.longitude,
|
||||
}
|
||||
tierCache.set(key, info)
|
||||
|
||||
return info
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function recenterOnFirstStop(): void {
|
||||
const located = stops.value.find(s => s.latitude != null && s.longitude != null)
|
||||
if (located) {
|
||||
mapCenter.value = [located.latitude as number, located.longitude as number]
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Ajout d'etapes (carte)
|
||||
// =============================================================================
|
||||
async function addTier(tier: VisitableTier): Promise<void> {
|
||||
// Pre-alimente le cache (la carte connait deja nom/adresse/coords du pin).
|
||||
tierCache.set(`${tier.tierType}-${tier.addressId}`, {
|
||||
label: tier.displayName,
|
||||
displayAddress: tier.address,
|
||||
latitude: tier.latitude,
|
||||
longitude: tier.longitude,
|
||||
})
|
||||
await postStop({
|
||||
tierType: tier.tierType,
|
||||
tierId: tier.tierId,
|
||||
addressId: tier.addressId,
|
||||
position: stops.value.length,
|
||||
})
|
||||
}
|
||||
|
||||
async function addTiers(tiers: VisitableTier[]): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
let position = stops.value.length
|
||||
for (const tier of tiers) {
|
||||
tierCache.set(`${tier.tierType}-${tier.addressId}`, {
|
||||
label: tier.displayName,
|
||||
displayAddress: tier.address,
|
||||
latitude: tier.latitude,
|
||||
longitude: tier.longitude,
|
||||
})
|
||||
await api.post('/tours/' + tourId.value + '/stops', {
|
||||
tierType: tier.tierType,
|
||||
tierId: tier.tierId,
|
||||
addressId: tier.addressId,
|
||||
position: position++,
|
||||
}, { toast: false })
|
||||
}
|
||||
await runCompute()
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** POST d'une etape puis recompute (factorise add tier / custom). */
|
||||
async function postStop(payload: Record<string, unknown>): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
await api.post('/tours/' + tourId.value + '/stops', payload, { toast: false })
|
||||
await runCompute()
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// =============================================================================
|
||||
async function onReorder(next: PlanningStop[]): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
// Optimisme : on reflete l'ordre immediatement, le serveur recalcule ensuite.
|
||||
stops.value = next.map((s, i) => ({ ...s, position: i }))
|
||||
busy.value = true
|
||||
try {
|
||||
const raw = await api.post<Tour>('/tours/' + tourId.value + '/reorder', {
|
||||
stopIds: next.map(s => s.id),
|
||||
}, { headers: { Accept: 'application/ld+json' }, toast: false })
|
||||
await applyTour(raw, true)
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
|
||||
await loadTour()
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeStop(stop: PlanningStop): Promise<void> {
|
||||
if (busy.value) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
await api.delete(`/tour_stops/${stop.id}`, {}, { toast: false })
|
||||
await runCompute()
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.stopError') })
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function viewTier(stop: PlanningStop): void {
|
||||
if (stop.tierId === null) {
|
||||
return
|
||||
}
|
||||
router.push(stop.tierType === 'supplier' ? `/suppliers/${stop.tierId}` : `/clients/${stop.tierId}`)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Actions tournee : compute / optimize / duplicate / pdf
|
||||
// =============================================================================
|
||||
async function runCompute(): Promise<void> {
|
||||
await runTourAction('/compute', 'computeError')
|
||||
}
|
||||
|
||||
async function runOptimize(): Promise<void> {
|
||||
await runTourAction('/optimize', 'optimizeError')
|
||||
}
|
||||
|
||||
/** Factorise compute/optimize : POST sans corps -> reapplique la tournee. */
|
||||
async function runTourAction(path: string, errorKey: string): Promise<void> {
|
||||
const wasBusy = busy.value
|
||||
busy.value = true
|
||||
try {
|
||||
const raw = await api.post<Tour>('/tours/' + tourId.value + path, {}, {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
await applyTour(raw, true)
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t(`field_sales.plan.toast.${errorKey}`) })
|
||||
}
|
||||
finally {
|
||||
busy.value = wasBusy ? busy.value : false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDuplicate(): Promise<void> {
|
||||
duplicateError.value = ''
|
||||
if (duplicateDate.value === null || duplicateDate.value === '') {
|
||||
duplicateError.value = t('field_sales.plan.duplicateModal.date')
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const copy = await api.post<Tour>('/tours/' + tourId.value + '/duplicate', {
|
||||
tourDate: duplicateDate.value,
|
||||
}, { headers: { Accept: 'application/ld+json' }, toast: false })
|
||||
toast.success({ title: t('field_sales.tours.title'), message: t('field_sales.plan.toast.duplicated') })
|
||||
duplicateOpen.value = false
|
||||
router.push(`/tours/${copy.id}/plan`)
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.duplicateError') })
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Ouvre la feuille de route PDF (le cookie JWT est envoye avec la requete). */
|
||||
function openPdf(): void {
|
||||
window.open(`/api/tours/${tourId.value}/roadbook.pdf`, '_blank')
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sauvegarde des parametres du panneau (PATCH, recompute si ETA impactee)
|
||||
// =============================================================================
|
||||
async function saveLabel(): Promise<void> {
|
||||
if (panel.label.trim() !== '' && panel.label !== tour.value?.label) {
|
||||
await patchTour({ label: panel.label.trim() }, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDate(): Promise<void> {
|
||||
if (panel.tourDate) {
|
||||
await patchTour({ tourDate: panel.tourDate }, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDepartureTime(): Promise<void> {
|
||||
if (panel.departureTime) {
|
||||
await patchTour({ departureTime: panel.departureTime }, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveVisitMinutes(): Promise<void> {
|
||||
const minutes = Number(panel.defaultVisitMinutes)
|
||||
if (!Number.isFinite(minutes) || minutes < 0) {
|
||||
return
|
||||
}
|
||||
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 ?? '') === '') {
|
||||
return
|
||||
}
|
||||
let coords: { latitude: string, longitude: string } | null = null
|
||||
if (label !== '') {
|
||||
try {
|
||||
coords = await autocomplete.geocode(label)
|
||||
}
|
||||
catch {
|
||||
coords = null
|
||||
}
|
||||
}
|
||||
await patchTour({
|
||||
startLabel: label === '' ? null : label,
|
||||
startLatitude: coords?.latitude ?? null,
|
||||
startLongitude: coords?.longitude ?? null,
|
||||
}, true)
|
||||
}
|
||||
|
||||
/** PATCH /tours/{id}. `recompute` enchaine /compute (ETA impactee). */
|
||||
async function patchTour(partial: Record<string, unknown>, recompute: boolean): Promise<void> {
|
||||
busy.value = true
|
||||
try {
|
||||
const raw = await api.patch<Tour>(`/tours/${tourId.value}`, partial, {
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
toast: false,
|
||||
})
|
||||
// PATCH renvoie tour:read SANS etapes : on ne touche pas a `stops`.
|
||||
await applyTour(raw, false)
|
||||
if (recompute) {
|
||||
await runCompute()
|
||||
}
|
||||
}
|
||||
catch {
|
||||
toast.error({ title: t('errors.title'), message: t('field_sales.plan.toast.saveError') })
|
||||
}
|
||||
finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Utilitaires
|
||||
// =============================================================================
|
||||
function extractTime(iso: string | null): string | null {
|
||||
const formatted = formatTime(iso)
|
||||
|
||||
return formatted === '—' ? null : formatted
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
router.push('/tours')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('field_sales.tours.title') }}
|
||||
<template #actions>
|
||||
<MalioButton
|
||||
v-if="canManage"
|
||||
variant="secondary"
|
||||
:label="t('field_sales.tours.add')"
|
||||
icon-name="mdi:add-bold"
|
||||
icon-position="left"
|
||||
@click="goToCreate"
|
||||
/>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<MalioDataTable
|
||||
:columns="columns"
|
||||
:items="rows"
|
||||
:total-items="totalItems"
|
||||
:page="currentPage"
|
||||
:per-page="itemsPerPage"
|
||||
:per-page-options="itemsPerPageOptions"
|
||||
row-clickable
|
||||
:empty-message="t('field_sales.tours.empty')"
|
||||
@row-click="onRowClick"
|
||||
@update:page="goToPage"
|
||||
@update:per-page="setItemsPerPage"
|
||||
>
|
||||
<template #cell-tourDate="{ item }">
|
||||
{{ formatDate(item.tourDate as string) }}
|
||||
</template>
|
||||
<template #cell-status="{ item }">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium" :class="statusClass(item.status as TourStatus)">
|
||||
{{ t(`field_sales.tours.status.${item.status}`) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-distance="{ item }">
|
||||
{{ formatDistance((item.totalDistanceM as number | null) ?? null) }}
|
||||
</template>
|
||||
<template #cell-duration="{ item }">
|
||||
{{ formatDuration((item.totalDurationS as number | null) ?? null) }}
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useToursRepository } from '~/modules/field-sales/composables/useToursRepository'
|
||||
import { formatDistance, formatDuration } from '~/modules/field-sales/composables/useTourPlanning'
|
||||
import type { Tour, TourStatus } from '~/modules/field-sales/types/tour'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
|
||||
useHead({ title: t('field_sales.tours.title') })
|
||||
|
||||
const canManage = computed(() => can('field_sales.tours.manage'))
|
||||
|
||||
const {
|
||||
items: tours,
|
||||
totalItems,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
itemsPerPageOptions,
|
||||
fetch: loadTours,
|
||||
goToPage,
|
||||
setItemsPerPage,
|
||||
} = useToursRepository()
|
||||
|
||||
const rows = computed(() => tours.value.map(tour => ({
|
||||
id: tour.id,
|
||||
label: tour.label,
|
||||
tourDate: tour.tourDate,
|
||||
status: tour.status,
|
||||
totalDistanceM: tour.totalDistanceM,
|
||||
totalDurationS: tour.totalDurationS,
|
||||
})))
|
||||
|
||||
const columns = [
|
||||
{ key: 'label', label: t('field_sales.tours.column.label') },
|
||||
{ key: 'tourDate', label: t('field_sales.tours.column.date') },
|
||||
{ key: 'status', label: t('field_sales.tours.column.status') },
|
||||
{ key: 'distance', label: t('field_sales.tours.column.distance') },
|
||||
{ key: 'duration', label: t('field_sales.tours.column.duration') },
|
||||
]
|
||||
|
||||
/** Couleur du badge de statut. */
|
||||
function statusClass(status: TourStatus): string {
|
||||
switch (status) {
|
||||
case 'planned':
|
||||
return 'bg-blue-100 text-blue-800'
|
||||
case 'in_progress':
|
||||
return 'bg-amber-100 text-amber-800'
|
||||
case 'done':
|
||||
return 'bg-green-100 text-green-800'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
/** Date courte FR (la date arrive en ISO depuis l'API). */
|
||||
function formatDate(iso: string): string {
|
||||
if (!iso) {
|
||||
return ''
|
||||
}
|
||||
const date = new Date(iso)
|
||||
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString('fr-FR')
|
||||
}
|
||||
|
||||
/** Clic ligne → ecran de planification de la tournee. */
|
||||
function onRowClick(item: Record<string, unknown>): void {
|
||||
router.push(`/tours/${(item as { id: Tour['id'] }).id}/plan`)
|
||||
}
|
||||
|
||||
function goToCreate(): void {
|
||||
router.push('/tours/new')
|
||||
}
|
||||
|
||||
onMounted(loadTours)
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader>
|
||||
{{ t('field_sales.tours.new.title') }}
|
||||
</PageHeader>
|
||||
|
||||
<div class="mx-auto max-w-xl">
|
||||
<form class="flex flex-col gap-4" @submit.prevent="submit">
|
||||
<MalioInputText
|
||||
v-model="form.label"
|
||||
:label="t('field_sales.tours.new.label')"
|
||||
required
|
||||
:error="errors.label"
|
||||
/>
|
||||
<MalioDate
|
||||
v-model="form.tourDate"
|
||||
:label="t('field_sales.tours.new.date')"
|
||||
required
|
||||
:error="errors.tourDate"
|
||||
/>
|
||||
|
||||
<div class="mt-2 flex justify-end gap-3">
|
||||
<MalioButton
|
||||
variant="secondary"
|
||||
:label="t('field_sales.tours.new.cancel')"
|
||||
type="button"
|
||||
@click="cancel"
|
||||
/>
|
||||
<MalioButton
|
||||
variant="primary"
|
||||
:label="t('field_sales.tours.new.create')"
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useFormErrors } from '~/shared/composables/useFormErrors'
|
||||
import type { Tour } from '~/modules/field-sales/types/tour'
|
||||
|
||||
/**
|
||||
* Creation d'une tournee (draft). Formulaire minimal (nom + date) : le reste de
|
||||
* la planification (etapes, point de depart, heure) se fait sur l'ecran de
|
||||
* planification une fois la tournee creee. Validation inline 422 via useFormErrors.
|
||||
*/
|
||||
const { t } = useI18n()
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
const { can } = usePermissions()
|
||||
const { errors, clearErrors, handleApiError } = useFormErrors()
|
||||
|
||||
useHead({ title: t('field_sales.tours.new.title') })
|
||||
|
||||
// Garde-fou : sans manage, on renvoie vers la liste (le back refuse de toute facon).
|
||||
if (!can('field_sales.tours.manage')) {
|
||||
router.replace('/tours')
|
||||
}
|
||||
|
||||
const form = reactive<{ label: string, tourDate: string | null }>({
|
||||
label: '',
|
||||
tourDate: null,
|
||||
})
|
||||
const submitting = ref(false)
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (submitting.value) {
|
||||
return
|
||||
}
|
||||
clearErrors()
|
||||
submitting.value = true
|
||||
try {
|
||||
const tour = await api.post<Tour>('/tours', {
|
||||
label: form.label,
|
||||
tourDate: form.tourDate,
|
||||
}, { toast: false })
|
||||
|
||||
// Enchaine directement sur la planification de la tournee creee.
|
||||
router.push(`/tours/${tour.id}/plan`)
|
||||
}
|
||||
catch (e) {
|
||||
handleApiError(e, { fallbackMessage: t('field_sales.tours.new.error') })
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
router.push('/tours')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Types du module « Tournées » (field_sales, M6.5).
|
||||
*
|
||||
* Reflet des DTO exposes par l'API (groupes `tour:read` / `tour_stop:read` /
|
||||
* VisitableTier). Les dates/heures arrivent en chaines ISO 8601 ; le formatage
|
||||
* d'affichage (HH:MM, jj/mm/aaaa) est fait dans les ecrans.
|
||||
*/
|
||||
|
||||
/** Type de Tiers visitable cote front (aligne sur l'enum ouvert du back). */
|
||||
export type TierType = 'client' | 'supplier' | 'custom'
|
||||
|
||||
/** Cycle de vie d'une tournee (RG-6.02). */
|
||||
export type TourStatus = 'draft' | 'planned' | 'in_progress' | 'done'
|
||||
|
||||
/** Une etape de tournee (tour_stop:read). */
|
||||
export interface TourStop {
|
||||
id: number
|
||||
tierType: TierType
|
||||
tierId: number | null
|
||||
addressId: number | null
|
||||
customLabel: string | null
|
||||
customAddress: string | null
|
||||
customLatitude: string | null
|
||||
customLongitude: string | null
|
||||
position: number
|
||||
visitMinutes: number | null
|
||||
/** Distance depuis l'etape precedente (m), calculee (compute). */
|
||||
legDistanceM: number | null
|
||||
/** Duree de trajet depuis l'etape precedente (s), calculee. */
|
||||
legDurationS: number | null
|
||||
/** Heure d'arrivee estimee (ISO time), calculee (RG-6.11). */
|
||||
eta: string | null
|
||||
}
|
||||
|
||||
/** Une tournee (tour:read + stops embarquees en tour:item:read). */
|
||||
export interface Tour {
|
||||
id: number
|
||||
label: string
|
||||
/** Date de realisation (ISO date). */
|
||||
tourDate: string
|
||||
/** Heure de depart (ISO time). */
|
||||
departureTime: string
|
||||
startLatitude: string | null
|
||||
startLongitude: string | null
|
||||
startLabel: string | null
|
||||
defaultVisitMinutes: number
|
||||
status: TourStatus
|
||||
totalDistanceM: number | null
|
||||
totalDurationS: number | null
|
||||
stops?: TourStop[]
|
||||
}
|
||||
|
||||
/** Un pin de la carte = une adresse geolocalisee d'un Tiers (VisitableTier). */
|
||||
export interface VisitableTier {
|
||||
id: string
|
||||
tierType: Exclude<TierType, 'custom'>
|
||||
tierId: number
|
||||
addressId: number
|
||||
displayName: string
|
||||
address: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
}
|
||||
|
||||
/** Liens d'ouverture de navigation externe (« Y aller »). */
|
||||
export interface NavigationLinks {
|
||||
waze: string
|
||||
google: string
|
||||
apple: string
|
||||
}
|
||||
|
||||
/** Totaux d'une tournee recalcules cote front (feedback instantane). */
|
||||
export interface TourTotals {
|
||||
/** Distance cumulee des trajets (m). */
|
||||
totalDistanceM: number
|
||||
/** Duree totale = trajets + visites (s). */
|
||||
totalDurationS: number
|
||||
/** Duree de trajet seule (s). */
|
||||
travelDurationS: number
|
||||
/** Duree de visite cumulee (s). */
|
||||
visitDurationS: number
|
||||
/** Nombre de visites (etapes). */
|
||||
visitCount: number
|
||||
}
|
||||
Reference in New Issue
Block a user