fix(orders): use side panel for list, bottom sheet for detail
All checks were successful
Build Docker Image / build (push) Successful in 3m56s
All checks were successful
Build Docker Image / build (push) Successful in 3m56s
- orders/index.vue: Reverted to side panel pattern for map interaction - orders/[id].vue: Converted to CatalogPage + bottom sheet pattern List page uses #panel slot to interact with map markers. Detail page uses fixed bottom sheet (70vh) with glass styling.
This commit is contained in:
@@ -1,48 +1,121 @@
|
|||||||
<template>
|
<template>
|
||||||
<Section variant="plain">
|
<div>
|
||||||
<Stack gap="8">
|
<CatalogPage
|
||||||
<template v-if="hasOrderError">
|
:items="mapPoints"
|
||||||
<div class="text-sm text-error">
|
:loading="isLoadingOrder"
|
||||||
{{ orderError }}
|
:use-server-clustering="false"
|
||||||
|
map-id="order-detail-map"
|
||||||
|
point-color="#6366f1"
|
||||||
|
:show-panel="false"
|
||||||
|
:hide-view-toggle="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Bottom Sheet -->
|
||||||
|
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
|
||||||
|
<!-- Glass sheet -->
|
||||||
|
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
||||||
|
<!-- Drag handle -->
|
||||||
|
<div class="flex justify-center py-2">
|
||||||
|
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<Button @click="loadOrder">{{ t('ordersDetail.errors.retry') }}</Button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-else-if="isLoadingOrder" class="text-sm text-base-content/60">
|
<!-- Header -->
|
||||||
{{ t('ordersDetail.states.loading') }}
|
<div class="px-6 pb-4 border-b border-white/10">
|
||||||
</div>
|
<!-- Back button -->
|
||||||
|
<NuxtLink :to="localePath('/clientarea/orders')" class="inline-flex items-center gap-1 text-white/60 hover:text-white text-sm mb-3">
|
||||||
|
<Icon name="lucide:arrow-left" size="16" />
|
||||||
|
{{ t('common.back') }}
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
<template v-else>
|
<template v-if="hasOrderError">
|
||||||
<Card padding="lg" class="border border-base-300">
|
<div class="bg-error/20 border border-error/30 rounded-lg p-4">
|
||||||
<RouteSummaryHeader :title="orderTitle" :meta="orderMeta" />
|
<div class="font-semibold text-white mb-2">{{ t('common.error') }}</div>
|
||||||
</Card>
|
<div class="text-sm text-white/70 mb-3">{{ orderError }}</div>
|
||||||
|
<button class="btn btn-sm bg-white/10 border-white/20 text-white" @click="loadOrder">
|
||||||
|
{{ t('ordersDetail.errors.retry') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<Card v-if="orderRoutesForMap.length" padding="lg" class="border border-base-300">
|
<template v-else-if="!isLoadingOrder && order">
|
||||||
<Stack gap="4">
|
<div class="flex items-center gap-3">
|
||||||
<RouteStagesList
|
<div class="w-12 h-12 rounded-xl bg-indigo-500/20 flex items-center justify-center">
|
||||||
:stages="orderStageItems"
|
<Icon name="lucide:package" size="24" class="text-indigo-400" />
|
||||||
:empty-text="t('ordersDetail.sections.stages.empty')"
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-bold text-lg text-white truncate">{{ orderTitle }}</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<span v-for="(meta, idx) in orderMeta" :key="idx" class="text-xs text-white/50">
|
||||||
|
{{ meta }}{{ idx < orderMeta.length - 1 ? ' · ' : '' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="animate-pulse">
|
||||||
|
<div class="h-12 bg-white/10 rounded-xl w-48" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scrollable content -->
|
||||||
|
<div v-if="!hasOrderError && order" class="overflow-y-auto h-[calc(70vh-140px)] px-6 py-4 space-y-4">
|
||||||
|
<!-- 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="showLoading"
|
||||||
|
:showUnloading="showUnloading"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="divider my-0"></div>
|
<!-- Map preview (small) -->
|
||||||
|
<div v-if="orderRoutesForMap.length" class="bg-white/5 rounded-xl p-4 border border-white/10">
|
||||||
<RequestRoutesMap :routes="orderRoutesForMap" :height="260" />
|
<div class="font-semibold text-white mb-3 flex items-center gap-2">
|
||||||
</Stack>
|
<Icon name="lucide:map" size="18" />
|
||||||
</Card>
|
{{ t('ordersDetail.sections.map.title', 'Карта') }}
|
||||||
|
</div>
|
||||||
<div class="space-y-3">
|
<RequestRoutesMap :routes="orderRoutesForMap" :height="200" />
|
||||||
<Heading :level="3" weight="semibold">{{ t('ordersDetail.sections.timeline.title') }}</Heading>
|
</div>
|
||||||
<GanttTimeline
|
|
||||||
v-if="order?.stages"
|
|
||||||
:stages="order.stages"
|
|
||||||
:showLoading="showLoading"
|
|
||||||
:showUnloading="showUnloading"
|
|
||||||
/>
|
|
||||||
<Text v-else tone="muted">{{ t('ordersDetail.sections.timeline.empty') }}</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Stack>
|
</div>
|
||||||
</Section>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -52,7 +125,6 @@ import type { RouteStageItem } from '~/components/RouteStagesList.vue'
|
|||||||
// Types from GraphQL
|
// Types from GraphQL
|
||||||
type OrderType = NonNullable<GetOrderQueryResult['getOrder']>
|
type OrderType = NonNullable<GetOrderQueryResult['getOrder']>
|
||||||
type StageType = NonNullable<NonNullable<OrderType['stages']>[number]>
|
type StageType = NonNullable<NonNullable<OrderType['stages']>[number]>
|
||||||
type TripType = NonNullable<NonNullable<StageType['trips']>[number]>
|
|
||||||
type CompanyType = NonNullable<StageType['selectedCompany']>
|
type CompanyType = NonNullable<StageType['selectedCompany']>
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
@@ -62,6 +134,7 @@ definePageMeta({
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
const order = ref<OrderType | null>(null)
|
const order = ref<OrderType | null>(null)
|
||||||
const isLoadingOrder = ref(true)
|
const isLoadingOrder = ref(true)
|
||||||
@@ -70,6 +143,35 @@ const orderError = ref('')
|
|||||||
const showLoading = ref(true)
|
const showLoading = ref(true)
|
||||||
const showUnloading = ref(true)
|
const showUnloading = ref(true)
|
||||||
|
|
||||||
|
// Map points for route visualization
|
||||||
|
const mapPoints = computed(() => {
|
||||||
|
if (!order.value) return []
|
||||||
|
|
||||||
|
const points: Array<{ uuid: string; name: string; latitude: number; longitude: number }> = []
|
||||||
|
|
||||||
|
// Add source
|
||||||
|
if (order.value.sourceLatitude && order.value.sourceLongitude) {
|
||||||
|
points.push({
|
||||||
|
uuid: 'source',
|
||||||
|
name: order.value.sourceLocationName || t('ordersDetail.labels.source'),
|
||||||
|
latitude: order.value.sourceLatitude,
|
||||||
|
longitude: order.value.sourceLongitude
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add destination
|
||||||
|
if (order.value.destinationLatitude && order.value.destinationLongitude) {
|
||||||
|
points.push({
|
||||||
|
uuid: 'destination',
|
||||||
|
name: order.value.destinationLocationName || t('ordersDetail.labels.destination'),
|
||||||
|
latitude: order.value.destinationLatitude,
|
||||||
|
longitude: order.value.destinationLongitude
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return points
|
||||||
|
})
|
||||||
|
|
||||||
const orderTitle = computed(() => {
|
const orderTitle = computed(() => {
|
||||||
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
|
const source = order.value?.sourceLocationName || t('ordersDetail.labels.source_unknown')
|
||||||
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')
|
const destination = order.value?.destinationLocationName || t('ordersDetail.labels.destination_unknown')
|
||||||
|
|||||||
@@ -1,119 +1,111 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<CatalogPage
|
||||||
<CatalogPage
|
:items="mapPoints"
|
||||||
:items="mapPoints"
|
:loading="isLoading"
|
||||||
:loading="isLoading"
|
:use-server-clustering="false"
|
||||||
:use-server-clustering="false"
|
map-id="orders-map"
|
||||||
map-id="orders-map"
|
point-color="#6366f1"
|
||||||
point-color="#6366f1"
|
:hovered-id="hoveredOrderId"
|
||||||
:hovered-id="hoveredOrderId"
|
:show-panel="true"
|
||||||
:show-panel="false"
|
panel-width="w-96"
|
||||||
:hide-view-toggle="true"
|
:hide-view-toggle="true"
|
||||||
@select="onMapSelect"
|
@select="onMapSelect"
|
||||||
@update:hovered-id="hoveredOrderId = $event"
|
@update:hovered-id="hoveredOrderId = $event"
|
||||||
/>
|
>
|
||||||
|
<template #panel>
|
||||||
<!-- Bottom Sheet -->
|
<!-- Panel header -->
|
||||||
<div class="fixed inset-x-0 bottom-0 z-50 flex flex-col" style="height: 70vh">
|
<div class="p-4 border-b border-white/10 flex-shrink-0">
|
||||||
<!-- Glass sheet -->
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="relative flex-1 bg-black/40 backdrop-blur-xl rounded-t-2xl border-t border-white/20 shadow-2xl overflow-hidden">
|
<div class="flex items-center gap-2">
|
||||||
<!-- Drag handle -->
|
<div class="w-8 h-8 rounded-lg bg-indigo-500/20 flex items-center justify-center">
|
||||||
<div class="flex justify-center py-2">
|
<Icon name="lucide:package" size="16" class="text-indigo-400" />
|
||||||
<div class="w-12 h-1.5 bg-white/30 rounded-full" />
|
</div>
|
||||||
</div>
|
<div>
|
||||||
|
<span class="font-semibold text-sm">{{ t('cabinetNav.orders') }}</span>
|
||||||
<!-- Header -->
|
<div class="text-xs text-white/50">{{ filteredItems.length }} {{ t('orders.total', 'total') }}</div>
|
||||||
<div class="px-6 pb-4 border-b border-white/10">
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div class="w-10 h-10 rounded-xl bg-indigo-500/20 flex items-center justify-center">
|
|
||||||
<Icon name="lucide:package" size="20" class="text-indigo-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="font-bold text-white">{{ t('cabinetNav.orders') }}</div>
|
|
||||||
<div class="text-xs text-white/50">{{ filteredItems.length }} {{ t('orders.total', 'total') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Search -->
|
|
||||||
<div class="relative mb-3">
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="text"
|
|
||||||
:placeholder="t('common.search')"
|
|
||||||
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
|
|
||||||
/>
|
|
||||||
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Filter dropdown -->
|
|
||||||
<div class="dropdown dropdown-end w-full">
|
|
||||||
<label tabindex="0" class="btn btn-sm w-full bg-white/10 border-white/20 text-white hover:bg-white/20 justify-between">
|
|
||||||
<span>{{ selectedFilterLabel }}</span>
|
|
||||||
<Icon name="lucide:chevron-down" size="14" />
|
|
||||||
</label>
|
|
||||||
<ul tabindex="0" class="dropdown-content menu menu-sm z-50 p-2 shadow bg-base-200 rounded-box w-full mt-2">
|
|
||||||
<li v-for="filter in filters" :key="filter.id">
|
|
||||||
<a
|
|
||||||
:class="{ 'active': selectedFilter === filter.id }"
|
|
||||||
@click="selectedFilter = filter.id"
|
|
||||||
>{{ filter.label }}</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scrollable content -->
|
<!-- Search -->
|
||||||
<div class="overflow-y-auto h-[calc(70vh-180px)] px-6 py-4">
|
<div class="relative mb-3">
|
||||||
<template v-if="displayItems.length > 0">
|
<input
|
||||||
<div class="space-y-2">
|
v-model="searchQuery"
|
||||||
<div
|
type="text"
|
||||||
v-for="item in displayItems"
|
:placeholder="t('common.search')"
|
||||||
:key="item.uuid"
|
class="input input-sm w-full bg-white/10 border-white/20 text-white placeholder:text-white/50"
|
||||||
class="bg-white/5 rounded-xl p-4 border border-white/10 hover:bg-white/10 transition-colors cursor-pointer"
|
/>
|
||||||
@mouseenter="hoveredOrderId = item.uuid"
|
<Icon name="lucide:search" size="16" class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50" />
|
||||||
@mouseleave="hoveredOrderId = undefined"
|
|
||||||
@click="onSelectOrder(item)"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span class="font-semibold text-white">#{{ item.name }}</span>
|
|
||||||
<span class="badge badge-sm" :class="getStatusBadgeClass(item.status)">
|
|
||||||
{{ getStatusText(item.status) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-white/70 mb-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Icon name="lucide:map-pin" size="14" class="text-white/40" />
|
|
||||||
{{ item.sourceLocationName }}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2 mt-1">
|
|
||||||
<Icon name="lucide:navigation" size="14" class="text-white/40" />
|
|
||||||
{{ item.destinationLocationName }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-xs text-white/50 mt-2">
|
|
||||||
{{ getOrderDate(item) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<div class="text-4xl mb-3">📦</div>
|
|
||||||
<div class="font-semibold text-white mb-1">{{ t('orders.no_orders') }}</div>
|
|
||||||
<div class="text-sm text-white/60">{{ t('orders.no_orders_desc') }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Filter dropdown -->
|
||||||
<div class="px-6 py-3 border-t border-white/10">
|
<div class="dropdown dropdown-end w-full">
|
||||||
<span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ filteredItems.length }}</span>
|
<label tabindex="0" class="btn btn-sm w-full bg-white/10 border-white/20 text-white hover:bg-white/20 justify-between">
|
||||||
|
<span>{{ selectedFilterLabel }}</span>
|
||||||
|
<Icon name="lucide:chevron-down" size="14" />
|
||||||
|
</label>
|
||||||
|
<ul tabindex="0" class="dropdown-content menu menu-sm z-50 p-2 shadow bg-base-200 rounded-box w-full mt-2">
|
||||||
|
<li v-for="filter in filters" :key="filter.id">
|
||||||
|
<a
|
||||||
|
:class="{ 'active': selectedFilter === filter.id }"
|
||||||
|
@click="selectedFilter = filter.id"
|
||||||
|
>{{ filter.label }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<!-- Orders list -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-3 space-y-2">
|
||||||
|
<template v-if="displayItems.length > 0">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="item in displayItems"
|
||||||
|
:key="item.uuid"
|
||||||
|
:to="localePath(`/clientarea/orders/${item.uuid}`)"
|
||||||
|
class="block"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<div class="text-3xl mb-2">📦</div>
|
||||||
|
<div class="font-semibold text-sm mb-1">{{ t('orders.no_orders') }}</div>
|
||||||
|
<div class="text-xs text-white/60">{{ t('orders.no_orders_desc') }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="p-3 border-t border-white/10 flex-shrink-0">
|
||||||
|
<span class="text-xs text-white/50">{{ displayItems.length }} {{ t('catalog.of') }} {{ filteredItems.length }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CatalogPage>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -182,12 +174,6 @@ const onMapSelect = (item: { uuid?: string | null }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onSelectOrder = (item: { uuid?: string | null }) => {
|
|
||||||
if (item.uuid) {
|
|
||||||
navigateTo(localePath(`/clientarea/orders/${item.uuid}`))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await init()
|
await init()
|
||||||
|
|
||||||
const getOrderDate = (order: TeamOrder) => {
|
const getOrderDate = (order: TeamOrder) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user