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:
parent
49c43296e4
commit
5dfef524e2
@ -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')));
|
||||
|
||||
|
||||
@ -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 };
|
||||
@ -1,5 +1,4 @@
|
||||
export * from './add-contact';
|
||||
export * from './become-master';
|
||||
export * from './help';
|
||||
export * from './registration';
|
||||
export * from './share-bot';
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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()}` : 'Все записи'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
Загрузить еще
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
@ -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 удален - больше не нужен при равенстве пользователей
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user