Compare commits

...

12 Commits

Author SHA1 Message Date
vchikalkin
b53a276540 feat(service-select): share services list for all
enhance service card display with duration formatting and improve layout
2025-07-03 16:35:04 +03:00
vchikalkin
e2d92ade16 fix(order-buttons): update cancel and confirm button logic based on order state 2025-07-03 16:21:44 +03:00
vchikalkin
76de850bf5 feat(profile): add BookContactButton component to enhance booking functionality 2025-07-03 16:15:22 +03:00
vchikalkin
6eb421bfd4 feat(bot): implement add contact wizard scene and enhance contact handling logic 2025-07-03 14:49:15 +03:00
vchikalkin
0bfebce1e3 fix: pnpm-lock.yaml 2025-07-03 14:48:53 +03:00
vchikalkin
687142f9af feat(bot): add share bot command and update environment configuration for BOT_URL 2025-07-03 13:49:35 +03:00
vchikalkin
b9371c34ad fix(contacts, orders): replace empty state messages with DataNotFound component for better user feedback 2025-07-03 13:27:07 +03:00
vchikalkin
f6354d41f6 refactor(order-buttons, action-panel): streamline button handlers and add return functionality 2025-07-03 12:52:33 +03:00
vchikalkin
d587ea23b6 fix react typings 2025-07-03 12:20:41 +03:00
vchikalkin
4983e7b36b fix(api/notify, api/orders): enhance notification messages and update order state handling for masters 2025-07-03 12:11:48 +03:00
vchikalkin
7f86fc164d fix(api/orders): update master validation logic to handle optional masters 2025-07-03 12:00:50 +03:00
vchikalkin
b7a217787d web/packages: upgrade next 2025-07-03 12:00:10 +03:00
18 changed files with 1622 additions and 1178 deletions

View File

@ -16,6 +16,7 @@ jobs:
- name: Create fake .env file for build - name: Create fake .env file for build
run: | run: |
echo "BOT_TOKEN=fake" > .env echo "BOT_TOKEN=fake" > .env
echo "BOT_URL=fake" > .env
echo "LOGIN_GRAPHQL=fake" >> .env echo "LOGIN_GRAPHQL=fake" >> .env
echo "PASSWORD_GRAPHQL=fake" >> .env echo "PASSWORD_GRAPHQL=fake" >> .env
echo "URL_GRAPHQL=http://localhost/graphql" >> .env echo "URL_GRAPHQL=http://localhost/graphql" >> .env
@ -64,6 +65,7 @@ jobs:
- name: Create real .env file for production - name: Create real .env file for production
run: | run: |
echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" > .env echo "BOT_TOKEN=${{ secrets.BOT_TOKEN }}" > .env
echo "BOT_URL=${{ secrets.BOT_URL }}" > .env
echo "LOGIN_GRAPHQL=${{ secrets.LOGIN_GRAPHQL }}" >> .env echo "LOGIN_GRAPHQL=${{ secrets.LOGIN_GRAPHQL }}" >> .env
echo "PASSWORD_GRAPHQL=${{ secrets.PASSWORD_GRAPHQL }}" >> .env echo "PASSWORD_GRAPHQL=${{ secrets.PASSWORD_GRAPHQL }}" >> .env
echo "URL_GRAPHQL=${{ secrets.URL_GRAPHQL }}" >> .env echo "URL_GRAPHQL=${{ secrets.URL_GRAPHQL }}" >> .env

View File

@ -3,6 +3,7 @@ import { z } from 'zod';
export const envSchema = z.object({ export const envSchema = z.object({
BOT_TOKEN: z.string(), BOT_TOKEN: z.string(),
BOT_URL: z.string(),
}); });
export const env = envSchema.parse(process.env); export const env = envSchema.parse(process.env);

View File

@ -4,11 +4,15 @@ import { env as environment } from './config/env';
import { import {
commandsList, commandsList,
KEYBOARD_REMOVE, KEYBOARD_REMOVE,
KEYBOARD_SHARE_BOT,
KEYBOARD_SHARE_PHONE, KEYBOARD_SHARE_PHONE,
MESSAGE_CANCEL,
MESSAGE_NOT_MASTER, MESSAGE_NOT_MASTER,
MESSAGE_SHARE_BOT,
MSG_ALREADY_MASTER, MSG_ALREADY_MASTER,
MSG_BECOME_MASTER, MSG_BECOME_MASTER,
MSG_CONTACT_ADDED, MSG_CONTACT_ADDED,
MSG_CONTACT_FORWARD,
MSG_ERROR, MSG_ERROR,
MSG_NEED_PHONE, MSG_NEED_PHONE,
MSG_PHONE_SAVED, MSG_PHONE_SAVED,
@ -16,13 +20,91 @@ import {
MSG_WELCOME, MSG_WELCOME,
MSG_WELCOME_BACK, MSG_WELCOME_BACK,
} from './message'; } from './message';
import { isCustomerMaster } from './utils/customer';
import { normalizePhoneNumber } from './utils/phone'; import { normalizePhoneNumber } from './utils/phone';
import { CustomersService } from '@repo/graphql/api/customers'; import { CustomersService } from '@repo/graphql/api/customers';
import { Enum_Customer_Role } from '@repo/graphql/types'; import { Enum_Customer_Role } from '@repo/graphql/types';
import { Telegraf } from 'telegraf'; import { Scenes, session, Telegraf, type Context as TelegrafContext } from 'telegraf';
import { message } from 'telegraf/filters'; import { message } from 'telegraf/filters';
import {
type SceneContextScene,
type SceneSession,
type WizardContextWizard,
type WizardSessionData,
} from 'telegraf/typings/scenes';
const bot = new Telegraf(environment.BOT_TOKEN); type BotContext = TelegrafContext & {
scene: SceneContextScene<BotContext, WizardSessionData>;
session: SceneSession<WizardSessionData>;
wizard: WizardContextWizard<BotContext>;
};
const bot = new Telegraf<BotContext>(environment.BOT_TOKEN);
const stage = new Scenes.Stage<BotContext>();
bot.use(session({ defaultSession: () => ({ __scenes: { cursor: 0, state: {} } }) }));
bot.use(stage.middleware());
const addContactScene = new Scenes.WizardScene<BotContext>(
'add-contact',
async (context) => {
await context.reply(MSG_SEND_CLIENT_CONTACT, { parse_mode: 'HTML' });
return context.wizard.next();
},
async (context) => {
if (!context.from) {
await context.reply('Ошибка: не удалось определить пользователя');
return context.scene.leave();
}
if (context.message && 'text' in context.message && context.message.text === '/cancel') {
await context.reply(MESSAGE_CANCEL + commandsList, { parse_mode: 'HTML' });
return context.scene.leave();
}
if (!('message' in context && context.message && 'contact' in context.message)) {
await context.reply('Пожалуйста, отправьте контакт клиента через кнопку Telegram');
return;
}
const telegramId = context.from.id;
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (!customer || !isCustomerMaster(customer)) {
await context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' });
return context.scene.leave();
}
const { contact } = context.message;
const name = (contact.first_name || '') + ' ' + (contact.last_name || '').trim();
const phone = normalizePhoneNumber(contact.phone_number);
try {
const { customer: existingCustomer } = await customerService.getCustomer({ phone });
let documentId = existingCustomer?.documentId;
if (!documentId) {
const createCustomerResult = await customerService.createCustomer({ name, phone });
documentId = createCustomerResult?.createCustomer?.documentId;
if (!documentId) throw new Error('Customer not created');
}
const masters = [customer.documentId];
await customerService.addMasters({ data: { masters }, documentId });
await context.reply(MSG_CONTACT_ADDED(name), { parse_mode: 'HTML' });
await context.reply(MSG_CONTACT_FORWARD, { parse_mode: 'HTML' });
await context.reply(MESSAGE_SHARE_BOT, { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' });
} catch (error) {
await context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
} finally {
await context.reply(commandsList, { ...KEYBOARD_REMOVE, parse_mode: 'HTML' });
context.scene.leave();
}
},
);
stage.register(addContactScene);
bot.start(async (context) => { bot.start(async (context) => {
const telegramId = context.from.id; const telegramId = context.from.id;
@ -40,21 +122,23 @@ bot.start(async (context) => {
return context.reply(MSG_WELCOME, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' }); return context.reply(MSG_WELCOME, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
}); });
bot.command('help', async (context) => {
return context.reply(commandsList, { ...KEYBOARD_REMOVE, parse_mode: 'HTML' });
});
bot.command('addcontact', async (context) => { bot.command('addcontact', async (context) => {
const telegramId = context.from.id; const telegramId = context.from.id;
const customerService = new CustomersService({ telegramId }); const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId }); const { customer } = await customerService.getCustomer({ telegramId });
if (!customer) { if (!customer) {
return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' }); return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
} }
if (customer.role !== Enum_Customer_Role.Master) { if (!isCustomerMaster(customer)) {
return context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' }); return context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' });
} }
return context.reply(MSG_SEND_CLIENT_CONTACT, { parse_mode: 'HTML' }); return context.scene.enter('add-contact');
}); });
bot.command('becomemaster', async (context) => { bot.command('becomemaster', async (context) => {
@ -67,7 +151,7 @@ bot.command('becomemaster', async (context) => {
return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' }); return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
} }
if (customer.role === Enum_Customer_Role.Master) { if (isCustomerMaster(customer)) {
return context.reply(MSG_ALREADY_MASTER, { parse_mode: 'HTML' }); return context.reply(MSG_ALREADY_MASTER, { parse_mode: 'HTML' });
} }
@ -86,60 +170,32 @@ bot.command('becomemaster', async (context) => {
} }
}); });
bot.command('sharebot', async (context) => {
await context.reply(MSG_CONTACT_FORWARD, { parse_mode: 'HTML' });
await context.reply(MESSAGE_SHARE_BOT, { ...KEYBOARD_SHARE_BOT, parse_mode: 'HTML' });
});
bot.on(message('contact'), async (context) => { bot.on(message('contact'), async (context) => {
const telegramId = context.from.id; const telegramId = context.from.id;
const customerService = new CustomersService({ telegramId }); const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId }); const { customer } = await customerService.getCustomer({ telegramId });
const isRegistration = !customer; if (!customer) {
const { contact } = context.message;
const name = (contact.first_name || '') + ' ' + (contact.last_name || '').trim();
const phone = normalizePhoneNumber(contact.phone_number);
const { contact } = context.message;
const name = (contact.first_name || '') + ' ' + (contact.last_name || '').trim();
const phone = normalizePhoneNumber(contact.phone_number);
if (isRegistration) {
const response = await customerService const response = await customerService
.createCustomer({ .createCustomer({ name, phone, telegramId: context.from.id })
name,
phone,
telegramId: context.from.id,
})
.catch((error) => { .catch((error) => {
context.reply(MSG_ERROR(error), { parse_mode: 'HTML' }); context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
}); });
if (response) { if (response) {
return context.reply(MSG_PHONE_SAVED + commandsList, { return context.reply(MSG_PHONE_SAVED + commandsList, {
...KEYBOARD_REMOVE, ...KEYBOARD_REMOVE,
parse_mode: 'HTML', parse_mode: 'HTML',
}); });
} }
} else {
if (customer.role !== Enum_Customer_Role.Master) {
return context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' });
}
try {
const createCustomerResult = await customerService.createCustomer({ name, phone });
const documentId = createCustomerResult?.createCustomer?.documentId;
if (!documentId) {
throw new Error('Customer not created');
}
const masters = [customer.documentId];
await customerService.addMasters({
data: { masters },
documentId,
});
return context.reply(MSG_CONTACT_ADDED(name), { parse_mode: 'HTML' });
} catch (error) {
context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
}
} }
}); });

View File

@ -1,9 +1,12 @@
import { env as environment } from './config/env';
import { type ReplyKeyboardRemove } from 'telegraf/types'; import { type ReplyKeyboardRemove } from 'telegraf/types';
export const commandsList = ` export const commandsList = `
\n<b>📋 Доступные команды:</b> \n<b>📋 Доступные команды:</b>
<b>/addcontact</b> добавить контакт клиента <b>/addcontact</b> добавить контакт клиента
<b>/becomemaster</b> стать мастером <b>/becomemaster</b> стать мастером
<b>/sharebot</b> поделиться ботом
<b>/help</b> список команд
`; `;
export const KEYBOARD_SHARE_PHONE = { export const KEYBOARD_SHARE_PHONE = {
@ -26,30 +29,51 @@ export const KEYBOARD_REMOVE = {
} as ReplyKeyboardRemove, } as ReplyKeyboardRemove,
}; };
export const KEYBOARD_SHARE_BOT = {
reply_markup: {
inline_keyboard: [
[
{
text: '🤖Воспользоваться ботом',
url: environment.BOT_URL,
},
],
],
},
};
export const MESSAGE_NOT_MASTER = export const MESSAGE_NOT_MASTER =
'⛔️ <b>Только мастер может добавлять контакты.</b>\nСтать мастером можно на странице профиля в приложении или с помощью команды <b>/becomemaster</b>.'; '⛔️ <b>Только мастер может добавлять контакты</b>\nСтать мастером можно на странице профиля в приложении или с помощью команды <b>/becomemaster</b>';
export const MSG_WELCOME = export const MSG_WELCOME =
'👋 <b>Добро пожаловать!</b>\nПожалуйста, поделитесь своим номером телефона для регистрации.'; '👋 <b>Добро пожаловать!</b>\nПожалуйста, поделитесь своим номером телефона для регистрации';
export const MSG_WELCOME_BACK = (name: string) => export const MSG_WELCOME_BACK = (name: string) =>
`👋 <b>С возвращением, ${name}!</b>\nЧтобы воспользоваться сервисом, откройте приложение.\n`; `👋 <b>С возвращением, ${name}!</b>\nЧтобы воспользоваться сервисом, откройте приложение.\n`;
export const MSG_NEED_PHONE = export const MSG_NEED_PHONE =
'📱 <b>Чтобы добавить контакт, сначала поделитесь своим номером телефона.</b>'; '📱 <b>Чтобы добавить контакт, сначала поделитесь своим номером телефона</b>';
export const MSG_SEND_CLIENT_CONTACT = export const MSG_SEND_CLIENT_CONTACT =
'👤 <b>Отправьте контакт клиента, которого вы хотите добавить.</b>'; '👤 <b>Отправьте контакт клиента, которого вы хотите добавить. \n<em>Для отмены операции используйте команду /cancel</em></b>';
export const MSG_ALREADY_MASTER = '🎉 <b>Вы уже являетесь мастером!</b>'; export const MSG_ALREADY_MASTER = '🎉 <b>Вы уже являетесь мастером!</b>';
export const MSG_BECOME_MASTER = '🥳 <b>Поздравляем! Теперь вы мастер.</b>'; export const MSG_BECOME_MASTER = '🥳 <b>Поздравляем! Теперь вы мастер</b>';
export const MSG_ERROR = (error?: unknown) => export const MSG_ERROR = (error?: unknown) =>
`❌ <b>Произошла ошибка.</b>\n${error ? String(error) : ''}`; `❌ <b>Произошла ошибка</b>\n${error ? String(error) : ''}`;
export const MSG_PHONE_SAVED = export const MSG_PHONE_SAVED =
'✅ <b>Спасибо! Мы сохранили ваш номер телефона.</b>\nТеперь вы можете открыть приложение или воспользоваться командами бота.'; '✅ <b>Спасибо! Мы сохранили ваш номер телефона</b>\nТеперь вы можете открыть приложение или воспользоваться командами бота';
export const MSG_CONTACT_ADDED = (name: string) => export const MSG_CONTACT_ADDED = (name: string) =>
`✅ <b>Добавили контакт:</b> <b>${name}</b>\ригласите пользователя в приложение, чтобы вы могли добавлять записи с этим контактом.`; `✅ <b>Добавили контакт в список ваших клиентов</b>\n\nИмя: <b>${name}</b>\n\ригласите клиента в приложение, чтобы вы могли добавлять записи с этим контактом`;
export const MSG_CONTACT_FORWARD =
'<em>Перешлите пользователю следующее сообщение, чтобы он мог начать пользоваться ботом ⬇️</em>';
export const MESSAGE_SHARE_BOT =
'📅 <b>Воспользуйтесь этим ботом для записи к вашему мастеру!</b>\nНажмите кнопку ниже, чтобы начать';
export const MESSAGE_CANCEL = '<b>❌ Отменена операции</b>';

View File

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

View File

@ -1,7 +1,12 @@
import { getCustomer } from '@/actions/api/customers'; import { getCustomer } from '@/actions/api/customers';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { ContactDataCard, PersonCard, ProfileOrdersList } from '@/components/profile'; import {
BookContactButton,
ContactDataCard,
PersonCard,
ProfileOrdersList,
} from '@/components/profile';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
type Props = { params: Promise<{ telegramId: string }> }; type Props = { params: Promise<{ telegramId: string }> };
@ -24,6 +29,7 @@ export default async function ProfilePage(props: Readonly<Props>) {
<PersonCard telegramId={telegramId} /> <PersonCard telegramId={telegramId} />
<ContactDataCard telegramId={telegramId} /> <ContactDataCard telegramId={telegramId} />
<ProfileOrdersList telegramId={telegramId} /> <ProfileOrdersList telegramId={telegramId} />
<BookContactButton telegramId={telegramId} />
</Container> </Container>
</HydrationBoundary> </HydrationBoundary>
); );

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import { DataNotFound } from '../shared/alert';
import { ContactRow } from '../shared/contact-row'; import { ContactRow } from '../shared/contact-row';
import { useCustomerContacts } from '@/hooks/api/contacts'; import { useCustomerContacts } from '@/hooks/api/contacts';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner'; import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
@ -9,7 +10,7 @@ export function ContactsList() {
if (isLoading) return <LoadingSpinner />; if (isLoading) return <LoadingSpinner />;
if (!contacts.length) return <div>Контакты не найдены</div>; if (!contacts.length) return <DataNotFound title="Контакты не найдены" />;
return ( return (
<div className="space-y-2"> <div className="space-y-2">

View File

@ -19,6 +19,7 @@ export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
if (!order) return null; if (!order) return null;
const isCreated = order?.state === Enum_Order_State.Created;
const isApproved = order?.state === Enum_Order_State.Approved; const isApproved = order?.state === Enum_Order_State.Approved;
const isCompleted = order?.state === Enum_Order_State.Completed; const isCompleted = order?.state === Enum_Order_State.Completed;
const isCancelling = order?.state === Enum_Order_State.Cancelling; const isCancelling = order?.state === Enum_Order_State.Cancelling;
@ -51,20 +52,11 @@ export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
return ( return (
<FloatingActionPanel <FloatingActionPanel
isLoading={isPending} isLoading={isPending}
onCancel={ onCancel={isCreated || (isMaster && isCancelling) || isApproved ? handleCancel : undefined}
isCancelled || (!isMaster && isCancelling) || isCompleted ? undefined : () => handleCancel() onComplete={isMaster && isApproved ? handleOnComplete : undefined}
} onConfirm={isMaster && isCreated ? handleApprove : undefined}
onComplete={isApproved && isMaster ? handleOnComplete : undefined}
onConfirm={
!isMaster ||
isApproved ||
(!isMaster && isCancelled) ||
(!isMaster && isCancelling) ||
isCompleted
? undefined
: () => handleApprove()
}
onRepeat={isCancelled || isCompleted ? handleOnRepeat : undefined} onRepeat={isCancelled || isCompleted ? handleOnRepeat : undefined}
onReturn={isMaster && isCancelled ? handleApprove : undefined}
/> />
); );
} }

View File

@ -1,33 +1,25 @@
'use client'; 'use client';
import { DataNotFound } from '@/components/shared/alert';
import { useServicesQuery } from '@/hooks/api/services'; import { useServicesQuery } from '@/hooks/api/services';
import { useOrderStore } from '@/stores/order'; import { useOrderStore } from '@/stores/order';
import { type ServiceFieldsFragment } from '@repo/graphql/types'; import { type ServiceFieldsFragment } from '@repo/graphql/types';
import { cn } from '@repo/ui/lib/utils'; import { cn } from '@repo/ui/lib/utils';
import { formatTime } from '@repo/utils/datetime-format';
export function ServiceSelect() { export function ServiceSelect() {
const masterId = useOrderStore((store) => store.masterId); const { data: { services } = {} } = useServicesQuery({});
const { data: { services } = {} } = useServicesQuery({ if (!services?.length) return <DataNotFound title="Услуги не найдены" />;
filters: {
master: {
documentId: {
eq: masterId,
},
},
},
});
if (!services?.length) return null;
return ( return (
<div> <div className="space-y-2 px-6">
{services.map((service) => service && <ServiceCard key={service.documentId} {...service} />)} {services.map((service) => service && <ServiceCard key={service.documentId} {...service} />)}
</div> </div>
); );
} }
function ServiceCard({ documentId, name }: Readonly<ServiceFieldsFragment>) { function ServiceCard({ documentId, duration, name }: Readonly<ServiceFieldsFragment>) {
const serviceId = useOrderStore((store) => store.serviceId); const serviceId = useOrderStore((store) => store.serviceId);
const setServiceId = useOrderStore((store) => store.setServiceId); const setServiceId = useOrderStore((store) => store.setServiceId);
@ -40,7 +32,7 @@ function ServiceCard({ documentId, name }: Readonly<ServiceFieldsFragment>) {
return ( return (
<label <label
className={cn( className={cn(
'flex items-center justify-between border-2 rounded-2xl bg-background p-4 px-6 cursor-pointer dark:bg-primary/5', 'flex items-center justify-between border-2 rounded-2xl bg-background p-4 cursor-pointer dark:bg-primary/5',
selected ? 'border-primary' : 'border-transparent', selected ? 'border-primary' : 'border-transparent',
)} )}
> >
@ -52,9 +44,23 @@ function ServiceCard({ documentId, name }: Readonly<ServiceFieldsFragment>) {
type="radio" type="radio"
value={documentId} value={documentId}
/> />
<div className="flex flex-col gap-2"> <div className="flex w-full items-center justify-between gap-2">
{name} <span className="text-base font-semibold text-foreground">{name}</span>
{/* <span className={cn('text-xs font-normal', 'text-muted-foreground')} /> */} <span
className={cn(
'inline-flex items-center gap-1 px-4 p-2 rounded-full bg-secondary dark:bg-background text-primary text-xs font-medium',
)}
>
<svg
className="size-4 opacity-70"
fill="currentColor"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M8 1.5a6.5 6.5 0 1 0 0 13a6.5 6.5 0 0 0 0-13ZM2.5 8a5.5 5.5 0 1 1 11 0a5.5 5.5 0 0 1-11 0Zm6-2.75a.5.5 0 0 0-1 0v3c0 .28.22.5.5.5h2a.5.5 0 0 0 0-1H8.5V5.25Z" />
</svg>
{formatTime(duration).user()}
</span>
</div> </div>
</label> </label>
); );

View File

@ -4,6 +4,62 @@ import { OrderCard } from '../shared/order-card';
import { type ProfileProps } from './types'; import { type ProfileProps } from './types';
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers'; import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
import { useOrdersQuery } from '@/hooks/api/orders'; import { useOrdersQuery } from '@/hooks/api/orders';
import { usePushWithData } from '@/hooks/url';
import { CalendarPlus } from 'lucide-react';
export function BookContactButton({ telegramId }: Readonly<ProfileProps>) {
const { data: { customer } = {}, isLoading: isCustomerLoading } = useCustomerQuery();
const isMaster = useIsMaster();
const { data: { customer: profile } = {}, isLoading: isProfileLoading } = useCustomerQuery({
telegramId,
});
const push = usePushWithData();
const handleBook = () => {
if (!profile || !customer) return;
if (isMaster) {
push('/orders/add', {
client: {
documentId: profile.documentId,
},
slot: {
master: {
documentId: customer.documentId,
},
},
});
} else {
push('/orders/add', {
client: {
documentId: customer.documentId,
},
slot: {
master: {
documentId: profile.documentId,
},
},
});
}
};
if (isCustomerLoading || isProfileLoading || !profile || !customer) return null;
return (
<div className="flex flex-col items-center justify-center">
<button
className="flex items-center justify-center gap-2 self-center rounded-xl bg-primary/5 px-6 py-2 font-semibold text-primary shadow-sm transition-colors hover:bg-primary/10"
onClick={handleBook}
type="button"
>
<CalendarPlus className="size-5 text-primary" />
<span>{isMaster ? 'Записать' : 'Записаться'}</span>
</button>
</div>
);
}
export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) { export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
const { data: { customer } = {} } = useCustomerQuery(); const { data: { customer } = {} } = useCustomerQuery();

View File

@ -2,7 +2,7 @@
import { Button } from '@repo/ui/components/ui/button'; import { Button } from '@repo/ui/components/ui/button';
import { Card } from '@repo/ui/components/ui/card'; import { Card } from '@repo/ui/components/ui/card';
import { Ban, Check, Lock, RotateCcw, Trash2, Unlock } from 'lucide-react'; import { Ban, Check, Lock, RotateCcw, Trash2, Undo, Unlock } from 'lucide-react';
type FloatingActionPanelProps = { type FloatingActionPanelProps = {
readonly isLoading?: boolean; readonly isLoading?: boolean;
@ -12,6 +12,7 @@ type FloatingActionPanelProps = {
readonly onConfirm?: () => void; readonly onConfirm?: () => void;
readonly onDelete?: () => void; readonly onDelete?: () => void;
readonly onRepeat?: () => void; readonly onRepeat?: () => void;
readonly onReturn?: () => void;
readonly onToggle?: () => void; readonly onToggle?: () => void;
}; };
@ -23,10 +24,12 @@ export default function FloatingActionPanel({
onConfirm, onConfirm,
onDelete, onDelete,
onRepeat, onRepeat,
onReturn,
onToggle, onToggle,
}: FloatingActionPanelProps) { }: FloatingActionPanelProps) {
// Если не переданы обработчики, скрываем панель // Если не переданы обработчики, скрываем панель
if (!onCancel && !onConfirm && !onDelete && !onComplete && !onRepeat && !onToggle) return null; if (!onCancel && !onConfirm && !onDelete && !onComplete && !onRepeat && !onToggle && !onReturn)
return null;
return ( return (
<Card className="fixed inset-x-4 bottom-4 z-50 rounded-3xl border-0 bg-background/95 p-4 shadow-2xl backdrop-blur-sm dark:bg-primary/5 md:bottom-6 md:left-auto md:right-6 md:p-6"> <Card className="fixed inset-x-4 bottom-4 z-50 rounded-3xl border-0 bg-background/95 p-4 shadow-2xl backdrop-blur-sm dark:bg-primary/5 md:bottom-6 md:left-auto md:right-6 md:p-6">
@ -124,6 +127,19 @@ export default function FloatingActionPanel({
<span>Подтвердить</span> <span>Подтвердить</span>
</Button> </Button>
)} )}
{/* Кнопка вернуть */}
{onReturn && (
<Button
className="w-full rounded-2xl bg-blue-400 text-sm text-white transition-all duration-200 hover:bg-blue-500 dark:bg-blue-500 dark:hover:bg-blue-600 sm:w-auto"
disabled={isLoading}
onClick={onReturn}
size="sm"
>
<Undo className="mr-2 size-4" />
<span>Вернуть</span>
</Button>
)}
</div> </div>
</Card> </Card>
); );

View File

@ -0,0 +1,12 @@
import { AlertCircleIcon } from 'lucide-react';
export function DataNotFound({ title }: { readonly title: string }) {
return (
<div className="p-4">
<div className="line-clamp-1 flex items-center justify-center text-sm font-medium tracking-tight text-secondary-foreground">
<AlertCircleIcon className="mr-2 size-4" />
<span className="line-clamp-1 font-medium tracking-tight">{title}</span>
</div>
</div>
);
}

View File

@ -35,7 +35,7 @@
"next-auth": "^4.24.11", "next-auth": "^4.24.11",
"next-intl": "^3.26.0", "next-intl": "^3.26.0",
"next-themes": "^0.4.4", "next-themes": "^0.4.4",
"next": "15.3.0", "next": "15.3.4",
"postcss": "catalog:", "postcss": "catalog:",
"radashi": "catalog:", "radashi": "catalog:",
"react-dom": "catalog:", "react-dom": "catalog:",

View File

@ -4,7 +4,7 @@ import { useOrderStore } from './context';
import { type Steps } from './types'; import { type Steps } from './types';
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers'; import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
import { type OrderFieldsFragment } from '@repo/graphql/types'; import { type OrderFieldsFragment } from '@repo/graphql/types';
import { useCallback, useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
const STEPS: Steps[] = [ const STEPS: Steps[] = [
'master-select', 'master-select',
@ -18,7 +18,6 @@ export const CLIENT_STEPS: Steps[] = STEPS.filter((step) => step !== 'client-sel
export function useInitOrderStore(initData: null | OrderFieldsFragment) { export function useInitOrderStore(initData: null | OrderFieldsFragment) {
const initialized = useRef(false); const initialized = useRef(false);
const { data: { customer } = {} } = useCustomerQuery(); const { data: { customer } = {} } = useCustomerQuery();
const isMaster = useIsMaster(); const isMaster = useIsMaster();
@ -29,19 +28,17 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
const setStepsSequence = useOrderStore((store) => store._setStepSequence); const setStepsSequence = useOrderStore((store) => store._setStepSequence);
const step = useOrderStore((store) => store.step); const step = useOrderStore((store) => store.step);
const setFirstStep = useCallback(() => {
const steps = isMaster ? MASTER_STEPS : CLIENT_STEPS;
setStepsSequence(steps);
setStep(steps[0] as Steps);
}, [isMaster, setStep, setStepsSequence]);
useEffect(() => { useEffect(() => {
if (initialized.current || !customer || step !== 'loading') return; if (initialized.current || !customer || step !== 'loading') return;
const steps = isMaster ? MASTER_STEPS : CLIENT_STEPS;
setStepsSequence(steps);
// Инициализация из initData (например, для повторного заказа)
if (initData) { if (initData) {
const masterId = initData.slot?.master?.documentId; const masterId = initData.slot?.master?.documentId;
const clientId = initData.client?.documentId; const clientId = initData.client?.documentId;
const serviceId = initData.services[0]?.documentId; const serviceId = initData.services?.[0]?.documentId;
if (masterId) setMasterId(masterId); if (masterId) setMasterId(masterId);
if (clientId) setClientId(clientId); if (clientId) setClientId(clientId);
@ -49,17 +46,20 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
if (masterId && clientId && serviceId) { if (masterId && clientId && serviceId) {
setStep('datetime-select'); setStep('datetime-select');
} else if (masterId && clientId) {
setStep('service-select');
} else { } else {
setFirstStep(); setStep(steps[0] ?? 'loading');
} }
} else { } else {
// Обычная инициализация (новый заказ)
if (isMaster) { if (isMaster) {
setMasterId(customer.documentId); setMasterId(customer.documentId);
} else { } else {
setClientId(customer.documentId); setClientId(customer.documentId);
} }
setFirstStep(); setStep(steps[0] ?? 'loading');
} }
initialized.current = true; initialized.current = true;
@ -68,7 +68,6 @@ export function useInitOrderStore(initData: null | OrderFieldsFragment) {
initData, initData,
isMaster, isMaster,
setClientId, setClientId,
setFirstStep,
setMasterId, setMasterId,
setServiceId, setServiceId,
setStep, setStep,

View File

@ -1,4 +1,4 @@
import type * as GQL from '../types'; import * as GQL from '../types';
import { notifyByTelegramId } from '../utils/notify'; import { notifyByTelegramId } from '../utils/notify';
import { BaseService } from './base'; import { BaseService } from './base';
import { CustomersService } from './customers'; import { CustomersService } from './customers';
@ -29,6 +29,9 @@ export class NotifyService extends BaseService {
const serviceId = String(variables.input.services?.[0] ?? ''); const serviceId = String(variables.input.services?.[0] ?? '');
const timeStart = String(variables.input.time_start ?? ''); const timeStart = String(variables.input.time_start ?? '');
const clientId = String(variables.input.client ?? ''); const clientId = String(variables.input.client ?? '');
const state = String(variables.input.state ?? '');
const isApproved = state === GQL.Enum_Order_State.Approved;
const { slot } = await slotsService.getSlot({ documentId: slotId }); const { slot } = await slotsService.getSlot({ documentId: slotId });
const { service } = await servicesService.getService({ documentId: serviceId }); const { service } = await servicesService.getService({ documentId: serviceId });
@ -45,13 +48,13 @@ export class NotifyService extends BaseService {
// Мастеру // Мастеру
if (master?.telegramId) { if (master?.telegramId) {
const message = `✅ <b>Запись создана!</b>\n<b>Дата:</b> ${slotDate}\n<b>Время:</b> ${timeStartString} - ${timeEndString}\n<b>Клиент:</b> ${client?.name ?? '-'}\n<b>Услуга:</b> ${service?.name ?? '-'}`; const message = `✅ <b>Запись создана${isApproved ? ' и подтверждена' : ''}!</b>\n<b>Дата:</b> ${slotDate}\n<b>Время:</b> ${timeStartString} - ${timeEndString}\n<b>Клиент:</b> ${client?.name ?? '-'}\n<b>Услуга:</b> ${service?.name ?? '-'}`;
await notifyByTelegramId(String(master.telegramId), message); await notifyByTelegramId(String(master.telegramId), message);
} }
// Клиенту // Клиенту
if (client?.telegramId) { if (client?.telegramId) {
const message = `✅ <b>Запись создана!</b>\n<b>Дата:</b> ${slotDate}\n<b>Время:</b> ${timeStartString} - ${timeEndString}\n<b>Мастер:</b> ${master?.name ?? '-'}\n<b>Услуга:</b> ${service?.name ?? '-'}`; const message = `✅ <b>Запись создана${isApproved ? ' и подтверждена' : ''}!</b>\n<b>Дата:</b> ${slotDate}\n<b>Время:</b> ${timeStartString} - ${timeEndString}\n<b>Мастер:</b> ${master?.name ?? '-'}\n<b>Услуга:</b> ${service?.name ?? '-'}`;
await notifyByTelegramId(String(client.telegramId), message); await notifyByTelegramId(String(client.telegramId), message);
} }
} }

View File

@ -1,7 +1,7 @@
/* eslint-disable canonical/id-match */ /* eslint-disable canonical/id-match */
import { getClientWithToken } from '../apollo/client'; import { getClientWithToken } from '../apollo/client';
import * as GQL from '../types'; import * as GQL from '../types';
import { Enum_Customer_Role, Enum_Slot_State } from '../types'; import { Enum_Slot_State } from '../types';
import { BaseService } from './base'; import { BaseService } from './base';
import { CustomersService } from './customers'; import { CustomersService } from './customers';
import { NotifyService } from './notify'; import { NotifyService } from './notify';
@ -41,29 +41,32 @@ export class OrdersService extends BaseService {
const servicesService = new ServicesService(this.customer); const servicesService = new ServicesService(this.customer);
const { customer } = await customersService.getCustomer(this.customer); const { customer } = await customersService.getCustomer(this.customer);
if (!customer) throw new Error(ERRORS.MISSING_USER);
const { slot } = await slotsService.getSlot({ documentId: variables.input.slot }); const { slot } = await slotsService.getSlot({ documentId: variables.input.slot });
if (slot?.state === Enum_Slot_State.Closed) { if (slot?.state === Enum_Slot_State.Closed) {
throw new Error(ERRORS.SLOT_CLOSED); throw new Error(ERRORS.SLOT_CLOSED);
} }
if (customer?.role === Enum_Customer_Role.Client) { const isMaster = isCustomerMaster(customer);
if (!isMaster) {
if (customer.documentId !== variables.input.client) { if (customer.documentId !== variables.input.client) {
throw new Error(ERRORS.INVALID_CLIENT); throw new Error(ERRORS.INVALID_CLIENT);
} }
const masters = await customersService.getMasters(this.customer); const { customers } = await customersService.getMasters(this.customer);
const masters = customers.at(0)?.masters;
const masterId = slot?.master?.documentId; const masterId = slot?.master?.documentId;
if (!masters.customers.some((master) => master?.documentId === masterId)) { if (!masters?.some((master) => master?.documentId === masterId)) {
throw new Error(ERRORS.INVALID_MASTER); throw new Error(ERRORS.INVALID_MASTER);
} }
} }
if ( if (isMaster && slot?.master?.documentId !== customer.documentId) {
customer?.role === Enum_Customer_Role.Master &&
slot?.master?.documentId !== customer.documentId
) {
throw new Error(ERRORS.INVALID_MASTER); throw new Error(ERRORS.INVALID_MASTER);
} }
@ -83,6 +86,7 @@ export class OrdersService extends BaseService {
...variables, ...variables,
input: { input: {
...variables.input, ...variables.input,
state: isMaster ? GQL.Enum_Order_State.Approved : GQL.Enum_Order_State.Created,
time_end: formatTime(endTime).db(), time_end: formatTime(endTime).db(),
}, },
}, },

2385
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -4,8 +4,8 @@ packages:
catalog: catalog:
"@apollo/client": ^3.12.4 "@apollo/client": ^3.12.4
"@types/node": ^20 "@types/node": ^20
"@types/react": ^19.1.2 "@types/react": ^19
"@types/react-dom": ^19.1.2 "@types/react-dom": ^19
"@vchikalkin/eslint-config-awesome": ^2.2.2 "@vchikalkin/eslint-config-awesome": ^2.2.2
autoprefixer: ^10.4.20 autoprefixer: ^10.4.20
dayjs: ^1.11.3 dayjs: ^1.11.3
@ -19,8 +19,8 @@ catalog:
postcss: ^8.4.49 postcss: ^8.4.49
postcss-load-config: ^6.0.1 postcss-load-config: ^6.0.1
prettier: ^3.2.5 prettier: ^3.2.5
react: ^19.1.0 react: ^19
react-dom: ^19.1.0 react-dom: ^19
radashi: ^12.5.1 radashi: ^12.5.1
rimraf: ^6.0.1 rimraf: ^6.0.1
tailwindcss: ^3.4.15 tailwindcss: ^3.4.15