All checks were successful
Build Docker Image / build (push) Successful in 3m59s
- Export InfoProductItem, InfoHubItem, InfoSupplierItem, InfoOfferItem types - Update InfoEntity interface to have explicit fields (no index signature) - Export CatalogHubItem, CatalogNearestHubItem from useCatalogHubs - Fix MapItem interfaces to accept nullable GraphQL types - Fix v-for :key bindings to handle null uuid - Add null guards in select-location pages - Update HubCard to accept nullable transportTypes - Add shims.d.ts for missing module declarations
235 lines
8.4 KiB
Vue
235 lines
8.4 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 link for offer -->
|
|
<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" />
|
|
{{ entity.supplierName || entity.teamName || $t('catalog.info.viewSupplier') }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Products Section (for hub/supplier) -->
|
|
<section v-if="entityType === 'hub' || entityType === 'supplier'">
|
|
<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>
|
|
|
|
<!-- 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="flex flex-col gap-2">
|
|
<HubCard
|
|
v-for="(hub, index) in relatedHubs"
|
|
:key="hub.uuid ?? index"
|
|
:hub="hub"
|
|
selectable
|
|
@select="onHubSelect(hub)"
|
|
/>
|
|
</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'
|
|
|
|
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]
|
|
'update:current-tab': [tab: string]
|
|
}>()
|
|
|
|
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 ?? [])
|
|
|
|
// 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
|
|
})
|
|
|
|
// 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'
|
|
})
|
|
|
|
// Format price
|
|
const formatPrice = (price: number | string) => {
|
|
const num = typeof price === 'string' ? parseFloat(price) : price
|
|
return new Intl.NumberFormat('ru-RU').format(num)
|
|
}
|
|
|
|
// Handlers for selecting related items
|
|
const onProductSelect = (product: InfoProductItem) => {
|
|
emit('select-product', product.uuid)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
</script>
|