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

420 lines
12 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,
ManagerSetOrderStatusDocument,
OrderStatus,
OrderDetailDocument,
type OrderDetailQuery,
} from '~/composables/graphql/generated';
import {
formatPrice,
} from '~/composables/useOrderDetailPresentation';
import { formatOrderCode } from '~/composables/useOrderCodePresentation';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/orders/:id',
alias: ['/client-orders/:id'],
});
const route = useRoute();
const orderId = computed(() => String(route.params.id || ''));
type ManagerOrderItem = NonNullable<OrderDetailQuery['order']>;
type StatusOption = {
value: OrderStatus;
label: string;
};
const orderQuery = useQuery(OrderDetailDocument, () => ({
id: orderId.value,
}));
const setOfferMutation = useMutation(ManagerSetOrderOfferDocument);
const setOrderStatusMutation = useMutation(ManagerSetOrderStatusDocument);
const itemPriceDrafts = reactive<Record<string, string>>({});
const deliveryTermsDraft = ref('');
const deliveryFeeDraft = ref('');
const editingPriceItemId = ref<string | null>(null);
const editingDeliveryTerms = ref(false);
const editingDeliveryFee = ref(false);
const editingStatus = ref(false);
const statusDraft = ref<OrderStatus | ''>('');
const autosavePending = ref(false);
const statusMutationPending = 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) => {
editingPriceItemId.value = null;
editingDeliveryTerms.value = false;
editingDeliveryFee.value = false;
editingStatus.value = false;
statusDraft.value = '';
for (const key of Object.keys(itemPriceDrafts)) {
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));
const canEditOffer = computed(() => currentOrder.value != null);
const activePriceItemIds = computed(() => (
editingPriceItemId.value ? [editingPriceItemId.value] : []
));
const statusOptions: StatusOption[] = [
{ value: OrderStatus.New, label: 'Заявка' },
{ value: OrderStatus.ManagerProcessing, label: 'В обработке' },
{ value: OrderStatus.WaitingDoubleConfirm, label: 'Предложение' },
{ value: OrderStatus.Confirmed, label: 'Подтвержден' },
{ value: OrderStatus.InProgress, label: 'В работе' },
{ value: OrderStatus.Completed, label: 'Завершен' },
{ value: OrderStatus.ManagerBlocked, label: 'Пауза' },
{ value: OrderStatus.ManagerRejected, label: 'Отклонен менеджером' },
{ value: OrderStatus.ClientRejected, label: 'Отклонен клиентом' },
];
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 focusElement(selector: string) {
await nextTick();
document.querySelector<HTMLInputElement | HTMLSelectElement>(selector)?.focus();
}
async function saveOffer() {
if (!currentOrder.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] ?? ''),
})),
deliveryTerms: deliveryTermsDraft.value.trim(),
deliveryFee: parseMoneyDraft(deliveryFeeDraft.value),
},
});
await refetchOrder();
}
finally {
autosavePending.value = false;
}
}
async function openPriceEditor(itemId: string) {
if (!canEditOffer.value) {
return;
}
editingPriceItemId.value = itemId;
await focusElement(`[data-unit-price-input="${itemId}"]`);
}
function closePriceEditor(itemId: string) {
if (editingPriceItemId.value === itemId) {
editingPriceItemId.value = null;
}
}
async function openDeliveryTermsEditor() {
if (!canEditOffer.value) {
return;
}
editingDeliveryTerms.value = true;
await focusElement('[data-delivery-terms-input]');
}
function closeDeliveryTermsEditor() {
editingDeliveryTerms.value = false;
}
async function openDeliveryFeeEditor() {
if (!canEditOffer.value) {
return;
}
editingDeliveryFee.value = true;
await focusElement('[data-delivery-fee-input]');
}
function closeDeliveryFeeEditor() {
editingDeliveryFee.value = false;
}
async function openStatusEditor() {
if (statusMutationPending.value) {
return;
}
editingStatus.value = true;
statusDraft.value = currentOrder.value?.status ?? '';
await focusElement('[data-manager-status-select]');
}
function closeStatusEditor() {
if (!statusMutationPending.value) {
editingStatus.value = false;
statusDraft.value = '';
}
}
async function applyStatusChange() {
if (!currentOrder.value || !statusDraft.value) {
closeStatusEditor();
return;
}
if (statusDraft.value === currentOrder.value.status) {
closeStatusEditor();
return;
}
statusMutationPending.value = true;
try {
await setOrderStatusMutation.mutate({
orderId: currentOrder.value.id,
status: statusDraft.value,
});
await refetchOrder();
}
finally {
statusMutationPending.value = false;
closeStatusEditor();
}
}
watch(
[offerSignature, publishedSignature],
([nextSignature, currentSignature]) => {
if (autosaveTimer) {
clearTimeout(autosaveTimer);
autosaveTimer = null;
}
if (!canEditOffer.value || !nextSignature || nextSignature === currentSignature || autosavePending.value) {
return;
}
autosaveTimer = setTimeout(() => {
void saveOffer();
}, 700);
},
);
</script>
<template>
<section class="space-y-6">
<div v-if="orderQuery.loading.value" class="manager-empty-state">
Загружаем заказ...
</div>
<div v-else-if="!currentOrder" class="manager-empty-state">
Заказ не найден.
</div>
<template v-else>
<UiBackHeader
to="/admin/orders"
back-label="Назад к заказам клиентов"
:title="`Заказ ${currentOrderCode}`"
>
<template #actions>
<NuxtLink
:to="`/admin/orders/clients/${currentOrder.customerId}`"
class="btn rounded-full border border-[#d7e6dc] bg-white px-5 text-[#123824] hover:border-[#bed6c7] hover:bg-[#f6fbf8]"
>
Открыть клиента
</NuxtLink>
</template>
</UiBackHeader>
<div class="space-y-4">
<div class="surface-card rounded-3xl p-5">
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Статус заказа</p>
<label v-if="editingStatus" class="mt-3 block">
<select
v-model="statusDraft"
data-manager-status-select
class="w-full min-w-[220px] rounded-2xl bg-[#f3f5f4] px-4 py-3 text-sm font-semibold text-[#123824] outline-none transition focus:shadow-[0_0_0_3px_rgba(19,153,87,0.12)]"
:disabled="statusMutationPending"
@change="void applyStatusChange()"
@blur="closeStatusEditor"
>
<option
v-for="option in statusOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<button
v-else
type="button"
class="mt-3 rounded-2xl px-0 py-0 text-left"
@dblclick="void openStatusEditor()"
>
<OrdersOrderStatusBadge :status="currentOrder.status" />
</button>
</div>
<div>
<h2 class="text-xl font-bold text-[#123824]">Состав заказа</h2>
<OrdersOrderItemsTable
class="mt-4"
:items="currentOrder.items"
:calculation-payload="currentOrder.calculationPayload"
mode="manager-pricing"
:unit-price-drafts="itemPriceDrafts"
:editable-price-item-ids="activePriceItemIds"
:disabled="!canEditOffer"
:framed="false"
price-placeholder="Рассчитывается"
@update:unit-price="({ itemId, value }) => { itemPriceDrafts[itemId] = value; }"
@activate:unit-price="void openPriceEditor($event)"
@finish:unit-price="closePriceEditor($event)"
/>
<OrdersOrderDeliveryLine
class="mt-3"
mode="manager"
:can-edit="canEditOffer"
:delivery-address="currentOrder.deliveryAddress"
:delivery-terms="draftDeliveryTerms"
:delivery-fee="draftDeliveryFee"
:editing-delivery-terms="editingDeliveryTerms"
:editing-delivery-fee="editingDeliveryFee"
:delivery-terms-draft="deliveryTermsDraft"
:delivery-fee-draft="deliveryFeeDraft"
@update:delivery-terms="deliveryTermsDraft = $event"
@update:delivery-fee="deliveryFeeDraft = $event"
@activate:delivery-terms="void openDeliveryTermsEditor()"
@finish:delivery-terms="closeDeliveryTermsEditor()"
@activate:delivery-fee="void openDeliveryFeeEditor()"
@finish:delivery-fee="closeDeliveryFeeEditor()"
/>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-[#5c7b69]">
{{
offerTotal == null
? currentOrder.totalPrice == null
? 'Итог пока не задан.'
: `Текущий итог: ${formatPrice(currentOrder.totalPrice)}`
: autosavePending
? `Сохраняем: ${formatPrice(offerTotal)}`
: `Текущий итог: ${formatPrice(offerTotal)}`
}}
</p>
</div>
</div>
</div>
</template>
</section>
</template>