Files
web-frontend/app/pages/client-orders/index.vue
2026-04-04 11:20:51 +07:00

316 lines
10 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 { useQuery } from '@vue/apollo-composable';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import ruLocale from '@fullcalendar/core/locales/ru';
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
import {
ManagerOrdersDocument,
type ManagerOrdersQuery,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
});
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
type StatusFilter = 'ALL' | 'NEW' | 'PRICED' | 'IN_PROGRESS' | 'CLOSED';
const route = useRoute();
const router = useRouter();
const ordersQuery = useQuery(ManagerOrdersDocument, { status: null });
const search = ref('');
const statusFilter = ref<StatusFilter>('ALL');
const viewMode = computed<'cards' | 'calendar' | 'kanban'>(() => (
route.query.view === 'calendar'
? 'calendar'
: route.query.view === 'kanban'
? 'kanban'
: 'cards'
));
function setViewMode(view: 'cards' | 'calendar' | 'kanban') {
void router.replace({
query: {
...route.query,
view,
},
});
}
function getOrderGroup(order: ManagerOrderItem) {
if (order.status === 'NEW' || order.status === 'MANAGER_PROCESSING') {
return 'NEW';
}
if (order.status === 'WAITING_DOUBLE_CONFIRM' || order.status === 'CONFIRMED') {
return 'PRICED';
}
if (order.status === 'IN_PROGRESS') {
return 'IN_PROGRESS';
}
return 'CLOSED';
}
function matchesFilter(order: ManagerOrderItem) {
if (statusFilter.value === 'ALL') {
return true;
}
return getOrderGroup(order) === statusFilter.value;
}
const searchedOrders = computed<ManagerOrderItem[]>(() => {
const orders = ordersQuery.result.value?.managerOrders ?? [];
const query = search.value.trim().toLowerCase();
return orders.filter((order) => {
const text = [
order.code,
order.customerId,
order.deliveryAddress || '',
...order.items.map((item) => item.productName),
]
.join(' ')
.toLowerCase();
return !query || text.includes(query);
});
});
const filteredOrders = computed<ManagerOrderItem[]>(() => searchedOrders.value.filter((order) => matchesFilter(order)));
const statusTabs = computed<Array<{ id: StatusFilter; label: string; count: number }>>(() => [
{
id: 'ALL',
label: 'Все',
count: searchedOrders.value.length,
},
{
id: 'NEW',
label: 'Новые',
count: searchedOrders.value.filter((order) => getOrderGroup(order) === 'NEW').length,
},
{
id: 'PRICED',
label: 'Оценены',
count: searchedOrders.value.filter((order) => getOrderGroup(order) === 'PRICED').length,
},
{
id: 'IN_PROGRESS',
label: 'В работе',
count: searchedOrders.value.filter((order) => getOrderGroup(order) === 'IN_PROGRESS').length,
},
{
id: 'CLOSED',
label: 'Закрытые',
count: searchedOrders.value.filter((order) => getOrderGroup(order) === 'CLOSED').length,
},
]);
const kanbanColumns = computed(() => {
const columns = [
{
id: 'NEW' as const,
title: 'Новые',
tone: 'from-[#f5fff8] to-[#eefaf2]',
},
{
id: 'PRICED' as const,
title: 'Оценены',
tone: 'from-[#fff9ef] to-[#fff1d9]',
},
{
id: 'IN_PROGRESS' as const,
title: 'В работе',
tone: 'from-[#eef7ff] to-[#e2f0ff]',
},
{
id: 'CLOSED' as const,
title: 'Закрытые',
tone: 'from-[#f6f7f8] to-[#eceff2]',
},
];
return columns
.filter((column) => statusFilter.value === 'ALL' || statusFilter.value === column.id)
.map((column) => ({
...column,
orders: searchedOrders.value.filter((order) => getOrderGroup(order) === column.id),
}));
});
const calendarOptions = computed(() => ({
plugins: [dayGridPlugin],
locale: ruLocale,
initialView: 'dayGridMonth',
height: 'auto',
fixedWeekCount: false,
firstDay: 1,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: '',
},
buttonText: {
today: 'Сегодня',
},
events: filteredOrders.value.map((order: ManagerOrderItem) => ({
id: order.id,
title: `${order.code}${order.customerId}`,
start: new Date(order.createdAt).toISOString(),
allDay: true,
})),
eventClick: ({ event }: { event: { id: string } }) => {
void router.push(`/client-orders/${event.id}`);
},
}));
</script>
<template>
<section class="space-y-6">
<UiSectionSearchHero
v-model="search"
title="Заказы"
search-placeholder="Номер заказа, клиент, адрес или товар"
>
<template #controls>
<div class="flex w-full flex-col gap-3 md:w-auto md:flex-row">
<div class="tabs tabs-boxed w-fit bg-white">
<button class="tab" :class="{ 'tab-active': viewMode === 'cards' }" @click="setViewMode('cards')">
Карточки
</button>
<button class="tab" :class="{ 'tab-active': viewMode === 'calendar' }" @click="setViewMode('calendar')">
Календарь
</button>
<button class="tab" :class="{ 'tab-active': viewMode === 'kanban' }" @click="setViewMode('kanban')">
Kanban
</button>
</div>
</div>
</template>
<div class="flex flex-wrap gap-2">
<button
v-for="tab in statusTabs"
:key="tab.id"
class="inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm font-semibold transition"
:class="statusFilter === tab.id
? 'border-[#0d854a] bg-[#0d854a] text-white shadow-[0_18px_38px_rgba(13,133,74,0.18)]'
: 'border-[#d4e8da] bg-white text-[#355947] hover:border-[#b6d7c1]'"
@click="statusFilter = tab.id"
>
<span>{{ tab.label }}</span>
<span
class="rounded-full px-2 py-0.5 text-xs"
:class="statusFilter === tab.id ? 'bg-white/20 text-white' : 'bg-[#eef7f1] text-[#0d854a]'"
>
{{ tab.count }}
</span>
</button>
</div>
</UiSectionSearchHero>
<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-if="viewMode === 'calendar'" class="surface-card rounded-3xl p-4 md:p-5">
<FullCalendar :options="calendarOptions" />
</div>
<div v-else-if="viewMode === 'kanban'" class="grid gap-4 xl:grid-cols-4">
<div
v-for="column in kanbanColumns"
:key="column.id"
class="surface-card rounded-[32px] p-4"
>
<div
class="rounded-[24px] border border-white/60 bg-gradient-to-br p-4"
:class="column.tone"
>
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-lg font-bold text-[#123824]">{{ column.title }}</h2>
<p class="text-sm text-[#5c7b69]">Заказов: {{ column.orders.length }}</p>
</div>
<span class="rounded-full bg-white px-3 py-1 text-sm font-semibold text-[#123824]">
{{ column.orders.length }}
</span>
</div>
</div>
<div v-if="column.orders.length === 0" class="manager-empty-state mt-4 min-h-[180px]">
В этой колонке пока пусто.
</div>
<div v-else class="mt-4 space-y-3">
<NuxtLink
v-for="order in column.orders"
:key="order.id"
:to="`/client-orders/${order.id}`"
class="block rounded-[28px] border border-[#dbece2] bg-white p-4 shadow-[0_18px_40px_rgba(18,56,36,0.08)] transition hover:-translate-y-0.5"
>
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<h3 class="text-base font-bold text-[#123824]">{{ order.code }}</h3>
<p class="text-sm text-[#5c7b69]">Клиент: {{ order.customerId }}</p>
</div>
<OrderStatusBadge :status="order.status" />
</div>
<p class="mt-3 text-sm text-[#5c7b69]">Создан: {{ new Date(order.createdAt).toLocaleString() }}</p>
<p v-if="order.deliveryAddress" class="mt-1 text-sm text-[#5c7b69]">{{ order.deliveryAddress }}</p>
<ul class="mt-3 space-y-2 text-sm text-[#214735]">
<li
v-for="item in order.items.slice(0, 3)"
:key="item.id"
class="rounded-2xl border border-[#e2efe7] bg-[#f8fcf9] px-3 py-2"
>
{{ item.productName }} × {{ item.quantity }}
</li>
</ul>
<p v-if="order.items.length > 3" class="mt-2 text-xs font-semibold uppercase tracking-[0.18em] text-[#6a8a76]">
ещё {{ order.items.length - 3 }} поз.
</p>
</NuxtLink>
</div>
</div>
</div>
<div v-else class="space-y-4">
<NuxtLink
v-for="order in filteredOrders"
:key="order.id"
:to="`/client-orders/${order.id}`"
class="surface-card block rounded-3xl p-5"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<h2 class="text-lg font-bold text-[#123824]">{{ order.code }}</h2>
<p class="text-sm text-[#5c7b69]">Клиент: {{ order.customerId }}</p>
<p class="text-sm text-[#5c7b69]">Создан: {{ new Date(order.createdAt).toLocaleString() }}</p>
<p v-if="order.deliveryAddress" class="text-sm text-[#5c7b69]">Адрес: {{ order.deliveryAddress }}</p>
</div>
<OrderStatusBadge :status="order.status" />
</div>
<ul class="mt-4 grid gap-2 text-sm text-[#214735]">
<li
v-for="item in order.items"
:key="item.id"
class="rounded-2xl border border-[#d6ebde] bg-white px-4 py-3"
>
{{ item.productName }} × {{ item.quantity }}
</li>
</ul>
</NuxtLink>
</div>
</section>
</template>