UI improvements: filters, map layout, search bar
Some checks failed
Build Docker Image / build (push) Failing after 1m29s

- Add hubCountries query and country filter for hubs page
- Add getAvailableProducts query for offers (only products with active offers)
- Add sourceLatitude/sourceLongitude to orders GraphQL
- Fix ListMapLayout with position fixed for proper map height
- GlobalSearchBar: make fields wider, remove unit selector
- Remove status/isVerified filters from suppliers/offers (backend handles this)
This commit is contained in:
Ruslan Bakiev
2026-01-08 10:42:59 +07:00
parent 0c88cf383c
commit d6865d2129
15 changed files with 87 additions and 78 deletions

View File

@@ -1,23 +1,25 @@
<template> <template>
<div class="flex flex-col flex-1 min-h-0"> <div class="flex flex-col flex-1 min-h-0">
<!-- Desktop: side-by-side layout --> <!-- Desktop: side-by-side layout -->
<div class="hidden lg:flex flex-1 gap-4 min-h-0 overflow-hidden"> <div class="hidden lg:flex flex-1 gap-4 min-h-0">
<!-- Left side: List (scrollable) --> <!-- Left side: List (scrollable) -->
<div class="w-2/5 overflow-y-auto pr-2"> <div class="w-2/5 overflow-y-auto pr-2">
<slot name="list" /> <slot name="list" />
</div> </div>
<!-- Right side: Map (sticky, full height from SubNav to bottom) --> <!-- Right side: Map (fixed position, full height) -->
<div class="w-3/5 h-full rounded-lg overflow-hidden sticky top-0 self-start"> <div class="w-3/5 relative">
<ClientOnly> <div class="fixed top-28 right-6 w-[calc(60%-3rem)] h-[calc(100vh-8rem)] rounded-lg overflow-hidden">
<CatalogMap <ClientOnly>
ref="mapRef" <CatalogMap
:map-id="mapId" ref="mapRef"
:items="itemsWithCoords" :map-id="mapId"
:point-color="pointColor" :items="itemsWithCoords"
@select-item="onMapSelectItem" :point-color="pointColor"
/> @select-item="onMapSelectItem"
</ClientOnly> />
</ClientOnly>
</div>
</div> </div>
</div> </div>

View File

@@ -7,7 +7,7 @@
> >
<!-- Product field (clickable, navigates to /goods) --> <!-- Product field (clickable, navigates to /goods) -->
<div <div
class="flex flex-col px-4 py-2 min-w-32 pl-6 rounded-l-full hover:bg-base-200/50 border-r border-base-300 cursor-pointer" class="flex flex-col px-4 py-2 min-w-48 pl-6 rounded-l-full hover:bg-base-200/50 border-r border-base-300 cursor-pointer"
@click="goToProductSelection" @click="goToProductSelection"
> >
<label class="text-xs font-semibold text-base-content/60 mb-0.5"> <label class="text-xs font-semibold text-base-content/60 mb-0.5">
@@ -19,29 +19,23 @@
</div> </div>
<!-- Quantity field (editable) --> <!-- Quantity field (editable) -->
<div class="flex flex-col px-4 py-2 min-w-32 hover:bg-base-200/50 border-r border-base-300"> <div class="flex flex-col px-4 py-2 min-w-48 hover:bg-base-200/50 border-r border-base-300">
<label class="text-xs font-semibold text-base-content/60 mb-0.5"> <label class="text-xs font-semibold text-base-content/60 mb-0.5">
{{ $t('search.quantity') }} {{ $t('search.quantity') }}
</label> </label>
<div class="flex items-center gap-1"> <input
<input v-model="quantity"
v-model="quantity" type="number"
type="number" min="1"
min="1" :placeholder="$t('search.quantity_placeholder')"
:placeholder="$t('search.quantity_placeholder')" class="w-full bg-transparent outline-none text-sm"
class="w-16 bg-transparent outline-none text-sm" @change="syncQuantityToStore"
@change="syncQuantityToStore" />
/>
<select v-model="unit" class="bg-transparent outline-none text-sm text-base-content/70" @change="syncQuantityToStore">
<option value="t">{{ $t('units.t') }}</option>
<option value="kg">{{ $t('units.kg') }}</option>
</select>
</div>
</div> </div>
<!-- Destination field (clickable, navigates to /select-location) --> <!-- Destination field (clickable, navigates to /select-location) -->
<div <div
class="flex flex-col px-4 py-2 min-w-32 hover:bg-base-200/50 cursor-pointer" class="flex flex-col px-4 py-2 min-w-48 hover:bg-base-200/50 cursor-pointer"
@click="goToLocationSelection" @click="goToLocationSelection"
> >
<label class="text-xs font-semibold text-base-content/60 mb-0.5"> <label class="text-xs font-semibold text-base-content/60 mb-0.5">
@@ -67,7 +61,7 @@
<script setup lang="ts"> <script setup lang="ts">
const emit = defineEmits<{ const emit = defineEmits<{
search: [params: { productUuid?: string; quantity?: number; unit?: string; locationUuid?: string }] search: [params: { productUuid?: string; quantity?: number; locationUuid?: string }]
}>() }>()
const router = useRouter() const router = useRouter()
@@ -84,13 +78,11 @@ const locationUuid = computed(() => searchStore.searchForm.locationUuid || '')
const quantity = ref<number | undefined>( const quantity = ref<number | undefined>(
searchStore.searchForm.quantity ? Number(searchStore.searchForm.quantity) : undefined searchStore.searchForm.quantity ? Number(searchStore.searchForm.quantity) : undefined
) )
const unit = ref(searchStore.searchForm.unit || 't')
const syncQuantityToStore = () => { const syncQuantityToStore = () => {
if (quantity.value) { if (quantity.value) {
searchStore.setQuantity(String(quantity.value)) searchStore.setQuantity(String(quantity.value))
} }
searchStore.setUnit(unit.value)
} }
// Navigation to selection pages // Navigation to selection pages
@@ -135,7 +127,6 @@ const handleSearch = () => {
emit('search', { emit('search', {
productUuid: productUuid.value, productUuid: productUuid.value,
quantity: quantity.value, quantity: quantity.value,
unit: unit.value,
locationUuid: locationUuid.value locationUuid: locationUuid.value
}) })
} }
@@ -146,10 +137,4 @@ watch(() => searchStore.searchForm.quantity, (val) => {
quantity.value = Number(val) quantity.value = Number(val)
} }
}, { immediate: true }) }, { immediate: true })
watch(() => searchStore.searchForm.unit, (val) => {
if (val) {
unit.value = val
}
}, { immediate: true })
</script> </script>

View File

@@ -1,4 +1,4 @@
import { GetNodesDocument } from '~/composables/graphql/public/geo-generated' import { GetNodesDocument, GetHubCountriesDocument } from '~/composables/graphql/public/geo-generated'
const PAGE_SIZE = 24 const PAGE_SIZE = 24
@@ -6,6 +6,8 @@ const PAGE_SIZE = 24
const items = ref<any[]>([]) const items = ref<any[]>([])
const total = ref(0) const total = ref(0)
const selectedFilter = ref('all') const selectedFilter = ref('all')
const selectedCountry = ref('all')
const countries = ref<string[]>([])
const isLoading = ref(false) const isLoading = ref(false)
const isLoadingMore = ref(false) const isLoadingMore = ref(false)
const isInitialized = ref(false) const isInitialized = ref(false)
@@ -22,6 +24,11 @@ export function useCatalogHubs() {
{ id: 'air', label: t('catalogHubsSection.filters.air') } { id: 'air', label: t('catalogHubsSection.filters.air') }
]) ])
const countryFilters = computed(() => [
{ id: 'all', label: t('catalogHubsSection.filters.all_countries') },
...countries.value.map(c => ({ id: c, label: c }))
])
const itemsWithCoords = computed(() => const itemsWithCoords = computed(() =>
items.value.filter(h => h.latitude && h.longitude) items.value.filter(h => h.latitude && h.longitude)
) )
@@ -44,9 +51,10 @@ export function useCatalogHubs() {
if (replace) isLoading.value = true if (replace) isLoading.value = true
try { try {
const transportType = selectedFilter.value === 'all' ? null : selectedFilter.value const transportType = selectedFilter.value === 'all' ? null : selectedFilter.value
const country = selectedCountry.value === 'all' ? null : selectedCountry.value
const data = await execute( const data = await execute(
GetNodesDocument, GetNodesDocument,
{ limit: PAGE_SIZE, offset, transportType }, { limit: PAGE_SIZE, offset, transportType, country },
'public', 'public',
'geo' 'geo'
) )
@@ -59,6 +67,15 @@ export function useCatalogHubs() {
} }
} }
const loadCountries = async () => {
try {
const data = await execute(GetHubCountriesDocument, {}, 'public', 'geo')
countries.value = data?.hubCountries || []
} catch (e) {
console.error('Failed to load hub countries', e)
}
}
const loadMore = async () => { const loadMore = async () => {
if (isLoadingMore.value) return if (isLoadingMore.value) return
isLoadingMore.value = true isLoadingMore.value = true
@@ -70,7 +87,7 @@ export function useCatalogHubs() {
} }
// При смене фильтра - перезагрузка // При смене фильтра - перезагрузка
watch(selectedFilter, () => { watch([selectedFilter, selectedCountry], () => {
if (isInitialized.value) { if (isInitialized.value) {
fetchPage(0, true) fetchPage(0, true)
} }
@@ -79,7 +96,10 @@ export function useCatalogHubs() {
// Initialize data if not already loaded // Initialize data if not already loaded
const init = async () => { const init = async () => {
if (!isInitialized.value && items.value.length === 0) { if (!isInitialized.value && items.value.length === 0) {
await fetchPage(0, true) await Promise.all([
fetchPage(0, true),
loadCountries()
])
} }
} }
@@ -87,7 +107,9 @@ export function useCatalogHubs() {
items, items,
total, total,
selectedFilter, selectedFilter,
selectedCountry,
filters, filters,
countryFilters,
isLoading, isLoading,
isLoadingMore, isLoadingMore,
itemsWithCoords, itemsWithCoords,

View File

@@ -35,7 +35,6 @@ export function useCatalogOffers() {
{ {
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset, offset,
status: 'active',
productUuid: selectedProductUuid.value productUuid: selectedProductUuid.value
}, },
'public', 'public',

View File

@@ -1,4 +1,4 @@
import { GetProductsDocument } from '~/composables/graphql/public/exchange-generated' import { GetAvailableProductsDocument } from '~/composables/graphql/public/exchange-generated'
// Shared state // Shared state
const items = ref<any[]>([]) const items = ref<any[]>([])
@@ -13,12 +13,12 @@ export function useCatalogProducts() {
isLoading.value = true isLoading.value = true
try { try {
const data = await execute( const data = await execute(
GetProductsDocument, GetAvailableProductsDocument,
{}, {},
'public', 'public',
'exchange' 'exchange'
) )
items.value = data?.getProducts || [] items.value = data?.getAvailableProducts || []
isInitialized.value = true isInitialized.value = true
} finally { } finally {
isLoading.value = false isLoading.value = false

View File

@@ -5,20 +5,13 @@ const PAGE_SIZE = 24
// Shared state across list and map views // Shared state across list and map views
const items = ref<any[]>([]) const items = ref<any[]>([])
const total = ref(0) const total = ref(0)
const selectedFilter = ref('all')
const isLoading = ref(false) const isLoading = ref(false)
const isLoadingMore = ref(false) const isLoadingMore = ref(false)
const isInitialized = ref(false) const isInitialized = ref(false)
export function useCatalogSuppliers() { export function useCatalogSuppliers() {
const { t } = useI18n()
const { execute } = useGraphQL() const { execute } = useGraphQL()
const filters = computed(() => [
{ id: 'all', label: t('catalogSuppliersSection.filters.all') },
{ id: 'verified', label: t('catalogSuppliersSection.filters.verified') }
])
const itemsWithCoords = computed(() => const itemsWithCoords = computed(() =>
items.value.filter(s => s.latitude && s.longitude) items.value.filter(s => s.latitude && s.longitude)
) )
@@ -28,10 +21,9 @@ export function useCatalogSuppliers() {
const fetchPage = async (offset: number, replace = false) => { const fetchPage = async (offset: number, replace = false) => {
if (replace) isLoading.value = true if (replace) isLoading.value = true
try { try {
const isVerified = selectedFilter.value === 'verified' ? true : null
const data = await execute( const data = await execute(
GetSupplierProfilesDocument, GetSupplierProfilesDocument,
{ limit: PAGE_SIZE, offset, isVerified }, { limit: PAGE_SIZE, offset },
'public', 'public',
'exchange' 'exchange'
) )
@@ -54,13 +46,6 @@ export function useCatalogSuppliers() {
} }
} }
// При смене фильтра - перезагрузка
watch(selectedFilter, () => {
if (isInitialized.value) {
fetchPage(0, true)
}
})
// Initialize data if not already loaded // Initialize data if not already loaded
const init = async () => { const init = async () => {
if (!isInitialized.value && items.value.length === 0) { if (!isInitialized.value && items.value.length === 0) {
@@ -71,8 +56,6 @@ export function useCatalogSuppliers() {
return { return {
items, items,
total, total,
selectedFilter,
filters,
isLoading, isLoading,
isLoadingMore, isLoadingMore,
itemsWithCoords, itemsWithCoords,

View File

@@ -8,7 +8,10 @@
@select="onSelectHub" @select="onSelectHub"
> >
<template #filters> <template #filters>
<CatalogFilterSelect :filters="filters" v-model="selectedFilter" /> <div class="flex gap-2">
<CatalogFilterSelect :filters="filters" v-model="selectedFilter" />
<CatalogFilterSelect :filters="countryFilters" v-model="selectedCountry" />
</div>
</template> </template>
<template #card="{ item }"> <template #card="{ item }">
@@ -45,7 +48,9 @@ const {
items, items,
total, total,
selectedFilter, selectedFilter,
selectedCountry,
filters, filters,
countryFilters,
isLoading, isLoading,
isLoadingMore, isLoadingMore,
itemsByCountry, itemsByCountry,

View File

@@ -0,0 +1,9 @@
query GetAvailableProducts {
getAvailableProducts {
uuid
name
categoryId
categoryName
terminusSchemaId
}
}

View File

@@ -1,8 +1,7 @@
query GetOffers($status: String, $productUuid: String, $locationUuid: String, $categoryName: String, $teamUuid: String, $limit: Int, $offset: Int) { query GetOffers($productUuid: String, $locationUuid: String, $categoryName: String, $teamUuid: String, $limit: Int, $offset: Int) {
getOffers(status: $status, productUuid: $productUuid, locationUuid: $locationUuid, categoryName: $categoryName, teamUuid: $teamUuid, limit: $limit, offset: $offset) { getOffers(productUuid: $productUuid, locationUuid: $locationUuid, categoryName: $categoryName, teamUuid: $teamUuid, limit: $limit, offset: $offset) {
uuid uuid
teamUuid teamUuid
status
# Location # Location
locationUuid locationUuid
locationName locationName
@@ -25,5 +24,5 @@ query GetOffers($status: String, $productUuid: String, $locationUuid: String, $c
createdAt createdAt
updatedAt updatedAt
} }
getOffersCount(status: $status, productUuid: $productUuid, locationUuid: $locationUuid, categoryName: $categoryName, teamUuid: $teamUuid) getOffersCount(productUuid: $productUuid, locationUuid: $locationUuid, categoryName: $categoryName, teamUuid: $teamUuid)
} }

View File

@@ -1,5 +1,5 @@
query GetSupplierProfiles($country: String, $isVerified: Boolean, $limit: Int, $offset: Int) { query GetSupplierProfiles($country: String, $limit: Int, $offset: Int) {
getSupplierProfiles(country: $country, isVerified: $isVerified, limit: $limit, offset: $offset) { getSupplierProfiles(country: $country, limit: $limit, offset: $offset) {
uuid uuid
teamUuid teamUuid
name name
@@ -7,11 +7,9 @@ query GetSupplierProfiles($country: String, $isVerified: Boolean, $limit: Int, $
country country
countryCode countryCode
logoUrl logoUrl
isVerified
isActive
offersCount offersCount
latitude latitude
longitude longitude
} }
getSupplierProfilesCount(country: $country, isVerified: $isVerified) getSupplierProfilesCount(country: $country)
} }

View File

@@ -0,0 +1,3 @@
query GetHubCountries {
hubCountries
}

View File

@@ -1,5 +1,5 @@
query GetNodes($limit: Int, $offset: Int, $transportType: String) { query GetNodes($limit: Int, $offset: Int, $transportType: String, $country: String) {
nodes(limit: $limit, offset: $offset, transportType: $transportType) { nodes(limit: $limit, offset: $offset, transportType: $transportType, country: $country) {
uuid uuid
name name
latitude latitude
@@ -9,5 +9,5 @@ query GetNodes($limit: Int, $offset: Int, $transportType: String) {
syncedAt syncedAt
transportTypes transportTypes
} }
nodesCount(transportType: $transportType) nodesCount(transportType: $transportType, country: $country)
} }

View File

@@ -6,6 +6,8 @@ query GetTeamOrders {
totalAmount totalAmount
currency currency
sourceLocationName sourceLocationName
sourceLatitude
sourceLongitude
destinationLocationName destinationLocationName
createdAt createdAt
orderLines { orderLines {

View File

@@ -11,6 +11,7 @@
}, },
"filters": { "filters": {
"all": "All", "all": "All",
"all_countries": "All countries",
"auto": "Auto", "auto": "Auto",
"rail": "Rail", "rail": "Rail",
"sea": "Sea", "sea": "Sea",

View File

@@ -11,6 +11,7 @@
}, },
"filters": { "filters": {
"all": "Все", "all": "Все",
"all_countries": "Все страны",
"auto": "Авто", "auto": "Авто",
"rail": "Ж/д", "rail": "Ж/д",
"sea": "Море", "sea": "Море",