Inline manager order editing

This commit is contained in:
Ruslan Bakiev
2026-04-06 11:58:13 +07:00
parent f506c8cf86
commit dcb595263d
2 changed files with 285 additions and 29 deletions

View File

@@ -20,12 +20,16 @@ const props = defineProps<{
mode?: 'readonly' | 'manager-pricing' | 'cart';
editable?: boolean;
unitPriceDrafts?: Record<string, string>;
editablePriceItemIds?: string[];
disabled?: boolean;
framed?: boolean;
pricePlaceholder?: string;
}>();
const emit = defineEmits<{
'update:unit-price': [payload: { itemId: string; value: string }];
'activate:unit-price': [itemId: string];
'finish:unit-price': [itemId: string];
increment: [itemId: string];
decrement: [itemId: string];
remove: [itemId: string];
@@ -86,6 +90,18 @@ function updateUnitPrice(itemId: string, event: Event) {
});
}
function activateUnitPrice(itemId: string) {
if (!isPricingMode.value || props.disabled) {
return;
}
emit('activate:unit-price', itemId);
}
function finishUnitPrice(itemId: string) {
emit('finish:unit-price', itemId);
}
function incrementItem(itemId: string) {
emit('increment', itemId);
}
@@ -110,6 +126,8 @@ const mode = computed(() => props.mode ?? (props.editable ? 'manager-pricing' :
const isPricingMode = computed(() => mode.value === 'manager-pricing');
const isCartMode = computed(() => mode.value === 'cart');
const isFramed = computed(() => props.framed ?? true);
const editablePriceItemIds = computed(() => new Set(props.editablePriceItemIds ?? []));
const displayPricePlaceholder = computed(() => props.pricePlaceholder ?? '—');
function mapParameterEntries(source: Record<string, unknown> | null | undefined): ItemParameter[] {
if (!source || typeof source !== 'object') {
@@ -187,18 +205,26 @@ function itemParameters(item: OrderItemView) {
<div class="mt-4 md:mt-0">
<p class="mb-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#6a8a76] md:hidden">Цена</p>
<input
v-if="isPricingMode"
v-if="isPricingMode && editablePriceItemIds.has(item.id)"
:value="unitPriceDrafts?.[item.id] ?? ''"
type="number"
min="0"
step="0.01"
placeholder="Например, 125.50"
:data-unit-price-input="item.id"
class="w-full rounded-2xl bg-[#f3f5f4] px-4 py-3 text-sm text-[#123824] outline-none transition focus:shadow-[0_0_0_3px_rgba(19,153,87,0.12)] disabled:cursor-not-allowed disabled:bg-[#f4f8f5]"
:disabled="disabled"
@input="updateUnitPrice(item.id, $event)"
@blur="finishUnitPrice(item.id)"
@keydown.enter="finishUnitPrice(item.id)"
>
<p v-else class="text-sm font-semibold text-[#123824]">
{{ formatPrice(currentUnitPrice(item)) || '—' }}
<p
v-else
class="text-sm font-semibold text-[#123824]"
:class="isPricingMode && !disabled ? 'cursor-pointer transition hover:text-[#0d854a]' : ''"
@dblclick="activateUnitPrice(item.id)"
>
{{ formatPrice(currentUnitPrice(item)) || displayPricePlaceholder }}
</p>
</div>

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
CompleteOrderDocument,
Decision,
ManagerFinalizeOrderDocument,
ManagerSetOrderOfferDocument,
OrderDetailDocument,
StartOrderWorkDocument,
@@ -21,18 +24,31 @@ 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>(() =>
@@ -44,6 +60,12 @@ 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];
}
@@ -84,6 +106,58 @@ 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) {
@@ -144,6 +218,11 @@ 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;
@@ -169,13 +248,98 @@ async function saveOffer() {
}
}
async function startOrder() {
if (!currentOrder.value) {
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(
@@ -216,11 +380,57 @@ watch(
</div>
<div class="space-y-4">
<OrdersOrderStatusTimelineCard
:status="currentOrder.status"
:created-at="currentOrder.createdAt"
audience="manager"
/>
<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>
@@ -228,11 +438,15 @@ watch(
class="mt-4"
:items="currentOrder.items"
:calculation-payload="currentOrder.calculationPayload"
:editable="true"
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>
@@ -246,31 +460,53 @@ watch(
<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>
<p>{{ orderDeliveryStateText(draftDeliveryTerms) }}</p>
<label class="form-control">
<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>
<p>{{ orderLogisticsStateText(draftDeliveryFee) }}</p>
<label class="form-control">
<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>
@@ -279,22 +515,16 @@ watch(
<p class="text-sm text-[#5c7b69]">
{{
offerTotal == null
? 'Итог по заказу посчитается автоматически после заполнения всех цен.'
? 'Итог по заказу появится после заполнения всех цен.'
: autosavePending
? `Сохраняем и отправляем клиенту: ${formatPrice(offerTotal)}`
: `Предложение отправится клиенту автоматически: ${formatPrice(offerTotal)}`
? `Сохраняем: ${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>
<p v-if="canEditOffer" class="text-xs font-semibold uppercase tracking-[0.14em] text-[#6a8a76]">
Двойной клик по цене или условиям, чтобы изменить.
</p>
</div>
</div>
</div>