Adopt logistics visual system across webapp

This commit is contained in:
Ruslan Bakiev
2026-04-11 08:31:34 +07:00
parent ebe72907a4
commit a74e75049c
28 changed files with 1434 additions and 240 deletions

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import {
GetHubCountriesDocument,
HubsListDocument,
type GetHubCountriesQueryVariables,
type HubsListQueryResult,
type HubsListQueryVariables,
} from '~/composables/graphql/public/geo-generated'
definePageMeta({
layout: 'manager',
middleware: ['auth-oidc'],
})
type HubRecord = HubsListQueryResult['hubsList'][number]
const { execute } = useGraphQL()
const search = ref('')
const selectedCountry = ref('')
const [{ data: countriesData }, { data: hubsData, pending, error }] = await Promise.all([
useAsyncData('manager-tariff-countries', async () => {
const response = await execute(GetHubCountriesDocument, {} as GetHubCountriesQueryVariables, 'public', 'geo')
return response.hubCountries || []
}),
useAsyncData('manager-tariff-hubs', async () => {
const response = await execute(HubsListDocument, {
limit: 60,
offset: 0,
country: null,
transportType: null,
west: null,
south: null,
east: null,
north: null,
} as HubsListQueryVariables, 'public', 'geo')
return response.hubsList || []
}),
])
const countries = computed(() => (countriesData.value || []).filter(Boolean))
const hubs = computed(() => (Array.isArray(hubsData.value) ? hubsData.value.filter((item): item is HubRecord => item !== null) : []))
const filteredHubs = computed(() => {
const query = search.value.trim().toLowerCase()
return hubs.value.filter((hub) => {
if (selectedCountry.value && hub.country !== selectedCountry.value) {
return false
}
if (!query) return true
return [hub.name, hub.country, hub.countryCode, ...(hub.transportTypes || [])]
.filter(Boolean)
.join(' ')
.toLowerCase()
.includes(query)
})
})
const uniqueTransportTypes = computed(() => {
return new Set(
hubs.value.flatMap(hub => (hub.transportTypes || []).filter((item): item is string => Boolean(item))),
).size
})
</script>
<template>
<div>
<section class="grid gap-3 lg:grid-cols-3">
<article class="rounded-[28px] bg-white p-5">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Tariff anchors</p>
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ hubs.length }}</p>
<p class="mt-1 text-sm text-[#6f6353]">Хабы из geo graph для будущего tariff workspace</p>
</article>
<article class="rounded-[28px] bg-white p-5">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Countries</p>
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ countries.length }}</p>
<p class="mt-1 text-sm text-[#6f6353]">Страны, уже доступные для corridor pricing</p>
</article>
<article class="rounded-[28px] bg-white p-5">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Transport types</p>
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ uniqueTransportTypes }}</p>
<p class="mt-1 text-sm text-[#6f6353]">Основа для будущих tariff rules без Odoo</p>
</article>
</section>
<section class="mt-6 rounded-[28px] bg-white p-6">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Tariff topology</p>
<h2 class="mt-2 text-3xl font-black text-[#2f2418]">Graph-native corridor base</h2>
<p class="mt-4 max-w-[840px] text-sm leading-6 text-[#5f4b33]">
Здесь я не делаю вид, что тарифный backend уже существует. Но сам экран уже опирается на реальные geo microservices и показывает,
от каких hub nodes дальше строить tariff references, quotation routes и decision rules.
</p>
<div class="mt-6 flex flex-col gap-3 lg:flex-row">
<label class="block flex-1">
<span class="sr-only">Search hubs</span>
<input
v-model="search"
class="input h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 shadow-none"
placeholder="Поиск по хабу, стране или типу транспорта"
>
</label>
<select v-model="selectedCountry" class="select h-12 rounded-full border-0 bg-[#f6f1ea] px-5 shadow-none">
<option value="">Все страны</option>
<option v-for="country in countries" :key="country" :value="country">
{{ country }}
</option>
</select>
</div>
</section>
<section v-if="pending && !hubs.length" class="mt-6 rounded-[28px] bg-white p-8 text-center">
<p class="text-sm opacity-70">Загружаем graph hubs</p>
</section>
<section v-else-if="error" class="mt-6 rounded-[28px] bg-rose-50/92 p-6 text-rose-700">
<p class="text-sm font-medium">Не удалось загрузить hub topology</p>
<p class="mt-2 text-sm opacity-80">{{ error.message }}</p>
</section>
<section v-else class="mt-6 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<article
v-for="hub in filteredHubs"
:key="hub.uuid"
class="rounded-[28px] bg-white p-5 shadow-none transition-[transform,box-shadow] hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(62,47,26,0.12)]"
>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-bold uppercase tracking-[0.12em] text-[#8c7b67]">{{ hub.countryCode || 'Hub' }}</p>
<h3 class="mt-1 text-xl font-black leading-tight text-[#2f2418]">{{ hub.name || 'Unnamed hub' }}</h3>
<p class="mt-2 text-sm text-[#6f6353]">{{ hub.country || 'Country pending' }}</p>
</div>
<div class="rounded-full bg-[#f6f1ea] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#8a7761]">
{{ (hub.transportTypes || []).length }}
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<span
v-for="transportType in hub.transportTypes || []"
:key="transportType || 'transport'"
class="rounded-full bg-[#fbf8f4] px-3 py-1 text-xs font-semibold text-[#5f4b33]"
>
{{ transportType }}
</span>
</div>
<p class="mt-4 text-sm leading-6 text-[#5f4b33]">
Этот хаб можно использовать как anchor point для новой tariff reference модели внутри Optovia manager workspace.
</p>
</article>
<article
v-if="filteredHubs.length === 0"
class="rounded-[28px] bg-white p-8 text-center md:col-span-2 xl:col-span-3"
>
<p class="text-lg font-semibold text-[#2f2418]">Под фильтры ничего не попало</p>
<p class="mt-2 text-sm text-[#6f6353]">Сбрось поиск или выбери другую страну.</p>
</article>
</section>
</div>
</template>