Files
webapp/app/components/catalog/OrderDetailBottomSheet.vue
Ruslan Bakiev e4d6c9ce81
All checks were successful
Build Docker Image / build (push) Successful in 5m23s
feat(ui): refresh glass header and map bottom sheets
2026-03-08 08:56:58 +07:00

392 lines
14 KiB
Vue

<template>
<Transition name="order-slide">
<div
v-if="isOpen && orderUuid"
class="fixed inset-x-0 bottom-0 z-50 flex justify-center px-3 md:px-4"
style="height: 72vh"
>
<!-- Backdrop (clickable to close) -->
<div
class="absolute inset-0 -top-[32vh] bg-gradient-to-t from-black/45 via-black/20 to-transparent"
@click="emit('close')"
/>
<!-- Sheet content -->
<div class="relative flex w-full max-w-[980px] flex-col overflow-hidden rounded-t-[2rem] border border-white/60 bg-base-100/95 shadow-[0_-24px_70px_rgba(15,23,42,0.3)] backdrop-blur-xl">
<!-- Header with drag handle and close -->
<div class="sticky top-0 z-10 border-b border-base-300 bg-base-100/90">
<div class="flex justify-center py-2">
<div class="h-1.5 w-12 rounded-full bg-base-content/20" />
</div>
<div class="flex items-center justify-between px-6 pb-4">
<template v-if="hasOrderError">
<div class="flex-1">
<div class="font-black text-base-content">{{ t('common.error') }}</div>
<div class="text-sm text-base-content/60">{{ orderError }}</div>
</div>
</template>
<template v-else-if="!isLoadingOrder && order">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary/15">
<Icon name="lucide:package" size="24" class="text-primary" />
</div>
<div class="min-w-0">
<div class="truncate text-xl font-black text-base-content">{{ orderTitle }}</div>
<div class="mt-0.5 flex items-center gap-2">
<span class="badge badge-primary badge-sm">#{{ order.name }}</span>
<span v-if="order.status" class="badge badge-outline badge-sm">{{ order.status }}</span>
</div>
</div>
</div>
</template>
<template v-else>
<div class="flex items-center gap-3 flex-1">
<div class="h-10 w-10 animate-pulse rounded-xl bg-base-300/70" />
<div class="flex-1">
<div class="h-5 w-48 animate-pulse rounded bg-base-300/70" />
<div class="mt-1 h-4 w-32 animate-pulse rounded bg-base-300/70" />
</div>
</div>
</template>
<button class="btn btn-ghost btn-sm btn-circle flex-shrink-0 text-base-content/60 hover:text-base-content" @click="emit('close')">
<Icon name="lucide:x" size="20" />
</button>
</div>
</div>
<!-- Error state -->
<div v-if="hasOrderError" class="px-6 py-8 text-center">
<div class="mb-4 text-base-content/70">{{ orderError }}</div>
<button class="btn btn-sm btn-outline" @click="loadOrder">
{{ t('ordersDetail.errors.retry') }}
</button>
</div>
<!-- Scrollable content -->
<div v-else-if="order" class="h-[calc(72vh-110px)] overflow-y-auto px-6 py-4 space-y-4">
<!-- Order meta -->
<div class="flex flex-wrap gap-2 text-sm">
<span v-for="(meta, idx) in orderMeta" :key="idx" class="rounded-full border border-base-300 bg-base-200 px-3 py-1 text-base-content/70">
{{ meta }}
</span>
</div>
<!-- Route stages -->
<div v-if="orderStageItems.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:route" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}</span>
</div>
<div class="space-y-3">
<div
v-for="(stage, idx) in orderStageItems"
:key="stage.key || idx"
class="flex gap-3"
>
<div class="flex flex-col items-center">
<div class="h-3 w-3 rounded-full bg-primary" />
<div v-if="idx < orderStageItems.length - 1" class="my-1 w-0.5 flex-1 bg-base-300" />
</div>
<div class="flex-1 pb-3">
<div class="text-sm font-bold text-base-content">{{ stage.from }}</div>
<div v-if="stage.to && stage.to !== stage.from" class="mt-0.5 text-xs text-base-content/60">
{{ stage.to }}
</div>
<div v-if="stage.meta?.length" class="mt-1 text-xs text-base-content/50">
{{ stage.meta.join(' · ') }}
</div>
</div>
</div>
</div>
</div>
<!-- Timeline -->
<div v-if="order.stages?.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:calendar" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.timeline.title') }}</span>
</div>
<GanttTimeline
:stages="order.stages"
:showLoading="true"
:showUnloading="true"
/>
</div>
<!-- Map preview (small) -->
<div v-if="orderRoutesForMap.length" class="rounded-2xl border border-base-300 bg-base-100 p-4">
<div class="mb-3 flex items-center gap-2 text-base-content">
<Icon name="lucide:map" size="18" />
<span class="text-lg font-black">{{ t('ordersDetail.sections.map.title', 'Карта') }}</span>
</div>
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
</div>
</div>
<!-- Loading state -->
<div v-else class="px-6 py-4 space-y-4">
<div class="h-20 animate-pulse rounded-xl bg-base-300/70" />
<div class="h-32 animate-pulse rounded-xl bg-base-300/70" />
<div class="h-48 animate-pulse rounded-xl bg-base-300/70" />
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { GetOrderDocument, type GetOrderQueryResult } from '~/composables/graphql/team/orders-generated'
import type { RouteStageItem } from '~/components/RouteStagesList.vue'
// Types from GraphQL
type OrderType = NonNullable<GetOrderQueryResult['getOrder']>
type StageType = NonNullable<NonNullable<OrderType['stages']>[number]>
type CompanyType = NonNullable<StageType['selectedCompany']>
const props = defineProps<{
isOpen: boolean
orderUuid: string | null
}>()
const emit = defineEmits<{
'close': []
}>()
const { t } = useI18n()
const order = ref<OrderType | null>(null)
const isLoadingOrder = ref(false)
const hasOrderError = ref(false)
const orderError = ref('')
// Load order when uuid changes
watch(() => props.orderUuid, async (uuid) => {
if (uuid && props.isOpen) {
await loadOrder()
}
}, { immediate: true })
// Also load when opening
watch(() => props.isOpen, async (open) => {
if (open && props.orderUuid) {
await loadOrder()
} else if (!open) {
// Reset state when closing
order.value = null
hasOrderError.value = false
orderError.value = ''
}
})
const orderTitle = computed(() => {
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')
return `${source}${destination}`
})
const orderMeta = computed(() => {
const meta: string[] = []
const line = order.value?.orderLines?.[0]
if (line?.quantity) {
meta.push(`${line.quantity} ${line.unit || t('ordersDetail.labels.unit_tons')}`)
}
if (line?.productName) {
meta.push(line.productName)
}
if (order.value?.totalAmount) {
meta.push(formatPrice(order.value.totalAmount, order.value?.currency))
}
const durationDays = getOrderDuration()
if (durationDays) {
meta.push(`${durationDays} ${t('ordersDetail.labels.delivery_days')}`)
}
return meta
})
const orderRoutesForMap = computed(() => {
const stages = (order.value?.stages || [])
.filter((stage): stage is StageType => stage !== null)
.map((stage) => {
if (stage.stageType === 'transport') {
if (!stage.sourceLatitude || !stage.sourceLongitude || !stage.destinationLatitude || !stage.destinationLongitude) return null
return {
fromLat: stage.sourceLatitude,
fromLon: stage.sourceLongitude,
fromName: stage.sourceLocationName,
toLat: stage.destinationLatitude,
toLon: stage.destinationLongitude,
toName: stage.destinationLocationName,
transportType: stage.transportType
}
}
return null
})
.filter(Boolean)
if (!stages.length) return []
return [{ stages }]
})
// Company summary type
interface CompanySummary {
name: string | null | undefined
totalWeight: number
tripsCount: number
company: CompanyType | null | undefined
}
const orderStageItems = computed<RouteStageItem[]>(() => {
return (order.value?.stages || [])
.filter((stage): stage is StageType => stage !== null)
.map((stage) => {
const isTransport = stage.stageType === 'transport'
const from = isTransport ? stage.sourceLocationName : stage.locationName
const to = isTransport ? stage.destinationLocationName : stage.locationName
const meta: string[] = []
const dateRange = getStageDateRange(stage)
if (dateRange) {
meta.push(dateRange)
}
const companies = getCompaniesSummary(stage)
companies.forEach((company: CompanySummary) => {
meta.push(
`${company.name} · ${company.totalWeight || 0}${t('ordersDetail.labels.weight_unit')} · ${company.tripsCount || 0} ${t('ordersDetail.labels.trips')}`
)
})
return {
key: stage.uuid ?? undefined,
from: from ?? undefined,
to: to ?? undefined,
label: stage.name ?? undefined,
meta
}
})
})
const loadOrder = async () => {
if (!props.orderUuid) return
try {
isLoadingOrder.value = true
hasOrderError.value = false
const { data, error: orderErrorResp } = await useServerQuery('order-detail-sheet', GetOrderDocument, { orderUuid: props.orderUuid }, 'team', 'orders')
if (orderErrorResp.value) throw orderErrorResp.value
order.value = data.value?.getOrder ?? null
} catch (err: unknown) {
hasOrderError.value = true
orderError.value = err instanceof Error ? err.message : t('ordersDetail.errors.load_failed')
} finally {
isLoadingOrder.value = false
}
}
const formatPrice = (price: number, currency?: string | null) => {
if (!price) return t('ordersDetail.labels.price_zero')
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: currency || 'RUB',
minimumFractionDigits: 0
}).format(price)
}
const getCompaniesSummary = (stage: StageType): CompanySummary[] => {
const companies: CompanySummary[] = []
if (stage.stageType === 'service' && stage.selectedCompany) {
companies.push({
name: stage.selectedCompany.name,
totalWeight: 0,
tripsCount: 0,
company: stage.selectedCompany
})
return companies
}
if (stage.stageType === 'transport' && stage.trips?.length) {
const companiesMap = new Map<string, CompanySummary>()
stage.trips.forEach((trip) => {
if (!trip) return
const companyName = trip.company?.name || t('ordersDetail.labels.company_unknown')
const weight = trip.plannedWeight || 0
if (companiesMap.has(companyName)) {
const existing = companiesMap.get(companyName)!
existing.totalWeight += weight
existing.tripsCount += 1
} else {
companiesMap.set(companyName, {
name: companyName,
totalWeight: weight,
tripsCount: 1,
company: trip.company
})
}
})
return Array.from(companiesMap.values())
}
return []
}
const getOrderDuration = () => {
if (!order.value?.stages?.length) return 0
let minDate: Date | null = null
let maxDate: Date | null = null
order.value.stages.forEach((stage) => {
if (!stage) return
stage.trips?.forEach((trip) => {
if (!trip) return
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate || '')
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate || '')
if (!minDate || startDate < minDate) minDate = startDate
if (!maxDate || endDate > maxDate) maxDate = endDate
})
})
if (!minDate || !maxDate) return 0
const diffTime = Math.abs((maxDate as Date).getTime() - (minDate as Date).getTime())
return Math.ceil(diffTime / (1000 * 60 * 60 * 24))
}
const getStageDateRange = (stage: StageType) => {
if (!stage.trips?.length) return t('ordersDetail.labels.dates_undefined')
let minDate: Date | null = null
let maxDate: Date | null = null
stage.trips.forEach((trip) => {
if (!trip) return
const startDate = new Date(trip.plannedLoadingDate || trip.actualLoadingDate || '')
const endDate = new Date(trip.plannedUnloadingDate || trip.actualUnloadingDate || '')
if (!minDate || startDate < minDate) minDate = startDate
if (!maxDate || endDate > maxDate) maxDate = endDate
})
if (!minDate || !maxDate) return t('ordersDetail.labels.dates_undefined')
const formatDate = (date: Date) => date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
if ((minDate as Date).toDateString() === (maxDate as Date).toDateString()) return formatDate(minDate as Date)
return `${formatDate(minDate as Date)} - ${formatDate(maxDate as Date)}`
}
</script>
<style scoped>
.order-slide-enter-active,
.order-slide-leave-active {
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
}
.order-slide-enter-from,
.order-slide-leave-to {
transform: translateY(100%);
opacity: 0;
}
.order-slide-enter-to,
.order-slide-leave-from {
transform: translateY(0);
opacity: 1;
}
</style>