refactor(contact): remove customer master role checks and simplify contact addition

- Updated the `addContact` function to allow all users to add contacts, removing the previous restriction that only masters could do so.
- Deleted the `become-master` feature and related utility functions, streamlining the codebase.
- Adjusted command settings to reflect the removal of the master role functionality.
- Refactored components and hooks to eliminate dependencies on the master role, enhancing user experience and simplifying logic.
This commit is contained in:
vchikalkin 2025-09-08 12:51:35 +03:00
parent 49c43296e4
commit 5dfef524e2
28 changed files with 171 additions and 348 deletions

View File

@ -1,14 +1,13 @@
/* eslint-disable id-length */
import { type Context } from '@/bot/context';
import { KEYBOARD_REMOVE, KEYBOARD_SHARE_BOT, KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { isCustomerMaster } from '@/utils/customer';
import { isValidPhoneNumber, normalizePhoneNumber } from '@/utils/phone';
import { type Conversation } from '@grammyjs/conversations';
import { CustomersService } from '@repo/graphql/api/customers';
import { RegistrationService } from '@repo/graphql/api/registration';
export async function addContact(conversation: Conversation<Context, Context>, ctx: Context) {
// Проверяем, что пользователь является мастером
// Все пользователи могут добавлять контакты
const telegramId = ctx.from?.id;
if (!telegramId) {
return ctx.reply(await conversation.external(({ t }) => t('err-generic')));
@ -24,10 +23,6 @@ export async function addContact(conversation: Conversation<Context, Context>, c
);
}
if (!isCustomerMaster(customer)) {
return ctx.reply(await conversation.external(({ t }) => t('msg-not-master')));
}
// Просим отправить контакт клиента
await ctx.reply(await conversation.external(({ t }) => t('msg-send-client-contact')));

View File

@ -1,42 +0,0 @@
import { type Context } from '@/bot/context';
import { logHandle } from '@/bot/helpers/logging';
import { KEYBOARD_SHARE_PHONE } from '@/config/keyboards';
import { isCustomerMaster } from '@/utils/customer';
import { CustomersService } from '@repo/graphql/api/customers';
import { Enum_Customer_Role } from '@repo/graphql/types';
import { Composer } from 'grammy';
const composer = new Composer<Context>();
const feature = composer.chatType('private');
feature.command('becomemaster', logHandle('command-become-master'), async (ctx) => {
const telegramId = ctx.from.id;
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (!customer) {
return ctx.reply(ctx.t('msg-need-phone'), { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
}
if (isCustomerMaster(customer)) {
return ctx.reply(ctx.t('msg-already-master'), { parse_mode: 'HTML' });
}
// Обновляем роль клиента на мастер
const response = await customerService
.updateCustomer({
data: { role: Enum_Customer_Role.Master },
})
.catch((error) => {
ctx.reply(ctx.t('err-with-details', { error: String(error) }), { parse_mode: 'HTML' });
});
if (response) {
return ctx.reply(ctx.t('msg-become-master'), { parse_mode: 'HTML' });
}
return ctx.reply(ctx.t('err-generic'), { parse_mode: 'HTML' });
});
export { composer as becomeMaster };

View File

@ -1,5 +1,4 @@
export * from './add-contact';
export * from './become-master';
export * from './help';
export * from './registration';
export * from './share-bot';

View File

@ -5,7 +5,7 @@ import { type LanguageCode } from '@grammyjs/types';
import { type Api, type Bot, type RawApi } from 'grammy';
export async function setCommands({ api }: Bot<Context, Api<RawApi>>) {
const commands = createCommands(['start', 'addcontact', 'becomemaster', 'sharebot', 'help']);
const commands = createCommands(['start', 'addcontact', 'sharebot', 'help']);
for (const command of commands) {
addLocalizations(command);

View File

@ -1,5 +0,0 @@
import * as GQL from '@repo/graphql/types';
export function isCustomerMaster(customer: GQL.CustomerFieldsFragment) {
return customer?.role === GQL.Enum_Customer_Role.Master;
}

View File

@ -3,9 +3,7 @@ import { getSessionUser } from '@/actions/session';
import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation';
import { ContactDataCard, PersonCard, ProfileOrdersList } from '@/components/profile';
import { MasterServicesList } from '@/components/profile/services';
import { BookButton } from '@/components/shared/book-button';
import { isCustomerMaster } from '@repo/utils/customer';
import { ReadonlyServicesList } from '@/components/profile/services';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
// Тип параметров страницы
@ -32,27 +30,14 @@ export default async function ProfilePage(props: Readonly<Props>) {
// Проверка наличия данных
if (!profile || !currentUser) return null;
// Определяем роли и id
const isMaster = isCustomerMaster(currentUser);
const isProfileMaster = isCustomerMaster(profile);
const masterId = isMaster ? currentUser.documentId : profile.documentId;
const clientId = isMaster ? profile.documentId : currentUser.documentId;
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Профиль контакта" />
<Container className="px-0">
<PersonCard telegramId={contactTelegramId} />
<ContactDataCard telegramId={contactTelegramId} />
{isProfileMaster && <MasterServicesList masterId={profile.documentId} />}
<ReadonlyServicesList masterId={profile.documentId} />
<ProfileOrdersList telegramId={contactTelegramId} />
{masterId && clientId && (
<BookButton
clientId={clientId}
label={isMaster ? 'Записать' : 'Записаться'}
masterId={masterId}
/>
)}
</Container>
</HydrationBoundary>
);

View File

@ -2,25 +2,22 @@ import { getCustomer } from '@/actions/api/customers';
import { getSessionUser } from '@/actions/session';
import { Container } from '@/components/layout';
import { LinksCard, PersonCard, ProfileDataCard, SubscriptionInfoBar } from '@/components/profile';
import { isCustomerMaster } from '@repo/utils/customer';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
export default async function ProfilePage() {
const queryClient = new QueryClient();
const { telegramId } = await getSessionUser();
const { customer } = await queryClient.fetchQuery({
await queryClient.prefetchQuery({
queryFn: () => getCustomer({ telegramId }),
queryKey: ['customer', telegramId],
});
const isMaster = customer && isCustomerMaster(customer);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<Container className="px-0">
<PersonCard />
{isMaster ? <SubscriptionInfoBar /> : null}
<SubscriptionInfoBar />
<ProfileDataCard />
<LinksCard />
</Container>

View File

@ -1,6 +1,4 @@
import { getCustomer } from '@/actions/api/customers';
import { getSlot } from '@/actions/api/slots';
import { getSessionUser } from '@/actions/session';
import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation';
import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule';
@ -21,22 +19,13 @@ export default async function SlotPage(props: Readonly<Props>) {
queryKey: ['slot', documentId],
});
// Получаем текущего пользователя
const sessionUser = await getSessionUser();
const { customer: currentUser } = await queryClient.fetchQuery({
queryFn: () => getCustomer({ telegramId: sessionUser.telegramId }),
queryKey: ['customer', sessionUser.telegramId],
});
const masterId = currentUser?.documentId;
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Слот" />
<Container>
<SlotDateTime {...parameters} />
<SlotOrdersList {...parameters} />
{masterId && <BookButton label="Создать запись" masterId={masterId} />}
<BookButton label="Создать запись" />
<div className="pb-24" />
<SlotButtons {...parameters} />
</Container>

View File

@ -1,9 +1,10 @@
/* eslint-disable complexity */
/* eslint-disable canonical/id-match */
'use client';
import FloatingActionPanel from '../shared/action-panel';
import { type OrderComponentProps } from './types';
import { useIsMaster } from '@/hooks/api/customers';
import { useCustomerQuery } from '@/hooks/api/customers';
import { useOrderMutation, useOrderQuery } from '@/hooks/api/orders';
import { usePushWithData } from '@/hooks/url';
import { Enum_Order_State } from '@repo/graphql/types';
@ -12,13 +13,16 @@ import { isBeforeNow } from '@repo/utils/datetime-format';
export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
const push = usePushWithData();
const isMaster = useIsMaster();
const { data: { customer } = {} } = useCustomerQuery();
const { data: { order } = {} } = useOrderQuery({ documentId });
const { isPending, mutate: updateOrder } = useOrderMutation({ documentId });
if (!order) return null;
if (!order || !customer) return null;
// Проверяем роль относительно конкретного заказа
const isOrderMaster = order.slot?.master?.documentId === customer.documentId;
const isOrderClient = order.client?.documentId === customer.documentId;
const isCreated = order?.state === Enum_Order_State.Created;
const isApproved = order?.state === Enum_Order_State.Approved;
@ -27,21 +31,21 @@ export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
const isCancelled = order?.state === Enum_Order_State.Cancelled;
function handleApprove() {
if (isMaster) {
if (isOrderMaster) {
updateOrder({ data: { state: Enum_Order_State.Approved } });
}
}
function handleCancel() {
if (isMaster) {
if (isOrderMaster) {
updateOrder({ data: { state: Enum_Order_State.Cancelled } });
} else {
} else if (isOrderClient) {
updateOrder({ data: { state: Enum_Order_State.Cancelling } });
}
}
function handleOnComplete() {
if (isMaster) {
if (isOrderMaster) {
updateOrder({ data: { state: Enum_Order_State.Completed } });
}
}
@ -52,11 +56,11 @@ export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
const isOrderStale = order?.datetime_start && isBeforeNow(order?.datetime_start);
const canCancel = !isOrderStale && (isCreated || (isMaster && isCancelling) || isApproved);
const canComplete = isMaster && isApproved;
const canConfirm = !isOrderStale && isMaster && isCreated;
const canCancel = !isOrderStale && (isCreated || (isOrderMaster && isCancelling) || isApproved);
const canComplete = isOrderMaster && isApproved;
const canConfirm = !isOrderStale && isOrderMaster && isCreated;
const canRepeat = isCancelled || isCompleted;
const canReturn = !isOrderStale && isMaster && isCancelled;
const canReturn = !isOrderStale && isOrderMaster && isCancelled;
return (
<FloatingActionPanel

View File

@ -1,6 +1,6 @@
'use client';
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
import { useCustomerQuery } from '@/hooks/api/customers';
import { useOrdersQuery } from '@/hooks/api/orders';
import { useDateTimeStore } from '@/stores/datetime';
import { Calendar } from '@repo/ui/components/ui/calendar';
@ -11,35 +11,25 @@ import { useMemo, useState } from 'react';
export function DateSelect() {
const { data: { customer } = {} } = useCustomerQuery();
const isMaster = useIsMaster();
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
const clientId = isMaster ? undefined : customer?.documentId;
const masterId = isMaster ? customer?.documentId : undefined;
const { data: { orders } = { orders: [] } } = useOrdersQuery(
{
filters: {
client: {
documentId: {
eq: clientId,
},
},
// Показываем все записи где пользователь является клиентом или мастером
or: [
{ client: { documentId: { eq: customer?.documentId } } },
{ slot: { master: { documentId: { eq: customer?.documentId } } } },
],
slot: {
datetime_start: {
gte: dayjs(currentMonthDate).startOf('month').toISOString(),
lte: dayjs(currentMonthDate).endOf('month').toISOString(),
},
master: {
documentId: {
eq: masterId,
},
},
},
},
},
Boolean(clientId) || Boolean(masterId),
Boolean(customer?.documentId),
);
const daysWithOrders = useMemo(() => {

View File

@ -2,7 +2,7 @@
import { OrdersList as OrdersListComponent } from './orders-list';
import { useCurrentAndNext } from './utils';
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
import { useCustomerQuery } from '@/hooks/api/customers';
import { useOrdersInfiniteQuery } from '@/hooks/api/orders';
import { useDateTimeStore } from '@/stores/datetime';
import type * as GQL from '@repo/graphql/types';
@ -10,7 +10,6 @@ import { getDateUTCRange } from '@repo/utils/datetime-format';
export function OrdersList() {
const { data: { customer } = {} } = useCustomerQuery();
const isMaster = useIsMaster();
const selectedDate = useDateTimeStore((store) => store.date);
const { endOfDay, startOfDay } = getDateUTCRange(selectedDate).day();
@ -22,10 +21,13 @@ export function OrdersList() {
} = useOrdersInfiniteQuery(
{
filters: {
client: isMaster ? undefined : { documentId: { eq: customer?.documentId } },
// Показываем все записи где пользователь является клиентом или мастером
or: [
{ client: { documentId: { eq: customer?.documentId } } },
{ slot: { master: { documentId: { eq: customer?.documentId } } } },
],
slot: {
datetime_start: selectedDate ? { gte: startOfDay, lt: endOfDay } : undefined,
master: isMaster ? { documentId: { eq: customer?.documentId } } : undefined,
},
},
},
@ -37,17 +39,15 @@ export function OrdersList() {
const { current, next } = useCurrentAndNext(orders);
if (!orders?.length || isLoading) return null;
return (
<OrdersListComponent
avatarSource={isMaster ? 'client' : 'master'}
current={current}
hasNextPage={hasNextPage}
isLoading={isLoading}
next={next}
onLoadMore={() => fetchNextPage()}
onLoadMore={fetchNextPage}
orders={orders}
title={isMaster ? 'Записи клиентов' : 'Ваши записи'}
title={selectedDate ? `Записи на ${selectedDate.toLocaleDateString()}` : 'Все записи'}
/>
);
}

View File

@ -1,14 +1,17 @@
import { getOrderStatus, getStatusText, type OrderStatus } from './utils';
import { DataNotFound } from '@/components/shared/alert';
import { OrderCard } from '@/components/shared/order-card';
import type * as GQL from '@repo/graphql/types';
import { Button } from '@repo/ui/components/ui/button';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { cn } from '@repo/ui/lib/utils';
type Order = GQL.OrderFieldsFragment;
type OrdersListProps = Pick<Parameters<typeof OrderCard>[0], 'avatarSource'> & {
type OrdersListProps = {
readonly current: null | Order;
readonly hasNextPage?: boolean;
readonly isLoading?: boolean;
readonly next: null | Order;
readonly onLoadMore?: () => void;
readonly orders: Order[];
@ -16,9 +19,9 @@ type OrdersListProps = Pick<Parameters<typeof OrderCard>[0], 'avatarSource'> & {
};
export function OrdersList({
avatarSource,
current,
hasNextPage = false,
isLoading,
next,
onLoadMore,
orders,
@ -27,6 +30,8 @@ export function OrdersList({
return (
<div className="flex flex-col space-y-2">
<h1 className="font-bold">{title}</h1>
{isLoading && <LoadingSpinner />}
{!isLoading && !orders.length ? <DataNotFound title="Заказы не найдены" /> : null}
{orders?.map((order) => {
if (!order) return null;
@ -34,7 +39,7 @@ export function OrdersList({
return (
<DateStatusWrapper key={order.documentId} status={status}>
<OrderCard avatarSource={avatarSource} showDate {...order} />
<OrderCard showDate {...order} />
</DateStatusWrapper>
);
})}

View File

@ -1,24 +1,19 @@
'use client';
import { LinkButton } from './link-button';
import { useIsMaster } from '@/hooks/api/customers';
export function LinksCard() {
const isMaster = useIsMaster();
return (
<div className="flex flex-col gap-4 p-4 py-0">
<LinkButton
description="Указать доступные дни и время для записи клиентов"
description="Указать доступные дни и время для записи"
href="/profile/schedule"
text="График работы"
visible={isMaster}
/>
<LinkButton
description="Добавить и редактировать ваши услуги мастера"
description="Добавить и редактировать ваши услуги"
href="/profile/services"
text="Услуги"
visible={isMaster}
/>
</div>
);

View File

@ -4,12 +4,9 @@ type Props = {
readonly description?: string;
readonly href: string;
readonly text: string;
readonly visible?: boolean;
};
export function LinkButton({ description, href, text, visible }: Props) {
if (!visible) return null;
export function LinkButton({ description, href, text }: Props) {
return (
<Link href={href} rel="noopener noreferrer">
<div className="flex min-h-24 w-full flex-col rounded-2xl bg-background p-4 px-6 shadow-lg backdrop-blur-2xl dark:bg-primary/5">

View File

@ -1,15 +1,15 @@
'use client';
import { DataNotFound } from '../shared/alert';
import { OrderCard } from '../shared/order-card';
import { type ProfileProps } from './types';
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
import { useCustomerQuery } from '@/hooks/api/customers';
import { useOrdersInfiniteQuery } from '@/hooks/api/orders';
import { Button } from '@repo/ui/components/ui/button';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
const { data: { customer } = {} } = useCustomerQuery();
const isMaster = useIsMaster();
const { data: { customer: profile } = {} } = useCustomerQuery({ telegramId });
const {
@ -20,18 +20,17 @@ export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
} = useOrdersInfiniteQuery(
{
filters: {
client: {
documentId: {
eq: isMaster ? profile?.documentId : customer?.documentId,
// Показываем все записи между текущим пользователем и профилем
or: [
{
client: { documentId: { eq: customer?.documentId } },
slot: { master: { documentId: { eq: profile?.documentId } } },
},
},
slot: {
master: {
documentId: {
eq: isMaster ? customer?.documentId : profile?.documentId,
},
{
client: { documentId: { eq: profile?.documentId } },
slot: { master: { documentId: { eq: customer?.documentId } } },
},
},
],
},
},
{ enabled: Boolean(profile?.documentId) && Boolean(customer?.documentId) },
@ -39,22 +38,12 @@ export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
const orders = pages?.flatMap((page) => page.orders) ?? [];
if (!orders?.length || isLoading) return null;
return (
<div className="flex flex-col space-y-2 px-4">
<h1 className="font-bold">Недавние записи</h1>
{orders?.map(
(order) =>
order && (
<OrderCard
avatarSource={isMaster ? 'master' : 'client'}
key={order.documentId}
showDate
{...order}
/>
),
)}
{isLoading && <LoadingSpinner />}
{!isLoading && !orders.length ? <DataNotFound title="Записи не найдены" /> : null}
{orders?.map((order) => order && <OrderCard key={order.documentId} showDate {...order} />)}
{hasNextPage && (
<Button onClick={() => fetchNextPage()} variant="ghost">
Загрузить еще

View File

@ -1,5 +1,6 @@
'use client';
import { DataNotFound } from '@/components/shared/alert';
import { ServiceCard } from '@/components/shared/service-card';
import { useCustomerQuery } from '@/hooks/api/customers';
import { useServicesQuery } from '@/hooks/api/services';
@ -11,16 +12,14 @@ type MasterServicesListProps = {
};
// Компонент для отображения услуг мастера (без ссылок, только просмотр)
export function MasterServicesList({ masterId }: Readonly<MasterServicesListProps>) {
const { isLoading, services } = useMasterServices(masterId);
if (isLoading) return <LoadingSpinner />;
if (!services?.length) return null;
export function ReadonlyServicesList({ masterId }: Readonly<MasterServicesListProps>) {
const { isLoading, services } = useServices(masterId);
return (
<div className="space-y-2 px-4">
<h1 className="font-bold">Услуги</h1>
{isLoading && <LoadingSpinner />}
{!isLoading && !services?.length ? <DataNotFound title="Услуги не найдены" /> : null}
{services?.map(
(service) =>
service?.active && (
@ -35,14 +34,12 @@ export function MasterServicesList({ masterId }: Readonly<MasterServicesListProp
// Компонент для отображения услуг текущего пользователя (с ссылками)
export function ServicesList() {
const { isLoading, services } = useMasterServices();
if (isLoading) return null;
if (!services?.length) return null;
const { isLoading, services } = useServices();
return (
<div className="space-y-2 px-4">
{isLoading && <LoadingSpinner />}
{!isLoading && !services?.length ? <DataNotFound title="Услуги не найдены" /> : null}
{services?.map(
(service) =>
service && (
@ -57,14 +54,13 @@ export function ServicesList() {
);
}
// Общий хук для получения услуг мастера
function useMasterServices(masterId?: string) {
const { data: { customer } = {}, isLoading } = useCustomerQuery();
function useServices(masterId?: string) {
const { data: { customer } = {}, isLoading: isLoadingCustomer } = useCustomerQuery();
// Используем переданный masterId или текущего пользователя
const targetMasterId = masterId || customer?.documentId;
const { data: { services } = {} } = useServicesQuery({
const { data: { services } = {}, isLoading: isLoadingServices } = useServicesQuery({
filters: {
master: {
documentId: {
@ -75,8 +71,7 @@ function useMasterServices(masterId?: string) {
});
return {
isLoading: isLoading || !targetMasterId,
masterId: targetMasterId,
isLoading: isLoadingCustomer || isLoadingServices,
services,
};
}

View File

@ -8,7 +8,6 @@ import { useMasterSlotsQuery } from '@/hooks/api/slots';
import { useDateTimeStore } from '@/stores/datetime';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { getDateUTCRange, isNowOrAfter } from '@repo/utils/datetime-format';
import { type PropsWithChildren } from 'react';
export function DaySlotsList() {
const { data: { customer } = {} } = useCustomerQuery();
@ -22,30 +21,15 @@ export function DaySlotsList() {
},
});
if (isLoading) {
return <LoadingSpinner />;
}
const isSelectedDateTodayOrAfter = selectedDate && isNowOrAfter(selectedDate);
if (!slots?.length) {
return (
<Wrapper>
<DataNotFound title="Слоты не найдены" />
{isSelectedDateTodayOrAfter && <DaySlotAddForm />}
</Wrapper>
);
}
return (
<Wrapper>
<div className="flex flex-col space-y-2 px-4">
<h1 className="font-bold">Слоты</h1>
{slots.map((slot) => slot && <SlotCard key={slot.documentId} {...slot} />)}
{isLoading && <LoadingSpinner />}
{!isLoading && !slots?.length ? <DataNotFound title="Слоты не найдены" /> : null}
{slots?.map((slot) => slot && <SlotCard key={slot.documentId} {...slot} />)}
{isSelectedDateTodayOrAfter && <DaySlotAddForm />}
</Wrapper>
</div>
);
}
function Wrapper({ children }: Readonly<PropsWithChildren>) {
return <div className="flex flex-col space-y-2 px-4">{children}</div>;
}

View File

@ -1,5 +1,6 @@
'use client';
import { DataNotFound } from '../shared/alert';
import { type SlotComponentProps } from './types';
import { OrderCard } from '@/components/shared/order-card';
import { useOrdersQuery } from '@/hooks/api/orders';
@ -16,13 +17,11 @@ export function SlotOrdersList({ documentId }: Readonly<SlotComponentProps>) {
},
});
if (isLoading) return <LoadingSpinner />;
if (!orders?.length) return null;
return (
<div className="flex flex-col space-y-2">
<h1 className="font-bold">Записи</h1>
{isLoading && <LoadingSpinner />}
{!isLoading && !orders?.length ? <DataNotFound title="Записи не найдены" /> : null}
{orders?.map((order) => order && <OrderCard key={order.documentId} {...order} />)}
</div>
);

View File

@ -1,34 +1,28 @@
'use client';
import { useCustomerQuery } from '@/hooks/api/customers';
import { usePushWithData } from '@/hooks/url';
import { CalendarPlus } from 'lucide-react';
type BookButtonProps = {
clientId?: string;
disabled?: boolean;
label: string;
masterId: string;
onBooked?: () => void;
};
export function BookButton({
clientId,
disabled,
label,
masterId,
onBooked,
}: Readonly<BookButtonProps>) {
export function BookButton({ disabled, label, onBooked }: Readonly<BookButtonProps>) {
const { data: { customer } = {} } = useCustomerQuery();
const masterId = customer?.documentId;
const push = usePushWithData();
const handleBook = () => {
push('/orders/add', {
...(clientId && { client: { documentId: clientId } }),
slot: { master: { documentId: masterId } },
});
onBooked?.();
};
if (!masterId && !clientId) return null;
if (!masterId) return null;
return (
<div className="flex flex-col items-center justify-center">

View File

@ -2,7 +2,6 @@ import type * as GQL from '@repo/graphql/types';
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
import { Badge } from '@repo/ui/components/ui/badge';
import { cn } from '@repo/ui/lib/utils';
import { isCustomerMaster } from '@repo/utils/customer';
import Link from 'next/link';
import { memo } from 'react';
@ -31,8 +30,8 @@ export const ContactRow = memo(function ({ className, ...contact }: ContactRowPr
</Avatar>
<div>
<p className="font-medium">{contact.name}</p>
<p className="text-sm text-muted-foreground">
{isCustomerMaster(contact) ? 'Мастер' : 'Клиент'}
<p className="max-w-52 truncate text-xs text-muted-foreground">
{contact.services.map((service) => service?.name).join(', ')}
</p>
</div>
</div>

View File

@ -2,26 +2,26 @@
import { ReadonlyTimeRange } from './time-range/readonly';
import { getBadge } from '@/components/shared/status';
import { useCustomerQuery } from '@/hooks/api/customers';
import type * as GQL from '@repo/graphql/types';
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
import { formatDate } from '@repo/utils/datetime-format';
import Link from 'next/link';
type OrderComponentProps = GQL.OrderFieldsFragment & {
avatarSource?: 'client' | 'master';
showDate?: boolean;
};
type OrderCustomer = GQL.CustomerFieldsFragment;
export function OrderCard({
avatarSource,
documentId,
showDate,
...order
}: Readonly<OrderComponentProps>) {
export function OrderCard({ documentId, showDate, ...order }: Readonly<OrderComponentProps>) {
const services = order?.services.map((service) => service?.name).join(', ');
const customer = avatarSource === 'master' ? order?.slot?.master : order?.client;
const { data: { customer } = {} } = useCustomerQuery();
const client = order?.client;
const master = order?.slot?.master;
const avatarSource = client?.documentId === customer?.documentId ? master : client;
return (
<Link href={`/orders/${documentId}`} rel="noopener noreferrer">
@ -33,7 +33,7 @@ export function OrderCard({
)}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-4">
{customer && <CustomerAvatar customer={customer} />}
{avatarSource && <CustomerAvatar customer={avatarSource} />}
<div className="flex min-w-0 flex-1 flex-col">
<ReadonlyTimeRange
datetimeEnd={order?.datetime_end}

View File

@ -1,7 +1,7 @@
'use client';
import { getCustomer, updateCustomer } from '@/actions/api/customers';
import { isCustomerBanned, isCustomerMaster } from '@repo/utils/customer';
import { isCustomerBanned } from '@repo/utils/customer';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
@ -16,14 +16,6 @@ export const useCustomerQuery = (variables?: Parameters<typeof getCustomer>[0])
});
};
export const useIsMaster = () => {
const { data: { customer } = {} } = useCustomerQuery();
if (!customer) return false;
return isCustomerMaster(customer);
};
export const useIsBanned = () => {
const { data: { customer } = {} } = useCustomerQuery();

View File

@ -1,26 +1,23 @@
/* eslint-disable sonarjs/cognitive-complexity */
'use client';
import { useOrderStore } from './context';
import { type Steps } from './types';
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
import { useCustomerQuery } from '@/hooks/api/customers';
import { type OrderFieldsFragment } from '@repo/graphql/types';
import { sift } from 'radashi';
import { useEffect, useRef } from 'react';
const STEPS: Steps[] = [
// Унифицированные шаги для всех пользователей
const UNIFIED_STEPS: Steps[] = [
'master-select',
'client-select',
'service-select',
'datetime-select',
'success',
];
export const MASTER_STEPS: Steps[] = STEPS.filter((step) => step !== 'master-select');
export const CLIENT_STEPS: Steps[] = STEPS.filter((step) => step !== 'client-select');
export function useInitOrderStore(initData: null | OrderFieldsFragment) {
const initialized = useRef(false);
const { data: { customer } = {} } = useCustomerQuery();
const isMaster = useIsMaster();
const setMasterId = useOrderStore((store) => store.setMasterId);
const setClientId = useOrderStore((store) => store.setClientId);
@ -32,8 +29,7 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
useEffect(() => {
if (initialized.current || !customer || step !== 'loading') return;
const steps = isMaster ? MASTER_STEPS : CLIENT_STEPS;
setStepsSequence(steps);
setStepsSequence(UNIFIED_STEPS);
// Инициализация из initData (например, для повторного заказа)
if (initData) {
@ -50,24 +46,18 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
} else if (masterId && clientId) {
setStep('service-select');
} else {
setStep(steps[0] ?? 'loading');
setStep(UNIFIED_STEPS[0] ?? 'loading');
}
} else {
// Обычная инициализация (новый заказ)
if (isMaster) {
setMasterId(customer.documentId);
} else {
setClientId(customer.documentId);
}
setStep(steps[0] ?? 'loading');
// Обычная инициализация (новый заказ) - начинаем с выбора мастера
setClientId(customer.documentId);
setStep(UNIFIED_STEPS[0] ?? 'loading');
}
initialized.current = true;
}, [
customer,
initData,
isMaster,
setClientId,
setMasterId,
setServiceIds,

View File

@ -9,7 +9,6 @@ import { ServicesService } from './services';
import { SlotsService } from './slots';
import { SubscriptionsService } from './subscriptions';
import { type VariablesOf } from '@graphql-typed-document-node/core';
import { isCustomerMaster } from '@repo/utils/customer';
import { getMinutes, isBeforeNow, isNowOrAfter } from '@repo/utils/datetime-format';
import dayjs from 'dayjs';
@ -73,11 +72,7 @@ export class OrdersService extends BaseService {
// Если у мастера слота нет активной подписки и не осталось доступных заказов
if (!subscription?.isActive && remainingOrdersCount <= 0) {
throw new Error(
isCustomerMaster(customer)
? ERRORS.ORDER_LIMIT_EXCEEDED_MASTER
: ERRORS.ORDER_LIMIT_EXCEEDED_CLIENT,
);
throw new Error(ERRORS.ORDER_LIMIT_EXCEEDED_CLIENT);
}
const servicesService = new ServicesService(this._user);
@ -114,6 +109,8 @@ export class OrdersService extends BaseService {
},
});
const isSlotMaster = slot.master.documentId === customer.documentId;
const { mutate } = await getClientWithToken();
const mutationResult = await mutate({
@ -123,9 +120,7 @@ export class OrdersService extends BaseService {
input: {
...variables.input,
datetime_end: datetimeEnd,
state: isCustomerMaster(customer)
? GQL.Enum_Order_State.Approved
: GQL.Enum_Order_State.Created,
state: isSlotMaster ? GQL.Enum_Order_State.Approved : GQL.Enum_Order_State.Created,
},
},
});
@ -283,41 +278,16 @@ export class OrdersService extends BaseService {
// Получаем мастера слота
const slotMaster = slot.master;
if (!slotMaster) throw new Error(ERRORS.INVALID_MASTER);
if (!slotMaster.active || slotMaster.role !== GQL.Enum_Customer_Role.Master) {
if (!slotMaster.active) {
throw new Error(ERRORS.INACTIVE_MASTER);
}
// 2. Проверка ролей и связей
const isClientMaster = clientEntity.role === GQL.Enum_Customer_Role.Master;
const slotMasterId = slot.master?.documentId;
if (!slotMasterId) {
throw new Error(ERRORS.NOT_FOUND_MASTER);
}
if (isClientMaster) {
// Мастер может записывать только себя
if (slotMasterId !== clientId) {
throw new Error(ERRORS.INVALID_MASTER);
}
} else {
// Клиент не должен быть мастером слота
if (slotMasterId === clientId) {
throw new Error(ERRORS.NO_MASTER_SELF_BOOK);
}
const clientMasters = await customerService.getMasters({ documentId: clientId });
const isLinkedToSlotMaster = clientMasters?.masters.some(
(master) => master?.documentId === slotMasterId,
);
// Клиент должен быть привязан к мастеру слота
if (!isLinkedToSlotMaster) {
throw new Error(ERRORS.INVALID_MASTER);
}
}
// Проверка пересечений заказов по времени.
const { orders: overlappingOrders } = await this.getOrders({

View File

@ -207,7 +207,7 @@ export class SlotsService extends BaseService {
if (!masterEntity) throw new Error(ERRORS.NOT_FOUND_MASTER);
if (!masterEntity?.active || masterEntity.role !== 'master') {
if (!masterEntity?.active) {
throw new Error(ERRORS.INACTIVE_MASTER);
}

View File

@ -7,6 +7,10 @@ fragment CustomerFields on Customer {
photoUrl
role
telegramId
services(filters: { active: { eq: true } }) {
documentId
name
}
}
mutation CreateCustomer($name: String!, $telegramId: Long, $phone: String) {

File diff suppressed because one or more lines are too long

View File

@ -4,6 +4,4 @@ export function isCustomerBanned(customer: GQL.CustomerFieldsFragment): boolean
return Boolean(customer.bannedUntil && new Date() < new Date(customer.bannedUntil));
}
export function isCustomerMaster(customer: GQL.CustomerFieldsFragment) {
return customer?.role === GQL.Enum_Customer_Role.Master;
}
// isCustomerMaster удален - больше не нужен при равенстве пользователей