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'
|
entityType?: 'offer' | 'hub' | 'supplier'
|
||||||
initialCenter?: [number, number]
|
initialCenter?: [number, number]
|
||||||
initialZoom?: number
|
initialZoom?: number
|
||||||
|
relatedPoints?: Array<{
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
type: 'hub' | 'supplier' | 'offer'
|
||||||
|
}>
|
||||||
}>(), {
|
}>(), {
|
||||||
pointColor: '#f97316',
|
pointColor: '#f97316',
|
||||||
entityType: 'offer',
|
entityType: 'offer',
|
||||||
@@ -58,7 +65,8 @@ const props = withDefaults(defineProps<{
|
|||||||
initialZoom: 2,
|
initialZoom: 2,
|
||||||
useServerClustering: false,
|
useServerClustering: false,
|
||||||
items: () => [],
|
items: () => [],
|
||||||
clusteredPoints: () => []
|
clusteredPoints: () => [],
|
||||||
|
relatedPoints: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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 sourceId = computed(() => `${props.mapId}-points`)
|
||||||
const hoveredSourceId = computed(() => `${props.mapId}-hovered`)
|
const hoveredSourceId = computed(() => `${props.mapId}-hovered`)
|
||||||
|
const relatedSourceId = computed(() => `${props.mapId}-related`)
|
||||||
|
|
||||||
const emitBoundsChange = (map: MapboxMapType) => {
|
const emitBoundsChange = (map: MapboxMapType) => {
|
||||||
const bounds = map.getBounds()
|
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
|
// Auto-fit bounds to all items
|
||||||
if (!didFitBounds.value && props.items.length > 0) {
|
if (!didFitBounds.value && props.items.length > 0) {
|
||||||
const bounds = new LngLatBounds()
|
const bounds = new LngLatBounds()
|
||||||
@@ -470,6 +551,55 @@ const initServerClusteringLayers = async (map: MapboxMapType) => {
|
|||||||
'circle-stroke-color': '#ffffff'
|
'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
|
// Update map data when items or clusteredPoints change
|
||||||
@@ -490,6 +620,15 @@ watch(() => props.hoveredItem, () => {
|
|||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { 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 for pointColor or entityType changes - update colors and icons
|
||||||
watch([() => props.pointColor, () => props.entityType], async ([newColor, newType]) => {
|
watch([() => props.pointColor, () => props.entityType], async ([newColor, newType]) => {
|
||||||
if (!mapRef.value || !mapInitialized.value) return
|
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"
|
:entity-type="activeEntityType"
|
||||||
:hovered-item-id="hoveredId"
|
:hovered-item-id="hoveredId"
|
||||||
:hovered-item="hoveredItem"
|
:hovered-item="hoveredItem"
|
||||||
|
:related-points="relatedPoints"
|
||||||
@select-item="onMapSelect"
|
@select-item="onMapSelect"
|
||||||
@bounds-change="onBoundsChange"
|
@bounds-change="onBoundsChange"
|
||||||
/>
|
/>
|
||||||
@@ -210,6 +211,13 @@ const props = withDefaults(defineProps<{
|
|||||||
items?: MapItem[]
|
items?: MapItem[]
|
||||||
showPanel?: boolean
|
showPanel?: boolean
|
||||||
filterByBounds?: boolean
|
filterByBounds?: boolean
|
||||||
|
relatedPoints?: Array<{
|
||||||
|
uuid: string
|
||||||
|
name: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
type: 'hub' | 'supplier' | 'offer'
|
||||||
|
}>
|
||||||
}>(), {
|
}>(), {
|
||||||
loading: false,
|
loading: false,
|
||||||
useServerClustering: true,
|
useServerClustering: true,
|
||||||
@@ -218,7 +226,8 @@ const props = withDefaults(defineProps<{
|
|||||||
pointColor: '#f97316',
|
pointColor: '#f97316',
|
||||||
items: () => [],
|
items: () => [],
|
||||||
showPanel: false,
|
showPanel: false,
|
||||||
filterByBounds: false
|
filterByBounds: false,
|
||||||
|
relatedPoints: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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 SelectMode = 'product' | 'supplier' | 'hub' | null
|
||||||
export type MapViewMode = 'offers' | 'hubs' | 'suppliers'
|
export type MapViewMode = 'offers' | 'hubs' | 'suppliers'
|
||||||
export type CatalogMode = 'explore' | 'quote'
|
export type CatalogMode = 'explore' | 'quote'
|
||||||
|
export type InfoEntityType = 'hub' | 'supplier' | 'offer'
|
||||||
export type DisplayMode =
|
export type DisplayMode =
|
||||||
| 'map-default'
|
| 'map-default'
|
||||||
| 'grid-products'
|
| 'grid-products'
|
||||||
@@ -13,6 +14,11 @@ export type DisplayMode =
|
|||||||
| 'grid-products-in-hub'
|
| 'grid-products-in-hub'
|
||||||
| 'grid-offers'
|
| 'grid-offers'
|
||||||
|
|
||||||
|
export interface InfoId {
|
||||||
|
type: InfoEntityType
|
||||||
|
uuid: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SearchFilter {
|
export interface SearchFilter {
|
||||||
type: 'product' | 'supplier' | 'hub' | 'quantity'
|
type: 'product' | 'supplier' | 'hub' | 'quantity'
|
||||||
id: string
|
id: string
|
||||||
@@ -56,6 +62,17 @@ export function useCatalogSearch() {
|
|||||||
return null
|
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 productId = computed(() => route.query.product as string | undefined)
|
||||||
const supplierId = computed(() => route.query.supplier as string | undefined)
|
const supplierId = computed(() => route.query.supplier as string | undefined)
|
||||||
const hubId = computed(() => route.query.hub as string | undefined)
|
const hubId = computed(() => route.query.hub as string | undefined)
|
||||||
@@ -210,6 +227,14 @@ export function useCatalogSearch() {
|
|||||||
updateQuery({ qty })
|
updateQuery({ qty })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openInfo = (type: InfoEntityType, uuid: string) => {
|
||||||
|
updateQuery({ info: `${type}:${uuid}`, select: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeInfo = () => {
|
||||||
|
updateQuery({ info: null })
|
||||||
|
}
|
||||||
|
|
||||||
const clearAll = () => {
|
const clearAll = () => {
|
||||||
if (isMainPage.value) {
|
if (isMainPage.value) {
|
||||||
router.push({ path: localePath('/catalog'), query: {} })
|
router.push({ path: localePath('/catalog'), query: {} })
|
||||||
@@ -256,6 +281,7 @@ export function useCatalogSearch() {
|
|||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
selectMode,
|
selectMode,
|
||||||
|
infoId,
|
||||||
displayMode,
|
displayMode,
|
||||||
catalogMode,
|
catalogMode,
|
||||||
productId,
|
productId,
|
||||||
@@ -283,6 +309,8 @@ export function useCatalogSearch() {
|
|||||||
removeFilter,
|
removeFilter,
|
||||||
editFilter,
|
editFilter,
|
||||||
setQuantity,
|
setQuantity,
|
||||||
|
openInfo,
|
||||||
|
closeInfo,
|
||||||
clearAll,
|
clearAll,
|
||||||
setLabel,
|
setLabel,
|
||||||
setMapViewMode,
|
setMapViewMode,
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
:hovered-id="hoveredItemId"
|
:hovered-id="hoveredItemId"
|
||||||
:show-panel="showPanel"
|
:show-panel="showPanel"
|
||||||
:filter-by-bounds="filterByBounds"
|
:filter-by-bounds="filterByBounds"
|
||||||
|
:related-points="relatedPoints"
|
||||||
@select="onMapSelect"
|
@select="onMapSelect"
|
||||||
@bounds-change="onBoundsChange"
|
@bounds-change="onBoundsChange"
|
||||||
@update:filter-by-bounds="filterByBounds = $event"
|
@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>
|
<template #panel>
|
||||||
<!-- Selection mode: show list for picking product/hub/supplier -->
|
<!-- Selection mode: show list for picking product/hub/supplier -->
|
||||||
<SelectionPanel
|
<SelectionPanel
|
||||||
@@ -32,6 +33,24 @@
|
|||||||
@hover="onHoverItem"
|
@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 -->
|
<!-- Quote results: show offers after search -->
|
||||||
<QuotePanel
|
<QuotePanel
|
||||||
v-else-if="showQuoteResults"
|
v-else-if="showQuoteResults"
|
||||||
@@ -85,6 +104,7 @@ const onBoundsChange = (bounds: MapBounds) => {
|
|||||||
const {
|
const {
|
||||||
catalogMode,
|
catalogMode,
|
||||||
selectMode,
|
selectMode,
|
||||||
|
infoId,
|
||||||
productId,
|
productId,
|
||||||
supplierId,
|
supplierId,
|
||||||
hubId,
|
hubId,
|
||||||
@@ -94,9 +114,25 @@ const {
|
|||||||
entityColors,
|
entityColors,
|
||||||
selectItem,
|
selectItem,
|
||||||
cancelSelect,
|
cancelSelect,
|
||||||
|
openInfo,
|
||||||
|
closeInfo,
|
||||||
setLabel
|
setLabel
|
||||||
} = useCatalogSearch()
|
} = 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)
|
// Composables for data (initialize immediately when selectMode changes)
|
||||||
const {
|
const {
|
||||||
items: products,
|
items: products,
|
||||||
@@ -191,6 +227,69 @@ watch([filterByBounds, currentMapBounds], ([enabled, bounds]) => {
|
|||||||
setProductBoundsFilter(boundsToApply)
|
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
|
// Offers data for quote results
|
||||||
const offers = ref<any[]>([])
|
const offers = ref<any[]>([])
|
||||||
const offersLoading = ref(false)
|
const offersLoading = ref(false)
|
||||||
@@ -207,9 +306,9 @@ watch(searchTrigger, () => {
|
|||||||
// Loading state
|
// Loading state
|
||||||
const isLoading = computed(() => offersLoading.value || selectionLoading.value)
|
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(() => {
|
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
|
// Cluster node type based on map view mode
|
||||||
@@ -268,21 +367,43 @@ const onMapSelect = async (item: any) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default behavior for quote mode etc
|
// NEW: Default behavior - open Info directly
|
||||||
if (catalogMode.value === 'quote') {
|
let infoType: 'hub' | 'supplier' | 'offer'
|
||||||
console.log('Selected from map:', item)
|
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
|
// Handle selection from SelectionPanel
|
||||||
const onSelectItem = (type: string, item: any) => {
|
const onSelectItem = (type: string, item: any) => {
|
||||||
if (item.uuid && item.name) {
|
if (item.uuid && item.name) {
|
||||||
selectItem(type, item.uuid, item.name)
|
// NEW: Open Info instead of selecting directly
|
||||||
showQuoteResults.value = false
|
openInfo(type as 'hub' | 'supplier' | 'offer', item.uuid)
|
||||||
offers.value = []
|
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
|
// Search for offers
|
||||||
const onSearch = async () => {
|
const onSearch = async () => {
|
||||||
if (!canSearch.value) return
|
if (!canSearch.value) return
|
||||||
|
|||||||
Reference in New Issue
Block a user