Files
webapp/app/components/catalog/InfoPanel.vue
Ruslan Bakiev 20e0e73c58
All checks were successful
Build Docker Image / build (push) Successful in 3m59s
refactor: remove any types and fix TypeScript errors
- 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
2026-01-27 10:35:14 +07:00

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>