320 lines
10 KiB
Vue
320 lines
10 KiB
Vue
<template>
|
|
<div class="space-y-6">
|
|
<!-- Header -->
|
|
<Card padding="lg" class="border border-base-300">
|
|
<RouteSummaryHeader :title="summaryTitle" :meta="summaryMeta" />
|
|
</Card>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="pending" class="text-sm text-base-content/60">
|
|
Загрузка маршрутов...
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div v-else-if="error" class="text-sm text-error">
|
|
Ошибка загрузки маршрутов: {{ error.message }}
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div v-else-if="productRouteOptions.length > 0" class="space-y-4">
|
|
<OfferResultCard
|
|
v-for="(option, index) in productRouteOptions"
|
|
:key="option.sourceUuid ?? index"
|
|
:supplier-name="getSupplierName(option.sourceUuid)"
|
|
:location-name="getOfferData(option.sourceUuid)?.locationName"
|
|
:product-name="productName"
|
|
:price-per-unit="parseFloat(getOfferData(option.sourceUuid)?.pricePerUnit || '0') || null"
|
|
:quantity="getOfferData(option.sourceUuid)?.quantity"
|
|
:currency="getOfferData(option.sourceUuid)?.currency"
|
|
:unit="getOfferData(option.sourceUuid)?.unit"
|
|
:stages="getRouteStages(option)"
|
|
:total-time-seconds="option.routes?.[0]?.totalTimeSeconds ?? null"
|
|
:kyc-profile-uuid="getKycProfileUuid(option.sourceUuid)"
|
|
@select="navigateToOffer(option.sourceUuid)"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Legacy routes (fallback) -->
|
|
<div v-else-if="legacyRoutes.length > 0" 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>
|
|
|
|
<!-- Empty -->
|
|
<div v-else class="text-sm text-base-content/60">
|
|
Маршруты не найдены. Возможно, нет связи между точками в графе.
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { GetNodeDocument, NearestOffersDocument } from '~/composables/graphql/public/geo-generated'
|
|
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
|
|
|
|
interface RouteStage {
|
|
fromUuid?: string | null
|
|
fromName?: string | null
|
|
toName?: string | null
|
|
distanceKm?: number | null
|
|
travelTimeSeconds?: number | null
|
|
transportType?: string | null
|
|
}
|
|
|
|
interface RoutePathType {
|
|
totalDistanceKm?: number | null
|
|
totalTimeSeconds?: number | null
|
|
stages?: (RouteStage | null)[]
|
|
}
|
|
import { GetOfferDocument, GetSupplierProfileByTeamDocument, type GetOfferQueryResult, type GetSupplierProfileByTeamQueryResult } from '~/composables/graphql/public/exchange-generated'
|
|
import type { OfferWithRouteType, RouteStageType } from '~/composables/graphql/public/geo-generated'
|
|
|
|
const route = useRoute()
|
|
const localePath = useLocalePath()
|
|
const searchStore = useSearchStore()
|
|
const { execute } = useGraphQL()
|
|
|
|
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.quantity)
|
|
|
|
// Offer data for prices
|
|
type OfferData = NonNullable<GetOfferQueryResult['getOffer']>
|
|
const offersData = ref<Map<string, OfferData>>(new Map())
|
|
// Supplier data for KYC profile UUID (by team_uuid)
|
|
type SupplierData = NonNullable<GetSupplierProfileByTeamQueryResult['getSupplierProfileByTeam']>
|
|
const suppliersData = ref<Map<string, SupplierData>>(new Map())
|
|
|
|
const summaryTitle = computed(() => `${productName.value} → ${locationName.value}`)
|
|
const summaryMeta = computed(() => {
|
|
const meta: string[] = []
|
|
if (quantity.value) {
|
|
meta.push(`${quantity.value}`)
|
|
}
|
|
return meta
|
|
})
|
|
|
|
// Determine context
|
|
const productUuid = computed(() => (route.query.productUuid as string) || searchStore.searchForm.productUuid)
|
|
const destinationUuid = computed(() => (route.query.locationUuid as string) || searchStore.searchForm.locationUuid)
|
|
|
|
type ProductRouteOption = {
|
|
sourceUuid?: string | null
|
|
sourceName?: string | null
|
|
sourceLat?: number | null
|
|
sourceLon?: number | null
|
|
distanceKm?: number | null
|
|
routes?: RoutePathType[] | null
|
|
}
|
|
|
|
const fetchOffersByHub = async () => {
|
|
if (!productUuid.value || !destinationUuid.value) return null
|
|
|
|
// 1. Get hub node to get coordinates
|
|
const hubData = await execute(GetNodeDocument, { uuid: destinationUuid.value }, 'public', 'geo')
|
|
const hub = hubData?.node
|
|
|
|
if (!hub?.latitude || !hub?.longitude) {
|
|
console.warn('Hub has no coordinates')
|
|
return null
|
|
}
|
|
|
|
// 2. Find offers near hub for this product WITH routes calculated on backend
|
|
const offersResponse = await execute(
|
|
NearestOffersDocument,
|
|
{
|
|
lat: hub.latitude,
|
|
lon: hub.longitude,
|
|
productUuid: productUuid.value,
|
|
hubUuid: destinationUuid.value, // Pass hubUuid to get routes calculated on backend
|
|
radius: 500,
|
|
limit: 5
|
|
},
|
|
'public',
|
|
'geo'
|
|
)
|
|
|
|
const offers = offersResponse?.nearestOffers || []
|
|
|
|
// Offers already include routes from backend
|
|
const offersWithRoutes = offers
|
|
.filter((offer): offer is NonNullable<OfferWithRouteType> => offer !== null)
|
|
.map((offer) => ({
|
|
sourceUuid: offer.uuid,
|
|
sourceName: offer.productName,
|
|
sourceLat: offer.latitude,
|
|
sourceLon: offer.longitude,
|
|
distanceKm: offer.distanceKm,
|
|
routes: offer.routes || []
|
|
}))
|
|
|
|
return { offersByHub: offersWithRoutes }
|
|
}
|
|
|
|
const { data: productRoutesData, pending, error } = await useAsyncData(
|
|
() => `offers-by-hub-${productUuid.value}-${destinationUuid.value}`,
|
|
async () => {
|
|
if (productUuid.value && destinationUuid.value) {
|
|
return await fetchOffersByHub()
|
|
}
|
|
return null
|
|
},
|
|
{ watch: [productUuid, destinationUuid] }
|
|
)
|
|
|
|
const productRouteOptions = computed(() => {
|
|
const options = productRoutesData.value?.offersByHub as ProductRouteOption[] | undefined
|
|
return options?.filter(Boolean) || []
|
|
})
|
|
|
|
const legacyRoutes = computed<RoutePathType[]>(() => {
|
|
return [] // Legacy routes removed
|
|
})
|
|
|
|
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,
|
|
transportType: stage?.transportType
|
|
}))
|
|
}
|
|
|
|
// Get route stages for OfferResultCard stepper
|
|
const getRouteStages = (option: ProductRouteOption) => {
|
|
const route = option.routes?.[0]
|
|
if (!route?.stages) return []
|
|
return route.stages
|
|
.filter((stage): stage is NonNullable<RouteStageType> => stage !== null)
|
|
.map((stage) => ({
|
|
transportType: stage.transportType,
|
|
distanceKm: stage.distanceKm,
|
|
travelTimeSeconds: stage.travelTimeSeconds,
|
|
fromName: stage.fromName
|
|
}))
|
|
}
|
|
|
|
// Get offer data for card
|
|
const getOfferData = (uuid?: string | null) => {
|
|
if (!uuid) return null
|
|
return offersData.value.get(uuid)
|
|
}
|
|
|
|
// Get KYC profile UUID by offer UUID
|
|
const getKycProfileUuid = (offerUuid?: string | null) => {
|
|
if (!offerUuid) return null
|
|
const offer = offersData.value.get(offerUuid)
|
|
if (!offer?.teamUuid) return null
|
|
const supplier = suppliersData.value.get(offer.teamUuid)
|
|
return supplier?.kycProfileUuid || null
|
|
}
|
|
|
|
const getSupplierName = (offerUuid?: string | null) => {
|
|
if (!offerUuid) return null
|
|
const offer = offersData.value.get(offerUuid)
|
|
if (!offer?.teamUuid) return null
|
|
const supplier = suppliersData.value.get(offer.teamUuid)
|
|
return supplier?.name || null
|
|
}
|
|
|
|
// Navigate to offer detail page
|
|
const navigateToOffer = (offerUuid?: string | null) => {
|
|
if (!offerUuid) return
|
|
navigateTo(localePath(`/catalog/offers/detail/${offerUuid}`))
|
|
}
|
|
|
|
// Load offer details for prices
|
|
const loadOfferDetails = async (options: ProductRouteOption[]) => {
|
|
if (options.length === 0) {
|
|
offersData.value.clear()
|
|
suppliersData.value.clear()
|
|
return
|
|
}
|
|
|
|
const newOffersData = new Map<string, OfferData>()
|
|
const newSuppliersData = new Map<string, SupplierData>()
|
|
const teamUuidsToLoad = new Set<string>()
|
|
|
|
// First, load all offers
|
|
await Promise.all(options.map(async (option) => {
|
|
if (!option.sourceUuid) return
|
|
try {
|
|
const data = await execute(GetOfferDocument, { uuid: option.sourceUuid }, 'public', 'exchange')
|
|
if (data?.getOffer) {
|
|
newOffersData.set(option.sourceUuid, data.getOffer)
|
|
if (data.getOffer.teamUuid) {
|
|
teamUuidsToLoad.add(data.getOffer.teamUuid)
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading offer:', option.sourceUuid, error)
|
|
}
|
|
}))
|
|
|
|
// Then, load supplier profiles for all team UUIDs
|
|
await Promise.all([...teamUuidsToLoad].map(async (teamUuid) => {
|
|
try {
|
|
const data = await execute(GetSupplierProfileByTeamDocument, { teamUuid }, 'public', 'exchange')
|
|
if (data?.getSupplierProfileByTeam) {
|
|
newSuppliersData.set(teamUuid, data.getSupplierProfileByTeam)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading supplier:', teamUuid, error)
|
|
}
|
|
}))
|
|
|
|
offersData.value = newOffersData
|
|
suppliersData.value = newSuppliersData
|
|
}
|
|
|
|
// Watch for route options and load offers
|
|
watch(productRouteOptions, (options) => {
|
|
if (options.length > 0) {
|
|
loadOfferDetails(options)
|
|
}
|
|
}, { immediate: true })
|
|
|
|
// 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>
|