Refine order detail status layout

This commit is contained in:
Ruslan Bakiev
2026-04-06 20:58:08 +07:00
parent aabebe9b90
commit f1129199bd
4 changed files with 179 additions and 64 deletions

View File

@@ -1,30 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import {
getOrderStatusBadgePresentation,
type OrderStatusTone,
} from '~/composables/useOrderStatusPresentation';
const props = defineProps<{ const props = defineProps<{
status: string; status: string;
}>(); }>();
const statusLabel = computed(() => { const badgePresentation = computed(() => getOrderStatusBadgePresentation(props.status));
if (props.status === 'NEW' || props.status === 'MANAGER_PROCESSING') return 'Заявка';
if (props.status === 'WAITING_DOUBLE_CONFIRM' || props.status === 'CONFIRMED') return 'Предложение';
if (props.status === 'IN_PROGRESS') return 'В работе';
if (props.status === 'COMPLETED') return 'Завершен';
if (props.status === 'CLIENT_REJECTED' || props.status === 'MANAGER_REJECTED') return 'Отклонен';
if (props.status === 'MANAGER_BLOCKED') return 'Пауза';
return props.status;
});
const className = computed(() => { function dotClass(tone: OrderStatusTone) {
if (props.status === 'COMPLETED') return 'bg-[#139957]'; if (tone === 'success') return 'bg-[#139957]';
if (props.status === 'CLIENT_REJECTED' || props.status === 'MANAGER_REJECTED') return 'bg-[#d94b55]'; if (tone === 'danger') return 'bg-[#d94b55]';
if (props.status === 'MANAGER_BLOCKED') return 'bg-[#f1a43a]'; if (tone === 'warning') return 'bg-[#f1a43a]';
if (props.status === 'NEW' || props.status === 'MANAGER_PROCESSING') return 'bg-[#f1a43a]';
return 'bg-[#2e8de4]'; return 'bg-[#2e8de4]';
}); }
</script> </script>
<template> <template>
<span class="inline-flex items-center gap-2 text-sm font-semibold text-[#123824]"> <span class="inline-flex items-center gap-2 text-sm font-semibold leading-tight text-[#123824]">
<span class="h-2.5 w-2.5 rounded-full" :class="className" /> <span class="h-2.5 w-2.5 rounded-full" :class="dotClass(badgePresentation.tone)" />
<span>{{ statusLabel }}</span> <span>{{ badgePresentation.label }}</span>
</span> </span>
</template> </template>

View File

@@ -1,6 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue'; import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
import { getOrderStatusPresentation } from '~/composables/useOrderStatusPresentation'; import {
getOrderStatusBadgePresentation,
getOrderStatusPresentation,
type OrderStatusTone,
} from '~/composables/useOrderStatusPresentation';
const props = defineProps<{ const props = defineProps<{
status: string; status: string;
@@ -11,10 +15,53 @@ const props = defineProps<{
const isExpanded = ref(false); const isExpanded = ref(false);
const presentation = computed(() => getOrderStatusPresentation(props.status, props.createdAt, props.audience ?? 'client')); const presentation = computed(() => getOrderStatusPresentation(props.status, props.createdAt, props.audience ?? 'client'));
const currentBadge = computed(() => getOrderStatusBadgePresentation(props.status));
function currentToneClass(tone: OrderStatusTone) {
if (tone === 'success') {
return {
marker: 'bg-[#139957] ring-4 ring-[#dff4e8]',
panel: 'bg-[#eef8f2]',
title: 'text-[#123824]',
note: 'text-[#355947]',
date: 'text-[#139957]',
connector: 'bg-[#bfe0cb]',
};
}
if (tone === 'danger') {
return {
marker: 'bg-[#d94b55] ring-4 ring-[#fbe5e7]',
panel: 'bg-[#fff1f2]',
title: 'text-[#7e2130]',
note: 'text-[#9b4150]',
date: 'text-[#d94b55]',
connector: 'bg-[#f1c6cb]',
};
}
if (tone === 'warning') {
return {
marker: 'bg-[#f1a43a] ring-4 ring-[#fff0d9]',
panel: 'bg-[#fff7eb]',
title: 'text-[#6c4303]',
note: 'text-[#8f6420]',
date: 'text-[#c67d11]',
connector: 'bg-[#efd1a2]',
};
}
return {
marker: 'bg-[#2e8de4] ring-4 ring-[#e3effb]',
panel: 'bg-[#eef5fc]',
title: 'text-[#174b7e]',
note: 'text-[#436b92]',
date: 'text-[#2e8de4]',
connector: 'bg-[#c7dbef]',
};
}
function markerClass(state: 'done' | 'current' | 'upcoming') { function markerClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'current') { if (state === 'current') {
return 'bg-[#139957] ring-4 ring-[#dff4e8]'; return currentToneClass(currentBadge.value.tone).marker;
} }
if (state === 'done') { if (state === 'done') {
return 'bg-[#9dcfb0]'; return 'bg-[#9dcfb0]';
@@ -24,7 +71,9 @@ function markerClass(state: 'done' | 'current' | 'upcoming') {
function connectorClass(state: 'done' | 'current' | 'upcoming') { function connectorClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'done' || state === 'current') { if (state === 'done' || state === 'current') {
return 'bg-[#cfe5d7]'; return state === 'current'
? currentToneClass(currentBadge.value.tone).connector
: 'bg-[#cfe5d7]';
} }
return 'bg-[#e4ece7]'; return 'bg-[#e4ece7]';
@@ -32,7 +81,7 @@ function connectorClass(state: 'done' | 'current' | 'upcoming') {
function titleClass(state: 'done' | 'current' | 'upcoming') { function titleClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'current') { if (state === 'current') {
return 'text-[#123824]'; return currentToneClass(currentBadge.value.tone).title;
} }
if (state === 'done') { if (state === 'done') {
return 'text-[#355947]'; return 'text-[#355947]';
@@ -43,7 +92,7 @@ function titleClass(state: 'done' | 'current' | 'upcoming') {
function noteClass(state: 'done' | 'current' | 'upcoming') { function noteClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'current') { if (state === 'current') {
return 'text-[#355947]'; return currentToneClass(currentBadge.value.tone).note;
} }
if (state === 'done') { if (state === 'done') {
return 'text-[#557562]'; return 'text-[#557562]';
@@ -51,6 +100,28 @@ function noteClass(state: 'done' | 'current' | 'upcoming') {
return 'text-[#7d9688]'; return 'text-[#7d9688]';
} }
function stagePanelClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'current') {
return currentToneClass(currentBadge.value.tone).panel;
}
if (state === 'done') {
return 'bg-[#f3f7f4]';
}
return 'bg-[#f7faf8]';
}
function dateClass(state: 'done' | 'current' | 'upcoming') {
if (state === 'current') {
return currentToneClass(currentBadge.value.tone).date;
}
if (state === 'done') {
return 'text-[#5c7b69]';
}
return 'text-[#86a091]';
}
</script> </script>
<template> <template>
@@ -60,17 +131,19 @@ function noteClass(state: 'done' | 'current' | 'upcoming') {
class="flex w-full items-start justify-between gap-4 text-left" class="flex w-full items-start justify-between gap-4 text-left"
@click="isExpanded = !isExpanded" @click="isExpanded = !isExpanded"
> >
<div class="space-y-2"> <div class="space-y-3">
<div class="flex flex-wrap items-center gap-3">
<h2 class="text-2xl font-black leading-tight text-[#123824]"> <h2 class="text-2xl font-black leading-tight text-[#123824]">
{{ presentation.title }} {{ presentation.title }}
</h2> </h2>
<OrderStatusBadge :status="status" />
</div>
<p class="max-w-2xl text-sm leading-6 text-[#355947]"> <p class="max-w-2xl text-sm leading-6 text-[#355947]">
{{ presentation.summary }} {{ presentation.summary }}
</p> </p>
</div> </div>
<div class="flex items-center gap-3 pt-1"> <div class="flex items-center gap-3 pt-1">
<OrderStatusBadge :status="status" />
<span <span
class="flex h-10 w-10 items-center justify-center rounded-full bg-[#f2f5f3] text-[#123824] transition-transform" class="flex h-10 w-10 items-center justify-center rounded-full bg-[#f2f5f3] text-[#123824] transition-transform"
:class="{ 'rotate-180': isExpanded }" :class="{ 'rotate-180': isExpanded }"
@@ -97,15 +170,16 @@ function noteClass(state: 'done' | 'current' | 'upcoming') {
/> />
</div> </div>
<div class="min-w-0 flex-1 pb-5">
<div <div
class="min-w-0 flex-1 pb-5" class="rounded-[22px] px-4 py-4 transition-colors"
:class="index < presentation.stages.length - 1 ? 'border-b border-[#e1ebe4]' : ''" :class="stagePanelClass(stage.state)"
> >
<div class="flex flex-wrap items-center justify-between gap-2"> <div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-semibold" :class="titleClass(stage.state)"> <p class="text-sm font-semibold" :class="titleClass(stage.state)">
{{ stage.label }} {{ stage.label }}
</p> </p>
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]"> <p class="text-xs font-semibold uppercase tracking-[0.12em]" :class="dateClass(stage.state)">
{{ stage.dateLabel }} {{ stage.dateLabel }}
</p> </p>
</div> </div>
@@ -117,4 +191,5 @@ function noteClass(state: 'done' | 'current' | 'upcoming') {
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>

View File

@@ -9,6 +9,8 @@ type OrderStatusCode =
| 'IN_PROGRESS' | 'IN_PROGRESS'
| 'COMPLETED'; | 'COMPLETED';
export type OrderStatusTone = 'warning' | 'info' | 'success' | 'danger';
type TimelineStage = { type TimelineStage = {
code: string; code: string;
label: string; label: string;
@@ -17,6 +19,11 @@ type TimelineStage = {
state: 'done' | 'current' | 'upcoming'; state: 'done' | 'current' | 'upcoming';
}; };
type StatusBadgePresentation = {
label: string;
tone: OrderStatusTone;
};
type StatusPresentation = { type StatusPresentation = {
title: string; title: string;
summary: string; summary: string;
@@ -37,6 +44,18 @@ const DAY_FORMATTER = new Intl.DateTimeFormat('ru-RU', {
month: 'long', month: 'long',
}); });
const STATUS_BADGE_MAP: Record<string, StatusBadgePresentation> = {
NEW: { label: 'Заявка', tone: 'warning' },
MANAGER_PROCESSING: { label: 'Готовим предложение', tone: 'warning' },
WAITING_DOUBLE_CONFIRM: { label: 'Предложение', tone: 'info' },
CLIENT_REJECTED: { label: 'Отклонен', tone: 'danger' },
MANAGER_REJECTED: { label: 'Отклонен', tone: 'danger' },
MANAGER_BLOCKED: { label: 'Пауза', tone: 'warning' },
CONFIRMED: { label: 'Производство', tone: 'info' },
IN_PROGRESS: { label: 'Отгрузка', tone: 'success' },
COMPLETED: { label: 'Доставка', tone: 'success' },
};
function addDays(date: Date, days: number) { function addDays(date: Date, days: number) {
const next = new Date(date); const next = new Date(date);
next.setDate(next.getDate() + days); next.setDate(next.getDate() + days);
@@ -149,14 +168,14 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
stages: [ stages: [
{ {
code: 'NEW', code: 'NEW',
label: 'Заказ создан', label: 'Заявка',
note: 'Заказ принят в обработку.', note: 'Заказ принят в обработку.',
dateLabel: formatDay(dates.created), dateLabel: formatDay(dates.created),
state: 'done', state: 'done',
}, },
{ {
code: status, code: status,
label: 'Заказ остановлен', label: 'Отклонен',
note: 'Дальнейшее исполнение не планируется.', note: 'Дальнейшее исполнение не планируется.',
dateLabel: formatDay(dates.approval), dateLabel: formatDay(dates.approval),
state: 'current', state: 'current',
@@ -172,14 +191,14 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
stages: [ stages: [
{ {
code: 'NEW', code: 'NEW',
label: 'Заказ создан', label: 'Заявка',
note: 'Заказ принят в обработку.', note: 'Заказ принят в обработку.',
dateLabel: formatDay(dates.created), dateLabel: formatDay(dates.created),
state: 'done', state: 'done',
}, },
{ {
code: status, code: status,
label: 'Уточняем детали', label: 'Пауза',
note: 'После уточнения покажем плановые даты по исполнению.', note: 'После уточнения покажем плановые даты по исполнению.',
dateLabel: formatDay(dates.approval), dateLabel: formatDay(dates.approval),
state: 'current', state: 'current',
@@ -193,31 +212,45 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
const stages: TimelineStage[] = [ const stages: TimelineStage[] = [
{ {
code: 'NEW', code: 'NEW',
label: 'Заказ создан', label: 'Заявка',
note: 'Приняли заказ и начали обработку.', note: 'Приняли заказ и начали обработку.',
dateLabel: formatDay(dates.created), dateLabel: formatDay(dates.created),
state: currentIndex > 0 ? 'done' : 'current', state: currentIndex > 0 ? 'done' : currentIndex === 0 ? 'current' : 'upcoming',
},
{
code: 'MANAGER_PROCESSING',
label: 'Готовим предложение',
note: 'Собираем стоимость и плановые сроки по заказу.',
dateLabel: formatDay(dates.offer),
state: currentIndex > 1 ? 'done' : currentIndex === 1 ? 'current' : 'upcoming',
},
{
code: 'WAITING_DOUBLE_CONFIRM',
label: 'Предложение',
note: 'Цена и условия готовы, ждём подтверждения.',
dateLabel: formatDay(dates.approval),
state: currentIndex > 2 ? 'done' : currentIndex === 2 ? 'current' : 'upcoming',
}, },
{ {
code: 'CONFIRMED', code: 'CONFIRMED',
label: 'Производство', label: 'Производство',
note: 'Плановая дата запуска или выхода из производства.', note: 'Плановая дата запуска или выхода из производства.',
dateLabel: formatDay(dates.production), dateLabel: formatDay(dates.production),
state: currentIndex > 3 ? 'done' : currentIndex >= 3 ? 'current' : 'upcoming', state: currentIndex > 3 ? 'done' : currentIndex === 3 ? 'current' : 'upcoming',
}, },
{ {
code: 'IN_PROGRESS', code: 'IN_PROGRESS',
label: 'Отгрузка', label: 'Отгрузка',
note: 'Плановая дата передачи в логистику.', note: 'Плановая дата передачи в логистику.',
dateLabel: formatDay(dates.shipment), dateLabel: formatDay(dates.shipment),
state: currentIndex > 4 ? 'done' : currentIndex >= 4 ? 'current' : 'upcoming', state: currentIndex > 4 ? 'done' : currentIndex === 4 ? 'current' : 'upcoming',
}, },
{ {
code: 'COMPLETED', code: 'COMPLETED',
label: 'Доставка', label: 'Доставка',
note: 'Плановая дата получения заказа.', note: 'Плановая дата получения заказа.',
dateLabel: formatDay(dates.delivered), dateLabel: formatDay(dates.delivered),
state: currentIndex >= 5 ? 'current' : 'upcoming', state: currentIndex === 5 ? 'current' : 'upcoming',
}, },
]; ];
@@ -247,7 +280,7 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
if (status === 'CONFIRMED') { if (status === 'CONFIRMED') {
return { return {
title: ланируем производство', title: 'Производство запланировано',
summary: `Ориентируемся на производство ${formatDay(dates.production)}, затем отгрузку и доставку по плану.`, summary: `Ориентируемся на производство ${formatDay(dates.production)}, затем отгрузку и доставку по плану.`,
stages, stages,
}; };
@@ -255,7 +288,7 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
if (status === 'IN_PROGRESS') { if (status === 'IN_PROGRESS') {
return { return {
title: 'Заказ в работе', title: 'Готовим отгрузку',
summary: `Производство идёт. Следующая плановая дата: отгрузка около ${formatDay(dates.shipment)}.`, summary: `Производство идёт. Следующая плановая дата: отгрузка около ${formatDay(dates.shipment)}.`,
stages, stages,
}; };
@@ -268,6 +301,13 @@ function buildClientStages(status: string, createdAt: string | Date): StatusPres
}; };
} }
export function getOrderStatusBadgePresentation(status: string): StatusBadgePresentation {
return STATUS_BADGE_MAP[status] ?? {
label: status,
tone: 'info',
};
}
export function getOrderStatusPresentation( export function getOrderStatusPresentation(
status: string, status: string,
createdAt: string | Date, createdAt: string | Date,

View File

@@ -32,19 +32,23 @@ const currentOrderCode = computed(() => formatOrderCode(currentOrder.value?.code
</div> </div>
<template v-else> <template v-else>
<div class="surface-card rounded-3xl px-5 py-4"> <div class="flex flex-wrap items-center gap-4 px-1">
<div class="flex flex-wrap items-center gap-3"> <NuxtLink
<NuxtLink to="/orders" class="text-sm font-semibold text-[#0d854a]"> to="/orders"
Назад к моим заказам aria-label="Назад к моим заказам"
class="flex h-11 w-11 items-center justify-center rounded-full bg-white/70 text-[#0d854a] transition hover:bg-white"
>
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="none">
<path d="M11.5 4.5L6 10L11.5 15.5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</NuxtLink> </NuxtLink>
<span class="hidden h-4 w-px bg-[#d8e4dd] md:block" /> <span class="h-8 w-px bg-[#d8e4dd]" />
<span class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]"> <h1 class="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-black tracking-[-0.03em] text-[#123824] md:text-3xl">
<span class="text-sm font-bold uppercase tracking-[0.18em] text-[#6a8a76]">
Заказ Заказ
</span> </span>
<span class="text-lg font-black tracking-[-0.03em] text-[#123824]"> <span>{{ currentOrderCode }}</span>
{{ currentOrderCode }} </h1>
</span>
</div>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">