Simplify manager order editing UI

This commit is contained in:
Ruslan Bakiev
2026-04-06 12:22:33 +07:00
parent a2ab98823d
commit 5396354962
9 changed files with 126 additions and 368 deletions

View File

@@ -1,12 +1,10 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
CompleteOrderDocument,
Decision,
ManagerFinalizeOrderDocument,
ManagerSetOrderOfferDocument,
ManagerSetOrderStatusDocument,
OrderStatus,
OrderDetailDocument,
StartOrderWorkDocument,
type OrderDetailQuery,
} from '~/composables/graphql/generated';
import {
@@ -24,9 +22,8 @@ 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;
type StatusOption = {
value: OrderStatus;
label: string;
};
@@ -34,10 +31,8 @@ const orderQuery = useQuery(OrderDetailDocument, () => ({
id: orderId.value,
}));
const completeOrderMutation = useMutation(CompleteOrderDocument);
const finalizeOrderMutation = useMutation(ManagerFinalizeOrderDocument);
const startWorkMutation = useMutation(StartOrderWorkDocument);
const setOfferMutation = useMutation(ManagerSetOrderOfferDocument);
const setOrderStatusMutation = useMutation(ManagerSetOrderStatusDocument);
const itemPriceDrafts = reactive<Record<string, string>>({});
const deliveryTermsDraft = ref('');
@@ -46,7 +41,7 @@ const editingPriceItemId = ref<string | null>(null);
const editingDeliveryTerms = ref(false);
const editingDeliveryFee = ref(false);
const editingStatus = ref(false);
const statusActionDraft = ref<StatusAction | ''>('');
const statusDraft = ref<OrderStatus | ''>('');
const autosavePending = ref(false);
const statusMutationPending = ref(false);
let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
@@ -64,7 +59,7 @@ watch(
editingDeliveryTerms.value = false;
editingDeliveryFee.value = false;
editingStatus.value = false;
statusActionDraft.value = '';
statusDraft.value = '';
for (const key of Object.keys(itemPriceDrafts)) {
delete itemPriceDrafts[key];
@@ -101,63 +96,22 @@ function parseMoneyDraft(value: string) {
}
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 draftDeliveryFee = computed(() => parseMoneyDraft(deliveryFeeDraft.value));
const canEditOffer = computed(() => currentOrder.value != null);
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 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) {
@@ -224,7 +178,7 @@ async function focusElement(selector: string) {
}
async function saveOffer() {
if (!currentOrder.value || !offerReady.value || !canEditOffer.value) {
if (!currentOrder.value || !canEditOffer.value) {
return;
}
@@ -235,10 +189,10 @@ async function saveOffer() {
orderId: currentOrder.value.id,
itemPrices: currentOrder.value.items.map((item) => ({
itemId: item.id,
unitPrice: parseMoneyDraft(itemPriceDrafts[item.id] ?? '') ?? 0,
unitPrice: parseMoneyDraft(itemPriceDrafts[item.id] ?? ''),
})),
deliveryTerms: deliveryTermsDraft.value.trim(),
deliveryFee: parseMoneyDraft(deliveryFeeDraft.value) ?? 0,
deliveryFee: parseMoneyDraft(deliveryFeeDraft.value),
},
});
await refetchOrder();
@@ -290,24 +244,29 @@ function closeDeliveryFeeEditor() {
}
async function openStatusEditor() {
if (!canEditStatus.value || statusMutationPending.value) {
if (statusMutationPending.value) {
return;
}
editingStatus.value = true;
statusActionDraft.value = '';
statusDraft.value = currentOrder.value?.status ?? '';
await focusElement('[data-manager-status-select]');
}
function closeStatusEditor() {
if (!statusMutationPending.value) {
editingStatus.value = false;
statusActionDraft.value = '';
statusDraft.value = '';
}
}
async function applyStatusAction() {
if (!currentOrder.value || !statusActionDraft.value) {
async function applyStatusChange() {
if (!currentOrder.value || !statusDraft.value) {
closeStatusEditor();
return;
}
if (statusDraft.value === currentOrder.value.status) {
closeStatusEditor();
return;
}
@@ -315,25 +274,10 @@ async function applyStatusAction() {
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 setOrderStatusMutation.mutate({
orderId: currentOrder.value.id,
status: statusDraft.value,
});
await refetchOrder();
}
finally {
@@ -343,14 +287,14 @@ async function applyStatusAction() {
}
watch(
[offerReady, offerSignature, publishedSignature],
([ready, nextSignature, currentSignature]) => {
[offerSignature, publishedSignature],
([nextSignature, currentSignature]) => {
if (autosaveTimer) {
clearTimeout(autosaveTimer);
autosaveTimer = null;
}
if (!ready || !canEditOffer.value || !nextSignature || nextSignature === currentSignature || autosavePending.value) {
if (!canEditOffer.value || !nextSignature || nextSignature === currentSignature || autosavePending.value) {
return;
}
@@ -381,55 +325,35 @@ watch(
<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>
<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()"
<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"
>
<OrdersOrderStatusBadge :status="currentOrder.status" />
</button>
</div>
{{ option.label }}
</option>
</select>
</label>
<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>
<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>
@@ -515,16 +439,14 @@ watch(
<p class="text-sm text-[#5c7b69]">
{{
offerTotal == null
? 'Итог по заказу появится после заполнения всех цен.'
? currentOrder.totalPrice == null
? 'Итог пока не задан.'
: `Текущий итог: ${formatPrice(currentOrder.totalPrice)}`
: 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>