Files
webapp/app/pages/manager/orders/index.vue
2026-04-11 08:31:34 +07:00

311 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>