Simplify manager cabinet flows
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
139
app/pages/bonus-system/index.vue
Normal file
139
app/pages/bonus-system/index.vue
Normal 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>
|
||||||
49
app/pages/bonus-system/referrals/new.vue
Normal file
49
app/pages/bonus-system/referrals/new.vue
Normal 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>
|
||||||
64
app/pages/bonus-system/transactions/new.vue
Normal file
64
app/pages/bonus-system/transactions/new.vue
Normal 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>
|
||||||
87
app/pages/bonus-system/withdrawals/[id].vue
Normal file
87
app/pages/bonus-system/withdrawals/[id].vue
Normal 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>
|
||||||
@@ -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>
|
|
||||||
197
app/pages/client-orders/[id].vue
Normal file
197
app/pages/client-orders/[id].vue
Normal 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>
|
||||||
126
app/pages/client-orders/index.vue
Normal file
126
app/pages/client-orders/index.vue
Normal 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>
|
||||||
@@ -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
127
app/pages/clients/[id].vue
Normal 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
127
app/pages/clients/index.vue
Normal 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>
|
||||||
78
app/pages/clients/invite.vue
Normal file
78
app/pages/clients/invite.vue
Normal 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>
|
||||||
24
graphql/operations/manager/referral-stats.graphql
Normal file
24
graphql/operations/manager/referral-stats.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user