refactor(catalog): replace InfoPanel tabs with vertical sections
All checks were successful
Build Docker Image / build (push) Successful in 3m57s
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user