290 lines
10 KiB
Vue
290 lines
10 KiB
Vue
<script setup lang="ts">
|
||
import { useMutation, useQuery } from '@vue/apollo-composable';
|
||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
||
import {
|
||
BlockOrderDocument,
|
||
CompleteOrderDocument,
|
||
ManagerFinalizeOrderDocument,
|
||
ManagerOrdersDocument,
|
||
ManagerSetOrderOfferDocument,
|
||
StartOrderWorkDocument,
|
||
type ManagerOrdersQuery,
|
||
} from '~/composables/graphql/generated';
|
||
|
||
definePageMeta({
|
||
middleware: ['manager-only'],
|
||
});
|
||
|
||
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
|
||
|
||
const ACTIVE_STATUSES = new Set(['NEW', 'MANAGER_PROCESSING', 'WAITING_DOUBLE_CONFIRM', 'CONFIRMED', 'IN_PROGRESS']);
|
||
const CLOSED_STATUSES = new Set(['COMPLETED', 'CLIENT_REJECTED', 'MANAGER_REJECTED', 'MANAGER_BLOCKED']);
|
||
|
||
const ordersQuery = useQuery(ManagerOrdersDocument, { status: null });
|
||
|
||
const setOfferMutation = useMutation(ManagerSetOrderOfferDocument);
|
||
const finalizeMutation = useMutation(ManagerFinalizeOrderDocument);
|
||
const blockMutation = useMutation(BlockOrderDocument);
|
||
const startWorkMutation = useMutation(StartOrderWorkDocument);
|
||
const completeWorkMutation = useMutation(CompleteOrderDocument);
|
||
|
||
const search = ref('');
|
||
const statusFilter = ref<'ALL' | 'WAITING' | 'ACTIVE' | 'CLOSED'>('ALL');
|
||
|
||
const offerForm = reactive<Record<string, { deliveryTerms: string; deliveryFee: number; totalPrice: number }>>({});
|
||
|
||
watchEffect(() => {
|
||
for (const order of ordersQuery.result.value?.managerOrders ?? []) {
|
||
if (!offerForm[order.id]) {
|
||
offerForm[order.id] = {
|
||
deliveryTerms: order.deliveryTerms || 'Доставка 3-5 дней',
|
||
deliveryFee: Number(order.deliveryFee ?? 1000),
|
||
totalPrice: Number(order.totalPrice ?? 12500),
|
||
};
|
||
}
|
||
}
|
||
});
|
||
|
||
function matchesFilter(order: ManagerOrderItem) {
|
||
if (statusFilter.value === 'ALL') {
|
||
return true;
|
||
}
|
||
if (statusFilter.value === 'WAITING') {
|
||
return order.status === 'WAITING_DOUBLE_CONFIRM';
|
||
}
|
||
if (statusFilter.value === 'ACTIVE') {
|
||
return ACTIVE_STATUSES.has(order.status);
|
||
}
|
||
return CLOSED_STATUSES.has(order.status);
|
||
}
|
||
|
||
function getOffer(orderId: string) {
|
||
if (!offerForm[orderId]) {
|
||
offerForm[orderId] = {
|
||
deliveryTerms: 'Доставка 3-5 дней',
|
||
deliveryFee: 1000,
|
||
totalPrice: 12500,
|
||
};
|
||
}
|
||
|
||
return offerForm[orderId];
|
||
}
|
||
|
||
function updateOfferField(orderId: string, field: 'deliveryTerms' | 'deliveryFee' | 'totalPrice', value: string) {
|
||
const offer = getOffer(orderId);
|
||
if (field === 'deliveryTerms') {
|
||
offer.deliveryTerms = value;
|
||
return;
|
||
}
|
||
|
||
const numericValue = Number(value);
|
||
offer[field] = Number.isFinite(numericValue) ? numericValue : 0;
|
||
}
|
||
|
||
const filteredOrders = computed(() => {
|
||
const orders = ordersQuery.result.value?.managerOrders ?? [];
|
||
const query = search.value.trim().toLowerCase();
|
||
|
||
return orders.filter((order) => {
|
||
const haystack = [
|
||
order.code,
|
||
order.customerId || '',
|
||
order.deliveryAddress || '',
|
||
...order.items.map((item) => item.productName),
|
||
]
|
||
.join(' ')
|
||
.toLowerCase();
|
||
|
||
const matchSearch = !query || haystack.includes(query);
|
||
return matchSearch && matchesFilter(order);
|
||
});
|
||
});
|
||
|
||
async function refetchOrders() {
|
||
await ordersQuery.refetch({
|
||
status: null,
|
||
});
|
||
}
|
||
|
||
async function publishOffer(orderId: string) {
|
||
const form = getOffer(orderId);
|
||
|
||
await setOfferMutation.mutate({
|
||
input: {
|
||
orderId,
|
||
deliveryTerms: form.deliveryTerms,
|
||
deliveryFee: Number(form.deliveryFee),
|
||
totalPrice: Number(form.totalPrice),
|
||
},
|
||
});
|
||
|
||
await refetchOrders();
|
||
}
|
||
|
||
async function approve(orderId: string) {
|
||
await finalizeMutation.mutate({
|
||
orderId,
|
||
decision: 'APPROVE',
|
||
});
|
||
await refetchOrders();
|
||
}
|
||
|
||
async function reject(orderId: string) {
|
||
await finalizeMutation.mutate({
|
||
orderId,
|
||
decision: 'REJECT',
|
||
});
|
||
await refetchOrders();
|
||
}
|
||
|
||
async function blockOrder(orderId: string) {
|
||
await blockMutation.mutate({
|
||
input: {
|
||
orderId,
|
||
reason: 'Нужно уточнение параметров заказа.',
|
||
},
|
||
});
|
||
await refetchOrders();
|
||
}
|
||
|
||
async function start(orderId: string) {
|
||
await startWorkMutation.mutate({
|
||
orderId,
|
||
});
|
||
await refetchOrders();
|
||
}
|
||
|
||
async function complete(orderId: string) {
|
||
await completeWorkMutation.mutate({
|
||
orderId,
|
||
});
|
||
await refetchOrders();
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="space-y-6">
|
||
<div class="manager-hero">
|
||
<p class="manager-eyebrow">Заказы клиентов</p>
|
||
<h1 class="manager-title">Разбор заявок, офферов и статусов без отдельной менеджерки</h1>
|
||
<p class="manager-copy">
|
||
Фильтруйте заявки, публикуйте условия доставки, подтверждайте работу и доводите заказ до завершения прямо в основном кабинете.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="surface-card rounded-3xl p-4 md:p-5">
|
||
<div class="grid gap-3 md:grid-cols-[1fr_auto]">
|
||
<label class="form-control">
|
||
<span class="label-text">Поиск</span>
|
||
<input
|
||
v-model="search"
|
||
type="text"
|
||
class="input manager-field w-full"
|
||
placeholder="Номер, пользователь, адрес или товар"
|
||
>
|
||
</label>
|
||
|
||
<label class="form-control md:min-w-60">
|
||
<span class="label-text">Фильтр</span>
|
||
<select v-model="statusFilter" class="select manager-field w-full">
|
||
<option value="ALL">Все заказы</option>
|
||
<option value="WAITING">Ожидают подтверждения</option>
|
||
<option value="ACTIVE">Активные</option>
|
||
<option value="CLOSED">Закрытые</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="ordersQuery.loading.value" class="manager-empty-state">
|
||
Загружаем очередь заказов...
|
||
</div>
|
||
<div v-else-if="filteredOrders.length === 0" class="manager-empty-state">
|
||
По текущим условиям заказов не найдено.
|
||
</div>
|
||
|
||
<div v-else class="space-y-4">
|
||
<article
|
||
v-for="order in filteredOrders"
|
||
:key="order.id"
|
||
class="surface-card rounded-3xl p-5"
|
||
>
|
||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||
<div class="space-y-1">
|
||
<h2 class="text-xl font-bold text-[#123824]">{{ order.code }}</h2>
|
||
<div class="flex flex-wrap gap-3 text-sm text-[#5c7b69]">
|
||
<span>Клиент: {{ order.customerId || 'не указан' }}</span>
|
||
<span>{{ new Date(order.createdAt).toLocaleString() }}</span>
|
||
</div>
|
||
</div>
|
||
<OrderStatusBadge :status="order.status" />
|
||
</div>
|
||
|
||
<div class="mt-4 grid gap-3 lg:grid-cols-[1.1fr_0.9fr]">
|
||
<div class="space-y-3">
|
||
<div class="surface-subcard p-4">
|
||
<p class="text-sm font-semibold text-[#123824]">Состав заказа</p>
|
||
<ul class="mt-3 space-y-2 text-sm text-[#214735]">
|
||
<li
|
||
v-for="item in order.items"
|
||
:key="item.id"
|
||
class="manager-mini-card"
|
||
>
|
||
{{ item.productName }} × {{ item.quantity }}
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="surface-subcard p-4">
|
||
<p class="text-sm font-semibold text-[#123824]">Доставка</p>
|
||
<div class="mt-3 grid gap-3 text-sm text-[#214735] md:grid-cols-2">
|
||
<div class="manager-mini-card">
|
||
Адрес: {{ order.deliveryAddress || 'клиент еще не выбрал адрес' }}
|
||
</div>
|
||
<div class="manager-mini-card">
|
||
Условия: {{ order.deliveryTerms || 'ожидает оффера менеджера' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="surface-subcard p-4">
|
||
<p class="text-sm font-semibold text-[#123824]">Оффер и действия</p>
|
||
<div class="mt-3 space-y-3">
|
||
<input
|
||
:value="getOffer(order.id).deliveryTerms"
|
||
class="input manager-field w-full"
|
||
placeholder="Условия доставки"
|
||
@input="updateOfferField(order.id, 'deliveryTerms', ($event.target as HTMLInputElement).value)"
|
||
>
|
||
<input
|
||
:value="getOffer(order.id).deliveryFee"
|
||
type="number"
|
||
class="input manager-field w-full"
|
||
placeholder="Стоимость доставки"
|
||
@input="updateOfferField(order.id, 'deliveryFee', ($event.target as HTMLInputElement).value)"
|
||
>
|
||
<input
|
||
:value="getOffer(order.id).totalPrice"
|
||
type="number"
|
||
class="input manager-field w-full"
|
||
placeholder="Итоговая стоимость"
|
||
@input="updateOfferField(order.id, 'totalPrice', ($event.target as HTMLInputElement).value)"
|
||
>
|
||
</div>
|
||
|
||
<div class="mt-4 flex flex-wrap gap-2">
|
||
<button class="btn btn-primary btn-sm border-0" @click="publishOffer(order.id)">Публиковать оффер</button>
|
||
<button class="btn btn-success btn-sm border-0" @click="approve(order.id)">Подтвердить</button>
|
||
<button class="btn btn-error btn-sm border-0" @click="reject(order.id)">Отклонить</button>
|
||
<button class="btn btn-warning btn-sm border-0" @click="blockOrder(order.id)">Заблокировать</button>
|
||
<button class="btn btn-accent btn-sm border-0" :disabled="order.status !== 'CONFIRMED'" @click="start(order.id)">В работу</button>
|
||
<button class="btn btn-neutral btn-sm border-0" :disabled="order.status !== 'IN_PROGRESS'" @click="complete(order.id)">Завершить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
</template>
|