Files
web-frontend/app/pages/client-orders/index.vue
2026-04-06 16:12:54 +07:00

537 lines
15 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 {
ManagerOrdersDocument,
ManagerUsersDocument,
type ManagerOrdersQuery,
type ManagerUsersQuery,
} from '~/composables/graphql/generated';
import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnectionPresentation';
import { formatOrderCode } from '~/composables/useOrderCodePresentation';
import { formatPrice } from '~/composables/useOrderDetailPresentation';
definePageMeta({
middleware: ['manager-only'],
path: '/admin/orders',
alias: ['/client-orders'],
});
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
type ManagerUserItem = ManagerUsersQuery['managerUsers'][number];
type StatusFilter = 'ALL' | 'NEW' | 'PRICED' | 'IN_PROGRESS' | 'CLOSED';
const route = useRoute();
const router = useRouter();
const ordersQuery = useQuery(ManagerOrdersDocument, { status: null });
const usersQuery = useQuery(ManagerUsersDocument);
const search = ref('');
const statusFilter = ref<StatusFilter>('ALL');
const viewMode = computed<'list' | 'calendar'>(() => (
route.query.view === 'calendar'
? 'calendar'
: 'list'
));
function setViewMode(view: 'list' | 'calendar') {
void router.replace({
query: {
...route.query,
view,
},
});
}
const usersById = computed<Record<string, ManagerUserItem>>(() => Object.fromEntries(
(usersQuery.result.value?.managerUsers ?? []).map((user) => [user.id, user]),
));
function customerCardMeta(customerId: string) {
const customer = usersById.value[customerId];
if (!customer) {
return {
name: customerId,
avatarSrc: '',
fallbackAvatarSrc: '/favicon.ico',
initials: customerId.slice(0, 2).toUpperCase(),
};
}
return {
name: customer.fullName,
avatarSrc: messengerConnectionAvatarSrc(customer.telegramConnection),
fallbackAvatarSrc: '/favicon.ico',
initials: customer.fullName
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join(''),
};
}
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 orderGroupLabel(group: StatusFilter) {
if (group === 'NEW') {
return 'Заявка';
}
if (group === 'PRICED') {
return 'Расчёт';
}
if (group === 'IN_PROGRESS') {
return 'В работе';
}
if (group === 'CLOSED') {
return 'Закрыт';
}
return 'Все';
}
function orderGroupBadgeClass(group: StatusFilter) {
if (group === 'NEW') {
return 'manager-calendar-order-card__status manager-calendar-order-card__status--new';
}
if (group === 'PRICED') {
return 'manager-calendar-order-card__status manager-calendar-order-card__status--priced';
}
if (group === 'IN_PROGRESS') {
return 'manager-calendar-order-card__status manager-calendar-order-card__status--progress';
}
return 'manager-calendar-order-card__status manager-calendar-order-card__status--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,
formatOrderCode(order.code),
order.customerId,
customerCardMeta(order.customerId).name,
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 {
canLoadMore,
loadMore,
loadMoreSentinel,
remainingCount,
visibleItems: visibleOrders,
} = useIncrementalList(filteredOrders, {
pageSize: 24,
enabled: computed(() => viewMode.value === 'list'),
resetKeys: [search, statusFilter, viewMode],
});
function escapeHtml(value: string) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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 calendarOptions = computed(() => ({
plugins: [dayGridPlugin],
locale: ruLocale,
initialView: 'dayGridWeek',
height: 'auto',
fixedWeekCount: false,
firstDay: 1,
weekends: false,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridDay,dayGridWeek,dayGridMonth',
},
buttonText: {
today: 'Сегодня',
day: 'День',
week: 'Неделя',
month: 'Месяц',
},
events: filteredOrders.value.map((order: ManagerOrderItem) => {
const customer = customerCardMeta(order.customerId);
const group = getOrderGroup(order);
return {
id: order.id,
title: formatOrderCode(order.code),
start: new Date(order.createdAt).toISOString(),
allDay: true,
extendedProps: {
customerName: customer.name,
avatarSrc: customer.avatarSrc,
fallbackAvatarSrc: customer.fallbackAvatarSrc,
initials: customer.initials,
orderCode: formatOrderCode(order.code),
orderGroupLabel: orderGroupLabel(group),
orderGroupClass: orderGroupBadgeClass(group),
totalPriceLabel: formatPrice(order.totalPrice) ?? 'Цена уточняется',
},
};
}),
eventContent: ({ event }: { event: { extendedProps: Record<string, string> } }) => {
const customerName = escapeHtml(event.extendedProps.customerName || '');
const avatarSrc = event.extendedProps.avatarSrc || event.extendedProps.fallbackAvatarSrc;
const orderCode = escapeHtml(event.extendedProps.orderCode || '');
const orderGroupLabel = escapeHtml(event.extendedProps.orderGroupLabel || '');
const orderGroupClass = escapeHtml(event.extendedProps.orderGroupClass || '');
const totalPriceLabel = escapeHtml(event.extendedProps.totalPriceLabel || '');
const initials = escapeHtml(event.extendedProps.initials || '');
return {
html: `
<div class="manager-calendar-order-card">
<div class="manager-calendar-order-card__header">
<div class="manager-calendar-order-card__avatar-shell">
${avatarSrc
? `<img src="${escapeHtml(avatarSrc)}" alt="${customerName}" class="manager-calendar-order-card__avatar">`
: `<span class="manager-calendar-order-card__initials">${initials}</span>`}
</div>
<div class="manager-calendar-order-card__text">
<div class="manager-calendar-order-card__name">${customerName}</div>
<div class="manager-calendar-order-card__code">${orderCode}</div>
</div>
</div>
<div class="manager-calendar-order-card__meta">
<span class="${orderGroupClass}">${orderGroupLabel}</span>
<div class="manager-calendar-order-card__price">${totalPriceLabel}</div>
</div>
</div>
`,
};
},
eventClick: ({ event }: { event: { id: string } }) => {
void router.push(`/admin/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="inline-flex w-fit rounded-full border border-[#d7e9de] bg-white p-1">
<button
class="rounded-full px-4 py-2 text-sm font-semibold text-[#355947] transition"
:class="viewMode === 'list' ? 'bg-[#123824] text-white' : 'hover:bg-[#f4faf6]'"
@click="setViewMode('list')"
>
Список
</button>
<button
class="rounded-full px-4 py-2 text-sm font-semibold text-[#355947] transition"
:class="viewMode === 'calendar' ? 'bg-[#123824] text-white' : 'hover:bg-[#f4faf6]'"
@click="setViewMode('calendar')"
>
Календарь
</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="manager-calendar-shell">
<FullCalendar :options="calendarOptions" />
</div>
<div v-else class="space-y-4">
<OrdersOrderSummaryCard
v-for="order in visibleOrders"
:key="order.id"
:to="`/admin/orders/${order.id}`"
:code="order.code"
:status="order.status"
:created-at="order.createdAt"
:total-price="order.totalPrice"
:items="order.items"
/>
<div
v-if="canLoadMore"
ref="loadMoreSentinel"
class="flex justify-center pt-2"
>
<button class="btn btn-outline border-[#d7e9de] bg-white" @click="loadMore">
Показать ещё {{ Math.min(remainingCount, 24) }}
</button>
</div>
</div>
</section>
</template>
<style>
.manager-calendar-order-card {
display: grid;
gap: 0.55rem;
min-height: 84px;
border-radius: 18px;
background: #ffffff;
padding: 0.85rem 0.9rem;
box-shadow: 0 12px 26px rgba(18, 56, 36, 0.08);
}
.manager-calendar-order-card__header {
display: flex;
align-items: center;
gap: 0.7rem;
min-width: 0;
}
.manager-calendar-order-card__avatar-shell {
width: 34px;
height: 34px;
flex: 0 0 auto;
overflow: hidden;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: #edf3ef;
}
.manager-calendar-order-card__avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
.manager-calendar-order-card__initials {
font-size: 0.78rem;
font-weight: 800;
color: #123824;
}
.manager-calendar-order-card__text {
min-width: 0;
}
.manager-calendar-order-card__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.8rem;
line-height: 1.15;
font-weight: 800;
color: #123824;
}
.manager-calendar-order-card__code {
margin-top: 0.18rem;
font-size: 0.74rem;
line-height: 1.1;
font-weight: 700;
color: #688676;
}
.manager-calendar-order-card__meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.manager-calendar-order-card__status {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
padding: 0.28rem 0.58rem;
font-size: 0.64rem;
line-height: 1;
font-weight: 800;
white-space: nowrap;
}
.manager-calendar-order-card__status--new {
background: #edf3ef;
color: #355947;
}
.manager-calendar-order-card__status--priced {
background: #eef7f1;
color: #0d854a;
}
.manager-calendar-order-card__status--progress {
background: #edf2f6;
color: #2f5872;
}
.manager-calendar-order-card__status--closed {
background: #f1f3f2;
color: #617268;
}
.manager-calendar-order-card__price {
font-size: 0.76rem;
font-weight: 700;
color: #0d854a;
text-align: right;
white-space: nowrap;
}
.manager-calendar-shell :deep(.fc) {
--fc-border-color: #dbe5df;
--fc-button-bg-color: #123824;
--fc-button-border-color: #123824;
--fc-button-hover-bg-color: #0d854a;
--fc-button-hover-border-color: #0d854a;
--fc-button-active-bg-color: #0d854a;
--fc-button-active-border-color: #0d854a;
--fc-neutral-bg-color: transparent;
--fc-page-bg-color: transparent;
}
.manager-calendar-shell :deep(.fc-theme-standard td),
.manager-calendar-shell :deep(.fc-theme-standard th),
.manager-calendar-shell :deep(.fc-theme-standard .fc-scrollgrid) {
border-color: #dbe5df;
}
.manager-calendar-shell :deep(.fc-theme-standard .fc-scrollgrid) {
border-radius: 22px;
overflow: hidden;
}
.manager-calendar-shell :deep(.fc-col-header-cell-cushion) {
padding: 0.85rem 0.35rem;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #688676;
}
.manager-calendar-shell :deep(.fc-daygrid-day-frame) {
background: transparent;
padding: 0.5rem;
}
.manager-calendar-shell :deep(.fc-daygrid-day-top) {
justify-content: flex-end;
padding: 0.25rem 0.35rem 0.3rem;
}
.manager-calendar-shell :deep(.fc-daygrid-day-events) {
margin: 0;
padding: 0 0.3rem 0.45rem;
}
.manager-calendar-shell :deep(.fc-daygrid-event-harness) {
margin-top: 0.45rem;
}
.manager-calendar-shell :deep(.fc-daygrid-day-number) {
font-size: 0.78rem;
font-weight: 700;
color: #5a7968;
}
.fc .fc-daygrid-event {
border: 0;
background: transparent;
}
.fc .fc-daygrid-dot-event:hover,
.fc .fc-daygrid-event:hover {
background: transparent;
}
</style>