304 lines
10 KiB
Vue
304 lines
10 KiB
Vue
<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,
|
|
} from '~/composables/useOrderDetailPresentation';
|
|
import { formatOrderCode } from '~/composables/useOrderCodePresentation';
|
|
|
|
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,
|
|
);
|
|
|
|
const currentOrderCode = computed(() => formatOrderCode(currentOrder.value?.code));
|
|
|
|
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;
|
|
}
|
|
|
|
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">{{ currentOrderCode }}</h1>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<OrdersOrderStatusTimelineCard
|
|
:status="currentOrder.status"
|
|
:created-at="currentOrder.createdAt"
|
|
audience="manager"
|
|
/>
|
|
|
|
<div>
|
|
<h2 class="text-xl font-bold text-[#123824]">Состав заказа</h2>
|
|
<OrdersOrderItemsTable
|
|
class="mt-4"
|
|
:items="currentOrder.items"
|
|
:calculation-payload="currentOrder.calculationPayload"
|
|
:editable="true"
|
|
:unit-price-drafts="itemPriceDrafts"
|
|
:disabled="!canEditOffer"
|
|
:framed="false"
|
|
@update:unit-price="({ itemId, value }) => { itemPriceDrafts[itemId] = value; }"
|
|
/>
|
|
</div>
|
|
|
|
<div class="surface-card rounded-3xl p-5">
|
|
<h2 class="text-xl font-bold text-[#123824]">Доставка</h2>
|
|
<div class="mt-4 space-y-4 text-sm text-[#123824]">
|
|
<div class="space-y-1">
|
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Адрес</p>
|
|
<p class="text-base font-semibold">{{ currentOrder.deliveryAddress || 'Адрес пока не указан' }}</p>
|
|
</div>
|
|
<div class="grid gap-4 border-t border-[#deebe4] pt-4 md:grid-cols-2">
|
|
<div class="space-y-3">
|
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Комментарий по доставке</p>
|
|
<p>{{ orderDeliveryStateText(draftDeliveryTerms) }}</p>
|
|
<label class="form-control">
|
|
<input
|
|
v-model="deliveryTermsDraft"
|
|
type="text"
|
|
placeholder="Например, доставка до склада 2-3 дня"
|
|
class="input input-bordered manager-field w-full rounded-2xl bg-white"
|
|
:disabled="!canEditOffer"
|
|
>
|
|
</label>
|
|
</div>
|
|
<div class="space-y-3">
|
|
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Стоимость логистики</p>
|
|
<p>{{ orderLogisticsStateText(draftDeliveryFee) }}</p>
|
|
<label class="form-control">
|
|
<input
|
|
v-model="deliveryFeeDraft"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
placeholder="Например, 3000"
|
|
class="input input-bordered manager-field w-full rounded-2xl bg-white"
|
|
:disabled="!canEditOffer"
|
|
>
|
|
</label>
|
|
</div>
|
|
</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>
|