Files
webapp/app/components/catalog/InfoPanel.vue
Ruslan Bakiev 2ce3bd0bd2 Add Info panel for catalog with tabbed interface
Implemented Info mode для детального просмотра объектов каталога (hub/supplier/offer) с навигацией между связанными объектами.

Новые компоненты:
- InfoPanel.vue - панель с детальной информацией и табами для связанных объектов
- useCatalogInfo.ts - composable для управления Info state и загрузки данных

Изменения:
- useCatalogSearch.ts - добавлен infoId state и функции openInfo/closeInfo
- catalog/index.vue - интеграция InfoPanel, обработчики событий, relatedPoints для карты
- CatalogPage.vue - проброс relatedPoints в CatalogMap
- CatalogMap.vue - related points layer (cyan circles) для отображения связанных объектов

Флоу:
1. Клик на чип → Selection → Выбор → Info открывается
2. Клик на карту → Info открывается напрямую
3. В Info показываются табы со связанными объектами (top-12)
4. Клик на связанный объект → навигация к его Info
5. Кнопка "Добавить в фильтр" - добавляет объект в chips

URL sharing: ?info=type:uuid для шаринга ссылок

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-25 14:17:47 +07:00

278 lines
8.1 KiB
Vue

<template>
<MapPanel>
<template #header>
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="badge badge-sm" :style="{ backgroundColor: badgeColor }">
{{ badgeLabel }}
</span>
<h3 class="font-semibold text-base text-base-content">{{ entityName }}</h3>
</div>
<button
class="btn btn-ghost btn-xs btn-circle text-base-content/60 hover:text-base-content"
@click="emit('close')"
>
<Icon name="lucide:x" size="16" />
</button>
</div>
</template>
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-md" />
</div>
<!-- Content -->
<div v-else-if="entity" class="flex flex-col gap-4">
<!-- Entity details card -->
<div class="mb-2">
<HubCard v-if="entityType === 'hub'" :hub="entity" />
<SupplierCard v-else-if="entityType === 'supplier'" :supplier="entity" />
<OfferCard v-else-if="entityType === 'offer'" :offer="entity" compact />
</div>
<!-- Tabs for related objects -->
<div role="tablist" class="tabs tabs-boxed bg-white/10">
<a
v-for="tab in availableTabs"
:key="tab.id"
role="tab"
class="tab"
:class="{ 'tab-active': currentTab === tab.id }"
@click="currentTab = tab.id"
>
{{ tab.label }}
<span v-if="tab.count" class="ml-1 opacity-70">({{ tab.count }})</span>
</a>
</div>
<!-- Tab content -->
<div class="flex flex-col gap-2 min-h-[200px]">
<!-- Products tab -->
<div v-if="currentTab === 'products'">
<div v-if="relatedProducts.length === 0" class="text-center py-8 text-white/60">
<Icon name="lucide:package" size="32" class="mb-2 opacity-50" />
<p>{{ $t('catalog.info.noProducts') }}</p>
</div>
<div v-else class="flex flex-col gap-2">
<ProductCard
v-for="product in relatedProducts"
:key="product.uuid"
:product="product"
selectable
compact
@select="onProductSelect(product)"
/>
</div>
</div>
<!-- Hubs tab -->
<div v-if="currentTab === 'hubs'">
<div v-if="relatedHubs.length === 0" class="text-center py-8 text-white/60">
<Icon name="lucide:warehouse" size="32" class="mb-2 opacity-50" />
<p>{{ $t('catalog.info.noHubs') }}</p>
</div>
<div v-else class="flex flex-col gap-2">
<HubCard
v-for="hub in relatedHubs"
:key="hub.uuid"
:hub="hub"
selectable
@select="onHubSelect(hub)"
/>
</div>
</div>
<!-- Suppliers tab -->
<div v-if="currentTab === 'suppliers'">
<div v-if="relatedSuppliers.length === 0" class="text-center py-8 text-white/60">
<Icon name="lucide:factory" size="32" class="mb-2 opacity-50" />
<p>{{ $t('catalog.info.noSuppliers') }}</p>
</div>
<div v-else class="flex flex-col gap-2">
<SupplierCard
v-for="supplier in relatedSuppliers"
:key="supplier.uuid"
:supplier="supplier"
selectable
@select="onSupplierSelect(supplier)"
/>
</div>
</div>
<!-- Offers tab -->
<div v-if="currentTab === 'offers'">
<div v-if="!selectedProduct" class="text-center py-8 text-white/60">
<Icon name="lucide:info" size="32" class="mb-2 opacity-50" />
<p>{{ $t('catalog.info.selectProductFirst') }}</p>
</div>
<div v-else-if="relatedOffers.length === 0" class="text-center py-8 text-white/60">
<Icon name="lucide:shopping-bag" size="32" class="mb-2 opacity-50" />
<p>{{ $t('catalog.info.noOffers') }}</p>
</div>
<div v-else class="flex flex-col gap-2">
<OfferCard
v-for="offer in relatedOffers"
:key="offer.uuid"
:offer="offer"
selectable
compact
@select="onOfferSelect(offer)"
/>
</div>
</div>
</div>
<!-- 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>
</MapPanel>
</template>
<script setup lang="ts">
import type { InfoEntityType } from '~/composables/useCatalogSearch'
const props = defineProps<{
entityType: InfoEntityType
entityId: string
entity: any
relatedProducts?: any[]
relatedHubs?: any[]
relatedSuppliers?: any[]
relatedOffers?: any[]
selectedProduct?: string | null
loading?: boolean
}>()
const emit = defineEmits<{
'close': []
'add-to-filter': []
'open-info': [type: InfoEntityType, uuid: string]
'select-product': [uuid: string]
}>()
const { t } = useI18n()
const { entityColors } = useCatalogSearch()
// Current active tab
const currentTab = ref<string>('products')
// Entity name
const entityName = computed(() => {
return props.entity?.name || props.entityId.slice(0, 8) + '...'
})
// Badge color and label
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 badgeLabel = computed(() => {
if (props.entityType === 'hub') return t('catalog.entities.hub')
if (props.entityType === 'supplier') return t('catalog.entities.supplier')
if (props.entityType === 'offer') return t('catalog.entities.offer')
return ''
})
// Available tabs based on entity type and data
const availableTabs = computed(() => {
const tabs: Array<{ id: string; label: string; count?: number }> = []
if (props.entityType === 'hub') {
tabs.push({
id: 'products',
label: t('catalog.tabs.products'),
count: props.relatedProducts?.length || 0
})
tabs.push({
id: 'offers',
label: t('catalog.tabs.offers'),
count: props.relatedOffers?.length || 0
})
tabs.push({
id: 'suppliers',
label: t('catalog.tabs.suppliers'),
count: props.relatedSuppliers?.length || 0
})
} else if (props.entityType === 'supplier') {
tabs.push({
id: 'products',
label: t('catalog.tabs.products'),
count: props.relatedProducts?.length || 0
})
tabs.push({
id: 'offers',
label: t('catalog.tabs.offers'),
count: props.relatedOffers?.length || 0
})
tabs.push({
id: 'hubs',
label: t('catalog.tabs.hubs'),
count: props.relatedHubs?.length || 0
})
} else if (props.entityType === 'offer') {
if (props.relatedProducts && props.relatedProducts.length > 0) {
tabs.push({
id: 'products',
label: t('catalog.tabs.product')
})
}
tabs.push({
id: 'hubs',
label: t('catalog.tabs.hubs'),
count: props.relatedHubs?.length || 0
})
if (props.relatedSuppliers && props.relatedSuppliers.length > 0) {
tabs.push({
id: 'suppliers',
label: t('catalog.tabs.supplier')
})
}
}
return tabs
})
// Set default active tab when entity type changes
watch(
() => props.entityType,
() => {
if (availableTabs.value.length > 0) {
currentTab.value = availableTabs.value[0].id
}
},
{ immediate: true }
)
// Handlers for selecting related items
const onProductSelect = (product: any) => {
if (product.uuid) {
emit('select-product', product.uuid)
}
}
const onHubSelect = (hub: any) => {
if (hub.uuid) {
emit('open-info', 'hub', hub.uuid)
}
}
const onSupplierSelect = (supplier: any) => {
if (supplier.uuid) {
emit('open-info', 'supplier', supplier.uuid)
}
}
const onOfferSelect = (offer: any) => {
if (offer.uuid) {
emit('open-info', 'offer', offer.uuid)
}
}
</script>