Files
webapp/app/components/catalog/InfoPanel.vue
Ruslan Bakiev 3f92b3876d
Some checks failed
Build Docker Image / build (push) Has been cancelled
Show pin on hover in lists
2026-02-07 16:37:02 +07:00

505 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>
<div class="flex items-center gap-2">
<button
v-if="(entityType === 'hub' || entityType === 'supplier') && entity?.uuid"
class="rounded-full glass-bright border border-white/30 shadow-lg p-1.5 transition-transform hover:scale-105"
@click="emit('pin', entityType, { uuid: entity?.uuid, name: entity?.name })"
aria-label="Pin"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
<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>
</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">
<div
v-for="(product, index) in relatedProducts"
:key="product.uuid ?? index"
class="relative group"
>
<ProductCard
:product="product"
compact
selectable
@select="onProductSelect(product)"
/>
<button
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
@click.stop="emit('pin', 'product', product)"
aria-label="Pin product"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
</div>
</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="flex items-center gap-2 px-2 py-1 rounded-full border border-white/15 bg-white/10 text-xs text-white/80 hover:bg-white/20 transition-colors"
@click="emit('select-product', null)"
>
<Icon name="lucide:package" size="12" />
<span class="max-w-32 truncate">{{ selectedProductName }}</span>
<Icon name="lucide:x" size="12" />
</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="getOfferSupplierName(offer)"
:location-name="offer.locationName || offer.locationCountry || offer.country || offer.locationName"
:product-name="offer.productName"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:quantity="offer.quantity"
:currency="offer.currency"
:unit="offer.unit"
:stages="getOfferStages(offer)"
:total-time-seconds="offer.routes?.[0]?.totalTimeSeconds ?? null"
@select="onOfferSelect(offer)"
/>
</div>
</section>
<!-- Suppliers Section (for hub only) -->
<section v-if="entityType === 'hub' && !selectedProduct">
<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">
<div
v-for="(supplier, index) in relatedSuppliers"
:key="supplier.uuid ?? index"
class="relative group"
>
<SupplierCard
:supplier="supplier"
selectable
@select="onSupplierSelect(supplier)"
/>
<button
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
@click.stop="emit('pin', 'supplier', supplier)"
aria-label="Pin supplier"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
</div>
</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>
<div
v-for="(hub, index) in railHubs"
:key="hub.uuid ?? index"
class="relative group"
>
<HubCard
:hub="hub"
:origin="originCoords"
selectable
@select="onHubSelect(hub)"
/>
<button
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
@click.stop="emit('pin', 'hub', hub)"
aria-label="Pin hub"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
</div>
</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>
<div
v-for="(hub, index) in seaHubs"
:key="hub.uuid ?? index"
class="relative group"
>
<HubCard
:hub="hub"
:origin="originCoords"
selectable
@select="onHubSelect(hub)"
/>
<button
class="absolute -top-2 -right-2 opacity-0 group-hover:opacity-100 transition-opacity rounded-full glass-bright border border-white/30 shadow-lg p-1.5 hover:scale-105"
@click.stop="emit('pin', 'hub', hub)"
aria-label="Pin hub"
title="Pin"
>
<Icon name="lucide:pin" size="16" class="text-white" />
</button>
</div>
</div>
</template>
</div>
</section>
</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': []
'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]
'pin': [type: 'product' | 'hub' | 'supplier', item: { uuid?: string | null; name?: string | null }]
}>()
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)
)
const suppliersByUuid = computed(() => {
const map = new Map<string, string>()
relatedSuppliers.value.forEach(supplier => {
if (supplier?.uuid && supplier?.name) {
map.set(supplier.uuid, supplier.name)
}
if (supplier?.teamUuid && supplier?.name) {
map.set(supplier.teamUuid, supplier.name)
}
})
return map
})
const getOfferSupplierName = (offer: InfoOfferItem) => {
if (offer.supplierName) return offer.supplierName
if (offer.supplierUuid && suppliersByUuid.value.has(offer.supplierUuid)) {
return suppliersByUuid.value.get(offer.supplierUuid)
}
return null
}
const selectedProductName = computed(() => {
if (!props.selectedProduct) return ''
const match = relatedProducts.value.find(p => p.uuid === props.selectedProduct)
return match?.name || props.selectedProduct.slice(0, 8) + '...'
})
// 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,
travelTimeSeconds: stage.travelTimeSeconds,
fromName: stage.fromName
}))
}
</script>