Files
web-frontend/app/pages/client-orders/[id].vue
2026-04-04 14:01:46 +07:00

323 lines
11 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 { useMutation, useQuery } from '@vue/apollo-composable';
import {
ManagerSetOrderOfferDocument,
OrderDetailDocument,
StartOrderWorkDocument,
type OrderDetailQuery,
} from '~/composables/graphql/generated';
import {
formatPrice,
orderLogisticsStateText,
orderDeliveryStateText,
orderLineStateText,
} from '~/composables/useOrderDetailPresentation';
definePageMeta({
middleware: ['manager-only'],
});
const route = useRoute();
const orderId = computed(() => String(route.params.id || ''));
type ManagerOrderItem = NonNullable<OrderDetailQuery['order']>;
const orderQuery = useQuery(OrderDetailDocument, () => ({
id: orderId.value,
}));
const startWorkMutation = useMutation(StartOrderWorkDocument);
const setOfferMutation = useMutation(ManagerSetOrderOfferDocument);
const itemPriceDrafts = reactive<Record<string, string>>({});
const deliveryTermsDraft = ref('');
const deliveryFeeDraft = ref('');
const autosavePending = ref(false);
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
const currentOrder = computed<ManagerOrderItem | null>(() =>
orderQuery.result.value?.order ?? null,
);
watch(
currentOrder,
(order) => {
for (const key of Object.keys(itemPriceDrafts)) {
delete itemPriceDrafts[key];
}
if (!order) {
deliveryTermsDraft.value = '';
deliveryFeeDraft.value = '';
return;
}
for (const item of order.items) {
itemPriceDrafts[item.id] = item.unitPrice == null ? '' : String(item.unitPrice);
}
deliveryTermsDraft.value = order.deliveryTerms ?? '';
deliveryFeeDraft.value = order.deliveryFee == null ? '' : String(order.deliveryFee);
},
{ immediate: true },
);
function parseMoneyDraft(value: string) {
const trimmed = String(value).replace(',', '.').trim();
if (!trimmed) {
return null;
}
const normalized = Number(trimmed);
if (!Number.isFinite(normalized) || normalized < 0) {
return null;
}
return Math.round((normalized + Number.EPSILON) * 100) / 100;
}
function draftUnitPrice(itemId: string, fallback?: number | null) {
return parseMoneyDraft(itemPriceDrafts[itemId] ?? '') ?? fallback ?? null;
}
function draftLineTotal(item: ManagerOrderItem['items'][number]) {
const unitPrice = draftUnitPrice(item.id, item.unitPrice);
if (unitPrice == null) {
return null;
}
return Math.round((unitPrice * item.quantity + Number.EPSILON) * 100) / 100;
}
const draftDeliveryTerms = computed(() => deliveryTermsDraft.value.trim() || currentOrder.value?.deliveryTerms || null);
const draftDeliveryFee = computed(() => parseMoneyDraft(deliveryFeeDraft.value) ?? currentOrder.value?.deliveryFee ?? null);
const canEditOffer = computed(() => (
currentOrder.value != null
&& ['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED'].includes(currentOrder.value.status)
));
const offerSignature = computed(() => {
if (!currentOrder.value) {
return '';
}
return JSON.stringify({
deliveryTerms: deliveryTermsDraft.value.trim(),
deliveryFee: parseMoneyDraft(deliveryFeeDraft.value),
itemPrices: currentOrder.value.items.map((item) => ({
itemId: item.id,
unitPrice: parseMoneyDraft(itemPriceDrafts[item.id] ?? ''),
})),
});
});
const publishedSignature = computed(() => {
if (!currentOrder.value) {
return '';
}
return JSON.stringify({
deliveryTerms: currentOrder.value.deliveryTerms ?? '',
deliveryFee: currentOrder.value.deliveryFee ?? null,
itemPrices: currentOrder.value.items.map((item) => ({
itemId: item.id,
unitPrice: item.unitPrice ?? null,
})),
});
});
const offerReady = computed(() => {
if (!currentOrder.value) {
return false;
}
const deliveryFee = parseMoneyDraft(deliveryFeeDraft.value);
if (deliveryFee == null) {
return false;
}
return currentOrder.value.items.every((item) => parseMoneyDraft(itemPriceDrafts[item.id] ?? '') != null);
});
const offerTotal = computed(() => {
if (!currentOrder.value || !offerReady.value) {
return null;
}
const productsTotal = currentOrder.value.items.reduce((sum, item) => (
sum + item.quantity * (parseMoneyDraft(itemPriceDrafts[item.id] ?? '') ?? 0)
), 0);
return Math.round((productsTotal + (parseMoneyDraft(deliveryFeeDraft.value) ?? 0) + Number.EPSILON) * 100) / 100;
});
async function refetchOrder() {
await orderQuery.refetch({ id: orderId.value });
}
async function saveOffer() {
if (!currentOrder.value || !offerReady.value || !canEditOffer.value) {
return;
}
autosavePending.value = true;
try {
await setOfferMutation.mutate({
input: {
orderId: currentOrder.value.id,
itemPrices: currentOrder.value.items.map((item) => ({
itemId: item.id,
unitPrice: parseMoneyDraft(itemPriceDrafts[item.id] ?? '') ?? 0,
})),
deliveryTerms: deliveryTermsDraft.value.trim(),
deliveryFee: parseMoneyDraft(deliveryFeeDraft.value) ?? 0,
},
});
await refetchOrder();
}
finally {
autosavePending.value = false;
}
}
async function startOrder() {
if (!currentOrder.value) {
return;
}
await startWorkMutation.mutate({ orderId: currentOrder.value.id });
await refetchOrder();
}
watch(
[offerReady, offerSignature, publishedSignature],
([ready, nextSignature, currentSignature]) => {
if (autosaveTimer) {
clearTimeout(autosaveTimer);
autosaveTimer = null;
}
if (!ready || !canEditOffer.value || !nextSignature || nextSignature === currentSignature || autosavePending.value) {
return;
}
autosaveTimer = setTimeout(() => {
void saveOffer();
}, 700);
},
);
</script>
<template>
<section class="space-y-6">
<NuxtLink to="/client-orders" class="text-sm font-semibold text-[#0d854a]"> Назад к заказам клиентов</NuxtLink>
<div v-if="orderQuery.loading.value" class="manager-empty-state">
Загружаем заказ...
</div>
<div v-else-if="!currentOrder" class="manager-empty-state">
Заказ не найден.
</div>
<template v-else>
<div class="manager-hero">
<p class="manager-eyebrow">Заказ</p>
<h1 class="manager-title">{{ currentOrder.code }}</h1>
</div>
<div class="space-y-4">
<OrdersOrderStatusTimelineCard
:status="currentOrder.status"
:created-at="currentOrder.createdAt"
audience="manager"
/>
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Состав заказа</h2>
<ul class="mt-4 space-y-3">
<li v-for="item in currentOrder.items" :key="item.id" class="manager-mini-card space-y-3">
<div class="space-y-1">
<p class="text-sm font-semibold text-[#123824]">{{ item.productName }} × {{ item.quantity }}</p>
<p class="text-sm text-[#5c7b69]">{{ orderLineStateText(draftUnitPrice(item.id, item.unitPrice), draftLineTotal(item)) }}</p>
</div>
<label class="form-control">
<span class="mb-2 text-xs font-semibold uppercase tracking-[0.22em] text-[#6a8a76]">Цена за единицу</span>
<input
v-model="itemPriceDrafts[item.id]"
type="number"
min="0"
step="0.01"
placeholder="Например, 125.50"
class="input input-bordered w-full rounded-2xl bg-white"
:disabled="!canEditOffer"
>
</label>
</li>
</ul>
</div>
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Доставка</h2>
<div class="mt-4 space-y-3">
<div class="manager-mini-card text-sm text-[#123824]">
Адрес: {{ currentOrder.deliveryAddress || 'Адрес пока не указан' }}
</div>
<div class="manager-mini-card space-y-3 text-sm text-[#123824]">
<p>{{ orderDeliveryStateText(draftDeliveryTerms) }}</p>
<label class="form-control">
<span class="mb-2 text-xs font-semibold uppercase tracking-[0.22em] text-[#6a8a76]">Комментарий по доставке</span>
<input
v-model="deliveryTermsDraft"
type="text"
placeholder="Например, доставка до склада 2-3 дня"
class="input input-bordered w-full rounded-2xl bg-white"
:disabled="!canEditOffer"
>
</label>
</div>
<div class="manager-mini-card space-y-3 text-sm text-[#123824]">
<p>{{ orderLogisticsStateText(draftDeliveryFee) }}</p>
<label class="form-control">
<span class="mb-2 text-xs font-semibold uppercase tracking-[0.22em] text-[#6a8a76]">Стоимость логистики</span>
<input
v-model="deliveryFeeDraft"
type="number"
min="0"
step="0.01"
placeholder="Например, 3000"
class="input input-bordered w-full rounded-2xl bg-white"
:disabled="!canEditOffer"
>
</label>
</div>
</div>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-[#d6ebde] pt-4">
<p class="text-sm text-[#5c7b69]">
{{
offerTotal == null
? 'Итог по заказу посчитается автоматически после заполнения всех цен.'
: autosavePending
? `Сохраняем и отправляем клиенту: ${formatPrice(offerTotal)}`
: `Предложение отправится клиенту автоматически: ${formatPrice(offerTotal)}`
}}
</p>
</div>
</div>
<div v-if="['WAITING_DOUBLE_CONFIRM', 'CONFIRMED'].includes(currentOrder.status)" class="surface-card rounded-3xl p-5">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-xl font-bold text-[#123824]">Следующий шаг</h2>
<p class="mt-1 text-sm text-[#5c7b69]">Когда всё согласовано, менеджер просто переводит заказ в работу.</p>
</div>
<button class="btn btn-accent rounded-full border-0 px-5" @click="startOrder">Пустить в работу</button>
</div>
</div>
</div>
</template>
</section>
</template>