314 lines
10 KiB
Vue
314 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||
import {
|
||
BlockOrderDocument,
|
||
CompleteOrderDocument,
|
||
ManagerFinalizeOrderDocument,
|
||
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 finalizeMutation = useMutation(ManagerFinalizeOrderDocument);
|
||
const blockMutation = useMutation(BlockOrderDocument);
|
||
const startWorkMutation = useMutation(StartOrderWorkDocument);
|
||
const completeWorkMutation = useMutation(CompleteOrderDocument);
|
||
const setOfferMutation = useMutation(ManagerSetOrderOfferDocument);
|
||
|
||
const itemPriceDrafts = reactive<Record<string, string>>({});
|
||
const deliveryTermsDraft = ref('');
|
||
const deliveryFeeDraft = ref('');
|
||
|
||
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 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) {
|
||
return;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
async function approveOrder() {
|
||
if (!currentOrder.value) {
|
||
return;
|
||
}
|
||
|
||
await finalizeMutation.mutate({ orderId: currentOrder.value.id, decision: 'APPROVE' });
|
||
await refetchOrder();
|
||
}
|
||
|
||
async function rejectOrder() {
|
||
if (!currentOrder.value) {
|
||
return;
|
||
}
|
||
|
||
await finalizeMutation.mutate({ orderId: currentOrder.value.id, decision: 'REJECT' });
|
||
await refetchOrder();
|
||
}
|
||
|
||
async function blockOrder() {
|
||
if (!currentOrder.value) {
|
||
return;
|
||
}
|
||
|
||
await blockMutation.mutate({
|
||
input: {
|
||
orderId: currentOrder.value.id,
|
||
reason: 'Нужно уточнение параметров заказа.',
|
||
},
|
||
});
|
||
await refetchOrder();
|
||
}
|
||
|
||
async function startOrder() {
|
||
if (!currentOrder.value) {
|
||
return;
|
||
}
|
||
|
||
await startWorkMutation.mutate({ orderId: currentOrder.value.id });
|
||
await refetchOrder();
|
||
}
|
||
|
||
async function completeOrder() {
|
||
if (!currentOrder.value) {
|
||
return;
|
||
}
|
||
|
||
await completeWorkMutation.mutate({ orderId: currentOrder.value.id });
|
||
await refetchOrder();
|
||
}
|
||
</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"
|
||
/>
|
||
|
||
<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"
|
||
>
|
||
</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"
|
||
>
|
||
</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"
|
||
>
|
||
</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
|
||
? 'Итог по заказу посчитается автоматически после заполнения всех цен.'
|
||
: `Предварительный итог: ${formatPrice(offerTotal)}`
|
||
}}
|
||
</p>
|
||
<button
|
||
class="btn btn-primary rounded-full border-0 px-5"
|
||
:disabled="!offerReady || setOfferMutation.loading.value"
|
||
@click="saveOffer"
|
||
>
|
||
Сохранить условия
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="surface-card rounded-3xl p-5">
|
||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||
<h2 class="text-xl font-bold text-[#123824]">Действия менеджера</h2>
|
||
<div class="flex flex-wrap gap-2">
|
||
<button class="btn btn-success btn-sm border-0" @click="approveOrder">Подтвердить</button>
|
||
<button class="btn btn-error btn-sm border-0" @click="rejectOrder">Отклонить</button>
|
||
<button class="btn btn-warning btn-sm border-0" @click="blockOrder">Заблокировать</button>
|
||
<button class="btn btn-accent btn-sm border-0" :disabled="currentOrder.status !== 'CONFIRMED'" @click="startOrder">В работу</button>
|
||
<button class="btn btn-neutral btn-sm border-0" :disabled="currentOrder.status !== 'IN_PROGRESS'" @click="completeOrder">Завершить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</template>
|