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>
This commit is contained in:
Ruslan Bakiev
2026-01-25 14:17:47 +07:00
parent 9b99d8981c
commit 2ce3bd0bd2
6 changed files with 882 additions and 12 deletions

View File

@@ -10,11 +10,12 @@
:hovered-id="hoveredItemId"
:show-panel="showPanel"
:filter-by-bounds="filterByBounds"
:related-points="relatedPoints"
@select="onMapSelect"
@bounds-change="onBoundsChange"
@update:filter-by-bounds="filterByBounds = $event"
>
<!-- Panel slot - shows selection list OR quote results -->
<!-- Panel slot - shows selection list OR info OR quote results -->
<template #panel>
<!-- Selection mode: show list for picking product/hub/supplier -->
<SelectionPanel
@@ -32,6 +33,24 @@
@hover="onHoverItem"
/>
<!-- Info mode: show detailed info about selected entity -->
<InfoPanel
v-else-if="infoId"
:entity-type="infoId.type"
:entity-id="infoId.uuid"
:entity="entity"
:related-products="relatedProducts"
:related-hubs="relatedHubs"
:related-suppliers="relatedSuppliers"
:related-offers="relatedOffers"
:selected-product="selectedProduct"
:loading="infoLoading"
@close="onInfoClose"
@add-to-filter="onInfoAddToFilter"
@open-info="onInfoOpenRelated"
@select-product="selectInfoProduct"
/>
<!-- Quote results: show offers after search -->
<QuotePanel
v-else-if="showQuoteResults"
@@ -85,6 +104,7 @@ const onBoundsChange = (bounds: MapBounds) => {
const {
catalogMode,
selectMode,
infoId,
productId,
supplierId,
hubId,
@@ -94,9 +114,25 @@ const {
entityColors,
selectItem,
cancelSelect,
openInfo,
closeInfo,
setLabel
} = useCatalogSearch()
// Info panel composable
const {
entity,
relatedProducts,
relatedHubs,
relatedSuppliers,
relatedOffers,
selectedProduct,
isLoading: infoLoading,
loadInfo,
selectProduct: selectInfoProduct,
clearInfo
} = useCatalogInfo()
// Composables for data (initialize immediately when selectMode changes)
const {
items: products,
@@ -191,6 +227,69 @@ watch([filterByBounds, currentMapBounds], ([enabled, bounds]) => {
setProductBoundsFilter(boundsToApply)
})
// Watch infoId to load info data
watch(infoId, async (info) => {
if (info) {
await loadInfo(info.type, info.uuid)
} else {
clearInfo()
}
}, { immediate: true })
// Related points for Info mode (shown on map)
const relatedPoints = computed(() => {
if (!infoId.value) return []
const points: Array<{
uuid: string
name: string
latitude: number
longitude: number
type: 'hub' | 'supplier' | 'offer'
}> = []
// Add hubs
relatedHubs.value.forEach(hub => {
if (hub.latitude && hub.longitude) {
points.push({
uuid: hub.uuid,
name: hub.name,
latitude: hub.latitude,
longitude: hub.longitude,
type: 'hub'
})
}
})
// Add suppliers
relatedSuppliers.value.forEach(supplier => {
if (supplier.latitude && supplier.longitude) {
points.push({
uuid: supplier.uuid,
name: supplier.name,
latitude: supplier.latitude,
longitude: supplier.longitude,
type: 'supplier'
})
}
})
// Add offers
relatedOffers.value.forEach(offer => {
if (offer.latitude && offer.longitude) {
points.push({
uuid: offer.uuid,
name: offer.productName || offer.name,
latitude: offer.latitude,
longitude: offer.longitude,
type: 'offer'
})
}
})
return points
})
// Offers data for quote results
const offers = ref<any[]>([])
const offersLoading = ref(false)
@@ -207,9 +306,9 @@ watch(searchTrigger, () => {
// Loading state
const isLoading = computed(() => offersLoading.value || selectionLoading.value)
// Show panel when selecting OR when showing quote results
// Show panel when selecting OR when showing info OR when showing quote results
const showPanel = computed(() => {
return selectMode.value !== null || showQuoteResults.value
return selectMode.value !== null || infoId.value !== null || showQuoteResults.value
})
// Cluster node type based on map view mode
@@ -268,21 +367,43 @@ const onMapSelect = async (item: any) => {
}
}
// Default behavior for quote mode etc
if (catalogMode.value === 'quote') {
console.log('Selected from map:', item)
}
// NEW: Default behavior - open Info directly
let infoType: 'hub' | 'supplier' | 'offer'
if (mapViewMode.value === 'hubs') infoType = 'hub'
else if (mapViewMode.value === 'suppliers') infoType = 'supplier'
else infoType = 'offer'
openInfo(infoType, itemId)
setLabel(infoType, itemId, itemName)
}
// Handle selection from SelectionPanel
const onSelectItem = (type: string, item: any) => {
if (item.uuid && item.name) {
selectItem(type, item.uuid, item.name)
showQuoteResults.value = false
offers.value = []
// NEW: Open Info instead of selecting directly
openInfo(type as 'hub' | 'supplier' | 'offer', item.uuid)
setLabel(type, item.uuid, item.name)
}
}
// Handle Info panel events
const onInfoClose = () => {
closeInfo()
clearInfo()
}
const onInfoAddToFilter = () => {
if (!infoId.value || !entity.value) return
const { type, uuid } = infoId.value
const name = entity.value.name
selectItem(type, uuid, name)
closeInfo()
}
const onInfoOpenRelated = (type: 'hub' | 'supplier' | 'offer', uuid: string) => {
openInfo(type, uuid)
}
// Search for offers
const onSearch = async () => {
if (!canSearch.value) return