refactor(catalog): replace InfoPanel tabs with vertical sections
All checks were successful
Build Docker Image / build (push) Successful in 3m57s

- Remove all tabs from InfoPanel, use stacked sections instead
- Load suppliers (for hub) and hubs (for supplier) immediately
- Show entity header as text, not card
- Simplify relatedPoints to show all points on map
- Add translations for new section titles
This commit is contained in:
Ruslan Bakiev
2026-01-26 19:34:04 +07:00
parent 69bb978526
commit e905098cb5
5 changed files with 236 additions and 323 deletions

View File

@@ -1,8 +1,8 @@
<template>
<div class="flex flex-col h-full">
<!-- Header -->
<!-- Header with close button -->
<div class="flex-shrink-0 p-4 border-b border-white/10">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div
class="flex items-center justify-center w-6 h-6 rounded-full"
@@ -27,148 +27,103 @@
<!-- Content -->
<div v-else-if="entity" class="flex flex-col gap-4">
<!-- Entity details card -->
<div>
<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>
<!-- Entity Info Header (text, not card) -->
<div class="mb-2">
<!-- Location for hub/supplier -->
<p v-if="entityLocation" class="text-sm text-white/70 flex items-center gap-1">
<Icon name="lucide:map-pin" size="14" />
{{ entityLocation }}
</p>
<!-- 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 text-white/70"
:class="{
'tab-active !text-white !bg-white/20': activeTab === tab.id,
'pointer-events-none opacity-50': tab.loading
}"
@click="!tab.loading && onTabClick(tab.id)"
<!-- Price for offer -->
<p v-if="entityType === 'offer' && entity?.pricePerUnit" class="text-sm text-white/70 flex items-center gap-1">
<Icon name="lucide:tag" size="14" />
{{ formatPrice(entity.pricePerUnit) }} {{ entity.currency || 'RUB' }}/{{ entity.unit || 't' }}
</p>
<!-- Supplier link for offer -->
<button
v-if="entityType === 'offer' && entity?.teamUuid"
class="text-sm text-primary hover:underline flex items-center gap-1 mt-1"
@click="emit('open-info', 'supplier', entity.teamUuid)"
>
{{ tab.label }}
<span v-if="tab.loading" class="ml-1">
<span class="loading loading-spinner loading-xs" />
</span>
<span v-else-if="tab.count !== undefined" class="ml-1 opacity-70">({{ tab.count }})</span>
</a>
<Icon name="lucide:factory" size="14" />
{{ entity.teamName || $t('catalog.info.viewSupplier') }}
</button>
</div>
<!-- Tab content -->
<div class="flex flex-col gap-2">
<!-- Products tab (only for Offer entity) -->
<div v-if="activeTab === 'products' && entityType === 'offer'">
<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.empty.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>
<!-- Products Section (for hub/supplier) -->
<section v-if="entityType === 'hub' || entityType === 'supplier'">
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
<Icon name="lucide:package" size="16" />
{{ productsSectionTitle }}
<span v-if="loadingProducts" class="loading loading-spinner loading-xs" />
<span v-else-if="relatedProducts.length > 0" class="text-white/50">({{ relatedProducts.length }})</span>
</h3>
<div v-if="!loadingProducts && relatedProducts.length === 0" class="text-white/50 text-sm py-2">
{{ $t('catalog.empty.noProducts') }}
</div>
<!-- Hubs tab -->
<div v-if="activeTab === '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 v-else-if="!loadingProducts" class="flex flex-col gap-2">
<ProductCard
v-for="product in relatedProducts"
:key="product.uuid"
:product="product"
compact
selectable
@select="onProductSelect(product)"
/>
</div>
</section>
<!-- Suppliers tab -->
<div v-if="activeTab === '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>
<!-- Suppliers Section (for hub only) -->
<section v-if="entityType === 'hub'">
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
<Icon name="lucide:factory" size="16" />
{{ $t('catalog.info.suppliersNearby') }}
<span v-if="loadingSuppliers" class="loading loading-spinner loading-xs" />
<span v-else-if="relatedSuppliers.length > 0" class="text-white/50">({{ relatedSuppliers.length }})</span>
</h3>
<div v-if="!loadingSuppliers && relatedSuppliers.length === 0" class="text-white/50 text-sm py-2">
{{ $t('catalog.info.noSuppliers') }}
</div>
<!-- Offers tab (two-step for Hub/Supplier) -->
<div v-if="activeTab === 'offers'">
<!-- Step 1: Select product (for Hub/Supplier) -->
<div v-if="!selectedProduct && (entityType === 'hub' || entityType === 'supplier')">
<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.empty.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>
<!-- Step 2: Show offers for selected product -->
<div v-else-if="selectedProduct">
<!-- Back button to products -->
<button
class="btn btn-sm btn-ghost mb-2 text-white/80 hover:text-white"
@click="emit('select-product', null)"
>
<Icon name="lucide:arrow-left" size="16" />
{{ $t('common.back') }}
</button>
<div v-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.empty.noOffers') }}</p>
</div>
<div v-else class="flex flex-col gap-2">
<OfferResultCard
v-for="offer in relatedOffers"
:key="offer.uuid"
:location-name="offer.locationName || offer.productName"
:product-name="offer.productName"
:price-per-unit="offer.pricePerUnit ? Number(offer.pricePerUnit) : null"
:currency="offer.currency"
:unit="offer.unit"
:stages="getOfferStages(offer)"
@select="onOfferSelect(offer)"
/>
</div>
</div>
<!-- For Offer entity type - just show message -->
<div v-else 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="!loadingSuppliers" class="flex flex-col gap-2">
<SupplierCard
v-for="supplier in relatedSuppliers"
:key="supplier.uuid"
:supplier="supplier"
selectable
@select="onSupplierSelect(supplier)"
/>
</div>
</div>
</section>
<!-- Hubs Section (for supplier/offer) -->
<section v-if="entityType === 'supplier' || entityType === 'offer'">
<h3 class="text-sm font-semibold text-white/80 mb-2 flex items-center gap-2">
<Icon name="lucide:warehouse" size="16" />
{{ $t('catalog.info.nearestHubs') }}
<span v-if="loadingHubs" class="loading loading-spinner loading-xs" />
<span v-else-if="relatedHubs.length > 0" class="text-white/50">({{ relatedHubs.length }})</span>
</h3>
<div v-if="!loadingHubs && relatedHubs.length === 0" class="text-white/50 text-sm py-2">
{{ $t('catalog.info.noHubs') }}
</div>
<div v-else-if="!loadingHubs" class="flex flex-col gap-2">
<HubCard
v-for="hub in relatedHubs"
:key="hub.uuid"
:hub="hub"
selectable
@select="onHubSelect(hub)"
/>
</div>
</section>
<!-- Add to filter button -->
<button class="btn btn-primary btn-sm" @click="emit('add-to-filter')">
<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>
@@ -212,13 +167,26 @@ const { entityColors } = useCatalogSearch()
const relatedProducts = computed(() => props.relatedProducts ?? [])
const relatedHubs = computed(() => props.relatedHubs ?? [])
const relatedSuppliers = computed(() => props.relatedSuppliers ?? [])
const relatedOffers = computed(() => props.relatedOffers ?? [])
// Entity name
const entityName = computed(() => {
return props.entity?.name || props.entity?.productName || props.entityId.slice(0, 8) + '...'
})
// Entity location (address, city, country)
const entityLocation = computed(() => {
if (!props.entity) return null
const parts = [props.entity.address, props.entity.city, props.entity.country].filter(Boolean)
return parts.length > 0 ? parts.join(', ') : null
})
// Products section title based on entity type
const productsSectionTitle = computed(() => {
return props.entityType === 'hub'
? t('catalog.info.productsHere')
: t('catalog.info.productsFromSupplier')
})
// Badge color
const badgeColor = computed(() => {
if (props.entityType === 'hub') return entityColors.hub
@@ -234,92 +202,16 @@ const entityIcon = computed(() => {
return 'lucide:info'
})
// Available tabs based on entity type and data
const availableTabs = computed(() => {
const tabs: Array<{ id: string; label: string; count?: number; loading?: boolean }> = []
if (props.entityType === 'hub') {
// For hub: offers tab shows products first, then offers after product selection
const offersLoading = props.selectedProduct ? props.loadingOffers : props.loadingProducts
const offersCount = props.selectedProduct
? props.relatedOffers?.length
: props.relatedProducts?.length
tabs.push({
id: 'offers',
label: t('catalog.tabs.offers'),
count: offersLoading ? undefined : (offersCount || 0),
loading: offersLoading
})
tabs.push({
id: 'suppliers',
label: t('catalog.tabs.suppliers'),
count: props.loadingSuppliers ? undefined : (props.relatedSuppliers?.length || 0),
loading: props.loadingSuppliers
})
} else if (props.entityType === 'supplier') {
// For supplier: offers tab shows products first, then offers after product selection
const offersLoading = props.selectedProduct ? props.loadingOffers : props.loadingProducts
const offersCount = props.selectedProduct
? props.relatedOffers?.length
: props.relatedProducts?.length
tabs.push({
id: 'offers',
label: t('catalog.tabs.offers'),
count: offersLoading ? undefined : (offersCount || 0),
loading: offersLoading
})
tabs.push({
id: 'hubs',
label: t('catalog.tabs.hubs'),
count: props.loadingHubs ? undefined : (props.relatedHubs?.length || 0),
loading: props.loadingHubs
})
} 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.loadingHubs ? undefined : (props.relatedHubs?.length || 0),
loading: props.loadingHubs
})
if (props.relatedSuppliers && props.relatedSuppliers.length > 0) {
tabs.push({
id: 'suppliers',
label: t('catalog.tabs.supplier'),
count: props.loadingSuppliers ? undefined : (props.relatedSuppliers?.length || 0),
loading: props.loadingSuppliers
})
}
}
return tabs
})
// Active tab - use prop or default to first available
const activeTab = computed(() => {
// If prop is set and is a valid tab, use it
if (props.currentTab && availableTabs.value.some(t => t.id === props.currentTab)) {
return props.currentTab
}
// Default to first tab
return availableTabs.value[0]?.id || 'offers'
})
// Handler for tab click
const onTabClick = (tabId: string) => {
emit('update:current-tab', tabId)
// Format price
const formatPrice = (price: number | string) => {
const num = typeof price === 'string' ? parseFloat(price) : price
return new Intl.NumberFormat('ru-RU').format(num)
}
// Handlers for selecting related items
const onProductSelect = (product: any) => {
if (product.uuid) {
// Navigate to offer info for this product
emit('select-product', product.uuid)
}
}
@@ -335,20 +227,4 @@ const onSupplierSelect = (supplier: any) => {
emit('open-info', 'supplier', supplier.uuid)
}
}
const onOfferSelect = (offer: any) => {
if (offer.uuid) {
emit('open-info', 'offer', offer.uuid)
}
}
// Extract route stages for OfferResultCard
const getOfferStages = (offer: any) => {
const route = offer.routes?.[0]
if (!route?.stages) return []
return route.stages.filter(Boolean).map((stage: any) => ({
transportType: stage?.transportType,
distanceKm: stage?.distanceKm
}))
}
</script>

View File

@@ -29,7 +29,7 @@ export function useCatalogInfo() {
const isLoadingSuppliers = ref(false)
const isLoadingOffers = ref(false)
// Load hub info: hub details + products + suppliers
// Load hub info: hub details + products + suppliers (in parallel)
const loadHubInfo = async (uuid: string) => {
try {
// Load hub node details
@@ -44,23 +44,27 @@ export function useCatalogInfo() {
// Set default active tab to offers (first step shows products)
activeTab.value = 'offers'
// Load products (offers grouped by product)
// Load products AND suppliers in parallel
isLoadingProducts.value = true
try {
const offersData = await execute(
NearestOffersDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 500
},
'public',
'geo'
)
isLoadingSuppliers.value = true
// Load products (offers grouped by product) and extract suppliers
execute(
NearestOffersDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 500
},
'public',
'geo'
).then(offersData => {
// Group offers by product
const productsMap = new Map<string, any>()
const suppliersMap = new Map<string, any>()
offersData?.nearestOffers?.forEach((offer: any) => {
// Products
if (offer?.productUuid) {
if (!productsMap.has(offer.productUuid)) {
productsMap.set(offer.productUuid, {
@@ -71,19 +75,50 @@ export function useCatalogInfo() {
}
productsMap.get(offer.productUuid)!.offersCount++
}
})
relatedProducts.value = Array.from(productsMap.values())
} finally {
isLoadingProducts.value = false
}
// Note: Suppliers and Offers loaded after product selection via loadOffersForHub
// Suppliers (extract from offers)
if (offer?.supplierUuid) {
if (!suppliersMap.has(offer.supplierUuid)) {
suppliersMap.set(offer.supplierUuid, {
uuid: offer.supplierUuid,
name: offer.supplierName || 'Supplier',
latitude: offer.latitude,
longitude: offer.longitude
})
}
}
})
relatedProducts.value = Array.from(productsMap.values())
// Load supplier profiles for detailed info
const supplierUuids = Array.from(suppliersMap.keys()).slice(0, 12)
if (supplierUuids.length > 0) {
Promise.all(
supplierUuids.map(supplierId =>
execute(GetSupplierProfileDocument, { uuid: supplierId }, 'public', 'exchange')
.then(data => data?.getSupplierProfile)
.catch(() => suppliersMap.get(supplierId)) // Fallback to basic info
)
).then(profiles => {
relatedSuppliers.value = profiles.filter(Boolean)
isLoadingSuppliers.value = false
})
} else {
relatedSuppliers.value = []
isLoadingSuppliers.value = false
}
}).finally(() => {
isLoadingProducts.value = false
})
} catch (error) {
console.error('Error loading hub info:', error)
isLoadingProducts.value = false
isLoadingSuppliers.value = false
}
}
// Load supplier info: supplier details + products
// Load supplier info: supplier details + products + hubs (in parallel)
const loadSupplierInfo = async (uuid: string) => {
try {
// Load supplier node details (might be geo node)
@@ -113,20 +148,21 @@ export function useCatalogInfo() {
// Set default active tab to offers (first step shows products)
activeTab.value = 'offers'
// Load products (offers grouped by product)
// Load products AND hubs in parallel
isLoadingProducts.value = true
try {
const offersData = await execute(
NearestOffersDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 500
},
'public',
'geo'
)
isLoadingHubs.value = true
// Load products (offers grouped by product)
execute(
NearestOffersDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 500
},
'public',
'geo'
).then(offersData => {
// Group offers by product
const productsMap = new Map<string, any>()
offersData?.nearestOffers?.forEach((offer: any) => {
@@ -142,13 +178,30 @@ export function useCatalogInfo() {
}
})
relatedProducts.value = Array.from(productsMap.values())
} finally {
}).finally(() => {
isLoadingProducts.value = false
}
})
// Note: Hubs will be loaded after product selection
// Load hubs near supplier
execute(
NearestHubsDocument,
{
lat: entity.value.latitude,
lon: entity.value.longitude,
radius: 1000,
limit: 12
},
'public',
'geo'
).then(hubsData => {
relatedHubs.value = hubsData?.nearestHubs || []
}).finally(() => {
isLoadingHubs.value = false
})
} catch (error) {
console.error('Error loading supplier info:', error)
isLoadingProducts.value = false
isLoadingHubs.value = false
}
}

View File

@@ -44,7 +44,6 @@
:related-suppliers="relatedSuppliers"
:related-offers="relatedOffers"
:selected-product="infoProduct ?? null"
:current-tab="infoTab"
:loading="infoLoading"
:loading-products="isLoadingProducts"
:loading-hubs="isLoadingHubs"
@@ -54,7 +53,6 @@
@add-to-filter="onInfoAddToFilter"
@open-info="onInfoOpenRelated"
@select-product="onInfoSelectProduct"
@update:current-tab="setInfoTab"
/>
<!-- Quote results: show offers after search -->
@@ -111,7 +109,6 @@ const {
catalogMode,
selectMode,
infoId,
infoTab,
infoProduct,
productId,
supplierId,
@@ -124,7 +121,6 @@ const {
cancelSelect,
openInfo,
closeInfo,
setInfoTab,
setInfoProduct,
setLabel
} = useCatalogSearch()
@@ -261,7 +257,7 @@ watch(infoProduct, async (productUuid) => {
}
})
// Related points for Info mode (shown on map) - filtered by active tab
// Related points for Info mode (shown on map) - show all related entities
const relatedPoints = computed(() => {
if (!infoId.value) return []
@@ -273,57 +269,31 @@ const relatedPoints = computed(() => {
type: 'hub' | 'supplier' | 'offer'
}> = []
const currentTab = infoTab.value
// Add all 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'
})
}
})
// Show content based on active tab
// Hub entity: offers tab → offers, suppliers tab → suppliers
// Supplier entity: offers tab → offers, hubs tab → hubs
// Offer entity: hubs tab → hubs, suppliers tab → suppliers
// Add hubs (for supplier's hubs tab or offer's hubs tab)
if (currentTab === '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 (for hub's suppliers tab or offer's suppliers tab)
if (currentTab === '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 (for hub's/supplier's offers tab when product is selected)
if (currentTab === 'offers' && infoProduct.value) {
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'
})
}
})
}
// Add all 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'
})
}
})
return points
})

View File

@@ -56,7 +56,14 @@
},
"info": {
"selectProductFirst": "Select a product first",
"addToFilter": "Add to filter"
"addToFilter": "Add to filter",
"productsHere": "Products available here",
"productsFromSupplier": "Products from this supplier",
"nearestHubs": "Nearest hubs",
"suppliersNearby": "Suppliers nearby",
"noHubs": "No hubs found",
"noSuppliers": "No suppliers found",
"viewSupplier": "View supplier"
},
"modes": {
"explore": "Explore",

View File

@@ -56,7 +56,14 @@
},
"info": {
"selectProductFirst": "Сначала выберите товар",
"addToFilter": "Добавить в фильтр"
"addToFilter": "Добавить в фильтр",
"productsHere": "Товары доступные здесь",
"productsFromSupplier": "Товары от этого поставщика",
"nearestHubs": "Ближайшие хабы",
"suppliersNearby": "Поставщики рядом",
"noHubs": "Хабы не найдены",
"noSuppliers": "Поставщики не найдены",
"viewSupplier": "Посмотреть поставщика"
},
"modes": {
"explore": "Исследовать",