feat(orders): open order details in bottom sheet (no page transition)
All checks were successful
Build Docker Image / build (push) Successful in 4m1s
All checks were successful
Build Docker Image / build (push) Successful in 4m1s
- Created OrderDetailBottomSheet.vue component (like KycBottomSheet) - Click on order in list opens bottom sheet instead of navigating - Slide-up animation with backdrop - Click backdrop or X to close
This commit is contained in:
391
app/components/catalog/OrderDetailBottomSheet.vue
Normal file
391
app/components/catalog/OrderDetailBottomSheet.vue
Normal file
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<Transition name="order-slide">
|
||||
<div
|
||||
v-if="isOpen && orderUuid"
|
||||
class="fixed inset-x-0 bottom-0 z-50 flex flex-col"
|
||||
style="height: 70vh"
|
||||
>
|
||||
<!-- Backdrop (clickable to close) -->
|
||||
<div
|
||||
class="absolute inset-0 -top-[30vh] bg-black/30"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
|
||||
<!-- Sheet content -->
|
||||
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
||||
<!-- Header with drag handle and close -->
|
||||
<div class="sticky top-0 z-10 bg-black/30 backdrop-blur-md border-b border-white/10">
|
||||
<div class="flex justify-center py-2">
|
||||
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between px-6 pb-4">
|
||||
<template v-if="hasOrderError">
|
||||
<div class="flex-1">
|
||||
<div class="font-semibold text-white">{{ t('common.error') }}</div>
|
||||
<div class="text-sm text-white/50">{{ orderError }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="!isLoadingOrder && order">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="w-10 h-10 bg-indigo-500/20 rounded-xl flex items-center justify-center flex-shrink-0">
|
||||
<Icon name="lucide:package" size="24" class="text-indigo-400" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold text-white truncate">{{ orderTitle }}</div>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span class="badge badge-primary badge-sm">#{{ order.name }}</span>
|
||||
<span v-if="order.status" class="badge badge-outline badge-sm text-white/60">{{ order.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<div class="w-10 h-10 bg-white/10 rounded-xl animate-pulse" />
|
||||
<div class="flex-1">
|
||||
<div class="h-5 bg-white/10 rounded w-48 animate-pulse" />
|
||||
<div class="h-4 bg-white/10 rounded w-32 mt-1 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<button class="btn btn-ghost btn-sm btn-circle text-white/60 hover:text-white flex-shrink-0" @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="text-white/70 mb-4">{{ orderError }}</div>
|
||||
<button class="btn btn-sm bg-white/10 border-white/20 text-white" @click="loadOrder">
|
||||
{{ t('ordersDetail.errors.retry') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div v-else-if="order" class="overflow-y-auto h-[calc(70vh-100px)] 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="px-3 py-1 bg-white/10 rounded-full text-white/70">
|
||||
{{ meta }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Route stages -->
|
||||
<div v-if="orderStageItems.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<Icon name="lucide:route" size="18" />
|
||||
{{ t('ordersDetail.sections.stages.title', 'Маршрут') }}
|
||||
</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="w-3 h-3 rounded-full bg-indigo-500" />
|
||||
<div v-if="idx < orderStageItems.length - 1" class="w-0.5 flex-1 bg-white/20 my-1" />
|
||||
</div>
|
||||
<div class="flex-1 pb-3">
|
||||
<div class="text-sm text-white font-medium">{{ stage.from }}</div>
|
||||
<div v-if="stage.to && stage.to !== stage.from" class="text-xs text-white/50 mt-0.5">
|
||||
→ {{ stage.to }}
|
||||
</div>
|
||||
<div v-if="stage.meta?.length" class="text-xs text-white/40 mt-1">
|
||||
{{ stage.meta.join(' · ') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div v-if="order.stages?.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<Icon name="lucide:calendar" size="18" />
|
||||
{{ t('ordersDetail.sections.timeline.title') }}
|
||||
</div>
|
||||
<GanttTimeline
|
||||
:stages="order.stages"
|
||||
:showLoading="true"
|
||||
:showUnloading="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Map preview (small) -->
|
||||
<div v-if="orderRoutesForMap.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||
<Icon name="lucide:map" size="18" />
|
||||
{{ t('ordersDetail.sections.map.title', 'Карта') }}
|
||||
</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 bg-white/5 rounded-xl animate-pulse" />
|
||||
<div class="h-32 bg-white/5 rounded-xl animate-pulse" />
|
||||
<div class="h-48 bg-white/5 rounded-xl animate-pulse" />
|
||||
</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>
|
||||
@@ -1,17 +1,18 @@
|
||||
<template>
|
||||
<CatalogPage
|
||||
:items="mapPoints"
|
||||
:loading="isLoading"
|
||||
:use-server-clustering="false"
|
||||
map-id="orders-map"
|
||||
point-color="#6366f1"
|
||||
:hovered-id="hoveredOrderId"
|
||||
:show-panel="true"
|
||||
panel-width="w-96"
|
||||
:hide-view-toggle="true"
|
||||
@select="onMapSelect"
|
||||
@update:hovered-id="hoveredOrderId = $event"
|
||||
>
|
||||
<div>
|
||||
<CatalogPage
|
||||
:items="mapPoints"
|
||||
:loading="isLoading"
|
||||
:use-server-clustering="false"
|
||||
map-id="orders-map"
|
||||
point-color="#6366f1"
|
||||
:hovered-id="hoveredOrderId"
|
||||
:show-panel="true"
|
||||
panel-width="w-96"
|
||||
:hide-view-toggle="true"
|
||||
@select="onMapSelect"
|
||||
@update:hovered-id="hoveredOrderId = $event"
|
||||
>
|
||||
<template #panel>
|
||||
<!-- Panel header -->
|
||||
<div class="p-4 border-b border-white/10 flex-shrink-0">
|
||||
@@ -58,38 +59,35 @@
|
||||
<!-- Orders list -->
|
||||
<div class="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
<template v-if="displayItems.length > 0">
|
||||
<NuxtLink
|
||||
<div
|
||||
v-for="item in displayItems"
|
||||
:key="item.uuid"
|
||||
:to="localePath(`/clientarea/orders/${item.uuid}`)"
|
||||
class="block"
|
||||
class="bg-white/10 rounded-lg p-3 hover:bg-white/20 transition-colors cursor-pointer"
|
||||
:class="{ 'ring-2 ring-indigo-500': selectedOrderId === item.uuid }"
|
||||
@click="selectedOrderId = item.uuid"
|
||||
@mouseenter="hoveredOrderId = item.uuid"
|
||||
@mouseleave="hoveredOrderId = undefined"
|
||||
>
|
||||
<div
|
||||
class="bg-white/10 rounded-lg p-3 hover:bg-white/20 transition-colors cursor-pointer"
|
||||
@mouseenter="hoveredOrderId = item.uuid"
|
||||
@mouseleave="hoveredOrderId = undefined"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-semibold text-sm">#{{ item.name }}</span>
|
||||
<span class="badge badge-sm" :class="getStatusBadgeClass(item.status)">
|
||||
{{ getStatusText(item.status) }}
|
||||
</span>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-semibold text-sm">#{{ item.name }}</span>
|
||||
<span class="badge badge-sm" :class="getStatusBadgeClass(item.status)">
|
||||
{{ getStatusText(item.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-white/70 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="lucide:map-pin" size="12" class="text-white/40" />
|
||||
<span class="truncate">{{ item.sourceLocationName }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-white/70 space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="lucide:map-pin" size="12" class="text-white/40" />
|
||||
<span class="truncate">{{ item.sourceLocationName }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="lucide:navigation" size="12" class="text-white/40" />
|
||||
<span class="truncate">{{ item.destinationLocationName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-white/50 mt-2">
|
||||
{{ getOrderDate(item) }}
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon name="lucide:navigation" size="12" class="text-white/40" />
|
||||
<span class="truncate">{{ item.destinationLocationName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div class="text-xs text-white/50 mt-2">
|
||||
{{ getOrderDate(item) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="text-center py-8">
|
||||
@@ -105,7 +103,15 @@
|
||||
<span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ filteredItems.length }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</CatalogPage>
|
||||
</CatalogPage>
|
||||
|
||||
<!-- Order Detail Bottom Sheet -->
|
||||
<OrderDetailBottomSheet
|
||||
:is-open="!!selectedOrderId"
|
||||
:order-uuid="selectedOrderId"
|
||||
@close="selectedOrderId = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -118,7 +124,6 @@ definePageMeta({
|
||||
middleware: ['auth-oidc']
|
||||
})
|
||||
|
||||
const localePath = useLocalePath()
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
@@ -133,6 +138,7 @@ const {
|
||||
|
||||
const hoveredOrderId = ref<string>()
|
||||
const searchQuery = ref('')
|
||||
const selectedOrderId = ref<string | null>(null)
|
||||
|
||||
// Selected filter label
|
||||
const selectedFilterLabel = computed(() => {
|
||||
@@ -170,7 +176,7 @@ const displayItems = computed(() => {
|
||||
|
||||
const onMapSelect = (item: { uuid?: string | null }) => {
|
||||
if (item.uuid) {
|
||||
navigateTo(localePath(`/clientarea/orders/${item.uuid}`))
|
||||
selectedOrderId.value = item.uuid
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user