feat(customers): add getCustomers API and enhance customer queries

- Introduced getCustomers action and corresponding server method to fetch customer data with pagination and sorting.
- Updated hooks to support infinite querying of customers, improving data handling in components.
- Refactored ContactsList and related components to utilize the new customer fetching logic, enhancing user experience.
- Adjusted filter labels in dropdowns for better clarity and user understanding.
This commit is contained in:
vchikalkin 2025-09-10 12:50:54 +03:00
parent 30bdc0447f
commit d8f374d5da
12 changed files with 301 additions and 105 deletions

View File

@ -4,5 +4,6 @@ import { wrapClientAction } from '@/utils/actions';
export const addInvitedBy = wrapClientAction(customers.addInvitedBy);
export const getInvited = wrapClientAction(customers.getInvited);
export const getCustomer = wrapClientAction(customers.getCustomer);
export const getCustomers = wrapClientAction(customers.getCustomers);
export const getInvitedBy = wrapClientAction(customers.getInvitedBy);
export const updateCustomer = wrapClientAction(customers.updateCustomer);

View File

@ -18,6 +18,12 @@ export async function getCustomer(...variables: Parameters<CustomersService['get
return wrapServerAction(() => service.getCustomer(...variables));
}
export async function getCustomers(...variables: Parameters<CustomersService['getCustomers']>) {
const service = await getService();
return wrapServerAction(() => service.getCustomers(...variables));
}
export async function getInvited(...variables: Parameters<CustomersService['getInvited']>) {
const service = await getService();

View File

@ -1,4 +1,4 @@
import { ContactsFilter, ContactsList } from '@/components/contacts';
import { ContactsList } from '@/components/contacts';
import { ContactsContextProvider } from '@/context/contacts';
import { Card } from '@repo/ui/components/ui/card';
@ -8,7 +8,7 @@ export default function ContactsPage() {
<Card>
<div className="flex flex-row items-center justify-between space-x-4 p-4">
<h1 className="font-bold">Контакты</h1>
<ContactsFilter />
{/* <ContactsFilter /> */}
</div>
<div className="p-4 pt-0">
<ContactsList />

View File

@ -2,25 +2,65 @@
import { DataNotFound } from '../shared/alert';
import { ContactRow } from '../shared/contact-row';
import { useCustomerContacts } from '@/hooks/api/contacts';
import { useCustomerQuery, useCustomersInfiniteQuery } from '@/hooks/api/customers';
import { Button } from '@repo/ui/components/ui/button';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
export function ContactsList() {
const { contacts, isLoading } = useCustomerContacts();
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
if (isLoading) return <LoadingSpinner />;
const {
data: { pages } = {},
fetchNextPage,
hasNextPage,
isLoading: isLoadingContacts,
} = useCustomersInfiniteQuery(
{
filters: {
or: [
{
invited: {
documentId: {
contains: customer?.documentId,
},
},
},
{
invitedBy: {
documentId: {
eq: customer?.documentId,
},
},
},
],
},
},
{ enabled: Boolean(customer?.documentId) },
);
if (!contacts.length) return <DataNotFound title="Контакты не найдены" />;
const isLoading = isLoadingCustomer || isLoadingContacts;
const contacts = pages?.flatMap((page) => page.customers);
return (
<div className="space-y-2">
{contacts.map((contact) => (
<ContactRow
description={contact.services.map((service) => service?.name).join(', ')}
key={contact.documentId}
{...contact}
/>
))}
<div className="flex flex-col space-y-2">
{isLoading && <LoadingSpinner />}
{!isLoading && !contacts?.length ? <DataNotFound title="Контакты не найдены" /> : null}
{contacts?.map(
(contact) =>
contact && (
<ContactRow
description={contact.services.map((service) => service?.name).join(', ')}
key={contact.documentId}
{...contact}
/>
),
)}
{hasNextPage && (
<Button onClick={() => fetchNextPage()} variant="ghost">
Загрузить еще
</Button>
)}
</div>
);
}

View File

@ -14,7 +14,7 @@ import { use } from 'react';
const filterLabels: Record<FilterType, string> = {
all: 'Все',
invited: 'Приглашенные',
invitedBy: 'Кто пригласил',
invitedBy: 'Пригласили вас',
};
export function ContactsFilter() {
@ -29,9 +29,13 @@ export function ContactsFilter() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setFilter('all')}>Все</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilter('invited')}>Приглашенные</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilter('invitedBy')}>Кто пригласил</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilter('all')}>{filterLabels['all']}</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilter('invited')}>
{filterLabels['invited']}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setFilter('invitedBy')}>
{filterLabels['invitedBy']}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@ -3,114 +3,186 @@
import { DataNotFound } from '@/components/shared/alert';
import { UserAvatar } from '@/components/shared/user-avatar';
import { CardSectionHeader } from '@/components/ui';
import { ContactsContextProvider } from '@/context/contacts';
import { useCustomerContacts } from '@/hooks/api/contacts';
import { useCustomerQuery } from '@/hooks/api/customers';
import { useCustomerQuery, useCustomersInfiniteQuery } from '@/hooks/api/customers';
import { useOrderStore } from '@/stores/order';
import { withContext } from '@/utils/context';
import { type CustomerFieldsFragment } from '@repo/graphql/types';
import { Button } from '@repo/ui/components/ui/button';
import { Card } from '@repo/ui/components/ui/card';
import { Label } from '@repo/ui/components/ui/label';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { cn } from '@repo/ui/lib/utils';
import { sift } from 'radashi';
type ContactsGridProps = {
readonly contacts: CustomerFieldsFragment[];
readonly hasNextPage?: boolean;
readonly isLoading?: boolean;
readonly onClick: () => void;
readonly onFetchNextPage?: () => void;
readonly onSelect: (contactId: null | string) => void;
readonly selected?: null | string;
readonly title: string;
};
export function ContactsGridBase({ contacts, onSelect, selected, title }: ContactsGridProps) {
export function ClientsGrid() {
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts();
const clientId = useOrderStore((store) => store.clientId);
const setClientId = useOrderStore((store) => store.setClientId);
const masterId = useOrderStore((store) => store.masterId);
return (
<ContactsGridBase
contacts={contacts.filter((contact) => contact.documentId !== masterId)}
hasNextPage={Boolean(hasNextPage)}
isLoading={isLoading}
onClick={() => {
if (clientId) setClientId(null);
}}
onFetchNextPage={fetchNextPage}
onSelect={(contactId) => setClientId(contactId)}
selected={clientId}
title="Выбор клиента"
/>
);
}
export function ContactsGridBase({
contacts,
hasNextPage,
isLoading,
onClick,
onFetchNextPage,
onSelect,
selected,
title,
}: ContactsGridProps) {
const { data: { customer } = {} } = useCustomerQuery();
return (
<Card className="p-4">
<div className="flex flex-col gap-4">
<CardSectionHeader title={title} />
{isLoading && <LoadingSpinner />}
{!isLoading && (!contacts || contacts.length === 0) ? (
<DataNotFound title="Контакты не найдены" />
) : null}
<div className="grid max-h-screen grid-cols-4 gap-2 overflow-y-auto">
{contacts.map((contact) => {
if (!contact) return null;
{!isLoading &&
contacts?.map((contact) => {
if (!contact) return null;
const isCurrentUser = contact?.documentId === customer?.documentId;
const isCurrentUser = contact.documentId === customer?.documentId;
return (
<Label
className="flex cursor-pointer flex-col items-center"
key={contact?.documentId}
>
<input
checked={selected === contact?.documentId}
className="hidden"
name="user"
onChange={() => onSelect(contact?.documentId)}
type="radio"
value={contact?.documentId}
/>
<div
className={cn(
'rounded-full border-2 transition-all duration-75',
selected === contact?.documentId ? 'border-primary' : 'border-transparent',
)}
return (
<Label
className="flex cursor-pointer flex-col items-center"
key={contact.documentId}
>
<UserAvatar {...contact} size="md" />
</div>
<span
className={cn(
'mt-2 max-w-20 break-words text-center text-sm font-medium',
isCurrentUser && 'font-bold',
)}
>
{contact?.name}
</span>
</Label>
);
})}
<input
checked={selected === contact.documentId}
className="hidden"
name="user"
onChange={() => onSelect(contact.documentId)}
onClick={onClick}
type="radio"
value={contact.documentId}
/>
<div
className={cn(
'rounded-full border-2 transition-all duration-75',
selected === contact.documentId ? 'border-primary' : 'border-transparent',
)}
>
<UserAvatar {...contact} size="md" />
</div>
<span
className={cn(
'mt-2 max-w-20 break-words text-center text-sm font-medium',
isCurrentUser && 'font-bold',
)}
>
{contact.name}
</span>
</Label>
);
})}
</div>
{hasNextPage && onFetchNextPage && (
<Button onClick={onFetchNextPage} variant="ghost">
Загрузить еще
</Button>
)}
</div>
</Card>
);
}
export const MastersGrid = withContext(ContactsContextProvider)(function () {
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
const { contacts, isLoading: isLoadingContacts } = useCustomerContacts();
export function MastersGrid() {
const { contacts, fetchNextPage, hasNextPage, isLoading } = useContacts();
const masterId = useOrderStore((store) => store.masterId);
const setMasterId = useOrderStore((store) => store.setMasterId);
const isLoading = isLoadingContacts || isLoadingCustomer;
if (isLoading) return <LoadingSpinner />;
const mastersContacts = (customer ? [{ ...customer, name: 'Я' }] : []).concat(contacts);
if (!mastersContacts.length) return <DataNotFound title="Контакты не найдены" />;
const clientId = useOrderStore((store) => store.clientId);
return (
<ContactsGridBase
contacts={mastersContacts.filter((contact) => contact.active)}
contacts={contacts.filter((contact) => contact.documentId !== clientId)}
hasNextPage={Boolean(hasNextPage)}
isLoading={isLoading}
onClick={() => {
if (masterId) setMasterId(null);
}}
onFetchNextPage={fetchNextPage}
onSelect={(contactId) => setMasterId(contactId)}
selected={masterId}
title="Выбор мастера"
/>
);
});
}
export const ClientsGrid = withContext(ContactsContextProvider)(function () {
const { contacts, isLoading } = useCustomerContacts();
const clientId = useOrderStore((store) => store.clientId);
const setClientId = useOrderStore((store) => store.setClientId);
function useContacts() {
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
if (isLoading) return <LoadingSpinner />;
if (!contacts.length) return <DataNotFound title="Контакты не найдены" />;
return (
<ContactsGridBase
contacts={contacts.filter((contact) => contact.active)}
onSelect={(contactId) => setClientId(contactId)}
selected={clientId}
title="Выбор клиента"
/>
const {
data: { pages } = { pages: [] },
isLoading: isLoadingContacts,
...query
} = useCustomersInfiniteQuery(
{
filters: {
or: [
{
invited: {
documentId: {
contains: customer?.documentId,
},
},
},
{
invitedBy: {
documentId: {
eq: customer?.documentId,
},
},
},
],
},
},
{ enabled: Boolean(customer?.documentId) },
);
});
const isLoading = isLoadingContacts || isLoadingCustomer;
const contacts = sift(
pages
.flatMap((page) => page.customers)
.filter((contact) => Boolean(contact && contact?.active)),
);
return {
isLoading,
...query,
contacts: [{ ...customer, name: 'Я' } as CustomerFieldsFragment, ...contacts],
};
}

View File

@ -1,8 +1,8 @@
'use client';
import { getCustomer, updateCustomer } from '@/actions/api/customers';
import { getCustomer, getCustomers, updateCustomer } from '@/actions/api/customers';
import { isCustomerBanned } from '@repo/utils/customer';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
export const useCustomerQuery = (variables?: Parameters<typeof getCustomer>[0]) => {
@ -17,6 +17,44 @@ export const useCustomerQuery = (variables?: Parameters<typeof getCustomer>[0])
});
};
export const useCustomersQuery = (
variables: Parameters<typeof getCustomers>[0],
enabled?: boolean,
) =>
useQuery({
enabled,
queryFn: () => getCustomers(variables),
queryKey: ['customers', variables],
staleTime: 60 * 1_000,
});
export const useCustomersInfiniteQuery = (
variables: Omit<Parameters<typeof getCustomers>[0], 'pagination'>,
{ enabled = true, pageSize = 10 } = {},
) => {
const queryFunction = ({ pageParam: page = 1 }) =>
getCustomers({
...variables,
pagination: {
page,
pageSize,
},
});
return useInfiniteQuery({
enabled,
getNextPageParam: (lastPage, _allPages, lastPageParameter) => {
if (!lastPage?.customers?.length) return undefined;
return lastPageParameter + 1;
},
initialPageParam: 1,
queryFn: queryFunction,
queryKey: ['customers', variables, 'infinite'],
staleTime: 60 * 1_000,
});
};
export const useIsBanned = () => {
const { data: { customer } = {} } = useCustomerQuery();

View File

@ -52,8 +52,6 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
setStep(UNIFIED_STEPS[0] ?? 'loading');
}
} else {
// Обычная инициализация (новый заказ) - начинаем с выбора мастера
setClientId(customer.documentId);
setStep(UNIFIED_STEPS[0] ?? 'loading');
}

View File

@ -4,6 +4,8 @@ import * as GQL from '../types';
import { BaseService } from './base';
import { type VariablesOf } from '@graphql-typed-document-node/core';
const DEFAULT_CUSTOMERS_SORT = ['name:asc'];
export class CustomersService extends BaseService {
async addInvitedBy(variables: VariablesOf<typeof GQL.UpdateCustomerDocument>) {
await this.checkIsBanned();
@ -58,6 +60,22 @@ export class CustomersService extends BaseService {
return { customer };
}
async getCustomers(variables: VariablesOf<typeof GQL.GetCustomersDocument>) {
await this.checkIsBanned();
const { query } = await getClientWithToken();
const result = await query({
query: GQL.GetCustomersDocument,
variables: {
sort: DEFAULT_CUSTOMERS_SORT,
...variables,
},
});
return result.data;
}
async getInvited(variables?: VariablesOf<typeof GQL.GetInvitedDocument>) {
await this.checkIsBanned();

View File

@ -31,6 +31,7 @@ export const ERRORS = {
NO_MASTER_SELF_BOOK: 'Нельзя записать к самому себе',
NO_ORDER_IN_PAST: 'Нельзя создать запись на время в прошлом',
NO_ORDER_OUT_OF_SLOT: 'Время заказа выходит за пределы слота',
NO_SELF_ORDER: 'Нельзя записать к самому себе',
NOT_FOUND_CLIENT: 'Клиент не найден',
NOT_FOUND_MASTER: 'Мастер не найден',
NOT_FOUND_ORDER: 'Заказ не найден',
@ -255,6 +256,8 @@ export class OrdersService extends BaseService {
if (!slot) throw new Error(ERRORS.MISSING_SLOT);
if (clientId === slot?.master?.documentId) throw new Error(ERRORS.NO_SELF_ORDER);
// Проверка, что заказ укладывается в рамки слота
if (
new Date(datetime_start) < new Date(slot.datetime_start) ||

View File

@ -33,6 +33,18 @@ query GetCustomer($phone: String, $telegramId: Long, $documentId: ID) {
}
}
mutation UpdateCustomer($documentId: ID!, $data: CustomerInput!) {
updateCustomer(documentId: $documentId, data: $data) {
...CustomerFields
}
}
query GetCustomers($filters: CustomerFiltersInput, $pagination: PaginationArg, $sort: [String!]) {
customers(filters: $filters, pagination: $pagination, sort: $sort) {
...CustomerFields
}
}
query GetInvitedBy($phone: String, $telegramId: Long, $documentId: ID) {
customers(
filters: {
@ -64,9 +76,3 @@ query GetInvited($phone: String, $telegramId: Long) {
}
}
}
mutation UpdateCustomer($documentId: ID!, $data: CustomerInput!) {
updateCustomer(documentId: $documentId, data: $data) {
...CustomerFields
}
}

File diff suppressed because one or more lines are too long