249 lines
8.0 KiB
Vue
249 lines
8.0 KiB
Vue
<template>
|
|
<div class="space-y-10">
|
|
<Card padding="lg" class="border border-base-300">
|
|
<RouteSummaryHeader :title="summaryTitle" :meta="summaryMeta" />
|
|
</Card>
|
|
|
|
<div v-if="pending" class="text-sm text-base-content/60">
|
|
Загрузка маршрутов...
|
|
</div>
|
|
|
|
<div v-else-if="error" class="text-sm text-error">
|
|
Ошибка загрузки маршрутов: {{ error.message }}
|
|
</div>
|
|
|
|
<div v-else-if="productRouteOptions.length > 0 || legacyRoutes.length > 0" class="space-y-10">
|
|
<div v-if="productRouteOptions.length" class="space-y-10">
|
|
<div
|
|
v-for="(option, optionIndex) in productRouteOptions"
|
|
:key="option.sourceUuid || optionIndex"
|
|
class="space-y-6"
|
|
>
|
|
<div class="space-y-1">
|
|
<Heading :level="3" weight="semibold">Источник {{ optionIndex + 1 }}</Heading>
|
|
<Text tone="muted" size="sm">{{ option.sourceName || 'Склад' }}</Text>
|
|
</div>
|
|
|
|
<div v-if="option.routes?.length" class="space-y-6">
|
|
<Card
|
|
v-for="(route, routeIndex) in option.routes"
|
|
:key="routeIndex"
|
|
padding="lg"
|
|
class="border border-base-300"
|
|
>
|
|
<Stack gap="4">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<Text weight="semibold">Маршрут {{ routeIndex + 1 }}</Text>
|
|
<Text tone="muted" size="sm">
|
|
{{ formatDistance(route.totalDistanceKm) }} км · {{ formatDuration(route.totalTimeSeconds) }}
|
|
</Text>
|
|
</div>
|
|
|
|
<RouteStagesList :stages="mapRouteStages(route)" />
|
|
|
|
<div class="divider my-0"></div>
|
|
|
|
<RequestRoutesMap :routes="[route]" :height="240" />
|
|
</Stack>
|
|
</Card>
|
|
</div>
|
|
|
|
<Text v-else tone="muted" size="sm">
|
|
Маршруты от источника не найдены.
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-if="!productRouteOptions.length && legacyRoutes.length">
|
|
<div class="space-y-6">
|
|
<Card
|
|
v-for="(route, routeIndex) in legacyRoutes"
|
|
:key="routeIndex"
|
|
padding="lg"
|
|
class="border border-base-300"
|
|
>
|
|
<Stack gap="4">
|
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
|
<Text weight="semibold">Маршрут {{ routeIndex + 1 }}</Text>
|
|
<Text tone="muted" size="sm">
|
|
{{ formatDistance(route.totalDistanceKm) }} км · {{ formatDuration(route.totalTimeSeconds) }}
|
|
</Text>
|
|
</div>
|
|
|
|
<RouteStagesList :stages="mapRouteStages(route)" />
|
|
|
|
<div class="divider my-0"></div>
|
|
|
|
<RequestRoutesMap :routes="[route]" :height="240" />
|
|
</Stack>
|
|
</Card>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-else class="text-sm text-base-content/60">
|
|
Маршруты не найдены. Возможно, нет связи между точками в графе.
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { FindRoutesDocument } from '~/composables/graphql/public/geo-generated'
|
|
import type { RoutePathType } from '~/composables/graphql/public/geo-generated'
|
|
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
|
|
|
|
const route = useRoute()
|
|
const searchStore = useSearchStore()
|
|
|
|
const productName = computed(() => searchStore.searchForm.product || (route.query.product as string) || 'Товар')
|
|
const locationName = computed(() => searchStore.searchForm.location || (route.query.location as string) || 'Назначение')
|
|
const quantity = computed(() => (route.query.quantity as string) || (searchStore.searchForm as any)?.quantity)
|
|
|
|
const summaryTitle = computed(() => `${productName.value} → ${locationName.value}`)
|
|
const summaryMeta = computed(() => {
|
|
const meta: string[] = []
|
|
if (quantity.value) {
|
|
meta.push(`${quantity.value}`)
|
|
}
|
|
return meta
|
|
})
|
|
|
|
// Determine context (new flow: product + destination; legacy: from param)
|
|
const productUuid = computed(() => (route.query.productUuid as string) || searchStore.searchForm.productUuid)
|
|
const destinationUuid = computed(() => (route.query.locationUuid as string) || searchStore.searchForm.locationUuid)
|
|
const legacyFromUuid = computed(() => route.params.id as string | undefined)
|
|
|
|
type ProductRouteOption = {
|
|
sourceUuid?: string | null
|
|
sourceName?: string | null
|
|
sourceLat?: number | null
|
|
sourceLon?: number | null
|
|
distanceKm?: number | null
|
|
routes?: RoutePathType[] | null
|
|
}
|
|
|
|
const fetchProductRoutes = async () => {
|
|
if (!productUuid.value || !destinationUuid.value) return null
|
|
const { client } = useApolloClient('publicGeo')
|
|
const { default: gql } = await import('graphql-tag')
|
|
|
|
const query = gql`
|
|
query FindProductRoutes($productUuid: String!, $toUuid: String!, $limitSources: Int, $limitRoutes: Int) {
|
|
findProductRoutes(
|
|
productUuid: $productUuid
|
|
toUuid: $toUuid
|
|
limitSources: $limitSources
|
|
limitRoutes: $limitRoutes
|
|
) {
|
|
sourceUuid
|
|
sourceName
|
|
sourceLat
|
|
sourceLon
|
|
distanceKm
|
|
routes {
|
|
totalDistanceKm
|
|
totalTimeSeconds
|
|
stages {
|
|
fromUuid
|
|
fromName
|
|
fromLat
|
|
fromLon
|
|
toUuid
|
|
toName
|
|
toLat
|
|
toLon
|
|
distanceKm
|
|
travelTimeSeconds
|
|
transportType
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
const { data } = await client.query({
|
|
query,
|
|
variables: {
|
|
productUuid: productUuid.value,
|
|
toUuid: destinationUuid.value,
|
|
limitSources: 5,
|
|
limitRoutes: 1
|
|
}
|
|
})
|
|
return data
|
|
}
|
|
|
|
const fetchLegacyRoutes = async () => {
|
|
if (!legacyFromUuid.value || !destinationUuid.value) return null
|
|
const { client } = useApolloClient('publicGeo')
|
|
const { data } = await client.query({
|
|
query: FindRoutesDocument,
|
|
variables: {
|
|
fromUuid: legacyFromUuid.value,
|
|
toUuid: destinationUuid.value,
|
|
limit: 3
|
|
}
|
|
})
|
|
return data
|
|
}
|
|
|
|
const { data: productRoutesData, pending, error } = await useAsyncData(
|
|
() => `product-routes-${productUuid.value}-${destinationUuid.value}-${legacyFromUuid.value || 'none'}`,
|
|
async () => {
|
|
// Prefer product-based routes; fallback to legacy if no product
|
|
if (productUuid.value && destinationUuid.value) {
|
|
return await fetchProductRoutes()
|
|
}
|
|
if (legacyFromUuid.value && destinationUuid.value) {
|
|
return await fetchLegacyRoutes()
|
|
}
|
|
return null
|
|
},
|
|
{ watch: [productUuid, destinationUuid, legacyFromUuid] }
|
|
)
|
|
|
|
const productRouteOptions = computed(() => {
|
|
const options = productRoutesData.value?.findProductRoutes as ProductRouteOption[] | undefined
|
|
return options?.filter(Boolean) || []
|
|
})
|
|
|
|
const legacyRoutes = computed(() => {
|
|
const data = productRoutesData.value?.findRoutes
|
|
if (!data) return []
|
|
return (data as (RoutePathType | null)[]).filter((r): r is RoutePathType => r !== null)
|
|
})
|
|
|
|
const mapRouteStages = (route: RoutePathType): RouteStageItem[] => {
|
|
return (route.stages || [])
|
|
.filter(Boolean)
|
|
.map((stage, index) => ({
|
|
key: stage?.fromUuid || `stage-${index}`,
|
|
from: stage?.fromName || 'Начало',
|
|
to: stage?.toName || 'Конец',
|
|
distanceKm: stage?.distanceKm,
|
|
durationSeconds: stage?.travelTimeSeconds
|
|
}))
|
|
}
|
|
|
|
// Formatting helpers
|
|
const formatDistance = (km: number | null | undefined) => {
|
|
if (!km) return '0'
|
|
return Math.round(km).toLocaleString()
|
|
}
|
|
|
|
const formatDuration = (seconds: number | null | undefined) => {
|
|
if (!seconds) return '-'
|
|
const hours = Math.floor(seconds / 3600)
|
|
const minutes = Math.floor((seconds % 3600) / 60)
|
|
if (hours > 24) {
|
|
const days = Math.floor(hours / 24)
|
|
const remainingHours = hours % 24
|
|
return `${days}д ${remainingHours}ч`
|
|
}
|
|
if (hours > 0) {
|
|
return `${hours}ч ${minutes}м`
|
|
}
|
|
return `${minutes}м`
|
|
}
|
|
</script>
|