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:
parent
30bdc0447f
commit
d8f374d5da
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -52,8 +52,6 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
|
||||
setStep(UNIFIED_STEPS[0] ?? 'loading');
|
||||
}
|
||||
} else {
|
||||
// Обычная инициализация (новый заказ) - начинаем с выбора мастера
|
||||
setClientId(customer.documentId);
|
||||
setStep(UNIFIED_STEPS[0] ?? 'loading');
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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) ||
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user