Add user and manager order detail pages

This commit is contained in:
Ruslan Bakiev
2026-04-04 10:36:05 +07:00
parent 67e377fbe0
commit c4ce422221
8 changed files with 294 additions and 75 deletions

View File

@@ -542,6 +542,7 @@ export type QueryManagerNotificationHistoryArgs = {
export type QueryManagerOrdersArgs = {
customerId?: InputMaybe<Scalars['ID']['input']>;
status?: InputMaybe<OrderStatus>;
};
@@ -842,11 +843,17 @@ export type ManagerFinalizeOrderMutation = { __typename?: 'Mutation', managerFin
export type ManagerOrdersQueryVariables = Exact<{
status?: InputMaybe<OrderStatus>;
customerId?: InputMaybe<Scalars['ID']['input']>;
}>;
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 ManagerUsersDetailQueryVariables = Exact<{ [key: string]: never; }>;
export type ManagerUsersDetailQuery = { __typename?: 'Query', managerUsers: Array<{ __typename?: 'ManagerUser', id: string, email: string, fullName: string, companyName?: string | null, inn?: string | null, createdAt: any, orderCount: number, lastOrderAt?: any | null, telegramConnection?: { __typename?: 'MessengerConnection', id: string, type: MessengerType, channelId: string, displayName?: string | null, username?: string | null, avatarAvailable: boolean } | null }> };
export type ManagerUsersQueryVariables = Exact<{ [key: string]: never; }>;
@@ -1690,8 +1697,8 @@ export function useManagerFinalizeOrderMutation(options: VueApolloComposable.Use
}
export type ManagerFinalizeOrderMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<ManagerFinalizeOrderMutation, ManagerFinalizeOrderMutationVariables>;
export const ManagerOrdersDocument = gql`
query ManagerOrders($status: OrderStatus) {
managerOrders(status: $status) {
query ManagerOrders($status: OrderStatus, $customerId: ID) {
managerOrders(status: $status, customerId: $customerId) {
id
code
status
@@ -1724,6 +1731,7 @@ export const ManagerOrdersDocument = gql`
* @example
* const { result, loading, error } = useManagerOrdersQuery({
* status: // value for 'status'
* customerId: // value for 'customerId'
* });
*/
export function useManagerOrdersQuery(variables: ManagerOrdersQueryVariables | VueCompositionApi.Ref<ManagerOrdersQueryVariables> | ReactiveFunction<ManagerOrdersQueryVariables> = {}, options: VueApolloComposable.UseQueryOptions<ManagerOrdersQuery, ManagerOrdersQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<ManagerOrdersQuery, ManagerOrdersQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<ManagerOrdersQuery, ManagerOrdersQueryVariables>> = {}) {
@@ -1733,6 +1741,48 @@ export function useManagerOrdersLazyQuery(variables: ManagerOrdersQueryVariables
return VueApolloComposable.useLazyQuery<ManagerOrdersQuery, ManagerOrdersQueryVariables>(ManagerOrdersDocument, variables, options);
}
export type ManagerOrdersQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<ManagerOrdersQuery, ManagerOrdersQueryVariables>;
export const ManagerUsersDetailDocument = gql`
query ManagerUsersDetail {
managerUsers {
id
email
fullName
companyName
inn
createdAt
orderCount
lastOrderAt
telegramConnection {
id
type
channelId
displayName
username
avatarAvailable
}
}
}
`;
/**
* __useManagerUsersDetailQuery__
*
* To run a query within a Vue component, call `useManagerUsersDetailQuery` and pass it any options that fit your needs.
* When your component renders, `useManagerUsersDetailQuery` 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 } = useManagerUsersDetailQuery();
*/
export function useManagerUsersDetailQuery(options: VueApolloComposable.UseQueryOptions<ManagerUsersDetailQuery, ManagerUsersDetailQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<ManagerUsersDetailQuery, ManagerUsersDetailQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<ManagerUsersDetailQuery, ManagerUsersDetailQueryVariables>> = {}) {
return VueApolloComposable.useQuery<ManagerUsersDetailQuery, ManagerUsersDetailQueryVariables>(ManagerUsersDetailDocument, {}, options);
}
export function useManagerUsersDetailLazyQuery(options: VueApolloComposable.UseQueryOptions<ManagerUsersDetailQuery, ManagerUsersDetailQueryVariables> | VueCompositionApi.Ref<VueApolloComposable.UseQueryOptions<ManagerUsersDetailQuery, ManagerUsersDetailQueryVariables>> | ReactiveFunction<VueApolloComposable.UseQueryOptions<ManagerUsersDetailQuery, ManagerUsersDetailQueryVariables>> = {}) {
return VueApolloComposable.useLazyQuery<ManagerUsersDetailQuery, ManagerUsersDetailQueryVariables>(ManagerUsersDetailDocument, {}, options);
}
export type ManagerUsersDetailQueryCompositionFunctionResult = VueApolloComposable.UseQueryReturn<ManagerUsersDetailQuery, ManagerUsersDetailQueryVariables>;
export const ManagerUsersDocument = gql`
query ManagerUsers {
managerUsers {

View File

@@ -1,58 +1,94 @@
<script setup lang="ts">
import { useMutation, useQuery } from '@vue/apollo-composable';
import {
ManagerOrdersDocument,
ManagerUsersDetailDocument,
RegistrationRequestsDocument,
ReviewRegistrationRequestDocument,
type ManagerOrdersQuery,
type ManagerUsersDetailQuery,
type RegistrationRequestsQuery,
} from '~/composables/graphql/generated';
import { messengerConnectionAvatarSrc } from '~/composables/useMessengerConnectionPresentation';
definePageMeta({
middleware: ['manager-only'],
});
type ManagerUserItem = ManagerUsersDetailQuery['managerUsers'][number];
type ManagerOrderItem = ManagerOrdersQuery['managerOrders'][number];
type RequestItem = RegistrationRequestsQuery['registrationRequests'][number];
const route = useRoute();
const requestId = computed(() => String(route.params.id || ''));
const entityId = computed(() => String(route.params.id || ''));
const isRequestMode = computed(() => route.query.tab === 'requests');
const backTarget = computed(() => (
route.query.tab === 'requests' ? '/clients?tab=requests' : '/clients'
isRequestMode.value ? '/clients?tab=requests' : '/clients'
));
const clientQuery = useQuery(RegistrationRequestsDocument, {
const usersQuery = useQuery(ManagerUsersDetailDocument);
const requestsQuery = useQuery(RegistrationRequestsDocument, {
status: null,
});
const userOrdersQuery = useQuery(ManagerOrdersDocument, () => ({
status: null,
customerId: isRequestMode.value ? null : entityId.value,
}));
const reviewMutation = useMutation(ReviewRegistrationRequestDocument);
const currentClient = computed(() =>
(clientQuery.result.value?.registrationRequests ?? []).find((item) => item.id === requestId.value),
const currentUser = computed<ManagerUserItem | null>(() =>
(usersQuery.result.value?.managerUsers ?? []).find((item: ManagerUserItem) => item.id === entityId.value) ?? null,
);
const currentRequest = computed(() =>
(requestsQuery.result.value?.registrationRequests ?? []).find((item: RequestItem) => item.id === entityId.value),
);
const currentUserOrders = computed<ManagerOrderItem[]>(() => userOrdersQuery.result.value?.managerOrders ?? []);
function userInitials(fullName: string) {
const parts = fullName
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2);
if (!parts.length) {
return 'FR';
}
return parts.map((part) => part[0]?.toUpperCase() ?? '').join('');
}
async function approveRequest() {
if (!currentClient.value) {
if (!currentRequest.value) {
return;
}
await reviewMutation.mutate({
input: {
requestId: currentClient.value.id,
requestId: currentRequest.value.id,
decision: 'APPROVE',
},
});
await clientQuery.refetch({ status: null });
await requestsQuery.refetch({ status: null });
}
async function rejectRequest() {
if (!currentClient.value) {
if (!currentRequest.value) {
return;
}
await reviewMutation.mutate({
input: {
requestId: currentClient.value.id,
requestId: currentRequest.value.id,
decision: 'REJECT',
rejectionReason: 'Не хватает данных для регистрации.',
},
});
await clientQuery.refetch({ status: null });
await requestsQuery.refetch({ status: null });
}
</script>
@@ -60,71 +96,106 @@ async function rejectRequest() {
<section class="space-y-6">
<NuxtLink :to="backTarget" class="text-sm font-semibold text-[#0d854a]"> Назад к пользователям</NuxtLink>
<div v-if="clientQuery.loading.value" class="manager-empty-state">
Загружаем карточку клиента...
</div>
<template v-if="isRequestMode">
<div v-if="requestsQuery.loading.value" class="manager-empty-state">
Загружаем карточку клиента...
</div>
<div v-else-if="!currentClient" class="manager-empty-state">
Карточка клиента не найдена.
</div>
<div v-else-if="!currentRequest" 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">{{ currentRequest.companyName }}</h1>
<p class="manager-copy">Контакт: {{ currentRequest.contactName }} · {{ currentRequest.email }}</p>
</div>
<div v-if="currentRequest.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">
{{ currentRequest.status === 'APPROVED' ? 'Активен' : currentRequest.status === 'REJECTED' ? 'Отклонен' : 'На проверке' }}
</p>
</div>
<div class="manager-stat-card">
<p class="manager-stat-label">Дата заявки</p>
<p class="manager-stat-value text-lg">{{ new Date(currentRequest.createdAt).toLocaleDateString() }}</p>
</div>
<div class="manager-stat-card">
<p class="manager-stat-label">ИНН</p>
<p class="manager-stat-value text-lg">{{ currentRequest.inn || 'Не указан' }}</p>
</div>
</div>
</template>
</template>
<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 v-if="usersQuery.loading.value || userOrdersQuery.loading.value" class="manager-empty-state">
Загружаем пользователя...
</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 v-else-if="!currentUser" class="manager-empty-state">
Пользователь не найден.
</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>
<template v-else>
<div class="surface-card flex flex-col gap-6 rounded-[36px] p-6 md:flex-row md:items-center">
<div class="shrink-0">
<img
v-if="messengerConnectionAvatarSrc(currentUser.telegramConnection)"
:src="messengerConnectionAvatarSrc(currentUser.telegramConnection)"
:alt="currentUser.fullName"
class="h-28 w-28 rounded-[36px] object-cover shadow-[0_12px_30px_rgba(18,56,36,0.14)]"
>
<div
v-else
class="flex h-28 w-28 items-center justify-center rounded-[36px] bg-[linear-gradient(135deg,#dff7e9_0%,#c2ead3_100%)] text-4xl font-black text-[#123824]"
>
{{ userInitials(currentUser.fullName) }}
</div>
</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 class="space-y-2">
<p class="manager-eyebrow">Пользователь</p>
<h1 class="manager-title">{{ currentUser.fullName }}</h1>
<p class="text-sm text-[#466653]">{{ currentUser.email }}</p>
<p v-if="currentUser.companyName" class="text-sm text-[#466653]">{{ currentUser.companyName }}</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>
<div class="surface-card rounded-3xl p-5">
<h2 class="text-xl font-bold text-[#123824]">Заказы пользователя</h2>
<div v-if="currentUserOrders.length === 0" class="manager-empty-state mt-4">
У пользователя пока нет заказов.
</div>
<div v-else class="mt-4 space-y-3">
<NuxtLink
v-for="order in currentUserOrders"
:key="order.id"
:to="`/client-orders/${order.id}`"
class="manager-mini-card block"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-sm font-semibold text-[#123824]">{{ order.code }}</p>
<p class="text-sm text-[#355947]">{{ new Date(order.createdAt).toLocaleString() }}</p>
<p v-if="order.deliveryAddress" class="text-sm text-[#355947]">{{ order.deliveryAddress }}</p>
</div>
<p class="text-sm font-semibold text-[#466653]">{{ order.status }}</p>
</div>
</NuxtLink>
</div>
</div>
</template>
</template>
</section>
</template>

View File

@@ -147,9 +147,10 @@ function userInitials(fullName: string) {
Пользователи по текущему запросу не найдены.
</div>
<div v-else class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-6">
<article
<NuxtLink
v-for="user in filteredUsers"
:key="user.id"
:to="`/clients/${user.id}`"
class="surface-card flex min-h-[280px] flex-col rounded-[32px] p-6"
>
<div class="flex justify-center">
@@ -172,7 +173,7 @@ function userInitials(fullName: string) {
<div class="pt-8 text-center">
<h2 class="text-lg font-bold leading-tight text-[#123824]">{{ user.fullName }}</h2>
</div>
</article>
</NuxtLink>
</div>
</template>

View File

@@ -69,10 +69,11 @@ const filteredOrders = computed(() => {
</div>
<div v-else class="space-y-3">
<article
<NuxtLink
v-for="order in filteredOrders"
:key="order.id"
class="surface-card rounded-3xl p-4 md:p-5"
:to="`/orders/${order.id}`"
class="surface-card block rounded-3xl p-4 md:p-5"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
@@ -95,7 +96,7 @@ const filteredOrders = computed(() => {
{{ item.productName }} × {{ item.quantity }}
</li>
</ul>
</article>
</NuxtLink>
</div>
</section>
</template>

76
app/pages/orders/[id].vue Normal file
View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { useQuery } from '@vue/apollo-composable';
import OrderStatusBadge from '~/components/orders/OrderStatusBadge.vue';
import {
MyOrdersDocument,
type MyOrdersQuery,
} from '~/composables/graphql/generated';
type OrderItem = MyOrdersQuery['myOrders'][number];
const route = useRoute();
const orderId = computed(() => String(route.params.id || ''));
const ordersQuery = useQuery(MyOrdersDocument);
const currentOrder = computed<OrderItem | null>(() =>
(ordersQuery.result.value?.myOrders ?? []).find((item: OrderItem) => item.id === orderId.value) ?? null,
);
</script>
<template>
<section class="space-y-6">
<NuxtLink to="/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="manager-hero">
<p class="manager-eyebrow">Заказ</p>
<h1 class="manager-title">{{ currentOrder.code }}</h1>
</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 flex flex-wrap items-center justify-between gap-3">
<div class="space-y-1 text-sm text-[#355947]">
<p>Создан: {{ new Date(currentOrder.createdAt).toLocaleString() }}</p>
</div>
<OrderStatusBadge :status="currentOrder.status" />
</div>
</div>
<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>
</div>
</div>
</template>
</section>
</template>