534 lines
18 KiB
Vue
534 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||
import {
|
||
CompleteOrderDocument,
|
||
Decision,
|
||
ManagerFinalizeOrderDocument,
|
||
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']>;
|
||
type StatusAction = 'APPROVE' | 'REJECT' | 'START' | 'COMPLETE';
|
||
type StatusActionOption = {
|
||
value: StatusAction;
|
||
label: string;
|
||
};
|
||
|
||
const orderQuery = useQuery(OrderDetailDocument, () => ({
|
||
id: orderId.value,
|
||
}));
|
||
|
||
const completeOrderMutation = useMutation(CompleteOrderDocument);
|
||
const finalizeOrderMutation = useMutation(ManagerFinalizeOrderDocument);
|
||
const startWorkMutation = useMutation(StartOrderWorkDocument);
|
||
const setOfferMutation = useMutation(ManagerSetOrderOfferDocument);
|
||
|
||
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 statusActionDraft = ref<StatusAction | ''>('');
|
||
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;
|
||
statusActionDraft.value = '';
|
||
|
||
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 activePriceItemIds = computed(() => (
|
||
editingPriceItemId.value ? [editingPriceItemId.value] : []
|
||
));
|
||
const managerStatusSummary = computed(() => {
|
||
if (!currentOrder.value) {
|
||
return '';
|
||
}
|
||
|
||
if (['NEW', 'MANAGER_PROCESSING'].includes(currentOrder.value.status)) {
|
||
return 'Заполните цены по позициям и логистике. После этого заказ можно перевести в предложение.';
|
||
}
|
||
|
||
if (['WAITING_DOUBLE_CONFIRM', 'CONFIRMED'].includes(currentOrder.value.status)) {
|
||
return 'Предложение уже собрано. Следующий шаг для менеджера: перевести заказ в работу или остановить его.';
|
||
}
|
||
|
||
if (currentOrder.value.status === 'IN_PROGRESS') {
|
||
return 'Заказ уже в работе. После исполнения его можно завершить.';
|
||
}
|
||
|
||
if (currentOrder.value.status === 'COMPLETED') {
|
||
return 'Заказ завершён и больше не редактируется.';
|
||
}
|
||
|
||
return 'Заказ остановлен. Если нужно продолжить работу, сначала поправьте условия и затем снова переведите его в предложение.';
|
||
});
|
||
const statusActions = computed<StatusActionOption[]>(() => {
|
||
if (!currentOrder.value) {
|
||
return [];
|
||
}
|
||
|
||
const options: StatusActionOption[] = [];
|
||
|
||
if (offerReady.value && ['NEW', 'MANAGER_PROCESSING', 'MANAGER_BLOCKED', 'CLIENT_REJECTED', 'MANAGER_REJECTED'].includes(currentOrder.value.status)) {
|
||
options.push({ value: 'APPROVE', label: 'Предложение' });
|
||
}
|
||
|
||
if (['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'MANAGER_BLOCKED'].includes(currentOrder.value.status)) {
|
||
options.push({ value: 'REJECT', label: 'Отклонен' });
|
||
}
|
||
|
||
if (['WAITING_DOUBLE_CONFIRM', 'CONFIRMED'].includes(currentOrder.value.status)) {
|
||
options.push({ value: 'START', label: 'В работе' });
|
||
}
|
||
|
||
if (currentOrder.value.status === 'IN_PROGRESS') {
|
||
options.push({ value: 'COMPLETE', label: 'Завершен' });
|
||
}
|
||
|
||
return options;
|
||
});
|
||
const canEditStatus = computed(() => statusActions.value.length > 0);
|
||
|
||
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 || !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 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 (!canEditStatus.value || statusMutationPending.value) {
|
||
return;
|
||
}
|
||
|
||
editingStatus.value = true;
|
||
statusActionDraft.value = '';
|
||
await focusElement('[data-manager-status-select]');
|
||
}
|
||
|
||
function closeStatusEditor() {
|
||
if (!statusMutationPending.value) {
|
||
editingStatus.value = false;
|
||
statusActionDraft.value = '';
|
||
}
|
||
}
|
||
|
||
async function applyStatusAction() {
|
||
if (!currentOrder.value || !statusActionDraft.value) {
|
||
closeStatusEditor();
|
||
return;
|
||
}
|
||
|
||
statusMutationPending.value = true;
|
||
|
||
try {
|
||
if (statusActionDraft.value === 'APPROVE') {
|
||
await finalizeOrderMutation.mutate({
|
||
orderId: currentOrder.value.id,
|
||
decision: Decision.Approve,
|
||
});
|
||
}
|
||
else if (statusActionDraft.value === 'REJECT') {
|
||
await finalizeOrderMutation.mutate({
|
||
orderId: currentOrder.value.id,
|
||
decision: Decision.Reject,
|
||
});
|
||
}
|
||
else if (statusActionDraft.value === 'START') {
|
||
await startWorkMutation.mutate({ orderId: currentOrder.value.id });
|
||
}
|
||
else if (statusActionDraft.value === 'COMPLETE') {
|
||
await completeOrderMutation.mutate({ orderId: currentOrder.value.id });
|
||
}
|
||
|
||
await refetchOrder();
|
||
}
|
||
finally {
|
||
statusMutationPending.value = false;
|
||
closeStatusEditor();
|
||
}
|
||
}
|
||
|
||
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">
|
||
<div class="surface-card rounded-3xl p-5">
|
||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||
<div class="space-y-2">
|
||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Статус заказа</p>
|
||
|
||
<label v-if="editingStatus && canEditStatus" class="block">
|
||
<select
|
||
v-model="statusActionDraft"
|
||
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 applyStatusAction()"
|
||
@blur="closeStatusEditor"
|
||
>
|
||
<option value="" disabled>Выберите статус</option>
|
||
<option
|
||
v-for="option in statusActions"
|
||
:key="option.value"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
|
||
<button
|
||
v-else
|
||
type="button"
|
||
class="rounded-2xl px-0 py-0 text-left"
|
||
:class="canEditStatus ? 'cursor-pointer' : 'cursor-default'"
|
||
@dblclick="void openStatusEditor()"
|
||
>
|
||
<OrdersOrderStatusBadge :status="currentOrder.status" />
|
||
</button>
|
||
</div>
|
||
|
||
<p class="text-sm text-[#5c7b69]">
|
||
{{
|
||
statusMutationPending
|
||
? 'Обновляем статус...'
|
||
: canEditStatus
|
||
? 'Двойной клик по статусу, чтобы изменить.'
|
||
: 'Статус уже финальный.'
|
||
}}
|
||
</p>
|
||
</div>
|
||
|
||
<p class="mt-4 max-w-2xl text-sm leading-6 text-[#355947]">
|
||
{{ managerStatusSummary }}
|
||
</p>
|
||
</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)"
|
||
/>
|
||
</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 md:grid-cols-2">
|
||
<div class="space-y-3">
|
||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Комментарий по доставке</p>
|
||
<label v-if="editingDeliveryTerms" class="form-control">
|
||
<input
|
||
v-model="deliveryTermsDraft"
|
||
data-delivery-terms-input
|
||
type="text"
|
||
placeholder="Например, доставка до склада 2-3 дня"
|
||
class="input input-bordered manager-field w-full rounded-2xl bg-white"
|
||
:disabled="!canEditOffer"
|
||
@blur="closeDeliveryTermsEditor"
|
||
@keydown.enter="closeDeliveryTermsEditor"
|
||
>
|
||
</label>
|
||
<button
|
||
v-else
|
||
type="button"
|
||
class="w-full rounded-2xl px-0 py-0 text-left text-sm text-[#123824]"
|
||
:class="canEditOffer ? 'cursor-pointer transition hover:text-[#0d854a]' : 'cursor-default'"
|
||
@dblclick="void openDeliveryTermsEditor()"
|
||
>
|
||
{{ orderDeliveryStateText(draftDeliveryTerms) }}
|
||
</button>
|
||
</div>
|
||
<div class="space-y-3">
|
||
<p class="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">Стоимость логистики</p>
|
||
<label v-if="editingDeliveryFee" class="form-control">
|
||
<input
|
||
v-model="deliveryFeeDraft"
|
||
data-delivery-fee-input
|
||
type="number"
|
||
min="0"
|
||
step="0.01"
|
||
placeholder="Например, 3000"
|
||
class="input input-bordered manager-field w-full rounded-2xl bg-white"
|
||
:disabled="!canEditOffer"
|
||
@blur="closeDeliveryFeeEditor"
|
||
@keydown.enter="closeDeliveryFeeEditor"
|
||
>
|
||
</label>
|
||
<button
|
||
v-else
|
||
type="button"
|
||
class="w-full rounded-2xl px-0 py-0 text-left text-sm text-[#123824]"
|
||
:class="canEditOffer ? 'cursor-pointer transition hover:text-[#0d854a]' : 'cursor-default'"
|
||
@dblclick="void openDeliveryFeeEditor()"
|
||
>
|
||
{{ orderLogisticsStateText(draftDeliveryFee) }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||
<p class="text-sm text-[#5c7b69]">
|
||
{{
|
||
offerTotal == null
|
||
? 'Итог по заказу появится после заполнения всех цен.'
|
||
: autosavePending
|
||
? `Сохраняем: ${formatPrice(offerTotal)}`
|
||
: `Текущий итог: ${formatPrice(offerTotal)}`
|
||
}}
|
||
</p>
|
||
|
||
<p v-if="canEditOffer" class="text-xs font-semibold uppercase tracking-[0.14em] text-[#6a8a76]">
|
||
Двойной клик по цене или условиям, чтобы изменить.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</template>
|