Simplify manager cabinet flows

This commit is contained in:
Ruslan Bakiev
2026-04-03 19:23:08 +07:00
parent 1c19b06451
commit 1c2070b8d8
14 changed files with 1073 additions and 680 deletions

View File

@@ -21,6 +21,7 @@ const centerCapsule = computed<NavItem[]>(() => {
items.push( items.push(
{ to: '/clients', label: 'Клиенты' }, { to: '/clients', label: 'Клиенты' },
{ to: '/client-orders', label: 'Заказы клиентов' }, { to: '/client-orders', label: 'Заказы клиентов' },
{ to: '/bonus-system', label: 'Бонусы' },
); );
} }
@@ -45,6 +46,9 @@ function isActive(path: string) {
if (path === '/client-orders') { if (path === '/client-orders') {
return route.path === '/client-orders' || route.path.startsWith('/client-orders/'); return route.path === '/client-orders' || route.path.startsWith('/client-orders/');
} }
if (path === '/bonus-system') {
return route.path === '/bonus-system' || route.path.startsWith('/bonus-system/');
}
return route.path === path; return route.path === path;
} }
</script> </script>

View File

@@ -700,6 +700,11 @@ export type ManagerOrdersQueryVariables = Exact<{
export type ManagerOrdersQuery = { __typename?: 'Query', managerOrders: Array<{ __typename?: 'Order', id: string, code: string, status: OrderStatus, kind: OrderKind, customerId: string, deliveryAddress?: string | null, deliveryTerms?: string | null, deliveryFee?: number | null, totalPrice?: number | null, createdAt: any, items: Array<{ __typename?: 'OrderItem', id: string, productName: string, quantity: number }> }> }; export type ManagerOrdersQuery = { __typename?: 'Query', managerOrders: Array<{ __typename?: 'Order', id: string, code: string, status: OrderStatus, kind: OrderKind, customerId: string, deliveryAddress?: string | null, deliveryTerms?: string | null, deliveryFee?: number | null, totalPrice?: number | null, createdAt: any, items: Array<{ __typename?: 'OrderItem', id: string, productName: string, quantity: number }> }> };
export type ReferralStatsQueryVariables = Exact<{ [key: string]: never; }>;
export type ReferralStatsQuery = { __typename?: 'Query', referralStats: { __typename?: 'ReferralStats', referrerId: string, availableBalance: number, referralsCount: number, transactions: Array<{ __typename?: 'BonusTransaction', id: string, userId: string, amount: number, reason: string, orderId?: string | null, createdAt: any }>, pendingWithdrawals: Array<{ __typename?: 'RewardWithdrawalRequest', id: string, requesterId: string, amount: number, status: WithdrawalStatus, reviewComment?: string | null, createdAt: any, updatedAt: any }> } };
export type RegistrationRequestsQueryVariables = Exact<{ export type RegistrationRequestsQueryVariables = Exact<{
status?: InputMaybe<RegistrationStatus>; status?: InputMaybe<RegistrationStatus>;
}>; }>;
@@ -1290,6 +1295,52 @@ export function useManagerOrdersLazyQuery(variables: ManagerOrdersQueryVariables
return VueApolloComposable.useLazyQuery<ManagerOrdersQuery, ManagerOrdersQueryVariables>(ManagerOrdersDocument, variables, options); return VueApolloComposable.useLazyQuery<ManagerOrdersQuery, ManagerOrdersQueryVariables>(ManagerOrdersDocument, variables, options);
} }
export type ManagerOrdersQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<ManagerOrdersQuery, ManagerOrdersQueryVariables>; export type ManagerOrdersQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<ManagerOrdersQuery, ManagerOrdersQueryVariables>;
export const ReferralStatsDocument = gql`
query ReferralStats {
referralStats {
referrerId
availableBalance
referralsCount
transactions {
id
userId
amount
reason
orderId
createdAt
}
pendingWithdrawals {
id
requesterId
amount
status
reviewComment
createdAt
updatedAt
}
}
}
`;
/**
* __useReferralStatsQuery__
*
* To run a query within a Vue component, call `useReferralStatsQuery` and pass it any options that fit your needs.
* When your component renders, `useReferralStatsQuery` returns an object from Apollo Client that contains result, loading and error properties
* you can use to render your UI.
*
* @param options that will be passed into the query, supported options are listed on: https://v4.apollo.vuejs.org/guide-composable/query.html#options;
*
* @example
* const { result, loading, error } = useReferralStatsQuery();
*/
export function useReferralStatsQuery(options: VueApolloComposable.UseQueryOptions<ReferralStatsQuery, ReferralStatsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<ReferralStatsQuery, ReferralStatsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<ReferralStatsQuery, ReferralStatsQueryVariables>> = {}) {
return VueApolloComposable.useQuery<ReferralStatsQuery, ReferralStatsQueryVariables>(ReferralStatsDocument, {}, options);
}
export function useReferralStatsLazyQuery(options: VueApolloComposable.UseQueryOptions<ReferralStatsQuery, ReferralStatsQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<ReferralStatsQuery, ReferralStatsQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<ReferralStatsQuery, ReferralStatsQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<ReferralStatsQuery, ReferralStatsQueryVariables>(ReferralStatsDocument, {}, options);
}
export type ReferralStatsQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<ReferralStatsQuery, ReferralStatsQueryVariables>;
export const RegistrationRequestsDocument = gql` export const RegistrationRequestsDocument = gql`
query RegistrationRequests($status: RegistrationStatus) { query RegistrationRequests($status: RegistrationStatus) {
registrationRequests(status: $status) { registrationRequests(status: $status) {

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import {
ReferralStatsDocument,
type ReferralStatsQuery,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
});
type TransactionItem = ReferralStatsQuery['referralStats']['transactions'][number];
type WithdrawalItem = ReferralStatsQuery['referralStats']['pendingWithdrawals'][number];
const bonusQuery = useQuery(ReferralStatsDocument);
const search = ref('');
const transactions = computed<TransactionItem[]>(() => bonusQuery.result.value?.referralStats.transactions ?? []);
const withdrawals = computed<WithdrawalItem[]>(() => bonusQuery.result.value?.referralStats.pendingWithdrawals ?? []);
const filteredTransactions = computed(() => {
const query = search.value.trim().toLowerCase();
return transactions.value.filter((transaction) => {
if (!query) {
return true;
}
return [
transaction.userId,
transaction.reason,
transaction.orderId || '',
String(transaction.amount),
]
.join(' ')
.toLowerCase()
.includes(query);
});
});
</script>
<template>
<section class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div class="manager-hero">
<p class="manager-eyebrow">Бонусы</p>
<h1 class="manager-title">Отдельный раздел для бонусной системы</h1>
<p class="manager-copy">Здесь остаются история начислений и заявки на вывод, без смешивания с клиентами.</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/bonus-system/referrals/new" class="btn btn-secondary border-0">Создать связь</NuxtLink>
<NuxtLink to="/bonus-system/transactions/new" class="btn btn-primary border-0">Добавить бонус</NuxtLink>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<div class="manager-stat-card">
<p class="manager-stat-label">Баланс</p>
<p class="manager-stat-value">{{ bonusQuery.result.value?.referralStats.availableBalance ?? 0 }}</p>
</div>
<div class="manager-stat-card">
<p class="manager-stat-label">Рефералы</p>
<p class="manager-stat-value">{{ bonusQuery.result.value?.referralStats.referralsCount ?? 0 }}</p>
</div>
<div class="manager-stat-card">
<p class="manager-stat-label">Заявки на вывод</p>
<p class="manager-stat-value">{{ withdrawals.length }}</p>
</div>
</div>
<div class="surface-card rounded-3xl p-4 md:p-5">
<label class="form-control">
<span class="label-text">Search</span>
<input
v-model="search"
type="text"
class="input manager-field w-full"
placeholder="Пользователь, причина, заказ или сумма"
>
</label>
</div>
<div class="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">История транзакций</h2>
<div v-if="bonusQuery.loading.value" class="manager-empty-state mt-4">
Загружаем историю...
</div>
<div v-else-if="filteredTransactions.length === 0" class="manager-empty-state mt-4">
Транзакции по текущему запросу не найдены.
</div>
<div v-else class="mt-4 space-y-3">
<article v-for="transaction in filteredTransactions" :key="transaction.id" class="surface-subcard p-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-sm font-semibold text-[#123824]">{{ transaction.reason }}</p>
<p class="mt-1 text-sm text-[#5c7b69]">Пользователь: {{ transaction.userId }}</p>
</div>
<div class="manager-mini-card text-sm font-semibold text-[#123824]">
{{ transaction.amount }}
</div>
</div>
<div class="mt-3 flex flex-wrap gap-3 text-xs text-[#5c7b69]">
<span>{{ new Date(transaction.createdAt).toLocaleString() }}</span>
<span v-if="transaction.orderId">Заказ: {{ transaction.orderId }}</span>
</div>
</article>
</div>
</div>
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Заявки на вывод</h2>
<div v-if="bonusQuery.loading.value" class="manager-empty-state mt-4">
Загружаем заявки...
</div>
<div v-else-if="withdrawals.length === 0" class="manager-empty-state mt-4">
Активных заявок на вывод сейчас нет.
</div>
<div v-else class="mt-4 space-y-3">
<article v-for="withdrawal in withdrawals" :key="withdrawal.id" class="surface-subcard p-4">
<div class="space-y-2">
<p class="text-sm font-semibold text-[#123824]">Пользователь: {{ withdrawal.requesterId }}</p>
<p class="text-sm text-[#355947]">Сумма: {{ withdrawal.amount }}</p>
<p class="text-xs text-[#5c7b69]">{{ new Date(withdrawal.createdAt).toLocaleString() }}</p>
</div>
<div class="mt-4">
<NuxtLink :to="`/bonus-system/withdrawals/${withdrawal.id}`" class="btn btn-accent btn-sm border-0">
Проверить вывод
</NuxtLink>
</div>
</article>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import { CreateReferralDocument } from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
});
const refereeUserId = ref('');
const createdReferralId = ref('');
const createReferralMutation = useMutation(CreateReferralDocument);
async function createReferral() {
createdReferralId.value = '';
const response = await createReferralMutation.mutate({
input: {
refereeUserId: refereeUserId.value,
},
});
createdReferralId.value = response?.data?.createReferral.id ?? '';
}
</script>
<template>
<section class="space-y-6 max-w-3xl">
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]"> Назад к бонусам</NuxtLink>
<div class="manager-hero">
<p class="manager-eyebrow">Бонусы</p>
<h1 class="manager-title">Создать реферальную связь</h1>
</div>
<div class="surface-card rounded-3xl p-5">
<label class="form-control">
<span class="label-text">ID приглашенного пользователя</span>
<input v-model="refereeUserId" class="input manager-field w-full" placeholder="user id">
</label>
<div class="mt-4">
<button class="btn btn-primary border-0" @click="createReferral">Создать</button>
</div>
</div>
<div v-if="createdReferralId" class="surface-card rounded-3xl p-5 text-sm text-[#123824]">
Создана связь: <span class="font-semibold">{{ createdReferralId }}</span>
</div>
</section>
</template>

View File

@@ -0,0 +1,64 @@
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import { AddBonusTransactionDocument } from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
});
const userId = ref('');
const amount = ref(100);
const reason = ref('Реферальный бонус');
const createdTransactionId = ref('');
const addBonusMutation = useMutation(AddBonusTransactionDocument);
async function addBonus() {
createdTransactionId.value = '';
const response = await addBonusMutation.mutate({
input: {
userId: userId.value,
amount: Number(amount.value),
reason: reason.value,
},
});
createdTransactionId.value = response?.data?.addBonusTransaction.id ?? '';
}
</script>
<template>
<section class="space-y-6 max-w-3xl">
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]"> Назад к бонусам</NuxtLink>
<div class="manager-hero">
<p class="manager-eyebrow">Бонусы</p>
<h1 class="manager-title">Добавить бонусную транзакцию</h1>
</div>
<div class="surface-card rounded-3xl p-5 space-y-3">
<label class="form-control">
<span class="label-text">ID пользователя</span>
<input v-model="userId" class="input manager-field w-full" placeholder="user id">
</label>
<label class="form-control">
<span class="label-text">Сумма</span>
<input v-model="amount" type="number" class="input manager-field w-full" placeholder="100">
</label>
<label class="form-control">
<span class="label-text">Причина</span>
<input v-model="reason" class="input manager-field w-full" placeholder="Причина начисления">
</label>
<div>
<button class="btn btn-primary border-0" @click="addBonus">Начислить</button>
</div>
</div>
<div v-if="createdTransactionId" class="surface-card rounded-3xl p-5 text-sm text-[#123824]">
Создана транзакция: <span class="font-semibold">{{ createdTransactionId }}</span>
</div>
</section>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
ReferralStatsDocument,
ReviewRewardWithdrawalDocument,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
});
const route = useRoute();
const withdrawalId = computed(() => String(route.params.id || ''));
const bonusQuery = useQuery(ReferralStatsDocument);
const reviewMutation = useMutation(ReviewRewardWithdrawalDocument);
const decision = ref<'APPROVE' | 'REJECT'>('APPROVE');
const reviewComment = ref('');
const reviewResult = ref('');
const currentWithdrawal = computed(() =>
(bonusQuery.result.value?.referralStats.pendingWithdrawals ?? []).find((item) => item.id === withdrawalId.value),
);
async function reviewWithdrawal() {
if (!currentWithdrawal.value) {
return;
}
const response = await reviewMutation.mutate({
input: {
withdrawalId: currentWithdrawal.value.id,
decision: decision.value,
reviewComment: reviewComment.value || undefined,
},
});
reviewResult.value = response?.data?.reviewRewardWithdrawal.status ?? '';
await bonusQuery.refetch();
}
</script>
<template>
<section class="space-y-6 max-w-3xl">
<NuxtLink to="/bonus-system" class="text-sm font-semibold text-[#0d854a]"> Назад к бонусам</NuxtLink>
<div v-if="bonusQuery.loading.value" class="manager-empty-state">
Загружаем заявку на вывод...
</div>
<div v-else-if="!currentWithdrawal" class="manager-empty-state">
Заявка на вывод не найдена.
</div>
<template v-else>
<div class="manager-hero">
<p class="manager-eyebrow">Вывод</p>
<h1 class="manager-title">Проверка заявки на вывод</h1>
<p class="manager-copy">Пользователь: {{ currentWithdrawal.requesterId }} · Сумма: {{ currentWithdrawal.amount }}</p>
</div>
<div class="surface-card rounded-3xl p-5 space-y-3">
<label class="form-control">
<span class="label-text">Решение</span>
<select v-model="decision" class="select manager-field w-full">
<option value="APPROVE">Одобрить</option>
<option value="REJECT">Отклонить</option>
</select>
</label>
<label class="form-control">
<span class="label-text">Комментарий</span>
<textarea v-model="reviewComment" class="textarea manager-field min-h-28 w-full" placeholder="Комментарий для заявки" />
</label>
<div>
<button class="btn btn-primary border-0" @click="reviewWithdrawal">Сохранить решение</button>
</div>
</div>
<div v-if="reviewResult" class="surface-card rounded-3xl p-5 text-sm text-[#123824]">
Новый статус: <span class="font-semibold">{{ reviewResult }}</span>
</div>
</template>
</section>
</template>

View File

@@ -1,289 +0,0 @@
<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>

View File

@@ -0,0 +1,197 @@
<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,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
});
const route = useRoute();
const orderId = computed(() => String(route.params.id || ''));
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 currentOrder = computed(() =>
(ordersQuery.result.value?.managerOrders ?? []).find((item) => item.id === orderId.value),
);
const offerForm = reactive({
deliveryTerms: '',
deliveryFee: 0,
totalPrice: 0,
});
watchEffect(() => {
if (!currentOrder.value) {
return;
}
offerForm.deliveryTerms = currentOrder.value.deliveryTerms || 'Доставка 3-5 дней';
offerForm.deliveryFee = Number(currentOrder.value.deliveryFee ?? 1000);
offerForm.totalPrice = Number(currentOrder.value.totalPrice ?? 12500);
});
async function refetchOrder() {
await ordersQuery.refetch({ status: null });
}
async function publishOffer() {
if (!currentOrder.value) {
return;
}
await setOfferMutation.mutate({
input: {
orderId: currentOrder.value.id,
deliveryTerms: offerForm.deliveryTerms,
deliveryFee: Number(offerForm.deliveryFee),
totalPrice: Number(offerForm.totalPrice),
},
});
await refetchOrder();
}
async function approveOrder() {
if (!currentOrder.value) {
return;
}
await finalizeMutation.mutate({ orderId: currentOrder.value.id, decision: 'APPROVE' });
await refetchOrder();
}
async function rejectOrder() {
if (!currentOrder.value) {
return;
}
await finalizeMutation.mutate({ orderId: currentOrder.value.id, decision: 'REJECT' });
await refetchOrder();
}
async function blockOrder() {
if (!currentOrder.value) {
return;
}
await blockMutation.mutate({
input: {
orderId: currentOrder.value.id,
reason: 'Нужно уточнение параметров заказа.',
},
});
await refetchOrder();
}
async function startOrder() {
if (!currentOrder.value) {
return;
}
await startWorkMutation.mutate({ orderId: currentOrder.value.id });
await refetchOrder();
}
async function completeOrder() {
if (!currentOrder.value) {
return;
}
await completeWorkMutation.mutate({ orderId: currentOrder.value.id });
await refetchOrder();
}
</script>
<template>
<section class="space-y-6">
<NuxtLink to="/client-orders" class="text-sm font-semibold text-[#0d854a]"> Назад к заказам клиентов</NuxtLink>
<div v-if="ordersQuery.loading.value" class="manager-empty-state">
Загружаем заказ...
</div>
<div v-else-if="!currentOrder" class="manager-empty-state">
Заказ не найден.
</div>
<template v-else>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="manager-hero">
<p class="manager-eyebrow">Заказ</p>
<h1 class="manager-title">{{ currentOrder.code }}</h1>
<p class="manager-copy">Клиент: {{ currentOrder.customerId }} · {{ new Date(currentOrder.createdAt).toLocaleString() }}</p>
</div>
<OrderStatusBadge :status="currentOrder.status" />
</div>
<div class="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
<div class="space-y-4">
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Состав заказа</h2>
<ul class="mt-4 space-y-3">
<li v-for="item in currentOrder.items" :key="item.id" class="manager-mini-card text-sm text-[#123824]">
{{ item.productName }} × {{ item.quantity }}
</li>
</ul>
</div>
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Доставка</h2>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<div class="manager-mini-card text-sm text-[#123824]">
Адрес: {{ currentOrder.deliveryAddress || 'не выбран' }}
</div>
<div class="manager-mini-card text-sm text-[#123824]">
Условия: {{ currentOrder.deliveryTerms || 'еще не указаны' }}
</div>
<div class="manager-mini-card text-sm text-[#123824]">
Стоимость доставки: {{ currentOrder.deliveryFee ?? '—' }}
</div>
<div class="manager-mini-card text-sm text-[#123824]">
Итого: {{ currentOrder.totalPrice ?? '—' }}
</div>
</div>
</div>
</div>
<div class="space-y-4">
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Оффер</h2>
<div class="mt-4 space-y-3">
<input v-model="offerForm.deliveryTerms" class="input manager-field w-full" placeholder="Условия доставки">
<input v-model="offerForm.deliveryFee" type="number" class="input manager-field w-full" placeholder="Стоимость доставки">
<input v-model="offerForm.totalPrice" type="number" class="input manager-field w-full" placeholder="Итоговая стоимость">
<button class="btn btn-primary border-0" @click="publishOffer">Публиковать оффер</button>
</div>
</div>
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Действия</h2>
<div class="mt-4 flex flex-wrap gap-2">
<button class="btn btn-success btn-sm border-0" @click="approveOrder">Подтвердить</button>
<button class="btn btn-error btn-sm border-0" @click="rejectOrder">Отклонить</button>
<button class="btn btn-warning btn-sm border-0" @click="blockOrder">Заблокировать</button>
<button class="btn btn-accent btn-sm border-0" :disabled="currentOrder.status !== 'CONFIRMED'" @click="startOrder">В работу</button>
<button class="btn btn-neutral btn-sm border-0" :disabled="currentOrder.status !== 'IN_PROGRESS'" @click="completeOrder">Завершить</button>
</div>
</div>
</div>
</div>
</template>
</section>
</template>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
import {
ManagerOrdersDocument,
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 search = ref('');
const statusFilter = ref<'ALL' | 'WAITING' | 'ACTIVE' | 'CLOSED'>('ALL');
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);
}
const filteredOrders = computed(() => {
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();
const matchesSearch = !query || text.includes(query);
return matchesSearch && matchesFilter(order);
});
});
</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">Search</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">
<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>
<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 md:grid-cols-3">
<div class="manager-mini-card text-sm text-[#123824]">
Позиций: <span class="font-semibold">{{ order.items.length }}</span>
</div>
<div class="manager-mini-card text-sm text-[#123824]">
Итого: <span class="font-semibold">{{ order.totalPrice ?? 'без оффера' }}</span>
</div>
<div class="manager-mini-card text-sm text-[#123824]">
Адрес: <span class="font-semibold">{{ order.deliveryAddress || 'не выбран' }}</span>
</div>
</div>
</NuxtLink>
</div>
</section>
</template>

View File

@@ -1,391 +0,0 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
AddBonusTransactionDocument,
CreateInvitationDocument,
CreateReferralDocument,
ManagerNotificationHistoryDocument,
MyMessengerConnectionsDocument,
RegistrationRequestsDocument,
ReviewRegistrationRequestDocument,
ReviewRewardWithdrawalDocument,
type ManagerNotificationHistoryQuery,
} from '~/composables/graphql/generated';
import {
messengerConnectionAvatarSrc,
messengerConnectionHandle,
messengerConnectionInitials,
messengerConnectionName,
} from '~/composables/useMessengerConnectionPresentation';
import { useGqlClient } from '~/composables/useGqlClient';
definePageMeta({
middleware: ['manager-only'],
});
type HistoryItem = ManagerNotificationHistoryQuery['managerNotificationHistory'][number];
const gql = useGqlClient();
const registrationQuery = useQuery(RegistrationRequestsDocument, {
status: 'PENDING',
});
const connectionsQuery = useQuery(MyMessengerConnectionsDocument);
const reviewRequestMutation = useMutation(ReviewRegistrationRequestDocument);
const createInvitationMutation = useMutation(CreateInvitationDocument);
const createReferralMutation = useMutation(CreateReferralDocument);
const addBonusMutation = useMutation(AddBonusTransactionDocument);
const reviewWithdrawalMutation = useMutation(ReviewRewardWithdrawalDocument);
const selectedHistoryChannel = ref<'TELEGRAM' | 'MAX'>('TELEGRAM');
const targetUserId = ref('');
const historyItems = ref<HistoryItem[]>([]);
const historyError = ref('');
const historyLoading = ref(false);
const invitationEmail = ref('');
const invitationCompanyName = ref('');
const invitationToken = ref('');
const referralUserId = ref('');
const referralCreatedId = ref('');
const bonusUserId = ref('');
const bonusAmount = ref(100);
const bonusReason = ref('Реферальный бонус');
const bonusCreatedId = ref('');
const withdrawalId = ref('');
const withdrawalDecision = ref<'APPROVE' | 'REJECT'>('APPROVE');
const withdrawalComment = ref('');
const withdrawalResult = ref('');
const pendingRequests = computed(() => registrationQuery.result.value?.registrationRequests ?? []);
const telegramConnection = computed(() =>
connectionsQuery.result.value?.myMessengerConnections?.find(
(item) => item.type === 'TELEGRAM' && item.isActive,
),
);
const maxConnection = computed(() =>
connectionsQuery.result.value?.myMessengerConnections?.find(
(item) => item.type === 'MAX' && item.isActive,
),
);
async function approveRequest(requestId: string) {
await reviewRequestMutation.mutate({
input: {
requestId,
decision: 'APPROVE',
},
});
await registrationQuery.refetch({
status: 'PENDING',
});
}
async function rejectRequest(requestId: string) {
await reviewRequestMutation.mutate({
input: {
requestId,
decision: 'REJECT',
rejectionReason: 'Не хватает данных для регистрации.',
},
});
await registrationQuery.refetch({
status: 'PENDING',
});
}
async function createInvitation() {
invitationToken.value = '';
const response = await createInvitationMutation.mutate({
input: {
email: invitationEmail.value,
companyName: invitationCompanyName.value,
expiresInDays: 7,
},
});
invitationToken.value = response?.data?.createInvitation.token ?? '';
}
async function createReferral() {
referralCreatedId.value = '';
const response = await createReferralMutation.mutate({
input: {
refereeUserId: referralUserId.value,
},
});
referralCreatedId.value = response?.data?.createReferral.id ?? '';
}
async function addBonus() {
bonusCreatedId.value = '';
const response = await addBonusMutation.mutate({
input: {
userId: bonusUserId.value,
amount: Number(bonusAmount.value),
reason: bonusReason.value,
},
});
bonusCreatedId.value = response?.data?.addBonusTransaction.id ?? '';
}
async function reviewWithdrawal() {
withdrawalResult.value = '';
const response = await reviewWithdrawalMutation.mutate({
input: {
withdrawalId: withdrawalId.value,
decision: withdrawalDecision.value,
reviewComment: withdrawalComment.value || undefined,
},
});
withdrawalResult.value = response?.data?.reviewRewardWithdrawal.status ?? '';
}
async function loadHistory() {
historyError.value = '';
historyItems.value = [];
if (!targetUserId.value.trim()) {
historyError.value = 'Укажите user ID клиента, чтобы загрузить историю уведомлений.';
return;
}
historyLoading.value = true;
try {
const response = await gql.query({
query: ManagerNotificationHistoryDocument,
variables: {
userId: targetUserId.value.trim(),
channel: selectedHistoryChannel.value,
limit: 50,
},
fetchPolicy: 'no-cache',
});
historyItems.value = response.data.managerNotificationHistory;
} catch (error) {
historyError.value = error instanceof Error ? error.message : 'Не удалось загрузить историю уведомлений.';
} finally {
historyLoading.value = false;
}
}
</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="grid gap-4 lg:grid-cols-3">
<div class="manager-stat-card">
<p class="manager-stat-label">Новые заявки</p>
<p class="manager-stat-value">{{ pendingRequests.length }}</p>
<p class="manager-helper">Очередь на проверку новых клиентов.</p>
</div>
<div class="manager-stat-card">
<p class="manager-stat-label">Telegram менеджера</p>
<p class="manager-stat-value text-lg">
{{ telegramConnection ? messengerConnectionName(telegramConnection) : 'Не подключен' }}
</p>
<p class="manager-helper">Личные каналы менеджера настраиваются в профиле.</p>
</div>
<div class="manager-stat-card">
<p class="manager-stat-label">Max менеджера</p>
<p class="manager-stat-value text-lg">
{{ maxConnection ? messengerConnectionName(maxConnection) : 'Не подключен' }}
</p>
<p class="manager-helper">Используется для тестов и рабочих уведомлений.</p>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
<div class="surface-card rounded-3xl p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-xl font-bold text-[#123824]">Заявки на регистрацию</h2>
<p class="manager-helper">Проверьте компанию и примите решение без перехода в отдельный кабинет.</p>
</div>
</div>
<div v-if="registrationQuery.loading.value" class="manager-empty-state mt-4">
Загружаем очередь заявок...
</div>
<div v-else-if="pendingRequests.length === 0" class="manager-empty-state mt-4">
Новых заявок сейчас нет.
</div>
<div v-else class="mt-4 space-y-3">
<article
v-for="request in pendingRequests"
:key="request.id"
class="surface-subcard p-4"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<h3 class="text-lg font-bold text-[#123824]">{{ request.companyName }}</h3>
<p class="text-sm text-[#466653]">{{ request.contactName }} {{ request.email }}</p>
<p class="text-xs text-[#5c7b69]">{{ new Date(request.createdAt).toLocaleString() }}</p>
</div>
<div class="flex flex-wrap gap-2">
<button class="btn btn-success btn-sm border-0" @click="approveRequest(request.id)">Одобрить</button>
<button class="btn btn-error btn-sm border-0" @click="rejectRequest(request.id)">Отклонить</button>
</div>
</div>
</article>
</div>
</div>
<div class="space-y-4">
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Инвайт клиенту</h2>
<p class="manager-helper mt-1">Быстро выдайте ссылку на вход новой компании.</p>
<div class="mt-4 space-y-3">
<input v-model="invitationEmail" type="email" class="input manager-field w-full" placeholder="Email клиента">
<input v-model="invitationCompanyName" type="text" class="input manager-field w-full" placeholder="Компания">
<button class="btn btn-primary border-0" @click="createInvitation">Создать инвайт</button>
<div v-if="invitationToken" class="manager-mini-card text-sm text-[#123824]">
Токен приглашения: <span class="font-semibold">{{ invitationToken }}</span>
</div>
</div>
</div>
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Рабочие каналы менеджера</h2>
<p class="manager-helper mt-1">
Подключение Telegram и Max выполняется в <NuxtLink to="/profile/notifications" class="font-semibold text-[#0d854a]">профиле</NuxtLink>.
</p>
<div class="mt-4 flex flex-wrap gap-2">
<div class="manager-channel-chip">
<div v-if="telegramConnection && messengerConnectionAvatarSrc(telegramConnection)" class="avatar">
<div class="manager-channel-dot h-8 w-8 rounded-full bg-[#123824]">
<img :src="messengerConnectionAvatarSrc(telegramConnection)" :alt="messengerConnectionName(telegramConnection)">
</div>
</div>
<span v-else class="manager-channel-dot bg-[#123824]">
{{ messengerConnectionInitials(telegramConnection, 'TG') }}
</span>
<span class="text-sm font-semibold text-[#123824]">
{{ telegramConnection ? messengerConnectionHandle(telegramConnection) || messengerConnectionName(telegramConnection) : 'Telegram не подключен' }}
</span>
</div>
<div class="manager-channel-chip">
<span class="manager-channel-dot bg-[#2b7fff]">
{{ messengerConnectionInitials(maxConnection, 'MX') }}
</span>
<span class="text-sm font-semibold text-[#123824]">
{{ maxConnection ? messengerConnectionHandle(maxConnection) || messengerConnectionName(maxConnection) : 'Max не подключен' }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<div class="surface-card rounded-3xl p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 class="text-xl font-bold text-[#123824]">История уведомлений клиента</h2>
<p class="manager-helper mt-1">Проверяйте, что именно ушло клиенту в Telegram или Max.</p>
</div>
<div class="tabs tabs-boxed">
<button class="tab" :class="{ 'tab-active': selectedHistoryChannel === 'TELEGRAM' }" @click="selectedHistoryChannel = 'TELEGRAM'">
Telegram
</button>
<button class="tab" :class="{ 'tab-active': selectedHistoryChannel === 'MAX' }" @click="selectedHistoryChannel = 'MAX'">
Max
</button>
</div>
</div>
<div class="mt-4 flex flex-col gap-3 md:flex-row">
<input v-model="targetUserId" class="input manager-field w-full" placeholder="User ID клиента">
<button class="btn btn-secondary border-0 md:w-auto" @click="loadHistory">Загрузить</button>
</div>
<div v-if="historyLoading" class="manager-empty-state mt-4">
Загружаем историю...
</div>
<div v-else-if="historyError" class="manager-empty-state mt-4 text-[#a34a34]">
{{ historyError }}
</div>
<div v-else-if="historyItems.length === 0" class="manager-empty-state mt-4">
История пока пустая.
</div>
<ul v-else class="mt-4 space-y-3">
<li
v-for="item in historyItems"
:key="item.id"
class="surface-subcard p-4"
>
<p class="font-semibold text-[#123824]">{{ item.title }}</p>
<p class="mt-1 text-sm text-[#355947]">{{ item.message }}</p>
<div class="mt-3 flex flex-wrap gap-3 text-xs text-[#5c7b69]">
<span>{{ new Date(item.createdAt).toLocaleString() }}</span>
<span v-if="item.orderId">Заказ: {{ item.orderId }}</span>
</div>
</li>
</ul>
</div>
<div class="space-y-4">
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Реферальная связь</h2>
<div class="mt-4 space-y-3">
<input v-model="referralUserId" class="input manager-field w-full" placeholder="ID приглашенного пользователя">
<button class="btn btn-primary border-0" @click="createReferral">Создать связь</button>
<div v-if="referralCreatedId" class="manager-mini-card text-sm text-[#123824]">
Создана связь: <span class="font-semibold">{{ referralCreatedId }}</span>
</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">
<input v-model="bonusUserId" class="input manager-field w-full" placeholder="ID пользователя">
<input v-model="bonusAmount" type="number" class="input manager-field w-full" placeholder="Сумма">
<input v-model="bonusReason" class="input manager-field w-full" placeholder="Причина">
<button class="btn btn-secondary border-0" @click="addBonus">Начислить</button>
<div v-if="bonusCreatedId" class="manager-mini-card text-sm text-[#123824]">
Создана транзакция: <span class="font-semibold">{{ bonusCreatedId }}</span>
</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">
<input v-model="withdrawalId" class="input manager-field w-full" placeholder="ID заявки на вывод">
<select v-model="withdrawalDecision" class="select manager-field w-full">
<option value="APPROVE">Одобрить</option>
<option value="REJECT">Отклонить</option>
</select>
<textarea v-model="withdrawalComment" class="textarea manager-field min-h-28 w-full" placeholder="Комментарий для клиента" />
<button class="btn btn-accent border-0" @click="reviewWithdrawal">Подтвердить решение</button>
<div v-if="withdrawalResult" class="manager-mini-card text-sm text-[#123824]">
Новый статус: <span class="font-semibold">{{ withdrawalResult }}</span>
</div>
</div>
</div>
</div>
</div>
</section>
</template>

127
app/pages/clients/[id].vue Normal file
View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
RegistrationRequestsDocument,
ReviewRegistrationRequestDocument,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
});
const route = useRoute();
const requestId = computed(() => String(route.params.id || ''));
const clientQuery = useQuery(RegistrationRequestsDocument, {
status: null,
});
const reviewMutation = useMutation(ReviewRegistrationRequestDocument);
const currentClient = computed(() =>
(clientQuery.result.value?.registrationRequests ?? []).find((item) => item.id === requestId.value),
);
async function approveRequest() {
if (!currentClient.value) {
return;
}
await reviewMutation.mutate({
input: {
requestId: currentClient.value.id,
decision: 'APPROVE',
},
});
await clientQuery.refetch({ status: null });
}
async function rejectRequest() {
if (!currentClient.value) {
return;
}
await reviewMutation.mutate({
input: {
requestId: currentClient.value.id,
decision: 'REJECT',
rejectionReason: 'Не хватает данных для регистрации.',
},
});
await clientQuery.refetch({ status: null });
}
</script>
<template>
<section class="space-y-6">
<NuxtLink to="/clients" class="text-sm font-semibold text-[#0d854a]"> Назад к клиентам</NuxtLink>
<div v-if="clientQuery.loading.value" class="manager-empty-state">
Загружаем карточку клиента...
</div>
<div v-else-if="!currentClient" class="manager-empty-state">
Карточка клиента не найдена.
</div>
<template v-else>
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div class="manager-hero">
<p class="manager-eyebrow">Клиент</p>
<h1 class="manager-title">{{ currentClient.companyName }}</h1>
<p class="manager-copy">Контакт: {{ currentClient.contactName }} · {{ currentClient.email }}</p>
</div>
<div v-if="currentClient.status === 'PENDING'" class="flex flex-wrap gap-2">
<button class="btn btn-success border-0" @click="approveRequest">Одобрить</button>
<button class="btn btn-error border-0" @click="rejectRequest">Отклонить</button>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<div class="manager-stat-card">
<p class="manager-stat-label">Статус</p>
<p class="manager-stat-value text-lg">
{{ currentClient.status === 'APPROVED' ? 'Активен' : currentClient.status === 'REJECTED' ? 'Отклонен' : 'На проверке' }}
</p>
</div>
<div class="manager-stat-card">
<p class="manager-stat-label">Дата заявки</p>
<p class="manager-stat-value text-lg">{{ new Date(currentClient.createdAt).toLocaleDateString() }}</p>
</div>
<div class="manager-stat-card">
<p class="manager-stat-label">ИНН</p>
<p class="manager-stat-value text-lg">{{ currentClient.inn || 'Не указан' }}</p>
</div>
</div>
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Информация</h2>
<div class="mt-4 grid gap-3 md:grid-cols-2">
<div class="manager-mini-card">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">Компания</p>
<p class="mt-2 text-sm text-[#123824]">{{ currentClient.companyName }}</p>
</div>
<div class="manager-mini-card">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">Контакт</p>
<p class="mt-2 text-sm text-[#123824]">{{ currentClient.contactName }}</p>
</div>
<div class="manager-mini-card">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">Email</p>
<p class="mt-2 text-sm text-[#123824]">{{ currentClient.email }}</p>
</div>
<div class="manager-mini-card">
<p class="text-xs font-semibold uppercase tracking-[0.12em] text-[#5c7b69]">Обновлено</p>
<p class="mt-2 text-sm text-[#123824]">{{ new Date(currentClient.updatedAt).toLocaleString() }}</p>
</div>
</div>
</div>
<div v-if="currentClient.rejectionReason" class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Причина отказа</h2>
<p class="mt-3 text-sm text-[#a34a34]">{{ currentClient.rejectionReason }}</p>
</div>
</template>
</section>
</template>

127
app/pages/clients/index.vue Normal file
View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import {
RegistrationRequestsDocument,
type RegistrationRequestsQuery,
} from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
});
type ClientCard = RegistrationRequestsQuery['registrationRequests'][number];
const clientsQuery = useQuery(RegistrationRequestsDocument, {
status: null,
});
const search = ref('');
function statusLabel(status: ClientCard['status']) {
if (status === 'APPROVED') {
return 'Активен';
}
if (status === 'REJECTED') {
return 'Отклонен';
}
return 'На проверке';
}
function statusClass(status: ClientCard['status']) {
if (status === 'APPROVED') {
return 'badge badge-success border-0';
}
if (status === 'REJECTED') {
return 'badge badge-error border-0';
}
return 'badge badge-warning border-0';
}
const filteredClients = computed(() => {
const items = clientsQuery.result.value?.registrationRequests ?? [];
const query = search.value.trim().toLowerCase();
return items.filter((item) => {
if (!query) {
return true;
}
return [
item.companyName,
item.contactName,
item.email,
item.inn || '',
]
.join(' ')
.toLowerCase()
.includes(query);
});
});
</script>
<template>
<section class="space-y-6">
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div class="manager-hero">
<p class="manager-eyebrow">Клиенты</p>
<h1 class="manager-title">Карточки клиентов без лишней нагрузки</h1>
<p class="manager-copy">Список заявок и клиентов, с которыми менеджер уже работает.</p>
</div>
<NuxtLink to="/clients/invite" class="btn btn-primary border-0">
Пригласить клиента
</NuxtLink>
</div>
<div class="surface-card rounded-3xl p-4 md:p-5">
<div class="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
<label class="form-control">
<span class="label-text">Search</span>
<input
v-model="search"
type="text"
class="input manager-field w-full"
placeholder="Компания, контакт, email или ИНН"
>
</label>
<div class="manager-mini-card text-sm text-[#123824] md:w-56">
Всего карточек: <span class="font-semibold">{{ filteredClients.length }}</span>
</div>
</div>
</div>
<div v-if="clientsQuery.loading.value" class="manager-empty-state">
Загружаем клиентов...
</div>
<div v-else-if="filteredClients.length === 0" class="manager-empty-state">
По текущему запросу карточки клиентов не найдены.
</div>
<div v-else class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
<NuxtLink
v-for="client in filteredClients"
:key="client.id"
:to="`/clients/${client.id}`"
class="surface-card rounded-3xl p-5"
>
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<h2 class="text-lg font-bold text-[#123824]">{{ client.companyName }}</h2>
<p class="text-sm text-[#466653]">{{ client.contactName }}</p>
</div>
<span :class="statusClass(client.status)">{{ statusLabel(client.status) }}</span>
</div>
<div class="mt-4 space-y-2 text-sm text-[#355947]">
<p>{{ client.email }}</p>
<p v-if="client.inn">ИНН: {{ client.inn }}</p>
<p>{{ new Date(client.createdAt).toLocaleDateString() }}</p>
</div>
<p v-if="client.rejectionReason" class="mt-4 text-sm text-[#a34a34]">
{{ client.rejectionReason }}
</p>
</NuxtLink>
</div>
</section>
</template>

View File

@@ -0,0 +1,78 @@
<script setup lang="ts">
import { useMutation } from '@vue/apollo-composable';
import { CreateInvitationDocument } from '~/composables/graphql/generated';
definePageMeta({
middleware: ['manager-only'],
});
const email = ref('');
const companyName = ref('');
const invitationResult = ref<null | { token: string; expiresAt: string }>(null);
const createInvitationMutation = useMutation(CreateInvitationDocument);
async function createInvitation() {
invitationResult.value = null;
const response = await createInvitationMutation.mutate({
input: {
email: email.value,
companyName: companyName.value,
expiresInDays: 7,
},
});
const invitation = response?.data?.createInvitation;
if (!invitation) {
return;
}
invitationResult.value = {
token: invitation.token,
expiresAt: invitation.expiresAt,
};
}
</script>
<template>
<section class="space-y-6 max-w-3xl">
<NuxtLink to="/clients" class="text-sm font-semibold text-[#0d854a]"> Назад к клиентам</NuxtLink>
<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-5">
<div class="grid gap-3">
<label class="form-control">
<span class="label-text">Email</span>
<input v-model="email" type="email" class="input manager-field w-full" placeholder="client@example.com">
</label>
<label class="form-control">
<span class="label-text">Компания</span>
<input v-model="companyName" type="text" class="input manager-field w-full" placeholder="Название компании">
</label>
<div>
<button class="btn btn-primary border-0" @click="createInvitation">Создать инвайт</button>
</div>
</div>
</div>
<div v-if="invitationResult" class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Инвайт создан</h2>
<div class="mt-4 space-y-3 text-sm text-[#123824]">
<div class="manager-mini-card">
Токен: <span class="font-semibold">{{ invitationResult.token }}</span>
</div>
<div class="manager-mini-card">
Действует до: <span class="font-semibold">{{ new Date(invitationResult.expiresAt).toLocaleString() }}</span>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,24 @@
query ReferralStats {
referralStats {
referrerId
availableBalance
referralsCount
transactions {
id
userId
amount
reason
orderId
createdAt
}
pendingWithdrawals {
id
requesterId
amount
status
reviewComment
createdAt
updatedAt
}
}
}