Files
webapp/app/components/CalcResultContent.vue
Ruslan Bakiev 2b6cccdead
All checks were successful
Build Docker Image / build (push) Successful in 5m8s
Fix all TypeScript errors and remove Storybook
- Remove all Storybook files and configuration
- Add type declarations for @vueuse/core, @formkit/core, vue3-apexcharts
- Fix TypeScript configuration (typeRoots, include paths)
- Fix Sentry config - move settings to plugin
- Fix nullable prop assignments with ?? operator
- Fix type narrowing issues with explicit type assertions
- Fix Card component linkable computed properties
- Update codegen with operationResultSuffix
- Fix GraphQL operation type definitions
2026-01-26 00:32:36 +07:00

324 lines
9.9 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"
:location-name="getOfferData(option.sourceUuid)?.locationName"
:product-name="productName"
:price-per-unit="getOfferData(option.sourceUuid)?.pricePerUnit"
:currency="getOfferData(option.sourceUuid)?.currency"
:unit="getOfferData(option.sourceUuid)?.unit"
:stages="getRouteStages(option)"
: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, RouteToCoordinateDocument } 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 } from '~/composables/graphql/public/exchange-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 as any)?.quantity)
// Offer data for prices
const offersData = ref<Map<string, any>>(new Map())
// Supplier data for KYC profile UUID (by team_uuid)
const suppliersData = ref<Map<string, any>>(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
const offersData = await execute(
NearestOffersDocument,
{
lat: hub.latitude,
lon: hub.longitude,
productUuid: productUuid.value,
radius: 500,
limit: 5
},
'public',
'geo'
)
const offers = offersData?.nearestOffers || []
// 3. For each offer, get route to hub coordinates
const offersWithRoutes = await Promise.all(
offers.map(async (offer: any) => {
try {
const routeData = await execute(
RouteToCoordinateDocument,
{
offerUuid: offer.uuid,
lat: hub.latitude!,
lon: hub.longitude!
},
'public',
'geo'
)
return {
sourceUuid: offer.uuid,
sourceName: offer.productName,
sourceLat: offer.latitude,
sourceLon: offer.longitude,
distanceKm: routeData?.routeToCoordinate?.distanceKm,
routes: routeData?.routeToCoordinate?.routes || []
}
} catch (e) {
console.warn('No route found for offer:', offer.uuid, e)
return {
sourceUuid: offer.uuid,
sourceName: offer.productName,
sourceLat: offer.latitude,
sourceLon: offer.longitude,
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(Boolean).map((stage: any) => ({
transportType: stage?.transportType,
distanceKm: stage?.distanceKm
}))
}
// 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
}
// 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, any>()
const newSuppliersData = new Map<string, any>()
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>