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,9 @@
<script setup lang="ts">
definePageMeta({
layout: 'manager',
middleware: ['auth-oidc'],
})
const localePath = useLocalePath()
await navigateTo(localePath('/manager/orders'), { redirectCode: 302 })
</script>

View File

@@ -0,0 +1,243 @@
<script setup lang="ts">
import {
GetOrderDocument,
type GetOrderQueryResult,
type GetOrderQueryVariables,
} from '~/composables/graphql/team/orders-generated'
definePageMeta({
layout: 'manager',
middleware: ['auth-oidc'],
})
type OrderRecord = NonNullable<GetOrderQueryResult['getOrder']>
type OrderStage = NonNullable<NonNullable<OrderRecord['stages']>[number]>
type OrderLine = NonNullable<NonNullable<OrderRecord['orderLines']>[number]>
type StageTrip = NonNullable<NonNullable<OrderStage['trips']>[number]>
const route = useRoute()
const localePath = useLocalePath()
const { execute } = useGraphQL()
const { locale } = useI18n()
const orderId = computed(() => String(route.params.id || '').trim())
const {
data,
pending,
error,
} = await useAsyncData(
() => `manager-order-${orderId.value}`,
async () => {
const response = await execute(GetOrderDocument, {
orderUuid: orderId.value,
} as GetOrderQueryVariables, 'team', 'orders')
return response.getOrder
},
)
const order = computed(() => data.value || null)
const stages = computed(() => ((order.value?.stages || []).filter((stage): stage is OrderStage => stage !== null)))
const lines = computed(() => ((order.value?.orderLines || []).filter((line): line is OrderLine => line !== null)))
const trips = computed(() => stages.value.flatMap(stage => (stage.trips || []).filter((trip): trip is StageTrip => trip !== null)))
function formatMoney(value?: number | null, currency?: string | null) {
return new Intl.NumberFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
style: 'currency',
currency: currency || 'USD',
maximumFractionDigits: 0,
}).format(Number(value || 0))
}
function formatDate(value?: string | null) {
if (!value) return 'Дата уточняется'
return new Intl.DateTimeFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(new Date(value))
}
function routeLabel() {
return `${order.value?.sourceLocationName || 'Откуда'} - ${order.value?.destinationLocationName || 'Куда'}`
}
function statusMeta(status?: string | null) {
const normalized = String(status || '').toLowerCase()
if (normalized === 'delivered' || normalized === 'completed') return { label: 'Завершён', className: 'bg-emerald-100 text-emerald-700' }
if (normalized === 'cancelled' || normalized === 'canceled') return { label: 'Отменён', className: 'bg-rose-100 text-rose-700' }
if (normalized === 'in_transit' || normalized === 'processing') return { label: 'В пути', className: 'bg-sky-100 text-sky-700' }
return { label: 'В работе', className: 'bg-amber-100 text-amber-700' }
}
function tripDateLabel(trip: StageTrip) {
return trip.actualLoadingDate || trip.realLoadingDate || trip.plannedLoadingDate || trip.plannedUnloadingDate
}
</script>
<template>
<div>
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<NuxtLink :to="localePath('/manager/orders')" class="inline-flex items-center gap-2 text-sm font-bold text-[#8a7761] transition hover:text-[#2f2418]">
<Icon name="lucide:arrow-left" size="16" />
<span>Назад к списку</span>
</NuxtLink>
<p class="mt-4 text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Order detail</p>
<h2 class="mt-1 text-3xl font-black text-[#2f2418]">{{ routeLabel() }}</h2>
<p class="mt-2 text-sm text-[#6f6353]">Заказ {{ order?.name || order?.uuid || orderId }}</p>
</div>
<div v-if="order" class="flex flex-wrap items-center gap-3">
<span class="inline-flex rounded-full px-3 py-1 text-xs font-semibold" :class="statusMeta(order.status).className">
{{ statusMeta(order.status).label }}
</span>
<div class="rounded-full bg-white px-4 py-2 text-sm font-bold text-[#2f2418]">
{{ formatMoney(order.totalAmount, order.currency) }}
</div>
</div>
</div>
<section v-if="pending" class="mt-6 rounded-[28px] bg-white p-8 text-center">
<p class="text-sm opacity-70">Загружаем заказ</p>
</section>
<section v-else-if="error || !order" class="mt-6 rounded-[28px] bg-rose-50/92 p-6 text-rose-700">
<p class="text-sm font-medium">Не удалось открыть заказ</p>
<p v-if="error" class="mt-2 text-sm opacity-80">{{ error.message }}</p>
</section>
<template v-else>
<section class="mt-6 grid gap-3 lg:grid-cols-4">
<article class="rounded-[28px] bg-white p-5">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Created</p>
<p class="mt-3 text-lg font-black text-[#2f2418]">{{ formatDate(order.createdAt) }}</p>
</article>
<article class="rounded-[28px] bg-white p-5">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Stages</p>
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ stages.length }}</p>
</article>
<article class="rounded-[28px] bg-white p-5">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Trips</p>
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ trips.length }}</p>
</article>
<article class="rounded-[28px] bg-white p-5">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Cargo lines</p>
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ lines.length }}</p>
</article>
</section>
<section class="mt-6 grid gap-4 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,0.9fr)]">
<article class="rounded-[28px] bg-white p-6">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Cargo manifest</p>
<h3 class="mt-2 text-2xl font-black text-[#2f2418]">Order lines</h3>
</div>
<div class="rounded-full bg-[#f6f1ea] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#8a7761]">
{{ lines.length }} items
</div>
</div>
<div class="mt-5 overflow-hidden rounded-[24px] border border-[#eadfce]">
<div class="grid grid-cols-[minmax(0,1.7fr)_120px_120px] gap-3 bg-[#fbf8f4] px-4 py-3 text-xs font-bold uppercase tracking-[0.12em] text-[#8c7b67]">
<span>Product</span>
<span>Qty</span>
<span>Subtotal</span>
</div>
<div v-if="lines.length">
<div
v-for="line in lines"
:key="line.uuid"
class="grid grid-cols-[minmax(0,1.7fr)_120px_120px] gap-3 border-t border-[#f1e7da] px-4 py-4 text-sm text-[#4f4130]"
>
<div>
<p class="font-bold text-[#2f2418]">{{ line.productName || 'Cargo item' }}</p>
<p class="mt-1 text-xs text-[#8a7761]">{{ line.uuid }}</p>
</div>
<span>{{ line.quantity || 0 }} {{ line.unit || '' }}</span>
<span>{{ formatMoney(line.subtotal, order.currency) }}</span>
</div>
</div>
<div v-else class="px-4 py-6 text-sm text-[#6f6353]">
Состав груза ещё не заполнен.
</div>
</div>
</article>
<article class="rounded-[28px] bg-white p-6">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Manager note</p>
<h3 class="mt-2 text-2xl font-black text-[#2f2418]">Summary</h3>
<p class="mt-4 text-sm leading-6 text-[#5f4b33]">
{{ order.notes || 'Комментарий пока не добавлен. Этот экран уже перенесён в manager-паттерн logistics и теперь опирается на Optovia GraphQL.' }}
</p>
<div class="mt-6 space-y-3 rounded-[24px] bg-[#fbf8f4] p-4">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="text-[#8a7761]">Source</span>
<span class="font-bold text-[#2f2418]">{{ order.sourceLocationName || 'Не указано' }}</span>
</div>
<div class="flex items-center justify-between gap-3 text-sm">
<span class="text-[#8a7761]">Destination</span>
<span class="font-bold text-[#2f2418]">{{ order.destinationLocationName || 'Не указано' }}</span>
</div>
<div class="flex items-center justify-between gap-3 text-sm">
<span class="text-[#8a7761]">Status</span>
<span class="font-bold text-[#2f2418]">{{ statusMeta(order.status).label }}</span>
</div>
</div>
</article>
</section>
<section class="mt-6 rounded-[28px] bg-white p-6">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Route flow</p>
<h3 class="mt-2 text-2xl font-black text-[#2f2418]">Stages and trips</h3>
</div>
<div class="rounded-full bg-[#f6f1ea] px-3 py-1 text-xs font-bold uppercase tracking-[0.12em] text-[#8a7761]">
{{ stages.length }} stages
</div>
</div>
<div class="mt-5 space-y-3">
<article
v-for="stage in stages"
:key="stage.uuid"
class="rounded-[24px] border border-[#eadfce] bg-[#fbf8f4] p-5"
>
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="text-xs font-bold uppercase tracking-[0.12em] text-[#8c7b67]">{{ stage.stageType || 'Stage' }}</p>
<h4 class="mt-1 text-xl font-black text-[#2f2418]">{{ stage.name || 'Этап маршрута' }}</h4>
<p class="mt-2 text-sm text-[#5f4b33]">
{{ stage.sourceLocationName || 'Источник' }} {{ stage.destinationLocationName || stage.locationName || 'Точка назначения' }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<span v-if="stage.transportType" class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#5f4b33]">
{{ stage.transportType }}
</span>
<span v-if="stage.selectedCompany?.name" class="rounded-full bg-white px-3 py-1 text-xs font-semibold text-[#5f4b33]">
{{ stage.selectedCompany.name }}
</span>
</div>
</div>
<div v-if="(stage.trips || []).length" class="mt-4 grid gap-3 md:grid-cols-2">
<div
v-for="trip in stage.trips"
:key="trip?.uuid"
class="rounded-[20px] bg-white px-4 py-4"
>
<p class="text-sm font-bold text-[#2f2418]">{{ trip?.name || 'Trip' }}</p>
<p class="mt-1 text-xs text-[#8a7761]">{{ trip?.company?.name || trip?.company?.country || 'Carrier pending' }}</p>
<p class="mt-3 text-sm text-[#5f4b33]">{{ formatDate(tripDateLabel(trip || {} as StageTrip)) }}</p>
</div>
</div>
</article>
</div>
</section>
</template>
</div>
</template>

View File

@@ -0,0 +1,310 @@
<script setup lang="ts">
import {
GetTeamOrdersDocument,
type GetTeamOrdersQueryResult,
type GetTeamOrdersQueryVariables,
} from '~/composables/graphql/team/orders-generated'
definePageMeta({
layout: 'manager',
middleware: ['auth-oidc'],
})
type TeamOrder = NonNullable<NonNullable<GetTeamOrdersQueryResult['getTeamOrders']>[number]>
type TeamStage = NonNullable<NonNullable<TeamOrder['stages']>[number]>
type OrdersViewMode = 'list' | 'calendar'
const { execute } = useGraphQL()
const localePath = useLocalePath()
const route = useRoute()
const { locale } = useI18n()
const search = ref('')
const visibleLimit = ref(12)
const {
data,
pending,
error,
refresh,
} = await useAsyncData('manager-orders-optovia', async () => {
const response = await execute(GetTeamOrdersDocument, {} as GetTeamOrdersQueryVariables, 'team', 'orders')
return response.getTeamOrders || []
})
const orders = computed(() => (Array.isArray(data.value) ? data.value.filter((item): item is TeamOrder => item !== null) : []))
const viewMode = computed<OrdersViewMode>(() => route.query.view === 'calendar' ? 'calendar' : 'list')
const filteredOrders = computed(() => {
const query = search.value.trim().toLowerCase()
if (!query) return orders.value
return orders.value.filter((item) => {
const haystack = [
item.uuid,
item.name,
item.status,
item.sourceLocationName,
item.destinationLocationName,
...(item.orderLines || []).map(line => line?.productName || ''),
...(item.stages || []).map(stage => stage?.name || ''),
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return haystack.includes(query)
})
})
const visibleOrders = computed(() => filteredOrders.value.slice(0, visibleLimit.value))
const canLoadMoreOrders = computed(() => visibleOrders.value.length < filteredOrders.value.length)
const totalTurnover = computed(() => orders.value.reduce((sum, item) => sum + Number(item.totalAmount || 0), 0))
const inTransitCount = computed(() => orders.value.filter(item => ['processing', 'in_transit'].includes(String(item.status || '').toLowerCase())).length)
const deliveredCount = computed(() => orders.value.filter(item => ['delivered', 'completed'].includes(String(item.status || '').toLowerCase())).length)
const orderViewTabs = computed<Array<{ key: OrdersViewMode, label: string, active: boolean }>>(() => ([
{ key: 'list', label: 'Списком', active: viewMode.value === 'list' },
{ key: 'calendar', label: 'Календарь', active: viewMode.value === 'calendar' },
]))
function formatMoney(value?: number | null, currency?: string | null) {
return new Intl.NumberFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
style: 'currency',
currency: currency || 'USD',
maximumFractionDigits: 0,
}).format(Number(value || 0))
}
function formatDate(value?: string | null) {
if (!value) return ''
return new Intl.DateTimeFormat(locale.value === 'ru' ? 'ru-RU' : 'en-US', {
day: '2-digit',
month: 'short',
}).format(new Date(value))
}
function routeLabel(item: TeamOrder) {
return `${item.sourceLocationName || 'Откуда'} - ${item.destinationLocationName || 'Куда'}`
}
function cargoLabel(item: TeamOrder) {
const lines = (item.orderLines || [])
.filter((line): line is NonNullable<(typeof item.orderLines)[number]> => line !== null)
.map(line => `${line.productName || 'Cargo'}${line.quantity ? ` · ${line.quantity} ${line.unit || ''}` : ''}`)
return lines.slice(0, 2).join(' · ') || 'Состав груза уточняется'
}
function statusMeta(status?: string | null) {
const normalized = String(status || '').toLowerCase()
if (normalized === 'delivered' || normalized === 'completed') return { label: 'Завершён', className: 'bg-emerald-100 text-emerald-700' }
if (normalized === 'cancelled' || normalized === 'canceled') return { label: 'Отменён', className: 'bg-rose-100 text-rose-700' }
if (normalized === 'in_transit' || normalized === 'processing') return { label: 'В пути', className: 'bg-sky-100 text-sky-700' }
return { label: 'В работе', className: 'bg-amber-100 text-amber-700' }
}
function checkpointLabel(item: TeamOrder) {
const stages = (item.stages || []).filter((stage): stage is TeamStage => stage !== null)
return stages[0]?.name || 'Этап уточняется'
}
function customerLabel(item: TeamOrder) {
return `Клиент ${(item.uuid || 'order').slice(-6).toUpperCase()}`
}
function stageCount(item: TeamOrder) {
return (item.stages || []).filter(Boolean).length
}
function mapCalendarOrders() {
return filteredOrders.value.map((item) => {
const stages = (item.stages || []).filter((stage): stage is TeamStage => stage !== null)
const checkpoints = stages.map((stage, index) => {
const firstTrip = (stage.trips || []).find(Boolean)
return {
code: stage.uuid || `stage-${index}`,
name: stage.name || 'Этап',
plannedDate: firstTrip?.plannedLoadingDate || firstTrip?.plannedUnloadingDate || null,
actualDate: firstTrip?.actualLoadingDate || firstTrip?.actualUnloadingDate || null,
completed: Boolean(firstTrip?.actualUnloadingDate || firstTrip?.realLoadingDate),
current: index === 0,
}
})
return {
id: item.uuid || '',
status: item.status || '',
totalAmount: Number(item.totalAmount || 0),
currency: item.currency || 'USD',
createdAt: item.createdAt || new Date().toISOString(),
pickupDate: checkpoints[0]?.plannedDate || null,
fromAddress: {
city: item.sourceLocationName || '',
country: '',
},
toAddress: {
city: item.destinationLocationName || '',
country: '',
},
currentCheckpoint: checkpoints[0] || null,
checkpoints,
}
}).filter(order => order.id)
}
async function openOrder(orderId?: string | null) {
if (!orderId) return
await navigateTo(localePath(`/manager/orders/${orderId}`))
}
async function setViewMode(nextMode: OrdersViewMode) {
const nextQuery = { ...route.query }
if (nextMode === 'calendar') {
nextQuery.view = 'calendar'
} else {
delete nextQuery.view
}
await navigateTo({
path: route.path,
query: nextQuery,
}, { replace: true })
}
async function loadMoreOrders() {
visibleLimit.value += 12
await refresh()
}
</script>
<template>
<div>
<section class="grid gap-3 lg:grid-cols-4">
<article class="rounded-[28px] bg-white p-5 shadow-none">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Orders</p>
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ orders.length }}</p>
<p class="mt-1 text-sm text-[#6f6353]">Активный пул заказов в microservice flow</p>
</article>
<article class="rounded-[28px] bg-white p-5 shadow-none">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">In transit</p>
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ inTransitCount }}</p>
<p class="mt-1 text-sm text-[#6f6353]">Маршруты, которые сейчас в движении</p>
</article>
<article class="rounded-[28px] bg-white p-5 shadow-none">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Delivered</p>
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ deliveredCount }}</p>
<p class="mt-1 text-sm text-[#6f6353]">Завершённые кейсы для менеджерского кабинета</p>
</article>
<article class="rounded-[28px] bg-white p-5 shadow-none">
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Turnover</p>
<p class="mt-3 text-3xl font-black text-[#2f2418]">{{ formatMoney(totalTurnover, orders[0]?.currency || 'USD') }}</p>
<p class="mt-1 text-sm text-[#6f6353]">Сумма по заказам без фронтового Odoo-слоя</p>
</article>
</section>
<section class="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Manager workspace</p>
<h2 class="mt-1 text-3xl font-black text-[#2f2418]">Orders</h2>
</div>
<div class="flex w-full flex-col gap-3 lg:w-auto lg:min-w-[620px] lg:flex-row">
<label class="block flex-1">
<span class="sr-only">Search orders</span>
<input
v-model="search"
class="input h-12 w-full rounded-full border-0 bg-white px-5 shadow-none"
placeholder="Поиск по маршруту, грузу или этапу"
>
</label>
<div class="inline-flex w-fit items-center rounded-full bg-white p-1 shadow-[0_16px_38px_rgba(38,29,18,0.08)]">
<button
v-for="tab in orderViewTabs"
:key="tab.key"
type="button"
class="rounded-full px-4 py-2 text-sm font-bold transition"
:class="tab.active ? 'bg-[#2f2418] text-white' : 'text-[#6a5947] hover:bg-[#f6f1ea]'"
@click="setViewMode(tab.key)"
>
{{ tab.label }}
</button>
</div>
</div>
</section>
<section v-if="pending && !orders.length" class="mt-6 rounded-[28px] bg-white p-8 text-center">
<p class="text-sm opacity-70">Загружаем заказы</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">Не удалось загрузить manager orders</p>
<p class="mt-2 text-sm opacity-80">{{ error.message }}</p>
</section>
<OrdersCalendarPanel
v-else-if="viewMode === 'calendar'"
class="mt-6"
:orders="mapCalendarOrders()"
@select="openOrder"
/>
<section v-else class="mt-6 space-y-3">
<article
v-for="item in visibleOrders"
:key="item.uuid"
class="cursor-pointer rounded-[28px] bg-white px-6 py-5 shadow-none transition-[transform,box-shadow] hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(62,47,26,0.12)]"
@click="openOrder(item.uuid)"
>
<div class="grid gap-3 md:grid-cols-[minmax(0,2.1fr)_minmax(0,1.15fr)_auto_auto] md:items-center">
<div class="flex min-w-0 items-start gap-4">
<UserAvatar
:seed="item.uuid"
:label="customerLabel(item)"
:size="52"
/>
<div class="min-w-0">
<p class="truncate text-sm font-bold text-[#8a7761]">{{ customerLabel(item) }}</p>
<p class="mt-1 truncate text-lg font-black leading-tight text-[#2f2418]">{{ routeLabel(item) }}</p>
<p class="mt-1 truncate text-sm text-[#6f6353]">{{ cargoLabel(item) }}</p>
</div>
</div>
<div class="min-w-0 text-sm text-[#6f6353]">
<p class="truncate">{{ checkpointLabel(item) }}</p>
<p class="mt-1 truncate text-xs text-[#998b78]">
{{ stageCount(item) }} этапов · {{ formatDate(item.createdAt) }}
</p>
</div>
<div class="md:text-right">
<span class="inline-flex rounded-full px-3 py-1 text-xs font-semibold" :class="statusMeta(item.status).className">
{{ statusMeta(item.status).label }}
</span>
</div>
<div class="whitespace-nowrap text-right text-lg font-semibold text-[#2f2418]">
{{ formatMoney(item.totalAmount, item.currency) }}
</div>
</div>
</article>
<article
v-if="visibleOrders.length === 0"
class="rounded-[28px] bg-white p-8 text-center"
>
<p class="text-lg font-semibold text-[#2f2418]">Заказы не найдены</p>
<p class="mt-2 text-sm text-[#6f6353]">Попробуй изменить строку поиска или фильтр представления.</p>
</article>
</section>
<ManagerListLoadMore
v-if="!error && visibleOrders.length"
class="mt-5"
:shown="visibleOrders.length"
:total="filteredOrders.length"
:can-load-more="canLoadMoreOrders"
:loading="pending"
:page-size="12"
item-label="заказов"
@load-more="loadMoreOrders"
/>
</div>
</template>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
definePageMeta({
layout: 'manager',
middleware: ['auth-oidc'],
})
type QuotationCard = {
id: string
client: string
from: string
to: string
mode: string
weight: string
status: 'draft' | 'pricing' | 'graph-ready'
note: string
}
const search = ref('')
const quotations = useState<QuotationCard[]>('manager-quotation-cards', () => [
{
id: 'qt-001',
client: 'Client 42A7',
from: 'Guangzhou',
to: 'Moscow',
mode: 'Multimodal',
weight: '800 kg',
status: 'pricing',
note: 'Базовый сценарий для переноса quotation flow из logistics.',
},
{
id: 'qt-002',
client: 'Client 90F2',
from: 'Shenzhen',
to: 'Novorossiysk',
mode: 'Sea + truck',
weight: '1 200 kg',
status: 'graph-ready',
note: 'Маршрут уже мыслится как graph-native corridor без Odoo-связки.',
},
{
id: 'qt-003',
client: 'Client 18D1',
from: 'Yiwu',
to: 'Kazan',
mode: 'Rail',
weight: '2 400 kg',
status: 'draft',
note: 'Черновик manager workspace под tariffs и decision rules.',
},
])
const filteredCards = computed(() => {
const query = search.value.trim().toLowerCase()
if (!query) return quotations.value
return quotations.value.filter((item) => {
return [item.client, item.from, item.to, item.mode, item.note, item.status]
.join(' ')
.toLowerCase()
.includes(query)
})
})
function createNewQuotation() {
quotations.value = [
{
id: `qt-${String(quotations.value.length + 1).padStart(3, '0')}`,
client: `Client ${(Math.random().toString(16).slice(2, 6)).toUpperCase()}`,
from: 'Shanghai',
to: 'Saint Petersburg',
mode: 'Multimodal',
weight: '950 kg',
status: 'draft',
note: 'Новый manager draft. Дальше его нужно привязать к реальному quotation microservice.',
},
...quotations.value,
]
}
function statusMeta(status: QuotationCard['status']) {
if (status === 'graph-ready') return { label: 'Graph ready', className: 'bg-emerald-100 text-emerald-700' }
if (status === 'pricing') return { label: 'Pricing', className: 'bg-amber-100 text-amber-700' }
return { label: 'Draft', className: 'bg-stone-200 text-stone-700' }
}
</script>
<template>
<div>
<section class="grid gap-3 lg:grid-cols-[minmax(0,1.3fr)_minmax(0,0.7fr)]">
<article class="rounded-[28px] bg-white p-6">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Quotations workspace</p>
<h2 class="mt-2 text-3xl font-black text-[#2f2418]">Exact logistics shell, Optovia logic next</h2>
<p class="mt-4 max-w-[760px] text-sm leading-6 text-[#5f4b33]">
Этот экран уже переведён в визуальный паттерн `logistic`: те же теплые поверхности, те же rounded cards, тот же manager rhythm.
Следующий шаг здесь очевидный: посадить quotation CRUD и tariff selection на ваши microservices и graph-модель.
</p>
</article>
<article class="rounded-[28px] bg-[#2f2418] p-6 text-white shadow-[0_20px_44px_rgba(47,36,24,0.18)]">
<p class="text-xs font-bold uppercase tracking-[0.16em] text-white/60">Migration note</p>
<h3 class="mt-2 text-2xl font-black">No Odoo frontend path</h3>
<p class="mt-4 text-sm leading-6 text-white/78">
Quotation UI here is ready to stop depending on old Odoo-driven flows. Сейчас это staging surface, дальше сюда подключается новый graph-native backend contract.
</p>
</article>
</section>
<section class="mt-6 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<label class="block flex-1">
<span class="sr-only">Search quotations</span>
<input
v-model="search"
class="input h-12 w-full rounded-full border-0 bg-white px-5 shadow-none"
placeholder="Поиск по клиенту, маршруту или статусу"
>
</label>
<button class="btn rounded-full border-0 bg-[#2f2418] px-6 text-white hover:bg-[#493824]" @click="createNewQuotation">
Создать quotation draft
</button>
</section>
<section class="mt-6 space-y-3">
<article
v-for="item in filteredCards"
:key="item.id"
class="rounded-[28px] bg-white px-6 py-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="grid gap-3 md:grid-cols-[minmax(0,2fr)_auto_minmax(0,1.3fr)_auto] md:items-center">
<div class="flex min-w-0 items-center gap-3 text-lg font-black leading-tight text-[#2f2418]">
<p class="truncate">{{ item.from }}</p>
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#efe7da] text-[#7a684f]">
<svg viewBox="0 0 20 20" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3.5 10h13" />
<path d="M12 5.5 16.5 10 12 14.5" />
</svg>
</span>
<p class="truncate">{{ item.to }}</p>
</div>
<p class="whitespace-nowrap text-sm font-medium text-[#6f6353]">
{{ item.weight }}
</p>
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-[#2f2418]">{{ item.client }}</p>
<p class="mt-1 truncate text-sm text-[#6f6353]">{{ item.mode }} · {{ item.note }}</p>
</div>
<div class="md:text-right">
<span class="inline-flex rounded-full px-3 py-1 text-xs font-semibold" :class="statusMeta(item.status).className">
{{ statusMeta(item.status).label }}
</span>
</div>
</div>
</article>
<article
v-if="filteredCards.length === 0"
class="rounded-[28px] bg-white p-8 text-center"
>
<p class="text-lg font-semibold text-[#2f2418]">Quotation cards не найдены</p>
<p class="mt-2 text-sm text-[#6f6353]">Измени строку поиска или добавь новый draft.</p>
</article>
</section>
</div>
</template>

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>