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

@@ -0,0 +1,296 @@
import type { InfoEntityType } from './useCatalogSearch'
import {
GetNodeDocument,
GetProductsNearHubDocument,
GetProductsBySupplierDocument,
GetHubsNearOfferDocument,
GetOffersByHubDocument,
GetOffersBySupplierProductDocument
} from '~/composables/graphql/public/geo-generated'
import {
GetOfferDocument,
GetSupplierProfileDocument
} from '~/composables/graphql/public/exchange-generated'
export function useCatalogInfo() {
const { execute } = useGraphQL()
// State
const entity = ref<any>(null)
const relatedProducts = ref<any[]>([])
const relatedHubs = ref<any[]>([])
const relatedSuppliers = ref<any[]>([])
const relatedOffers = ref<any[]>([])
const selectedProduct = ref<string | null>(null)
const activeTab = ref<string>('products')
const isLoading = ref(false)
// Load hub info: hub details + products + suppliers
const loadHubInfo = async (uuid: string) => {
try {
// Load hub node details
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
entity.value = nodeData?.node
// Load products near hub
const productsData = await execute(
GetProductsNearHubDocument,
{ hubUuid: uuid, radiusKm: 500 },
'public',
'geo'
)
relatedProducts.value = productsData?.productsNearHub || []
// Set default active tab to products
activeTab.value = 'products'
// Note: Suppliers loaded after product selection via loadOffersForHub
} catch (error) {
console.error('Error loading hub info:', error)
}
}
// Load supplier info: supplier details + products
const loadSupplierInfo = async (uuid: string) => {
try {
// Load supplier node details
const nodeData = await execute(GetNodeDocument, { uuid }, 'public', 'geo')
entity.value = nodeData?.node
// Also try to get supplier profile from exchange API for additional details
try {
const profileData = await execute(
GetSupplierProfileDocument,
{ supplierUuid: uuid },
'public',
'exchange'
)
if (profileData?.getSupplierProfile) {
entity.value = { ...entity.value, ...profileData.getSupplierProfile }
}
} catch (e) {
// Supplier profile might not exist, ignore
}
// Load products from supplier
const productsData = await execute(
GetProductsBySupplierDocument,
{ supplierUuid: uuid },
'public',
'geo'
)
relatedProducts.value = productsData?.productsBySupplier || []
// Set default active tab to products
activeTab.value = 'products'
// Note: Hubs will be loaded after product selection
} catch (error) {
console.error('Error loading supplier info:', error)
}
}
// Load offer info: offer details + supplier + hubs
const loadOfferInfo = async (uuid: string) => {
try {
// Load offer details from exchange API
const offerData = await execute(GetOfferDocument, { uuid }, 'public', 'exchange')
entity.value = offerData?.getOffer
// Load hubs near offer
const hubsData = await execute(
GetHubsNearOfferDocument,
{ offerUuid: uuid, limit: 12 },
'public',
'geo'
)
relatedHubs.value = hubsData?.hubsNearOffer || []
// If offer has supplier UUID, load supplier profile
if (entity.value?.teamUuid) {
try {
const supplierData = await execute(
GetSupplierProfileDocument,
{ supplierUuid: entity.value.teamUuid },
'public',
'exchange'
)
relatedSuppliers.value = supplierData?.getSupplierProfile
? [supplierData.getSupplierProfile]
: []
} catch (e) {
// Supplier might not exist
}
}
// Set default active tab to hubs
activeTab.value = 'hubs'
// Set product as "related product" (single item)
if (entity.value?.productUuid && entity.value?.productName) {
relatedProducts.value = [
{
uuid: entity.value.productUuid,
name: entity.value.productName
}
]
}
} catch (error) {
console.error('Error loading offer info:', error)
}
}
// Load offers for hub after product selection
const loadOffersForHub = async (hubUuid: string, productUuid: string) => {
try {
const offersData = await execute(
GetOffersByHubDocument,
{ hubUuid, productUuid, limit: 12 },
'public',
'geo'
)
relatedOffers.value = offersData?.offersByHub || []
// Extract unique suppliers from offers
const supplierUuids = new Set<string>()
relatedOffers.value.forEach((offer: any) => {
if (offer.teamUuid) {
supplierUuids.add(offer.teamUuid)
}
})
// Load supplier profiles (limit to 12)
const suppliers: any[] = []
for (const supplierUuid of Array.from(supplierUuids).slice(0, 12)) {
try {
const supplierData = await execute(
GetSupplierProfileDocument,
{ supplierUuid },
'public',
'exchange'
)
if (supplierData?.getSupplierProfile) {
suppliers.push(supplierData.getSupplierProfile)
}
} catch (e) {
// Supplier might not exist
}
}
relatedSuppliers.value = suppliers
} catch (error) {
console.error('Error loading offers for hub:', error)
}
}
// Load offers for supplier after product selection
const loadOffersForSupplier = async (supplierUuid: string, productUuid: string) => {
try {
const offersData = await execute(
GetOffersBySupplierProductDocument,
{ supplierUuid, productUuid },
'public',
'geo'
)
relatedOffers.value = offersData?.offersBySupplierProduct || []
// Load hubs for each offer and aggregate (limit to 12)
const allHubs = new Map<string, any>()
for (const offer of relatedOffers.value.slice(0, 3)) {
// Check first 3 offers
try {
const hubsData = await execute(
GetHubsNearOfferDocument,
{ offerUuid: offer.uuid, limit: 5 },
'public',
'geo'
)
hubsData?.hubsNearOffer?.forEach((hub: any) => {
if (!allHubs.has(hub.uuid)) {
allHubs.set(hub.uuid, hub)
}
})
} catch (e) {
// Hubs might not exist for this offer
}
if (allHubs.size >= 12) break
}
relatedHubs.value = Array.from(allHubs.values()).slice(0, 12)
} catch (error) {
console.error('Error loading offers for supplier:', error)
}
}
// Select product (triggers offers loading)
const selectProduct = async (productUuid: string) => {
selectedProduct.value = productUuid
if (!entity.value) return
const entityType = entity.value.uuid
if (!entityType) return
// Load offers based on entity type
if (entity.value.transportTypes) {
// This is a hub (has transportTypes)
await loadOffersForHub(entity.value.uuid, productUuid)
activeTab.value = 'offers'
} else if (entity.value.teamUuid || entity.value.onTimeRate !== undefined) {
// This is a supplier
await loadOffersForSupplier(entity.value.uuid, productUuid)
activeTab.value = 'offers'
}
}
// Set active tab
const setActiveTab = (tab: string) => {
activeTab.value = tab
}
// Main load method - dispatches to specific loaders
const loadInfo = async (type: InfoEntityType, uuid: string) => {
isLoading.value = true
clearInfo() // Clear previous data
try {
if (type === 'hub') {
await loadHubInfo(uuid)
} else if (type === 'supplier') {
await loadSupplierInfo(uuid)
} else if (type === 'offer') {
await loadOfferInfo(uuid)
}
} finally {
isLoading.value = false
}
}
// Clear all info data
const clearInfo = () => {
entity.value = null
relatedProducts.value = []
relatedHubs.value = []
relatedSuppliers.value = []
relatedOffers.value = []
selectedProduct.value = null
activeTab.value = 'products'
}
return {
// State
entity,
relatedProducts,
relatedHubs,
relatedSuppliers,
relatedOffers,
selectedProduct,
activeTab,
isLoading,
// Actions
loadInfo,
selectProduct,
setActiveTab,
clearInfo
}
}