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<{
|
||||
|
||||
Reference in New Issue
Block a user