Files
Starseed/frontend/modules/field-sales/components/TourStopList.vue
T
Matthieu f8793ab359
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Failing after 56s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Failing after 21s
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
2026-06-11 17:38:40 +02:00

149 lines
6.7 KiB
Vue

<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>