Refine cabinet layout, profile blocks, and list filters
This commit is contained in:
@@ -3,59 +3,123 @@ import { useMutation } from '@vue/apollo-composable';
|
||||
import { SubmitCalculationOrderDocument } from '~/composables/graphql/generated';
|
||||
import { useCounterpartyProfile } from '~/composables/useCounterpartyProfile';
|
||||
|
||||
const productName = ref('');
|
||||
const quantity = ref(1);
|
||||
const width = ref(100);
|
||||
const thickness = ref(50);
|
||||
const color = ref('прозрачный');
|
||||
type CartLine = {
|
||||
id: number;
|
||||
productName: string;
|
||||
quantity: number;
|
||||
width: number;
|
||||
thickness: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
const { mutate, loading, onDone, onError } = useMutation(SubmitCalculationOrderDocument);
|
||||
const { isComplete: isCounterpartyComplete, loading: counterpartyLoading } = useCounterpartyProfile();
|
||||
const submitMutation = useMutation(SubmitCalculationOrderDocument, { throws: 'never' });
|
||||
|
||||
const draft = reactive({
|
||||
productName: '',
|
||||
quantity: 1,
|
||||
width: 100,
|
||||
thickness: 50,
|
||||
color: 'прозрачный',
|
||||
});
|
||||
|
||||
const cartItems = ref<CartLine[]>([]);
|
||||
const nextLineId = ref(1);
|
||||
const sending = ref(false);
|
||||
const success = ref('');
|
||||
const errorMessage = ref('');
|
||||
const calculatedVolume = computed(() => Number(quantity.value) * Number(width.value) * Number(thickness.value));
|
||||
const { isComplete: isCounterpartyComplete, loading: counterpartyLoading } = useCounterpartyProfile();
|
||||
|
||||
onDone((result) => {
|
||||
success.value = `Заявка ${result.data?.submitCalculationOrder.code} отправлена`;
|
||||
errorMessage.value = '';
|
||||
});
|
||||
function lineVolume(item: CartLine) {
|
||||
return Number(item.quantity) * Number(item.width) * Number(item.thickness);
|
||||
}
|
||||
|
||||
onError((error) => {
|
||||
errorMessage.value = error.message;
|
||||
const totalItems = computed(() => cartItems.value.reduce((acc, item) => acc + Number(item.quantity), 0));
|
||||
const totalVolume = computed(() => cartItems.value.reduce((acc, item) => acc + lineVolume(item), 0));
|
||||
|
||||
function addToCart() {
|
||||
success.value = '';
|
||||
});
|
||||
errorMessage.value = '';
|
||||
|
||||
function submit() {
|
||||
if (!isCounterpartyComplete.value) {
|
||||
errorMessage.value = 'Сначала заполните карточку контрагента в профиле.';
|
||||
success.value = '';
|
||||
if (!draft.productName.trim()) {
|
||||
errorMessage.value = 'Укажите название позиции.';
|
||||
return;
|
||||
}
|
||||
|
||||
mutate({
|
||||
input: {
|
||||
productName: productName.value,
|
||||
quantity: Number(quantity.value),
|
||||
parameters: {
|
||||
width: Number(width.value),
|
||||
thickness: Number(thickness.value),
|
||||
color: color.value,
|
||||
},
|
||||
},
|
||||
if (Number(draft.quantity) <= 0) {
|
||||
errorMessage.value = 'Количество должно быть больше нуля.';
|
||||
return;
|
||||
}
|
||||
|
||||
cartItems.value.push({
|
||||
id: nextLineId.value,
|
||||
productName: draft.productName.trim(),
|
||||
quantity: Number(draft.quantity),
|
||||
width: Number(draft.width),
|
||||
thickness: Number(draft.thickness),
|
||||
color: draft.color.trim() || 'прозрачный',
|
||||
});
|
||||
|
||||
nextLineId.value += 1;
|
||||
draft.productName = '';
|
||||
}
|
||||
|
||||
function removeFromCart(id: number) {
|
||||
cartItems.value = cartItems.value.filter((item) => item.id !== id);
|
||||
}
|
||||
|
||||
async function submitCart() {
|
||||
success.value = '';
|
||||
errorMessage.value = '';
|
||||
|
||||
if (!isCounterpartyComplete.value) {
|
||||
errorMessage.value = 'Сначала заполните карточку контрагента в профиле.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (cartItems.value.length < 1) {
|
||||
errorMessage.value = 'Добавьте хотя бы одну позицию в корзину.';
|
||||
return;
|
||||
}
|
||||
|
||||
sending.value = true;
|
||||
const createdCodes: string[] = [];
|
||||
|
||||
for (const item of cartItems.value) {
|
||||
const result = await submitMutation.mutate({
|
||||
input: {
|
||||
productName: item.productName,
|
||||
quantity: Number(item.quantity),
|
||||
parameters: {
|
||||
width: Number(item.width),
|
||||
thickness: Number(item.thickness),
|
||||
color: item.color,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const code = result?.data?.submitCalculationOrder?.code;
|
||||
if (!code) {
|
||||
errorMessage.value = submitMutation.error.value?.message || 'Не удалось отправить одну из позиций.';
|
||||
sending.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
createdCodes.push(code);
|
||||
}
|
||||
|
||||
sending.value = false;
|
||||
cartItems.value = [];
|
||||
success.value = `Отправлено заявок: ${createdCodes.length}.`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Корзина и checkout</h1>
|
||||
<p class="mt-1 text-sm text-[#28543f]/80">Заполните параметры и отправьте расчёт менеджеру.</p>
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Корзина</h1>
|
||||
|
||||
<div class="mx-auto max-w-4xl rounded-[30px] p-1 shadow-[0_26px_60px_rgba(13,133,74,0.18)]">
|
||||
<div class="surface-card grid gap-6 rounded-[26px] p-5 md:grid-cols-[1.45fr_1fr] md:p-6">
|
||||
<div class="space-y-3">
|
||||
<div class="surface-card rounded-3xl p-5 md:p-6">
|
||||
<div class="grid gap-6 xl:grid-cols-[1.6fr_1fr]">
|
||||
<div class="space-y-4">
|
||||
<div v-if="counterpartyLoading.value" class="alert">
|
||||
Проверяем карточку контрагента...
|
||||
</div>
|
||||
@@ -64,57 +128,97 @@ function submit() {
|
||||
<NuxtLink to="/profile" class="link link-hover font-semibold">профиле</NuxtLink>.
|
||||
</div>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text font-semibold text-[#194631]">Название позиции</span>
|
||||
<input v-model="productName" type="text" class="input input-bordered border-[#d0e8d8] bg-white/80">
|
||||
</label>
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="form-control">
|
||||
<span class="label-text font-semibold text-[#194631]">Количество</span>
|
||||
<input v-model="quantity" type="number" min="1" class="input input-bordered border-[#d0e8d8] bg-white/80">
|
||||
</label>
|
||||
<label class="form-control">
|
||||
<span class="label-text font-semibold text-[#194631]">Ширина</span>
|
||||
<input v-model="width" type="number" min="1" class="input input-bordered border-[#d0e8d8] bg-white/80">
|
||||
</label>
|
||||
<label class="form-control">
|
||||
<span class="label-text font-semibold text-[#194631]">Толщина</span>
|
||||
<input v-model="thickness" type="number" min="1" class="input input-bordered border-[#d0e8d8] bg-white/80">
|
||||
</label>
|
||||
<label class="form-control">
|
||||
<span class="label-text font-semibold text-[#194631]">Цвет</span>
|
||||
<input v-model="color" type="text" class="input input-bordered border-[#d0e8d8] bg-white/80">
|
||||
</label>
|
||||
<div class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||
<h2 class="text-lg font-bold text-[#123824]">Добавить позицию</h2>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<label class="form-control xl:col-span-3">
|
||||
<span class="label-text">Название позиции</span>
|
||||
<input v-model="draft.productName" type="text" class="input input-bordered w-full" placeholder="Лист ПЭТ" >
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text">Количество</span>
|
||||
<input v-model="draft.quantity" type="number" min="1" class="input input-bordered w-full" >
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text">Ширина</span>
|
||||
<input v-model="draft.width" type="number" min="1" class="input input-bordered w-full" >
|
||||
</label>
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text">Толщина</span>
|
||||
<input v-model="draft.thickness" type="number" min="1" class="input input-bordered w-full" >
|
||||
</label>
|
||||
|
||||
<label class="form-control md:col-span-2 xl:col-span-3">
|
||||
<span class="label-text">Цвет</span>
|
||||
<input v-model="draft.color" type="text" class="input input-bordered w-full" >
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary mt-4" @click="addToCart">Добавить в корзину</button>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||
<h2 class="text-lg font-bold text-[#123824]">Список позиций</h2>
|
||||
|
||||
<div v-if="cartItems.length === 0" class="alert mt-3">
|
||||
Корзина пока пустая.
|
||||
</div>
|
||||
|
||||
<ul v-else class="mt-3 space-y-2">
|
||||
<li
|
||||
v-for="item in cartItems"
|
||||
:key="item.id"
|
||||
class="flex flex-col gap-2 rounded-xl border border-[#d6ebde] bg-white/75 px-3 py-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<p class="font-semibold text-[#123824]">{{ item.productName }}</p>
|
||||
<p class="text-sm opacity-80">
|
||||
{{ item.quantity }} шт. • {{ item.width }} × {{ item.thickness }} • {{ item.color }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-semibold text-[#123824]">Объем: {{ lineVolume(item) }}</span>
|
||||
<button class="btn btn-ghost btn-sm text-error" @click="removeFromCart(item.id)">Удалить</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn border-0 bg-[#139957] text-white hover:bg-[#0d854a]" :disabled="loading || counterpartyLoading.value || !isCounterpartyComplete" @click="submit">
|
||||
{{ loading ? 'Отправляем…' : 'Отправить менеджеру' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<aside class="rounded-2xl border border-[#cde8d8] bg-white/75 p-4">
|
||||
<h2 class="text-lg font-bold text-[#103221]">Итог checkout</h2>
|
||||
<aside class="rounded-2xl border border-base-300 bg-base-100 p-4">
|
||||
<h2 class="text-lg font-bold text-[#123824]">Итого</h2>
|
||||
|
||||
<ul class="mt-3 space-y-2 text-sm text-[#214735]">
|
||||
<li class="flex items-center justify-between">
|
||||
<span>Позиция</span>
|
||||
<span class="font-semibold">{{ productName || 'Не указано' }}</span>
|
||||
<span>Позиций</span>
|
||||
<span class="font-semibold">{{ cartItems.length }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span>Количество</span>
|
||||
<span class="font-semibold">{{ quantity }}</span>
|
||||
<span>Количество, шт.</span>
|
||||
<span class="font-semibold">{{ totalItems }}</span>
|
||||
</li>
|
||||
<li class="flex items-center justify-between">
|
||||
<span>Цвет</span>
|
||||
<span class="font-semibold">{{ color }}</span>
|
||||
<span>Суммарный объем</span>
|
||||
<span class="font-semibold">{{ totalVolume }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-4 rounded-xl bg-[#139957]/10 p-3 text-sm text-[#174631]">
|
||||
Оценочный объём: <span class="font-bold">{{ calculatedVolume }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn mt-4 w-full border-0 bg-[#139957] text-white hover:bg-[#0d854a]"
|
||||
:disabled="sending || counterpartyLoading.value || !isCounterpartyComplete || cartItems.length === 0"
|
||||
@click="submitCart"
|
||||
>
|
||||
{{ sending ? 'Отправляем…' : 'Оформить заявку' }}
|
||||
</button>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="alert alert-success mx-auto max-w-4xl">{{ success }}</div>
|
||||
<div v-if="success" class="alert alert-success">{{ success }}</div>
|
||||
<div v-if="errorMessage" class="alert alert-error">{{ errorMessage }}</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
<section class="space-y-6">
|
||||
<div class="surface-card rounded-[30px] p-6 md:p-8">
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20] md:text-4xl">Личный кабинет клиента</h1>
|
||||
<p class="mt-2 max-w-2xl text-sm text-[#28543f]/80 md:text-base">
|
||||
Быстрый доступ к каталогу, корзине, заказам и профилю в новом интерфейсе.
|
||||
</p>
|
||||
<div class="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
|
||||
<NuxtLink to="/products" class="rounded-2xl border border-[#d2e9db] bg-white/80 p-4 transition hover:-translate-y-0.5 hover:shadow-md">
|
||||
<h2 class="font-bold text-[#123824]">Каталог</h2>
|
||||
|
||||
@@ -95,7 +95,6 @@ async function sendTest() {
|
||||
<section class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Уведомления</h1>
|
||||
<p class="mt-1 text-sm text-[#28543f]/80">Управление Telegram и Max в едином стиле кабинета.</p>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
@@ -182,7 +181,7 @@ async function sendTest() {
|
||||
|
||||
<label class="form-control">
|
||||
<span class="label-text">Тестовое сообщение</span>
|
||||
<textarea v-model="customMessage" class="textarea textarea-bordered border-[#d0e8d8] bg-white/80" rows="3" />
|
||||
<textarea v-model="customMessage" class="textarea textarea-bordered" rows="3" />
|
||||
</label>
|
||||
|
||||
<button
|
||||
|
||||
@@ -3,14 +3,20 @@ import { useMutation, useQuery } from '@vue/apollo-composable';
|
||||
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
|
||||
import {
|
||||
ClientReviewOrderDocument,
|
||||
MyCurrentOrdersDocument,
|
||||
MyOrdersDocument,
|
||||
type MyOrdersQuery,
|
||||
} from '~/composables/graphql/generated';
|
||||
|
||||
const currentOrders = useQuery(MyCurrentOrdersDocument);
|
||||
type OrderItem = MyOrdersQuery['myOrders'][number];
|
||||
|
||||
const allOrders = useQuery(MyOrdersDocument);
|
||||
const reviewOrder = useMutation(ClientReviewOrderDocument);
|
||||
const actionError = ref('');
|
||||
const search = ref('');
|
||||
const statusFilter = ref<'ALL' | 'WAITING' | 'ACTIVE' | 'CLOSED'>('ALL');
|
||||
|
||||
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']);
|
||||
|
||||
reviewOrder.onError((error) => {
|
||||
actionError.value = error.message;
|
||||
@@ -19,108 +25,123 @@ reviewOrder.onError((error) => {
|
||||
async function approve(orderId: string) {
|
||||
actionError.value = '';
|
||||
await reviewOrder.mutate({ orderId, decision: 'APPROVE' });
|
||||
await Promise.all([currentOrders.refetch(), allOrders.refetch()]);
|
||||
await allOrders.refetch();
|
||||
}
|
||||
|
||||
async function reject(orderId: string) {
|
||||
actionError.value = '';
|
||||
await reviewOrder.mutate({ orderId, decision: 'REJECT' });
|
||||
await Promise.all([currentOrders.refetch(), allOrders.refetch()]);
|
||||
await allOrders.refetch();
|
||||
}
|
||||
|
||||
function matchesFilter(order: OrderItem) {
|
||||
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);
|
||||
}
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
const orders = allOrders.result.value?.myOrders ?? [];
|
||||
const normalizedSearch = search.value.trim().toLowerCase();
|
||||
|
||||
return orders.filter((order) => {
|
||||
const text = [
|
||||
order.code,
|
||||
...order.items.map((item) => item.productName),
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
const matchSearch = !normalizedSearch || text.includes(normalizedSearch);
|
||||
return matchSearch && matchesFilter(order);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Мои заказы</h1>
|
||||
<p class="mt-1 text-sm text-[#28543f]/80">Все заявки в широком карточном формате.</p>
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Заказы</h1>
|
||||
|
||||
<div v-if="actionError" class="alert alert-error">{{ actionError }}</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-lg font-bold text-[#123824]">Текущие</h2>
|
||||
<div v-if="currentOrders.loading.value" class="alert surface-card border-0">Загрузка...</div>
|
||||
<div v-else-if="(currentOrders.result.value?.myCurrentOrders?.length ?? 0) === 0" class="alert surface-card border-0">
|
||||
Активных заказов пока нет.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<article
|
||||
v-for="order in currentOrders.result.value?.myCurrentOrders ?? []"
|
||||
:key="order.id"
|
||||
class="surface-card rounded-3xl p-4 md:p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-[#123824]">{{ order.code }}</h3>
|
||||
<p class="text-xs text-[#355947]">{{ new Date(order.createdAt).toLocaleString() }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<OrderStatusBadge :status="order.status" />
|
||||
</div>
|
||||
</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 input-bordered w-full"
|
||||
placeholder="Номер заказа или товар"
|
||||
>
|
||||
</label>
|
||||
|
||||
<ul class="mt-4 grid gap-2 text-sm text-[#214735]">
|
||||
<li
|
||||
v-for="item in order.items"
|
||||
:key="item.id"
|
||||
class="rounded-xl border border-[#d6ebde] bg-white/75 px-3 py-2"
|
||||
>
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="order.status === 'WAITING_DOUBLE_CONFIRM'" class="mt-4 flex flex-wrap gap-2">
|
||||
<button class="btn btn-sm border-0 bg-[#139957] text-white hover:bg-[#0d854a]" @click="approve(order.id)">
|
||||
Подтвердить
|
||||
</button>
|
||||
<button class="btn btn-sm border-0 bg-[#d32422] text-white hover:bg-[#b31f1d]" @click="reject(order.id)">
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<label class="form-control md:min-w-60">
|
||||
<span class="label-text">Фильтр</span>
|
||||
<select v-model="statusFilter" class="select select-bordered w-full">
|
||||
<option value="ALL">Все заказы</option>
|
||||
<option value="WAITING">Ожидают подтверждения</option>
|
||||
<option value="ACTIVE">Активные</option>
|
||||
<option value="CLOSED">Закрытые</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h2 class="text-lg font-bold text-[#123824]">История заказов</h2>
|
||||
<div v-if="allOrders.loading.value" class="alert surface-card border-0">Загрузка истории...</div>
|
||||
<div v-else-if="(allOrders.result.value?.myOrders?.length ?? 0) === 0" class="alert surface-card border-0">
|
||||
История заказов пока пустая.
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<article
|
||||
v-for="order in allOrders.result.value?.myOrders ?? []"
|
||||
:key="order.id"
|
||||
class="surface-card rounded-3xl p-4 md:p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-[#123824]">{{ order.code }}</h3>
|
||||
<p class="text-xs text-[#355947]">{{ new Date(order.updatedAt).toLocaleString() }}</p>
|
||||
</div>
|
||||
<OrderStatusBadge :status="order.status" />
|
||||
</div>
|
||||
<div v-if="allOrders.loading.value" class="alert surface-card border-0">Загрузка заказов...</div>
|
||||
<div v-else-if="filteredOrders.length === 0" class="alert surface-card border-0">
|
||||
Заказы по текущим условиям не найдены.
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 text-sm text-[#214735] md:grid-cols-2">
|
||||
<div class="rounded-xl border border-[#d6ebde] bg-white/75 px-3 py-2">
|
||||
Условия доставки: {{ order.deliveryTerms || 'ожидает обработки менеджером' }}
|
||||
</div>
|
||||
<div class="rounded-xl border border-[#d6ebde] bg-white/75 px-3 py-2">
|
||||
Итого: {{ order.totalPrice ?? 'после обработки менеджером' }}
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<article
|
||||
v-for="order in filteredOrders"
|
||||
:key="order.id"
|
||||
class="surface-card rounded-3xl p-4 md:p-5"
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-[#123824]">{{ order.code }}</h2>
|
||||
<p class="text-xs text-[#355947]">{{ new Date(order.createdAt).toLocaleString() }}</p>
|
||||
</div>
|
||||
<OrderStatusBadge :status="order.status" />
|
||||
</div>
|
||||
|
||||
<div v-if="order.status === 'WAITING_DOUBLE_CONFIRM'" class="mt-4 flex flex-wrap gap-2">
|
||||
<button class="btn btn-sm border-0 bg-[#139957] text-white hover:bg-[#0d854a]" @click="approve(order.id)">
|
||||
Подтвердить
|
||||
</button>
|
||||
<button class="btn btn-sm border-0 bg-[#d32422] text-white hover:bg-[#b31f1d]" @click="reject(order.id)">
|
||||
Отклонить
|
||||
</button>
|
||||
<ul class="mt-4 grid gap-2 text-sm text-[#214735]">
|
||||
<li
|
||||
v-for="item in order.items"
|
||||
:key="item.id"
|
||||
class="rounded-xl border border-[#d6ebde] bg-white/75 px-3 py-2"
|
||||
>
|
||||
{{ item.productName }} × {{ item.quantity }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-4 grid gap-3 text-sm text-[#214735] md:grid-cols-2">
|
||||
<div class="rounded-xl border border-[#d6ebde] bg-white/75 px-3 py-2">
|
||||
Условия доставки: {{ order.deliveryTerms || 'ожидает обработки менеджером' }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div class="rounded-xl border border-[#d6ebde] bg-white/75 px-3 py-2">
|
||||
Итого: {{ order.totalPrice ?? 'после обработки менеджером' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="order.status === 'WAITING_DOUBLE_CONFIRM'" class="mt-4 flex flex-wrap gap-2">
|
||||
<button class="btn btn-sm border-0 bg-[#139957] text-white hover:bg-[#0d854a]" @click="approve(order.id)">
|
||||
Подтвердить
|
||||
</button>
|
||||
<button class="btn btn-sm border-0 bg-[#d32422] text-white hover:bg-[#b31f1d]" @click="reject(order.id)">
|
||||
Отклонить
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useQuery } from '@vue/apollo-composable';
|
||||
import { ClientProductsDocument } from '~/composables/graphql/generated';
|
||||
|
||||
const { result, loading, error } = useQuery(ClientProductsDocument);
|
||||
const search = ref('');
|
||||
const stockFilter = ref<'ALL' | 'CUSTOM' | 'STANDARD'>('ALL');
|
||||
|
||||
const coverPresets = [
|
||||
['#e9fbe5', '#acfcd5', '#7be9aa'],
|
||||
@@ -35,21 +37,58 @@ function createProductCover(name: string, sku: string) {
|
||||
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
|
||||
}
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
const list = result.value?.clientProducts ?? [];
|
||||
const normalizedSearch = search.value.trim().toLowerCase();
|
||||
|
||||
return list.filter((product) => {
|
||||
const matchSearch = !normalizedSearch
|
||||
|| product.name.toLowerCase().includes(normalizedSearch)
|
||||
|| product.sku.toLowerCase().includes(normalizedSearch);
|
||||
|
||||
const matchType = stockFilter.value === 'ALL'
|
||||
|| (stockFilter.value === 'CUSTOM' && product.isCustomizable)
|
||||
|| (stockFilter.value === 'STANDARD' && !product.isCustomizable);
|
||||
|
||||
return matchSearch && matchType;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-5">
|
||||
<div>
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Каталог</h1>
|
||||
<p class="mt-1 text-sm text-[#28543f]/80">Витрина в формате карточек: только визуал и название позиции.</p>
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Каталог</h1>
|
||||
|
||||
<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 input-bordered w-full"
|
||||
placeholder="Название или SKU"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="form-control md:min-w-56">
|
||||
<span class="label-text">Фильтр</span>
|
||||
<select v-model="stockFilter" class="select select-bordered w-full">
|
||||
<option value="ALL">Все товары</option>
|
||||
<option value="CUSTOM">Только кастомные</option>
|
||||
<option value="STANDARD">Только стандартные</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="alert surface-card border-0">Загрузка каталога...</div>
|
||||
<div v-else-if="error" class="alert alert-error">{{ error.message }}</div>
|
||||
|
||||
<div v-else-if="(result?.clientProducts?.length ?? 0) > 0" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div v-else-if="filteredProducts.length > 0" class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<article
|
||||
v-for="(product, index) in result?.clientProducts ?? []"
|
||||
v-for="(product, index) in filteredProducts"
|
||||
:key="product.id"
|
||||
class="surface-card product-card-anim overflow-hidden rounded-3xl p-3"
|
||||
:style="{ animationDelay: `${index * 55}ms` }"
|
||||
@@ -69,7 +108,7 @@ function createProductCover(name: string, sku: string) {
|
||||
</div>
|
||||
|
||||
<div v-else class="alert surface-card border-0">
|
||||
В каталоге пока нет активных товаров.
|
||||
Ничего не найдено по текущим параметрам.
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
MeDocument,
|
||||
MyCounterpartyProfileDocument,
|
||||
MyMessengerConnectionsDocument,
|
||||
RegisterSelfDocument,
|
||||
UpsertMyCounterpartyProfileDocument,
|
||||
} from '~/composables/graphql/generated';
|
||||
import { buildMessengerBotStartUrl } from '~/composables/useMessengerBotLink';
|
||||
@@ -42,13 +41,6 @@ type BankSuggestion = {
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
const registerForm = reactive({
|
||||
companyName: '',
|
||||
inn: '',
|
||||
contactName: '',
|
||||
email: '',
|
||||
});
|
||||
|
||||
const counterpartyForm = reactive({
|
||||
companyName: '',
|
||||
companyFullName: '',
|
||||
@@ -65,8 +57,6 @@ const counterpartyForm = reactive({
|
||||
signerBasis: '',
|
||||
});
|
||||
|
||||
const registerFeedback = ref('');
|
||||
const registerFeedbackTone = ref<'success' | 'error'>('success');
|
||||
const profileFeedback = ref('');
|
||||
const profileFeedbackTone = ref<'success' | 'error'>('success');
|
||||
|
||||
@@ -74,7 +64,6 @@ const meQuery = useQuery(MeDocument);
|
||||
const profileQuery = useQuery(MyCounterpartyProfileDocument);
|
||||
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
|
||||
|
||||
const registerMutation = useMutation(RegisterSelfDocument, { throws: 'never' });
|
||||
const saveCounterpartyMutation = useMutation(UpsertMyCounterpartyProfileDocument, { throws: 'never' });
|
||||
|
||||
const companySearch = ref('');
|
||||
@@ -258,28 +247,6 @@ function closeDropdownsFromOutside(event: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
async function registerSelf() {
|
||||
registerFeedback.value = '';
|
||||
const result = await registerMutation.mutate({
|
||||
input: {
|
||||
companyName: registerForm.companyName,
|
||||
inn: registerForm.inn.trim() ? registerForm.inn.trim() : null,
|
||||
contactName: registerForm.contactName,
|
||||
email: registerForm.email,
|
||||
},
|
||||
});
|
||||
|
||||
const payload = result?.data?.registerSelf;
|
||||
if (!payload) {
|
||||
registerFeedbackTone.value = 'error';
|
||||
registerFeedback.value = registerMutation.error.value?.message || 'Не удалось отправить заявку.';
|
||||
return;
|
||||
}
|
||||
|
||||
registerFeedbackTone.value = 'success';
|
||||
registerFeedback.value = 'Заявка на регистрацию отправлена менеджеру.';
|
||||
}
|
||||
|
||||
async function saveCounterpartyProfile() {
|
||||
profileFeedback.value = '';
|
||||
const result = await saveCounterpartyMutation.mutate({
|
||||
@@ -325,84 +292,10 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<section class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Профиль</h1>
|
||||
<p class="mt-1 text-sm text-[#28543f]/80">Заполните карточку контрагента, чтобы можно было оформлять заявки из корзины.</p>
|
||||
</div>
|
||||
<h1 class="text-3xl font-extrabold text-[#0f2f20]">Профиль</h1>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="grid gap-4 lg:grid-cols-[1.8fr_1fr]">
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Самостоятельная регистрация</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Компания</legend>
|
||||
<input v-model="registerForm.companyName" type="text" class="input input-bordered w-full" placeholder="ООО Пример" >
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">ИНН</legend>
|
||||
<input v-model="registerForm.inn" type="text" class="input input-bordered w-full" placeholder="7701234567" >
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Контактное лицо</legend>
|
||||
<input v-model="registerForm.contactName" type="text" class="input input-bordered w-full" placeholder="Иванов Иван" >
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">E-mail</legend>
|
||||
<input v-model="registerForm.email" type="email" class="input input-bordered w-full" placeholder="name@company.com" >
|
||||
</fieldset>
|
||||
|
||||
<button class="btn btn-primary w-full" :disabled="registerMutation.loading.value" @click="registerSelf">
|
||||
{{ registerMutation.loading.value ? 'Отправляем…' : 'Отправить заявку' }}
|
||||
</button>
|
||||
|
||||
<div v-if="registerFeedback" class="alert" :class="registerFeedbackTone === 'success' ? 'alert-success' : 'alert-error'">
|
||||
{{ registerFeedback }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Каналы уведомлений</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="rounded-2xl border border-[#d6ebde] bg-white/75 p-4">
|
||||
<p class="font-semibold">Telegram</p>
|
||||
<p class="text-sm opacity-80">
|
||||
{{ telegramConnection ? `Подключен: ${telegramConnection.channelId}` : 'Не подключен' }}
|
||||
</p>
|
||||
<a
|
||||
:href="telegramConnectUrl || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-secondary mt-3 w-full"
|
||||
:class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }"
|
||||
>
|
||||
{{ telegramConnection ? 'Переподключить Telegram' : 'Подключить Telegram' }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[#d6ebde] bg-white/75 p-4">
|
||||
<p class="font-semibold">Max</p>
|
||||
<p class="text-sm opacity-80">
|
||||
{{ maxConnection ? `Подключен: ${maxConnection.channelId}` : 'Не подключен' }}
|
||||
</p>
|
||||
<a
|
||||
:href="maxConnectUrl || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-accent mt-3 w-full"
|
||||
:class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }"
|
||||
>
|
||||
{{ maxConnection ? 'Переподключить Max' : 'Подключить Max' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="surface-card rounded-3xl p-5">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Карточка контрагента</h2>
|
||||
<span class="badge" :class="profileIsComplete ? 'badge-success' : 'badge-warning'">
|
||||
@@ -556,7 +449,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<fieldset class="fieldset">
|
||||
<legend class="fieldset-legend">Основание полномочий</legend>
|
||||
<textarea v-model="counterpartyForm.signerBasis" class="textarea textarea-bordered min-h-24 w-full" placeholder="Действует на основании Устава" />
|
||||
<textarea
|
||||
v-model="counterpartyForm.signerBasis"
|
||||
class="textarea textarea-bordered min-h-24 w-full"
|
||||
placeholder="Действует на основании Устава"
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<button class="btn btn-primary mt-4 w-full" :disabled="saveCounterpartyMutation.loading.value || !profileIsComplete" @click="saveCounterpartyProfile">
|
||||
@@ -578,6 +475,44 @@ onBeforeUnmount(() => {
|
||||
: 'Пока карточка не заполнена полностью, оформление заявки в корзине будет заблокировано.'
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="surface-card rounded-3xl p-5">
|
||||
<h2 class="text-xl font-bold text-[#123824]">Уведомления</h2>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="rounded-2xl border border-[#d6ebde] bg-white/75 p-4">
|
||||
<p class="font-semibold">Telegram</p>
|
||||
<p class="text-sm opacity-80">
|
||||
{{ telegramConnection ? `Подключен: ${telegramConnection.channelId}` : 'Не подключен' }}
|
||||
</p>
|
||||
<a
|
||||
:href="telegramConnectUrl || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-secondary mt-3 w-full"
|
||||
:class="{ 'btn-disabled pointer-events-none': !telegramConnectUrl }"
|
||||
>
|
||||
{{ telegramConnection ? 'Переподключить Telegram' : 'Подключить Telegram' }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-[#d6ebde] bg-white/75 p-4">
|
||||
<p class="font-semibold">Max</p>
|
||||
<p class="text-sm opacity-80">
|
||||
{{ maxConnection ? `Подключен: ${maxConnection.channelId}` : 'Не подключен' }}
|
||||
</p>
|
||||
<a
|
||||
:href="maxConnectUrl || undefined"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-accent mt-3 w-full"
|
||||
:class="{ 'btn-disabled pointer-events-none': !maxConnectUrl }"
|
||||
>
|
||||
{{ maxConnection ? 'Переподключить Max' : 'Подключить Max' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user