f8793ab359
- 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
149 lines
6.7 KiB
Vue
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>
|