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:
@@ -51,6 +51,13 @@ const props = withDefaults(defineProps<{
|
||||
entityType?: 'offer' | 'hub' | 'supplier'
|
||||
initialCenter?: [number, number]
|
||||
initialZoom?: number
|
||||
relatedPoints?: Array<{
|
||||
uuid: string
|
||||
name: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
type: 'hub' | 'supplier' | 'offer'
|
||||
}>
|
||||
}>(), {
|
||||
pointColor: '#f97316',
|
||||
entityType: 'offer',
|
||||
@@ -58,7 +65,8 @@ const props = withDefaults(defineProps<{
|
||||
initialZoom: 2,
|
||||
useServerClustering: false,
|
||||
items: () => [],
|
||||
clusteredPoints: () => []
|
||||
clusteredPoints: () => [],
|
||||
relatedPoints: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -170,8 +178,32 @@ const hoveredPointGeoJson = computed(() => ({
|
||||
}] : []
|
||||
}))
|
||||
|
||||
// Related points GeoJSON (for Info mode)
|
||||
const relatedPointsGeoJson = computed(() => {
|
||||
if (!props.relatedPoints || props.relatedPoints.length === 0) {
|
||||
return { type: 'FeatureCollection' as const, features: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: props.relatedPoints.map(point => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
uuid: point.uuid,
|
||||
name: point.name,
|
||||
type: point.type
|
||||
},
|
||||
geometry: {
|
||||
type: 'Point' as const,
|
||||
coordinates: [point.longitude, point.latitude]
|
||||
}
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const sourceId = computed(() => `${props.mapId}-points`)
|
||||
const hoveredSourceId = computed(() => `${props.mapId}-hovered`)
|
||||
const relatedSourceId = computed(() => `${props.mapId}-related`)
|
||||
|
||||
const emitBoundsChange = (map: MapboxMapType) => {
|
||||
const bounds = map.getBounds()
|
||||
@@ -338,6 +370,55 @@ const initClientClusteringLayers = async (map: MapboxMapType) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Related points layer (for Info mode - smaller cyan circles)
|
||||
map.addSource(relatedSourceId.value, {
|
||||
type: 'geojson',
|
||||
data: relatedPointsGeoJson.value
|
||||
})
|
||||
map.addLayer({
|
||||
id: `${props.mapId}-related-circles`,
|
||||
type: 'circle',
|
||||
source: relatedSourceId.value,
|
||||
paint: {
|
||||
'circle-radius': 8,
|
||||
'circle-color': '#06b6d4', // cyan
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
})
|
||||
map.addLayer({
|
||||
id: `${props.mapId}-related-labels`,
|
||||
type: 'symbol',
|
||||
source: relatedSourceId.value,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 11,
|
||||
'text-anchor': 'top',
|
||||
'text-offset': [0, 1.2]
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 1
|
||||
}
|
||||
})
|
||||
|
||||
// Click handlers for related points
|
||||
map.on('click', `${props.mapId}-related-circles`, (e) => {
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-circles`] })
|
||||
if (!features.length) return
|
||||
const props_data = features[0].properties
|
||||
emit('select-item', props_data?.uuid, props_data)
|
||||
})
|
||||
|
||||
map.on('mouseenter', `${props.mapId}-related-circles`, () => {
|
||||
map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
|
||||
map.on('mouseleave', `${props.mapId}-related-circles`, () => {
|
||||
map.getCanvas().style.cursor = ''
|
||||
})
|
||||
|
||||
// Auto-fit bounds to all items
|
||||
if (!didFitBounds.value && props.items.length > 0) {
|
||||
const bounds = new LngLatBounds()
|
||||
@@ -470,6 +551,55 @@ const initServerClusteringLayers = async (map: MapboxMapType) => {
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
})
|
||||
|
||||
// Related points layer (for Info mode - smaller cyan circles)
|
||||
map.addSource(relatedSourceId.value, {
|
||||
type: 'geojson',
|
||||
data: relatedPointsGeoJson.value
|
||||
})
|
||||
map.addLayer({
|
||||
id: `${props.mapId}-related-circles`,
|
||||
type: 'circle',
|
||||
source: relatedSourceId.value,
|
||||
paint: {
|
||||
'circle-radius': 8,
|
||||
'circle-color': '#06b6d4', // cyan
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
})
|
||||
map.addLayer({
|
||||
id: `${props.mapId}-related-labels`,
|
||||
type: 'symbol',
|
||||
source: relatedSourceId.value,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-size': 11,
|
||||
'text-anchor': 'top',
|
||||
'text-offset': [0, 1.2]
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': '#000000',
|
||||
'text-halo-width': 1
|
||||
}
|
||||
})
|
||||
|
||||
// Click handlers for related points
|
||||
map.on('click', `${props.mapId}-related-circles`, (e) => {
|
||||
const features = map.queryRenderedFeatures(e.point, { layers: [`${props.mapId}-related-circles`] })
|
||||
if (!features.length) return
|
||||
const props_data = features[0].properties
|
||||
emit('select-item', props_data?.uuid, props_data)
|
||||
})
|
||||
|
||||
map.on('mouseenter', `${props.mapId}-related-circles`, () => {
|
||||
map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
|
||||
map.on('mouseleave', `${props.mapId}-related-circles`, () => {
|
||||
map.getCanvas().style.cursor = ''
|
||||
})
|
||||
}
|
||||
|
||||
// Update map data when items or clusteredPoints change
|
||||
@@ -490,6 +620,15 @@ watch(() => props.hoveredItem, () => {
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Update related points layer when relatedPoints changes
|
||||
watch(() => props.relatedPoints, () => {
|
||||
if (!mapRef.value || !mapInitialized.value) return
|
||||
const source = mapRef.value.getSource(relatedSourceId.value) as mapboxgl.GeoJSONSource | undefined
|
||||
if (source) {
|
||||
source.setData(relatedPointsGeoJson.value)
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// Watch for pointColor or entityType changes - update colors and icons
|
||||
watch([() => props.pointColor, () => props.entityType], async ([newColor, newType]) => {
|
||||
if (!mapRef.value || !mapInitialized.value) return
|
||||
|
||||
277
app/components/catalog/InfoPanel.vue
Normal file
277
app/components/catalog/InfoPanel.vue
Normal file
@@ -0,0 +1,277 @@
|
||||
<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>
|
||||
@@ -23,6 +23,7 @@
|
||||
:entity-type="activeEntityType"
|
||||
:hovered-item-id="hoveredId"
|
||||
:hovered-item="hoveredItem"
|
||||
:related-points="relatedPoints"
|
||||
@select-item="onMapSelect"
|
||||
@bounds-change="onBoundsChange"
|
||||
/>
|
||||
@@ -210,6 +211,13 @@ const props = withDefaults(defineProps<{
|
||||
items?: MapItem[]
|
||||
showPanel?: boolean
|
||||
filterByBounds?: boolean
|
||||
relatedPoints?: Array<{
|
||||
uuid: string
|
||||
name: string
|
||||
latitude: number
|
||||
longitude: number
|
||||
type: 'hub' | 'supplier' | 'offer'
|
||||
}>
|
||||
}>(), {
|
||||
loading: false,
|
||||
useServerClustering: true,
|
||||
@@ -218,7 +226,8 @@ const props = withDefaults(defineProps<{
|
||||
pointColor: '#f97316',
|
||||
items: () => [],
|
||||
showPanel: false,
|
||||
filterByBounds: false
|
||||
filterByBounds: false,
|
||||
relatedPoints: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user