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:
296
app/composables/useCatalogInfo.ts
Normal file
296
app/composables/useCatalogInfo.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { LocationQuery } from 'vue-router'
|
||||
export type SelectMode = 'product' | 'supplier' | 'hub' | null
|
||||
export type MapViewMode = 'offers' | 'hubs' | 'suppliers'
|
||||
export type CatalogMode = 'explore' | 'quote'
|
||||
export type InfoEntityType = 'hub' | 'supplier' | 'offer'
|
||||
export type DisplayMode =
|
||||
| 'map-default'
|
||||
| 'grid-products'
|
||||
@@ -13,6 +14,11 @@ export type DisplayMode =
|
||||
| 'grid-products-in-hub'
|
||||
| 'grid-offers'
|
||||
|
||||
export interface InfoId {
|
||||
type: InfoEntityType
|
||||
uuid: string
|
||||
}
|
||||
|
||||
export interface SearchFilter {
|
||||
type: 'product' | 'supplier' | 'hub' | 'quantity'
|
||||
id: string
|
||||
@@ -56,6 +62,17 @@ export function useCatalogSearch() {
|
||||
return null
|
||||
})
|
||||
|
||||
// Parse info state from query param (format: "type:uuid")
|
||||
const infoId = computed<InfoId | null>(() => {
|
||||
const info = route.query.info as string | undefined
|
||||
if (!info) return null
|
||||
const [type, uuid] = info.split(':')
|
||||
if (['hub', 'supplier', 'offer'].includes(type) && uuid) {
|
||||
return { type: type as InfoEntityType, uuid }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const productId = computed(() => route.query.product as string | undefined)
|
||||
const supplierId = computed(() => route.query.supplier as string | undefined)
|
||||
const hubId = computed(() => route.query.hub as string | undefined)
|
||||
@@ -210,6 +227,14 @@ export function useCatalogSearch() {
|
||||
updateQuery({ qty })
|
||||
}
|
||||
|
||||
const openInfo = (type: InfoEntityType, uuid: string) => {
|
||||
updateQuery({ info: `${type}:${uuid}`, select: null })
|
||||
}
|
||||
|
||||
const closeInfo = () => {
|
||||
updateQuery({ info: null })
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
if (isMainPage.value) {
|
||||
router.push({ path: localePath('/catalog'), query: {} })
|
||||
@@ -256,6 +281,7 @@ export function useCatalogSearch() {
|
||||
return {
|
||||
// State
|
||||
selectMode,
|
||||
infoId,
|
||||
displayMode,
|
||||
catalogMode,
|
||||
productId,
|
||||
@@ -283,6 +309,8 @@ export function useCatalogSearch() {
|
||||
removeFilter,
|
||||
editFilter,
|
||||
setQuantity,
|
||||
openInfo,
|
||||
closeInfo,
|
||||
clearAll,
|
||||
setLabel,
|
||||
setMapViewMode,
|
||||
|
||||
Reference in New Issue
Block a user