416 lines
16 KiB
Vue
416 lines
16 KiB
Vue
<template>
|
||
<div class="flex flex-col h-full">
|
||
<!-- Header with close button -->
|
||
<div class="flex-shrink-0 p-4 border-b border-white/10">
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center gap-2">
|
||
<div
|
||
class="flex items-center justify-center w-6 h-6 rounded-full"
|
||
:style="{ backgroundColor: badgeColor }"
|
||
>
|
||
<Icon :name="entityIcon" size="14" class="text-white" />
|
||
</div>
|
||
<h3 class="font-semibold text-base text-white">{{ entityName }}</h3>
|
||
</div>
|
||
<button class="btn btn-ghost btn-xs btn-circle text-white/60 hover:text-white" @click="emit('close')">
|
||
<Icon name="lucide:x" size="16" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Content (scrollable) -->
|
||
<div class="flex-1 overflow-y-auto p-4">
|
||
<!-- Loading state -->
|
||
<div v-if="loading" class="flex items-center justify-center py-8">
|
||
<span class="loading loading-spinner loading-md text-white" />
|
||
</div>
|
||
|
||
<!-- Content -->
|
||
<div v-else-if="entity" class="flex flex-col gap-4">
|
||
<!-- Entity Info Header (text, not card) -->
|
||
<div class="mb-2">
|
||
<!-- Location for hub/supplier -->
|
||
<p v-if="entityLocation" class="text-sm text-white/70 flex items-center gap-1">
|
||
<Icon name="lucide:map-pin" size="14" />
|
||
{{ entityLocation }}
|
||
</p>
|
||
|
||
<!-- Price for offer -->
|
||
<p v-if="entityType === 'offer' && entity?.pricePerUnit" class="text-sm text-white/70 flex items-center gap-1">
|
||
<Icon name="lucide:tag" size="14" />
|
||
{{ formatPrice(entity.pricePerUnit) }} {{ entity.currency || 'RUB' }}/{{ entity.unit || 't' }}
|
||
</p>
|
||
|
||
<!-- Supplier for offer (clickable name) -->
|
||
<button
|
||
v-if="entityType === 'offer' && entity?.teamUuid"
|
||
class="text-sm text-primary hover:underline flex items-center gap-1 mt-1"
|
||
@click="emit('open-info', 'supplier', entity.teamUuid)"
|
||
>
|
||
<Icon name="lucide:factory" size="14" />
|
||
<span v-if="loadingSuppliers" class="loading loading-spinner loading-xs" />
|
||
<span v-else>{{ supplierDisplayName || $t('catalog.info.supplier') }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- KYC Teaser Section (for supplier) -->
|
||
<section v-if="entityType === 'supplier' && kycTeaser" class="bg-white/5 rounded-lg p-3">
|
||
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
|
||
<Icon name="lucide:shield-check" size="16" />
|
||
{{ $t('catalog.info.kycTeaser') }}
|
||
</h3>
|
||
|
||
<div class="flex flex-col gap-2 text-sm">
|
||
<!-- Company Type -->
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-white/60">{{ $t('catalog.info.companyType') }}</span>
|
||
<span class="text-white">{{ kycTeaser.companyType }}</span>
|
||
</div>
|
||
|
||
<!-- Registration Year -->
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-white/60">{{ $t('catalog.info.registrationYear') }}</span>
|
||
<span class="text-white">{{ kycTeaser.registrationYear }}</span>
|
||
</div>
|
||
|
||
<!-- Status -->
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-white/60">{{ $t('catalog.info.status') }}</span>
|
||
<span :class="kycTeaser.isActive ? 'text-success' : 'text-error'">
|
||
{{ kycTeaser.isActive ? $t('catalog.info.active') : $t('catalog.info.inactive') }}
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Sources Count -->
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-white/60">{{ $t('catalog.info.sourcesCount') }}</span>
|
||
<span class="text-white">{{ kycTeaser.sourcesCount }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- View Full Profile Button -->
|
||
<button
|
||
class="btn btn-ghost btn-xs text-primary mt-3 w-full"
|
||
@click="emit('open-kyc', kycProfileUuid)"
|
||
>
|
||
<Icon name="lucide:external-link" size="14" />
|
||
{{ $t('catalog.info.viewFullKyc') }}
|
||
</button>
|
||
</section>
|
||
|
||
<!-- Products Section (for hub/supplier) - hide when product selected -->
|
||
<section v-if="(entityType === 'hub' || entityType === 'supplier') && !selectedProduct">
|
||
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
|
||
<Icon name="lucide:package" size="16" />
|
||
{{ productsSectionTitle }}
|
||
<span v-if="loadingProducts" class="loading loading-spinner loading-xs" />
|
||
<span v-else-if="relatedProducts.length > 0" class="text-white/50">({{ relatedProducts.length }})</span>
|
||
</h3>
|
||
|
||
<div v-if="!loadingProducts && relatedProducts.length === 0" class="text-white/50 text-sm py-2">
|
||
{{ $t('catalog.empty.noProducts') }}
|
||
</div>
|
||
<div v-else-if="!loadingProducts" class="flex flex-col gap-2">
|
||
<ProductCard
|
||
v-for="(product, index) in relatedProducts"
|
||
:key="product.uuid ?? index"
|
||
:product="product"
|
||
compact
|
||
selectable
|
||
@select="onProductSelect(product)"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Offers Section (after product selected) -->
|
||
<section v-if="(entityType === 'hub' || entityType === 'supplier') && selectedProduct">
|
||
<div class="flex items-center justify-between mb-2">
|
||
<h3 class="text-sm font-semibold text-white/80 flex items-center gap-2">
|
||
<Icon name="lucide:shopping-bag" size="16" />
|
||
{{ $t('catalog.headers.offers') }}
|
||
<span v-if="loadingOffers" class="loading loading-spinner loading-xs" />
|
||
<span v-else-if="offersWithPrice.length > 0" class="text-white/50">({{ offersWithPrice.length }})</span>
|
||
</h3>
|
||
<button class="btn btn-ghost btn-xs text-white/60" @click="emit('select-product', null)">
|
||
<Icon name="lucide:x" size="14" />
|
||
{{ $t('common.cancel') }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="!loadingOffers && offersWithPrice.length === 0" class="text-white/50 text-sm py-2">
|
||
{{ $t('catalog.empty.noOffers') }}
|
||
</div>
|
||
<div v-else-if="!loadingOffers" class="flex flex-col gap-2">
|
||
<OfferResultCard
|
||
v-for="(offer, index) in offersWithPrice"
|
||
:key="offer.uuid ?? index"
|
||
:supplier-name="offer.supplierName"
|
||
:location-name="offer.locationName || offer.locationCountry || offer.locationName"
|
||
:product-name="offer.productName"
|
||
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
|
||
:currency="offer.currency"
|
||
:unit="offer.unit"
|
||
:stages="getOfferStages(offer)"
|
||
@select="onOfferSelect(offer)"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Suppliers Section (for hub only) -->
|
||
<section v-if="entityType === 'hub'">
|
||
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
|
||
<Icon name="lucide:factory" size="16" />
|
||
{{ $t('catalog.info.suppliersNearby') }}
|
||
<span v-if="loadingSuppliers" class="loading loading-spinner loading-xs" />
|
||
<span v-else-if="relatedSuppliers.length > 0" class="text-white/50">({{ relatedSuppliers.length }})</span>
|
||
</h3>
|
||
|
||
<div v-if="!loadingSuppliers && relatedSuppliers.length === 0" class="text-white/50 text-sm py-2">
|
||
{{ $t('catalog.info.noSuppliers') }}
|
||
</div>
|
||
<div v-else-if="!loadingSuppliers" class="flex flex-col gap-2">
|
||
<SupplierCard
|
||
v-for="(supplier, index) in relatedSuppliers"
|
||
:key="supplier.uuid ?? index"
|
||
:supplier="supplier"
|
||
selectable
|
||
@select="onSupplierSelect(supplier)"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Hubs Section (for supplier/offer) -->
|
||
<section v-if="entityType === 'supplier' || entityType === 'offer'">
|
||
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
|
||
<Icon name="lucide:warehouse" size="16" />
|
||
{{ $t('catalog.info.nearestHubs') }}
|
||
<span v-if="loadingHubs" class="loading loading-spinner loading-xs" />
|
||
<span v-else-if="relatedHubs.length > 0" class="text-white/50">({{ relatedHubs.length }})</span>
|
||
</h3>
|
||
|
||
<div v-if="!loadingHubs && relatedHubs.length === 0" class="text-white/50 text-sm py-2">
|
||
{{ $t('catalog.info.noHubs') }}
|
||
</div>
|
||
<div v-else-if="!loadingHubs" class="space-y-4">
|
||
<template v-if="railHubs.length">
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<Card padding="small" class="border border-white/10 bg-white/5">
|
||
<div class="flex items-center gap-2">
|
||
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center">
|
||
<Icon name="lucide:train-front" size="16" class="text-white/80" />
|
||
</div>
|
||
<div class="text-sm text-white/80">{{ $t('catalog.info.railHubs') }}</div>
|
||
</div>
|
||
</Card>
|
||
<HubCard
|
||
v-for="(hub, index) in railHubs"
|
||
:key="hub.uuid ?? index"
|
||
:hub="hub"
|
||
:origin="originCoords"
|
||
selectable
|
||
@select="onHubSelect(hub)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-if="seaHubs.length">
|
||
<div class="grid grid-cols-2 gap-2">
|
||
<Card padding="small" class="border border-white/10 bg-white/5">
|
||
<div class="flex items-center gap-2">
|
||
<div class="w-8 h-8 rounded-lg bg-white/10 flex items-center justify-center">
|
||
<Icon name="lucide:ship" size="16" class="text-white/80" />
|
||
</div>
|
||
<div class="text-sm text-white/80">{{ $t('catalog.info.seaHubs') }}</div>
|
||
</div>
|
||
</Card>
|
||
<HubCard
|
||
v-for="(hub, index) in seaHubs"
|
||
:key="hub.uuid ?? index"
|
||
:hub="hub"
|
||
:origin="originCoords"
|
||
selectable
|
||
@select="onHubSelect(hub)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Add to filter button -->
|
||
<button class="btn btn-primary btn-sm mt-2" @click="emit('add-to-filter')">
|
||
<Icon name="lucide:filter-plus" size="16" />
|
||
{{ $t('catalog.info.addToFilter') }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import type { InfoEntityType } from '~/composables/useCatalogSearch'
|
||
import type {
|
||
InfoEntity,
|
||
InfoProductItem,
|
||
InfoHubItem,
|
||
InfoSupplierItem,
|
||
InfoOfferItem
|
||
} from '~/composables/useCatalogInfo'
|
||
import type { RouteStageType } from '~/composables/graphql/public/geo-generated'
|
||
|
||
const props = defineProps<{
|
||
entityType: InfoEntityType
|
||
entityId: string
|
||
entity: InfoEntity | null
|
||
relatedProducts?: InfoProductItem[]
|
||
relatedHubs?: InfoHubItem[]
|
||
relatedSuppliers?: InfoSupplierItem[]
|
||
relatedOffers?: InfoOfferItem[]
|
||
selectedProduct?: string | null
|
||
currentTab?: string
|
||
loading?: boolean
|
||
loadingProducts?: boolean
|
||
loadingHubs?: boolean
|
||
loadingSuppliers?: boolean
|
||
loadingOffers?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
'close': []
|
||
'add-to-filter': []
|
||
'open-info': [type: InfoEntityType, uuid: string]
|
||
'select-product': [uuid: string | null]
|
||
'select-offer': [offer: { uuid: string; productUuid?: string | null }]
|
||
'update:current-tab': [tab: string]
|
||
'open-kyc': [uuid: string | undefined]
|
||
}>()
|
||
|
||
const { t } = useI18n()
|
||
const { entityColors } = useCatalogSearch()
|
||
|
||
// Safe accessors for optional arrays
|
||
const relatedProducts = computed(() => props.relatedProducts ?? [])
|
||
const relatedHubs = computed(() => props.relatedHubs ?? [])
|
||
const relatedSuppliers = computed(() => props.relatedSuppliers ?? [])
|
||
const relatedOffers = computed(() => props.relatedOffers ?? [])
|
||
const offersWithPrice = computed(() =>
|
||
relatedOffers.value.filter(o => o?.pricePerUnit != null)
|
||
)
|
||
|
||
// Entity name
|
||
const entityName = computed(() => {
|
||
return props.entity?.name || props.entity?.productName || props.entityId.slice(0, 8) + '...'
|
||
})
|
||
|
||
// Entity location (address, city, country)
|
||
const entityLocation = computed(() => {
|
||
if (!props.entity) return null
|
||
const parts = [props.entity.address, props.entity.city, props.entity.country].filter(Boolean)
|
||
return parts.length > 0 ? parts.join(', ') : null
|
||
})
|
||
|
||
const originCoords = computed(() => {
|
||
const lat = props.entity?.locationLatitude ?? props.entity?.latitude
|
||
const lon = props.entity?.locationLongitude ?? props.entity?.longitude
|
||
if (lat == null || lon == null) return null
|
||
return { latitude: Number(lat), longitude: Number(lon) }
|
||
})
|
||
|
||
// Products section title based on entity type
|
||
const productsSectionTitle = computed(() => {
|
||
return props.entityType === 'hub'
|
||
? t('catalog.info.productsHere')
|
||
: t('catalog.info.productsFromSupplier')
|
||
})
|
||
|
||
// Badge color
|
||
const badgeColor = computed(() => {
|
||
if (props.entityType === 'hub') return entityColors.hub
|
||
if (props.entityType === 'supplier') return entityColors.supplier
|
||
if (props.entityType === 'offer') return entityColors.offer
|
||
return '#666'
|
||
})
|
||
|
||
const entityIcon = computed(() => {
|
||
if (props.entityType === 'hub') return 'lucide:warehouse'
|
||
if (props.entityType === 'supplier') return 'lucide:factory'
|
||
if (props.entityType === 'offer') return 'lucide:shopping-bag'
|
||
return 'lucide:info'
|
||
})
|
||
|
||
// Supplier name for offer (from entity or relatedSuppliers)
|
||
const supplierDisplayName = computed(() => {
|
||
if (props.entity?.supplierName) return props.entity.supplierName
|
||
if (props.entity?.teamName) return props.entity.teamName
|
||
if (relatedSuppliers.value.length > 0 && relatedSuppliers.value[0]?.name) {
|
||
return relatedSuppliers.value[0].name
|
||
}
|
||
return null
|
||
})
|
||
|
||
// Format price
|
||
const formatPrice = (price: number | string) => {
|
||
const num = typeof price === 'string' ? parseFloat(price) : price
|
||
return new Intl.NumberFormat('ru-RU').format(num)
|
||
}
|
||
|
||
const railHubs = computed(() =>
|
||
relatedHubs.value.filter(h => h.transportTypes?.includes('rail'))
|
||
)
|
||
|
||
const seaHubs = computed(() =>
|
||
relatedHubs.value.filter(h => h.transportTypes?.includes('sea'))
|
||
)
|
||
|
||
// Mock KYC teaser data (will be replaced with real data later)
|
||
const kycTeaser = computed(() => {
|
||
if (props.entityType !== 'supplier') return null
|
||
// Mock data for now
|
||
return {
|
||
companyType: 'ООО',
|
||
registrationYear: 2018,
|
||
isActive: true,
|
||
sourcesCount: 3
|
||
}
|
||
})
|
||
|
||
// KYC Profile UUID - use real if available, otherwise mock for demo
|
||
const MOCK_KYC_UUID = 'demo-kyc-profile'
|
||
const kycProfileUuid = computed(() => {
|
||
return props.entity?.kycProfileUuid || MOCK_KYC_UUID
|
||
})
|
||
|
||
// Handlers for selecting related items
|
||
const onProductSelect = (product: InfoProductItem) => {
|
||
emit('select-product', product.uuid)
|
||
}
|
||
|
||
const onOfferSelect = (offer: InfoOfferItem) => {
|
||
if (offer.uuid) {
|
||
emit('select-offer', { uuid: offer.uuid, productUuid: offer.productUuid })
|
||
}
|
||
}
|
||
|
||
const onHubSelect = (hub: InfoHubItem) => {
|
||
if (hub.uuid) {
|
||
emit('open-info', 'hub', hub.uuid)
|
||
}
|
||
}
|
||
|
||
const onSupplierSelect = (supplier: InfoSupplierItem) => {
|
||
if (supplier.uuid) {
|
||
emit('open-info', 'supplier', supplier.uuid)
|
||
}
|
||
}
|
||
|
||
const getOfferStages = (offer: InfoOfferItem) => {
|
||
const route = offer.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
|
||
}))
|
||
}
|
||
</script>
|