Compare commits

...

115 Commits

Author SHA1 Message Date
vchikalkin
74fdefe65a orderUpdate: add status info 2025-06-27 13:39:47 +03:00
vchikalkin
bb65863f81 order: notify to telegram messages 2025-06-27 13:24:34 +03:00
vchikalkin
1e3ef51eb0 apps/bot: beautify messages 2025-06-26 18:57:38 +03:00
vchikalkin
7f3b38fa48 disable dashboard button 2025-06-26 18:49:00 +03:00
vchikalkin
9beee9902e order: add repeat button 2025-06-26 18:28:44 +03:00
vchikalkin
1fb3b67d79 order-card: add showDate variables in props 2025-06-26 15:19:55 +03:00
vchikalkin
7f6539d10a order-card: show date 2025-06-26 15:06:21 +03:00
vchikalkin
aa347fb032 api/orders: protect update order 2025-06-26 14:45:34 +03:00
vchikalkin
7dbc08f1d1 order: revert approve button for master 2025-06-26 14:33:13 +03:00
vchikalkin
f305987f68 hooks: invalidate orders & slots after mutate & delete 2025-06-26 14:12:51 +03:00
vchikalkin
8cc7c37f18 FloatingActionPanel: block buttons while pending request 2025-06-26 13:50:13 +03:00
vchikalkin
46ab2d67dc order: revert cancel button for master 2025-06-26 13:43:29 +03:00
vchikalkin
fead5353e7 create useIsMaster hook to prevent duplication 2025-06-26 13:23:03 +03:00
vchikalkin
f47ec19551 fix orders list for client 2025-06-26 12:42:52 +03:00
vchikalkin
98e0c33424 fix auth redirects 2025-06-26 12:20:08 +03:00
vchikalkin
5c89d41f2f show masters avatar in orders list 2025-06-26 11:50:11 +03:00
vchikalkin
46f60d969d remove getSlotsOrders fn 2025-06-25 18:22:20 +03:00
vchikalkin
a831aeb212 highlight days in horizontal calendar 2025-06-25 18:20:47 +03:00
vchikalkin
2ca11832a9 highlight days with slots in schedule calendar 2025-06-25 17:57:41 +03:00
vchikalkin
37e7a01ef2 action panel: hide if no handlers 2025-06-25 16:33:45 +03:00
vchikalkin
28bceab36d take into account cancelled and completed orders in the slot list 2025-06-25 16:27:49 +03:00
vchikalkin
a3fe14a53c fix badges & alerts 2025-06-25 14:24:11 +03:00
vchikalkin
24f71a9b66 add order status alert 2025-06-24 13:47:53 +03:00
vchikalkin
f6154d5fc2 order-card: add colors 2025-06-24 12:42:13 +03:00
vchikalkin
e861e6e917 order page: add buttons 2025-06-24 12:36:43 +03:00
vchikalkin
d7acd1ef9c order-services: fix types 2025-06-24 11:38:01 +03:00
vchikalkin
832f65714d app/profile: show shared orders 2025-06-24 11:26:26 +03:00
vchikalkin
5641a13890 exact types for Slot components & page 2025-06-24 10:54:52 +03:00
vchikalkin
25f6e26901 move OrderCard types close to component 2025-06-24 10:52:25 +03:00
vchikalkin
efebc9d8ef hooks/services: rename input -> variables 2025-06-24 10:30:48 +03:00
vchikalkin
f69aeb2353 hide ClientsOrdersList for non masters 2025-06-23 22:08:19 +03:00
vchikalkin
ff68ffbb6a fix floating panel overflows content 2025-06-23 21:25:48 +03:00
vchikalkin
c8ea506dc5 fix blur & colors 2025-06-23 21:12:07 +03:00
vchikalkin
4ed010056a slot page: replace buttons with floating panel 2025-06-23 20:55:07 +03:00
vchikalkin
2ba56c5949 order page 2025-06-23 20:15:19 +03:00
vchikalkin
6a2678653c fix show actual slot status after slot update 2025-06-23 16:19:46 +03:00
vchikalkin
085263654f fix create order query 2025-06-11 14:55:36 +03:00
vchikalkin
a7f00a3811 graphql: remove rename operations files 2025-06-10 17:15:31 +03:00
vchikalkin
25c90984dc fix queries, using formatDate & formatTime on client 2025-06-10 17:11:55 +03:00
vchikalkin
d15dd9ada7 packages: radash -> radashi 2025-06-10 15:36:01 +03:00
vchikalkin
8242d186fe fix create slot 2025-06-10 13:51:49 +03:00
vchikalkin
1b1963e5d9 prefetchQuery customer profile pages 2025-06-10 13:25:45 +03:00
vchikalkin
f6285d6ebf contacts: mark inactive contacts 2025-06-10 13:02:38 +03:00
vchikalkin
07d878214c SlotPage: add page header title 2025-06-08 17:42:24 +03:00
vchikalkin
16d448bab6 stores/schedule: export useScheduleStore 2025-06-08 17:41:31 +03:00
vchikalkin
ca8d88bfc3 SlotCard: use SlotComponentProps type 2025-06-08 17:30:22 +03:00
vchikalkin
d085a3d24d horizontal-calendar: switch months by arrow buttons 2025-06-08 17:21:31 +03:00
vchikalkin
0cb9e6b6ee graphql/api: remove throw new Error 2025-06-08 17:17:08 +03:00
vchikalkin
e4ec942a9c fix orders list in slot page 2025-06-08 17:13:53 +03:00
vchikalkin
f63ca6d93e add numberOfDaysBefore param 2025-06-08 15:14:55 +03:00
vchikalkin
a9efcfccf2 optimize orders list fetching 2025-06-08 14:59:13 +03:00
vchikalkin
1e84b4ec0e remove context/date.tsx 2025-06-08 14:36:48 +03:00
vchikalkin
f4609eb8d1 add horizontal calendar 2025-06-08 14:32:01 +03:00
vchikalkin
d0efd133f2 add launch.json 2025-06-08 14:17:39 +03:00
Vlad Chikalkin
3589ab974a
Refactor/components folder structure (#24)
* refactor components/navigation

* refactor components/orders

* refactor components/profile

* refactor components/schedule

* remove components/common/spinner
2025-05-23 17:35:13 +03:00
vchikalkin
2510e0bcae move order-card & time-range to @/components/shared 2025-05-23 16:48:20 +03:00
vchikalkin
5e13deecf0 finally organized stores & context 2025-05-23 16:29:42 +03:00
vchikalkin
d0e67a0f8a context: rename contexts properly 2025-05-23 16:22:25 +03:00
vchikalkin
a4608ead43 fix slots queries 2025-05-23 16:08:20 +03:00
vchikalkin
c710537727 replace ScheduleTimeContext with ScheduleStore 2025-05-23 14:32:39 +03:00
vchikalkin
2bb85af46b move order store -> orders\order-store 2025-05-23 13:55:53 +03:00
vchikalkin
1b99f7f18d components/orders: remove nested components dirs 2025-05-23 13:35:45 +03:00
vchikalkin
4160ed4540 stores/order: split into slices 2025-05-23 13:14:26 +03:00
vchikalkin
d8f853180b app/orders: fill page with content 2025-05-21 18:40:53 +03:00
vchikalkin
ebe8ee5437 fix build 2025-05-21 17:47:53 +03:00
vchikalkin
0698242257 take into existing orders when computing times 2025-05-21 17:26:10 +03:00
vchikalkin
f0b63a5e7e fix GetSlotsOrders order 2025-05-21 16:30:44 +03:00
vchikalkin
52d68964f1 take into service duration when computing times 2025-05-21 16:27:56 +03:00
vchikalkin
0b867a9136 getAvailableTimeSlots: add filter by orders 2025-05-20 19:53:51 +03:00
vchikalkin
b8880eedee move getAvailableTimeSlots to server 2025-05-20 19:25:25 +03:00
Vlad Chikalkin
9314cdd1cb
merge branch 'refactor-api' (#23)
* refactor customer api

* refactor slots api

* hooks/customers: use invalidateQueries

* refactor services api

* optimize hooks queryKey

* refactor orders api

* typo refactor hooks

* fix telegramId type (number)

* fix bot with new api

* rename customers masters & clients query

* fix useClientsQuery & useMastersQuery query

* new line after 'use client' & 'use server' directives
2025-05-20 14:27:51 +03:00
vchikalkin
fda1a0a531 packages/graphql: add eslint 2025-05-10 15:46:33 +03:00
vchikalkin
f2f7138c67 add result pages (success, error) 2025-05-09 18:26:18 +03:00
vchikalkin
0ed90d5451 split next-button into two buttons 2025-05-09 17:56:53 +03:00
vchikalkin
1528cc25b8 create order works! 2025-05-08 19:30:00 +03:00
vchikalkin
24fb2103f7 apps/web: rename actions/service -> actions/services 2025-05-08 16:47:02 +03:00
vchikalkin
3738c4e2a9 select time feature & get final order values 2025-05-07 17:33:35 +03:00
vchikalkin
7fcf67eece skip master select for master & client select for client 2025-05-06 13:14:48 +03:00
vchikalkin
b5306357c8 fix submit button not working 2025-05-06 10:57:46 +03:00
vchikalkin
7e886172f2 pass order store via context 2025-04-30 18:58:46 +03:00
vchikalkin
e6f2e6ccaf migrate from order context to zustand store 2025-04-29 17:48:11 +03:00
vchikalkin
2bc7607800 improve useSlots hook 2025-04-16 17:19:22 +03:00
vchikalkin
7e143b3054 split datetime-select into files 2025-04-16 16:03:58 +03:00
vchikalkin
1e6718508a fix steps for client & master 2025-04-16 15:22:25 +03:00
vchikalkin
68d2343e98 Revert "contacts: skip client step for client"
This reverts commit db9af07dab9df9428561a1952f5a2c91c5b9d88d.
2025-04-16 13:56:05 +03:00
vchikalkin
47144e8126 ServiceSelect: fix padding 2025-04-16 12:49:36 +03:00
vchikalkin
1883280dca fix react types 2025-04-16 12:47:36 +03:00
vchikalkin
db9af07dab contacts: skip client step for client 2025-04-16 12:45:01 +03:00
vchikalkin
bc974ffc40 back-button: fix steps using 2025-04-16 12:24:03 +03:00
vchikalkin
c09e79b024 .vscode: add launch.json 2025-04-16 11:23:36 +03:00
vchikalkin
2676e40df6 packages: upgrade next@15.3.0 2025-04-16 10:53:31 +03:00
vchikalkin
dd99e7d984 context/order: skip client-select in client steps 2025-04-10 14:36:27 +03:00
vchikalkin
ec32f56f8b hooks/profile: allow pass empty args to useProfileQuery/useProfileMutation 2025-04-10 12:46:07 +03:00
vchikalkin
8c8a588dfc context/order: split into files 2025-04-10 11:37:27 +03:00
vchikalkin
4143151cbb add self to masters list & border avatar 2025-04-09 19:12:01 +03:00
vchikalkin
8eece70ff4 add ClientsGrid & 'client-select' step 2025-04-09 16:51:25 +03:00
vchikalkin
2a830ceffb optimize useCustomerContacts 2025-04-09 16:10:27 +03:00
vchikalkin
0281e99403 create MastersGrid & master-select step 2025-04-09 13:48:20 +03:00
vchikalkin
461bca0a0b prepare for split contacts grid into masters/clients grid 2025-04-09 13:18:22 +03:00
vchikalkin
1e69802b82 Revert "add check icon for masters"
This reverts commit cc81a9a504918ebbffcca8d035c7c4984f109957.
2025-04-08 11:32:15 +03:00
vchikalkin
cc81a9a504 add check icon for masters 2025-04-07 18:27:51 +03:00
vchikalkin
687a5b66c0 fix step components rendering 2025-04-07 17:54:07 +03:00
vchikalkin
4f87d17e8e slot list: render immediately 2025-04-01 17:20:30 +03:00
vchikalkin
aacb7fa998 hooks/slot: rename index -> master 2025-03-14 17:58:44 +03:00
vchikalkin
5f0d707884 fix calendar padding 2025-03-14 14:37:59 +03:00
vchikalkin
79570efe1a save selected date to context 2025-03-14 13:56:36 +03:00
vchikalkin
cab23ac932 service component: comment span 2025-03-14 13:29:31 +03:00
vchikalkin
2bbe9731b1 disable submit button if no service selected 2025-03-13 13:47:44 +03:00
vchikalkin
3a649e5825 disable submit button if no customer selected 2025-03-12 17:18:06 +03:00
vchikalkin
cf5ceae115 components/order-form: add back button 2025-03-12 16:58:15 +03:00
vchikalkin
8931dfc69f Revert "context/order: add masterId"
This reverts commit d5d07d7b2f5b6673a621a30b00ad087c60675a3f.
2025-03-12 16:53:04 +03:00
vchikalkin
d5d07d7b2f context/order: add masterId 2025-03-12 14:19:36 +03:00
vchikalkin
4db10a7f63 add calendar & time picker 2025-03-10 18:58:04 +03:00
vchikalkin
b6d7fabba1 add service select 2025-03-10 18:18:23 +03:00
vchikalkin
fbc682b41f add contacts scroller 2025-03-07 16:13:58 +03:00
177 changed files with 4428 additions and 1644 deletions

34
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,34 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug server-side",
"port": 9230,
"request": "attach",
"skipFiles": [
"<node_internals>/**"
],
"type": "node",
"sourceMapPathOverrides": {
"/turbopack/[project]/*": "${webRoot}/*"
}
},
{
"type": "chrome",
"request": "launch",
"name": "Next.js: debug client-side",
"url": "http://localhost:3000",
"webRoot": "${workspaceFolder}",
"sourceMapPathOverrides": {
"/turbopack/[project]/*": "${webRoot}/*"
}
}
],
"compounds": [
{
"name": "Next.js: debug full stack",
"configurations": ["Next.js: debug client-side", "Next.js: debug server-side"],
"stopAll": true
}
]
}

View File

@ -13,7 +13,7 @@
"lint-staged": "lint-staged" "lint-staged": "lint-staged"
}, },
"dependencies": { "dependencies": {
"telegraf": "^4.16.3", "telegraf": "catalog:",
"zod": "catalog:" "zod": "catalog:"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,14 +1,23 @@
/* eslint-disable canonical/id-match */ /* eslint-disable canonical/id-match */
/* eslint-disable consistent-return */ /* eslint-disable consistent-return */
import { env as environment } from './config/env'; import { env as environment } from './config/env';
import { commandsList, KEYBOARD_REMOVE, KEYBOARD_SHARE_PHONE, MESSAGE_NOT_MASTER } from './message';
import { normalizePhoneNumber } from './utils/phone';
import { import {
createOrUpdateUser, commandsList,
getCustomer, KEYBOARD_REMOVE,
updateCustomerMaster, KEYBOARD_SHARE_PHONE,
updateCustomerProfile, MESSAGE_NOT_MASTER,
} from '@repo/graphql/api'; MSG_ALREADY_MASTER,
MSG_BECOME_MASTER,
MSG_CONTACT_ADDED,
MSG_ERROR,
MSG_NEED_PHONE,
MSG_PHONE_SAVED,
MSG_SEND_CLIENT_CONTACT,
MSG_WELCOME,
MSG_WELCOME_BACK,
} from './message';
import { normalizePhoneNumber } from './utils/phone';
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 { Telegraf } from 'telegraf';
import { message } from 'telegraf/filters'; import { message } from 'telegraf/filters';
@ -16,68 +25,72 @@ import { message } from 'telegraf/filters';
const bot = new Telegraf(environment.BOT_TOKEN); const bot = new Telegraf(environment.BOT_TOKEN);
bot.start(async (context) => { bot.start(async (context) => {
const data = await getCustomer({ telegramId: context.from.id }); const telegramId = context.from.id;
const customer = data?.data?.customers?.at(0);
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (customer) { if (customer) {
return context.reply( return context.reply(MSG_WELCOME_BACK(customer.name) + commandsList, {
`Приветствуем снова, ${customer.name} 👋. ...KEYBOARD_REMOVE,
Чтобы воспользоваться сервисом, откройте приложение.` + commandsList, parse_mode: 'HTML',
KEYBOARD_REMOVE, });
);
} }
return context.reply( return context.reply(MSG_WELCOME, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
'Добро пожаловать! Пожалуйста, поделитесь своим номером телефона.',
KEYBOARD_SHARE_PHONE,
);
}); });
bot.command('addcontact', async (context) => { bot.command('addcontact', async (context) => {
const data = await getCustomer({ telegramId: context.from.id }); const telegramId = context.from.id;
const customer = data?.data?.customers?.at(0);
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (!customer) { if (!customer) {
return context.reply( return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
'Чтобы добавить контакт, сначала поделитесь своим номером телефона.',
KEYBOARD_SHARE_PHONE,
);
} }
if (customer.role !== Enum_Customer_Role.Master) { if (customer.role !== Enum_Customer_Role.Master) {
return context.reply(MESSAGE_NOT_MASTER); return context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' });
} }
return context.reply('Отправьте контакт клиента, которого вы хотите добавить'); return context.reply(MSG_SEND_CLIENT_CONTACT, { parse_mode: 'HTML' });
}); });
bot.command('becomemaster', async (context) => { bot.command('becomemaster', async (context) => {
const data = await getCustomer({ telegramId: context.from.id }); const telegramId = context.from.id;
const customer = data?.data?.customers?.at(0);
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
if (!customer) { if (!customer) {
return context.reply('Сначала поделитесь своим номером телефона.', KEYBOARD_SHARE_PHONE); return context.reply(MSG_NEED_PHONE, { ...KEYBOARD_SHARE_PHONE, parse_mode: 'HTML' });
} }
if (customer.role === Enum_Customer_Role.Master) { if (customer.role === Enum_Customer_Role.Master) {
return context.reply('Вы уже являетесь мастером.'); return context.reply(MSG_ALREADY_MASTER, { parse_mode: 'HTML' });
} }
const response = await updateCustomerProfile({ const response = await customerService
data: { role: Enum_Customer_Role.Master }, .updateCustomer({
documentId: customer.documentId, data: {
}).catch((error) => { role: Enum_Customer_Role.Master,
context.reply('Произошла ошибка.\n' + error); },
})
.catch((error) => {
context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
}); });
if (response) { if (response) {
return context.reply('Вы стали мастером'); return context.reply(MSG_BECOME_MASTER, { parse_mode: 'HTML' });
} }
}); });
bot.on(message('contact'), async (context) => { bot.on(message('contact'), async (context) => {
const data = await getCustomer({ telegramId: context.from.id }); const telegramId = context.from.id;
const customer = data?.data?.customers?.at(0);
const customerService = new CustomersService({ telegramId });
const { customer } = await customerService.getCustomer({ telegramId });
const isRegistration = !customer; const isRegistration = !customer;
@ -86,40 +99,46 @@ bot.on(message('contact'), async (context) => {
const phone = normalizePhoneNumber(contact.phone_number); const phone = normalizePhoneNumber(contact.phone_number);
if (isRegistration) { if (isRegistration) {
const response = await createOrUpdateUser({ const response = await customerService
.createCustomer({
name, name,
phone, phone,
telegramId: context.from.id, telegramId: context.from.id,
}).catch((error) => { })
context.reply('Произошла ошибка.\n' + error); .catch((error) => {
context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
}); });
if (response) { if (response) {
return context.reply( return context.reply(MSG_PHONE_SAVED + commandsList, {
`Спасибо! Мы сохранили ваш номер телефона. Теперь можете открыть приложение или воспользоваться командами бота.` + ...KEYBOARD_REMOVE,
commandsList, parse_mode: 'HTML',
KEYBOARD_REMOVE, });
);
} }
} else { } else {
if (customer.role !== Enum_Customer_Role.Master) { if (customer.role !== Enum_Customer_Role.Master) {
return context.reply(MESSAGE_NOT_MASTER); return context.reply(MESSAGE_NOT_MASTER, { parse_mode: 'HTML' });
} }
try { try {
await createOrUpdateUser({ name, phone }); const createCustomerResult = await customerService.createCustomer({ name, phone });
await updateCustomerMaster({ const documentId = createCustomerResult?.createCustomer?.documentId;
masterId: customer.documentId,
operation: 'add', if (!documentId) {
phone, throw new Error('Customer not created');
}
const masters = [customer.documentId];
await customerService.addMasters({
data: { masters },
documentId,
}); });
return context.reply( return context.reply(MSG_CONTACT_ADDED(name), { parse_mode: 'HTML' });
`Добавили контакт ${name}. Пригласите пользователя в приложение и тогда вы сможете добавлять записи с этим контактом.`,
);
} catch (error) { } catch (error) {
context.reply('Произошла ошибка.\n' + error); context.reply(MSG_ERROR(error), { parse_mode: 'HTML' });
} }
} }
}); });

View File

@ -1,8 +1,9 @@
import { type ReplyKeyboardRemove } from 'telegraf/types'; import { type ReplyKeyboardRemove } from 'telegraf/types';
export const commandsList = ` export const commandsList = `
\оступные команды: \n<b>📋 Доступные команды:</b>
/addcontact - Добавить контакт клиента <b>/addcontact</b> добавить контакт клиента
<b>/becomemaster</b> стать мастером
`; `;
export const KEYBOARD_SHARE_PHONE = { export const KEYBOARD_SHARE_PHONE = {
@ -11,7 +12,7 @@ export const KEYBOARD_SHARE_PHONE = {
[ [
{ {
request_contact: true, request_contact: true,
text: 'Отправить номер телефона', text: '📱 Отправить номер телефона',
}, },
], ],
], ],
@ -26,4 +27,29 @@ export const KEYBOARD_REMOVE = {
}; };
export const MESSAGE_NOT_MASTER = export const MESSAGE_NOT_MASTER =
'Только мастер может добавлять контакты. \nСтать мастером можно на странице профиля в приложении или при помощи команды /becomemaster'; '⛔️ <b>Только мастер может добавлять контакты.</b>\nСтать мастером можно на странице профиля в приложении или с помощью команды <b>/becomemaster</b>.';
export const MSG_WELCOME =
'👋 <b>Добро пожаловать!</b>\nПожалуйста, поделитесь своим номером телефона для регистрации.';
export const MSG_WELCOME_BACK = (name: string) =>
`👋 <b>С возвращением, ${name}!</b>\nЧтобы воспользоваться сервисом, откройте приложение.\n`;
export const MSG_NEED_PHONE =
'📱 <b>Чтобы добавить контакт, сначала поделитесь своим номером телефона.</b>';
export const MSG_SEND_CLIENT_CONTACT =
'👤 <b>Отправьте контакт клиента, которого вы хотите добавить.</b>';
export const MSG_ALREADY_MASTER = '🎉 <b>Вы уже являетесь мастером!</b>';
export const MSG_BECOME_MASTER = '🥳 <b>Поздравляем! Теперь вы мастер.</b>';
export const MSG_ERROR = (error?: unknown) =>
`❌ <b>Произошла ошибка.</b>\n${error ? String(error) : ''}`;
export const MSG_PHONE_SAVED =
'✅ <b>Спасибо! Мы сохранили ваш номер телефона.</b>\nТеперь вы можете открыть приложение или воспользоваться командами бота.';
export const MSG_CONTACT_ADDED = (name: string) =>
`✅ <b>Добавили контакт:</b> <b>${name}</b>\ригласите пользователя в приложение, чтобы вы могли добавлять записи с этим контактом.`;

View File

@ -0,0 +1,42 @@
'use server';
import { useService } from './lib/service';
import { CustomersService } from '@repo/graphql/api/customers';
const getService = useService(CustomersService);
export async function addMasters(...variables: Parameters<CustomersService['addMasters']>) {
const service = await getService();
return service.addMasters(...variables);
}
export async function createCustomer(...variables: Parameters<CustomersService['createCustomer']>) {
const service = await getService();
return service.createCustomer(...variables);
}
export async function getClients(...variables: Parameters<CustomersService['getClients']>) {
const service = await getService();
return service.getClients(...variables);
}
export async function getCustomer(...variables: Parameters<CustomersService['getCustomer']>) {
const service = await getService();
return service.getCustomer(...variables);
}
export async function getMasters(...variables: Parameters<CustomersService['getMasters']>) {
const service = await getService();
return service.getMasters(...variables);
}
export async function updateCustomer(...variables: Parameters<CustomersService['updateCustomer']>) {
const service = await getService();
return service.updateCustomer(...variables);
}

View File

@ -0,0 +1,14 @@
import { authOptions } from '@/config/auth';
import { type BaseService } from '@repo/graphql/api/base';
import { getServerSession } from 'next-auth';
export function useService<T extends typeof BaseService>(service: T) {
return async function () {
const session = await getServerSession(authOptions);
if (!session?.user?.telegramId) throw new Error('Unauthorized');
const customer = { telegramId: session.user.telegramId };
return new service(customer) as InstanceType<T>;
};
}

View File

@ -0,0 +1,30 @@
'use server';
import { useService } from './lib/service';
import { OrdersService } from '@repo/graphql/api/orders';
const getServicesService = useService(OrdersService);
export async function createOrder(...variables: Parameters<OrdersService['createOrder']>) {
const service = await getServicesService();
return service.createOrder(...variables);
}
export async function getOrder(...variables: Parameters<OrdersService['getOrder']>) {
const service = await getServicesService();
return service.getOrder(...variables);
}
export async function getOrders(...variables: Parameters<OrdersService['getOrders']>) {
const service = await getServicesService();
return service.getOrders(...variables);
}
export async function updateOrder(...variables: Parameters<OrdersService['updateOrder']>) {
const service = await getServicesService();
return service.updateOrder(...variables);
}

View File

@ -0,0 +1,18 @@
'use server';
import { useService } from './lib/service';
import { ServicesService } from '@repo/graphql/api/services';
const getServicesService = useService(ServicesService);
export async function getService(...variables: Parameters<ServicesService['getService']>) {
const service = await getServicesService();
return service.getService(...variables);
}
export async function getServices(...variables: Parameters<ServicesService['getServices']>) {
const service = await getServicesService();
return service.getServices(...variables);
}

View File

@ -0,0 +1,44 @@
'use server';
import { useService } from './lib/service';
import { SlotsService } from '@repo/graphql/api/slots';
const getService = useService(SlotsService);
export async function createSlot(...variables: Parameters<SlotsService['createSlot']>) {
const service = await getService();
return service.createSlot(...variables);
}
export async function deleteSlot(...variables: Parameters<SlotsService['deleteSlot']>) {
const service = await getService();
return service.deleteSlot(...variables);
}
export async function getAvailableTimeSlots(
...variables: Parameters<SlotsService['getAvailableTimeSlots']>
) {
const service = await getService();
return service.getAvailableTimeSlots(...variables);
}
export async function getSlot(...variables: Parameters<SlotsService['getSlot']>) {
const service = await getService();
return service.getSlot(...variables);
}
export async function getSlots(...variables: Parameters<SlotsService['getSlots']>) {
const service = await getService();
return service.getSlots(...variables);
}
export async function updateSlot(...variables: Parameters<SlotsService['updateSlot']>) {
const service = await getService();
return service.updateSlot(...variables);
}

View File

@ -1,26 +0,0 @@
'use server';
import { authOptions } from '@/config/auth';
import { getCustomerClients, getCustomerMasters } from '@repo/graphql/api';
import { getServerSession } from 'next-auth/next';
export async function getClients() {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const response = await getCustomerClients({ telegramId: user?.telegramId });
return response.data?.customers?.at(0);
}
export async function getMasters() {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const response = await getCustomerMasters({ telegramId: user?.telegramId });
return response.data?.customers?.at(0);
}

View File

@ -1,4 +0,0 @@
'use server';
import * as api from '@repo/graphql/api';
export const getOrder = api.getOrder;

View File

@ -1,34 +0,0 @@
'use server';
import { authOptions } from '@/config/auth';
import { getCustomer, updateCustomerProfile } from '@repo/graphql/api';
import { type CustomerInput, type GetCustomerQueryVariables } from '@repo/graphql/types';
import { getServerSession } from 'next-auth/next';
export async function getProfile(input?: GetCustomerQueryVariables) {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const telegramId = input?.telegramId || user?.telegramId;
const { data } = await getCustomer({ telegramId });
const customer = data?.customers?.at(0);
return customer;
}
export async function updateProfile(input: CustomerInput) {
const session = await getServerSession(authOptions);
if (!session) throw new Error('Missing session');
const { user } = session;
const { data } = await getCustomer({ telegramId: user?.telegramId });
const customer = data.customers.at(0);
if (!customer) throw new Error('Customer not found');
await updateCustomerProfile({
data: input,
documentId: customer?.documentId,
});
}

View File

@ -0,0 +1,13 @@
'use server';
import { authOptions } from '@/config/auth';
import { getServerSession } from 'next-auth/next';
export async function getSessionUser() {
const session = await getServerSession(authOptions);
const user = session?.user;
if (!user?.telegramId) throw new Error('Missing session');
return user;
}

View File

@ -1,60 +0,0 @@
'use server';
// eslint-disable-next-line sonarjs/no-internal-api-use
import type * as ApolloTypes from '../../../packages/graphql/node_modules/@apollo/client/core';
import { getProfile } from './profile';
import { formatDate, formatTime } from '@/utils/date';
import * as api from '@repo/graphql/api';
import type * as GQL from '@repo/graphql/types';
type AddSlotInput = Omit<GQL.CreateSlotMutationVariables['input'], 'master'>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type FixTypescriptCringe = ApolloTypes.FetchResult;
export async function addSlot(input: AddSlotInput) {
const customer = await getProfile();
return api.createSlot({
...input,
date: formatDate(input.date).db(),
master: customer?.documentId,
time_end: formatTime(input.time_end).db(),
time_start: formatTime(input.time_start).db(),
});
}
export async function getSlots(input: GQL.GetSlotsQueryVariables) {
const customer = await getProfile();
if (!customer?.documentId) throw new Error('Customer not found');
return api.getSlots({
filters: {
...input.filters,
master: {
documentId: {
eq: customer.documentId,
},
},
},
});
}
export async function updateSlot(input: GQL.UpdateSlotMutationVariables) {
const customer = await getProfile();
if (!customer?.documentId) throw new Error('Customer not found');
return api.updateSlot({
...input,
data: {
...input.data,
date: input.data?.date ? formatDate(input.data.date).db() : undefined,
time_end: input.data?.time_end ? formatTime(input.data.time_end).db() : undefined,
time_start: input.data?.time_start ? formatTime(input.data.time_start).db() : undefined,
},
});
}
export const getSlot = api.getSlot;
export const deleteSlot = api.deleteSlot;

View File

@ -1,5 +1,6 @@
/* eslint-disable promise/prefer-await-to-then */ /* eslint-disable promise/prefer-await-to-then */
'use client'; 'use client';
import { getTelegramUser } from '@/mocks/get-telegram-user'; import { getTelegramUser } from '@/mocks/get-telegram-user';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner'; import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { signIn, useSession } from 'next-auth/react'; import { signIn, useSession } from 'next-auth/react';
@ -21,7 +22,7 @@ export default function Auth() {
signIn('telegram', { signIn('telegram', {
callbackUrl: '/profile', callbackUrl: '/profile',
redirect: false, redirect: false,
telegramId: String(user?.id), telegramId: user?.id,
}); });
}); });
} }

View File

@ -1,4 +1,5 @@
'use client'; 'use client';
import { useClientOnce } from '@/hooks/telegram'; import { useClientOnce } from '@/hooks/telegram';
import { isTMA } from '@telegram-apps/sdk-react'; import { isTMA } from '@telegram-apps/sdk-react';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@ -1,5 +1,6 @@
/* eslint-disable promise/prefer-await-to-then */ /* eslint-disable promise/prefer-await-to-then */
'use client'; 'use client';
import { initData, isMiniAppDark, useSignal } from '@telegram-apps/sdk-react'; import { initData, isMiniAppDark, useSignal } from '@telegram-apps/sdk-react';
import { signIn, useSession } from 'next-auth/react'; import { signIn, useSession } from 'next-auth/react';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
@ -28,7 +29,7 @@ function useAuth() {
signIn('telegram', { signIn('telegram', {
callbackUrl: '/profile', callbackUrl: '/profile',
redirect: false, redirect: false,
telegramId: String(initDataUser.id), telegramId: initDataUser.id,
}).then(() => redirect('/profile')); }).then(() => redirect('/profile'));
} }
}, [initDataUser?.id, status]); }, [initDataUser?.id, status]);

View File

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

View File

@ -0,0 +1,40 @@
import { getOrder } from '@/actions/api/orders';
import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation';
import {
OrderButtons,
OrderContacts,
OrderDateTime,
OrderServices,
OrderStatus,
} from '@/components/orders';
import { type OrderPageParameters } from '@/components/orders/types';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
type Props = { params: Promise<OrderPageParameters> };
export default async function ProfilePage(props: Readonly<Props>) {
const parameters = await props.params;
const documentId = parameters.documentId;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryFn: () => getOrder({ documentId }),
queryKey: ['order', documentId],
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Запись" />
<Container>
<OrderDateTime {...parameters} />
<OrderStatus {...parameters} />
<OrderContacts {...parameters} />
<OrderServices {...parameters} />
<div className="pb-24" />
<OrderButtons {...parameters} />
</Container>
</HydrationBoundary>
);
}

View File

@ -1,3 +1,14 @@
export default function AddOrdersPage() { import { Container } from '@/components/layout';
return 'Add Orders'; import { PageHeader } from '@/components/navigation';
import { OrderForm } from '@/components/orders';
export default async function AddOrdersPage() {
return (
<>
<PageHeader title="Новая запись" />
<Container className="px-0">
<OrderForm />
</Container>
</>
);
} }

View File

@ -1,3 +1,18 @@
export default function OrdersPage() { 'use client';
return 'Orders'; import { Container } from '@/components/layout';
import { ClientsOrdersList, OrdersList } from '@/components/orders';
import { DateSelect } from '@/components/orders/orders-list/date-select';
import { DateTimeStoreProvider } from '@/stores/datetime';
export default function ProfilePage() {
return (
<Container>
<DateTimeStoreProvider>
<div />
<DateSelect />
<ClientsOrdersList />
<OrdersList />
</DateTimeStoreProvider>
</Container>
);
} }

View File

@ -1,22 +1,29 @@
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 } from '@/components/profile'; import { 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 }> };
export default async function ProfilePage(props: Readonly<Props>) { export default async function ProfilePage(props: Readonly<Props>) {
const parameters = await props.params; const parameters = await props.params;
const { telegramId } = parameters; const telegramId = Number.parseInt(parameters.telegramId, 10);
const queryClient = new QueryClient(); const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryFn: () => getCustomer({ telegramId }),
queryKey: ['customer', telegramId],
});
return ( return (
<HydrationBoundary state={dehydrate(queryClient)}> <HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title="Профиль контакта" /> <PageHeader title="Профиль контакта" />
<Container className="px-0"> <Container className="px-0">
<PersonCard telegramId={telegramId} /> <PersonCard telegramId={telegramId} />
<ContactDataCard telegramId={telegramId} /> <ContactDataCard telegramId={telegramId} />
<ProfileOrdersList telegramId={telegramId} />
</Container> </Container>
</HydrationBoundary> </HydrationBoundary>
); );

View File

@ -1,3 +1,5 @@
import { getCustomer } from '@/actions/api/customers';
import { getSessionUser } from '@/actions/session';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { LinksCard, PersonCard, ProfileDataCard } from '@/components/profile'; import { LinksCard, PersonCard, ProfileDataCard } from '@/components/profile';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
@ -5,6 +7,13 @@ import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query
export default async function ProfilePage() { export default async function ProfilePage() {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const { telegramId } = await getSessionUser();
await queryClient.prefetchQuery({
queryFn: () => getCustomer({ telegramId }),
queryKey: ['customer', telegramId],
});
return ( return (
<HydrationBoundary state={dehydrate(queryClient)}> <HydrationBoundary state={dehydrate(queryClient)}>
<Container className="px-0"> <Container className="px-0">

View File

@ -1,6 +1,8 @@
import { ScheduleContextProvider } from '@/context/schedule'; 'use client';
import { DateTimeStoreProvider } from '@/stores/datetime';
import { type PropsWithChildren } from 'react'; import { type PropsWithChildren } from 'react';
export default async function Layout({ children }: Readonly<PropsWithChildren>) { export default function Layout({ children }: Readonly<PropsWithChildren>) {
return <ScheduleContextProvider>{children}</ScheduleContextProvider>; return <DateTimeStoreProvider>{children}</DateTimeStoreProvider>;
} }

View File

@ -1,22 +1,31 @@
import { getSlot } from '@/actions/api/slots';
import { Container } from '@/components/layout'; import { Container } from '@/components/layout';
import { PageHeader } from '@/components/navigation'; import { PageHeader } from '@/components/navigation';
import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule'; import { SlotButtons, SlotDateTime, SlotOrdersList } from '@/components/schedule';
import { type SlotPageParameters } from '@/components/schedule/types';
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query'; import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
type Props = { params: Promise<{ documentId: string }> }; type Props = { params: Promise<SlotPageParameters> };
export default async function ProfilePage(props: Readonly<Props>) { export default async function SlotPage(props: Readonly<Props>) {
const parameters = await props.params; const parameters = await props.params;
const documentId = parameters.documentId;
const queryClient = new QueryClient(); const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryFn: () => getSlot({ documentId }),
queryKey: ['slot', documentId],
});
return ( return (
<HydrationBoundary state={dehydrate(queryClient)}> <HydrationBoundary state={dehydrate(queryClient)}>
<PageHeader title={undefined} /> <PageHeader title="Слот" />
<Container> <Container>
<SlotDateTime {...parameters} /> <SlotDateTime {...parameters} />
<SlotButtons {...parameters} />
<SlotOrdersList {...parameters} /> <SlotOrdersList {...parameters} />
<div className="pb-24" />
<SlotButtons {...parameters} />
</Container> </Container>
</HydrationBoundary> </HydrationBoundary>
); );

View File

@ -1,18 +1,21 @@
'use client'; 'use client';
import { useProfileMutation } from '@/hooks/profile';
import { useCustomerMutation } from '@/hooks/api/customers';
import { initData, useSignal } from '@telegram-apps/sdk-react'; import { initData, useSignal } from '@telegram-apps/sdk-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export function UpdateProfile() { export function UpdateProfile() {
const initDataUser = useSignal(initData.user); const initDataUser = useSignal(initData.user);
const { mutate: updateProfile } = useProfileMutation({}); const { mutate: updateProfile } = useCustomerMutation();
const [hasUpdated, setHasUpdated] = useState(false); const [hasUpdated, setHasUpdated] = useState(false);
useEffect(() => { useEffect(() => {
if (!hasUpdated) { if (!hasUpdated) {
updateProfile({ updateProfile({
data: {
active: true, active: true,
photoUrl: initDataUser?.photoUrl || undefined, photoUrl: initDataUser?.photoUrl || undefined,
},
}); });
setHasUpdated(true); setHasUpdated(true);
} }

View File

@ -1,9 +0,0 @@
import { Loader2 } from 'lucide-react';
export function LoadingSpinner() {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-8 animate-spin text-primary" />
</div>
);
}

View File

@ -1,33 +1,8 @@
'use client'; 'use client';
import { LoadingSpinner } from '../common/spinner';
import { useCustomerContacts } from '@/hooks/contacts';
import * as GQL from '@repo/graphql/types';
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
import Link from 'next/link';
import { memo } from 'react';
type ContactRowProps = { import { ContactRow } from '../shared/contact-row';
readonly contact: GQL.CustomerFieldsFragment; import { useCustomerContacts } from '@/hooks/api/contacts';
}; import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
const ContactRow = memo(function ({ contact }: ContactRowProps) {
return (
<Link href={`/profile/${contact.telegramId}`} key={contact.telegramId}>
<div className="flex items-center space-x-4 rounded-lg py-2 transition-colors hover:bg-accent">
<Avatar>
<AvatarImage alt={contact.name} src={contact.photoUrl || ''} />
<AvatarFallback>{contact.name.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{contact.name}</p>
<p className="text-sm text-muted-foreground">
{contact.role === GQL.Enum_Customer_Role.Client ? 'Клиент' : 'Мастер'}
</p>
</div>
</div>
</Link>
);
});
export function ContactsList() { export function ContactsList() {
const { contacts, isLoading } = useCustomerContacts(); const { contacts, isLoading } = useCustomerContacts();
@ -39,7 +14,7 @@ export function ContactsList() {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{contacts.map((contact) => ( {contacts.map((contact) => (
<ContactRow contact={contact} key={contact.documentId} /> <ContactRow key={contact.documentId} {...contact} />
))} ))}
</div> </div>
); );

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import { ContactsFilterContext, type FilterType } from '@/context/contacts-filter';
import { ContactsContext, type FilterType } from '@/context/contacts';
import { Button } from '@repo/ui/components/ui/button'; import { Button } from '@repo/ui/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@ -17,7 +18,7 @@ const filterLabels: Record<FilterType, string> = {
}; };
export function ContactsFilter() { export function ContactsFilter() {
const { filter, setFilter } = use(ContactsFilterContext); const { filter, setFilter } = use(ContactsContext);
return ( return (
<DropdownMenu> <DropdownMenu>

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import { NavButton } from './components/nav-button';
import { NavButton } from './nav-button';
import { BookOpen, Newspaper, PlusCircle, User, Users } from 'lucide-react'; import { BookOpen, Newspaper, PlusCircle, User, Users } from 'lucide-react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
@ -12,7 +13,7 @@ export function BottomNav() {
return ( return (
<nav className="sticky inset-x-0 bottom-0 border-t border-border bg-background"> <nav className="sticky inset-x-0 bottom-0 border-t border-border bg-background">
<div className="grid grid-cols-5"> <div className="grid grid-cols-5">
<NavButton href="/dashboard" icon={<Newspaper />} label="Главное" /> <NavButton disabled href="/dashboard" icon={<Newspaper />} label="Главное" />
<NavButton href="/orders" icon={<BookOpen />} label="Записи" /> <NavButton href="/orders" icon={<BookOpen />} label="Записи" />
<NavButton href="/orders/add" icon={<PlusCircle />} label="Новая запись" /> <NavButton href="/orders/add" icon={<PlusCircle />} label="Новая запись" />
<NavButton href="/contacts" icon={<Users />} label="Контакты" /> <NavButton href="/contacts" icon={<Users />} label="Контакты" />

View File

@ -0,0 +1,37 @@
'use client';
import { Button } from '@repo/ui/components/ui/button';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
type NavButtonProps = {
readonly disabled?: boolean;
readonly href: string;
readonly icon: React.ReactNode;
readonly label: string;
};
export function NavButton({ disabled, href, icon, label }: NavButtonProps) {
const pathname = usePathname();
return (
<Button
asChild
className="flex flex-col items-center p-6"
disabled={disabled}
variant={pathname.endsWith(href) ? 'default' : 'ghost'}
>
{disabled ? (
<div className="flex cursor-not-allowed flex-col items-center opacity-50">
<span>{icon}</span>
<span className="mt-1 text-xs">{label}</span>
</div>
) : (
<Link href={href}>
<span>{icon}</span>
<span className="mt-1 text-xs">{label}</span>
</Link>
)}
</Button>
);
}

View File

@ -1,26 +0,0 @@
'use client';
import { Button } from '@repo/ui/components/ui/button';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
type NavButtonProps = {
readonly href: string;
readonly icon: React.ReactNode;
readonly label: string;
};
export function NavButton({ href, icon, label }: NavButtonProps) {
const pathname = usePathname();
return (
<Button
asChild
className="flex flex-col items-center p-6"
variant={pathname.endsWith(href) ? 'default' : 'ghost'}
>
<Link href={href}>
<span>{icon}</span>
<span className="mt-1 text-xs">{label}</span>
</Link>
</Button>
);
}

View File

@ -1,4 +1,5 @@
'use client'; 'use client';
import { ArrowLeft } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';

View File

@ -1,5 +1,6 @@
'use client'; 'use client';
import { BackButton } from './components/back-button';
import { BackButton } from './back-button';
type Props = { title: string | undefined }; type Props = { title: string | undefined };

View File

@ -0,0 +1,8 @@
export * from '../shared/action-panel';
export * from './order-buttons';
export * from './order-contacts';
export * from './order-datetime';
export * from './order-form';
export * from './order-services';
export * from './order-status';
export * from './orders-list';

View File

@ -0,0 +1,63 @@
/* 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 { useOrderMutation, useOrderQuery } from '@/hooks/api/orders';
import { usePushWithData } from '@/hooks/url';
import { Enum_Order_State } from '@repo/graphql/types';
export function OrderButtons({ documentId }: Readonly<OrderComponentProps>) {
const push = usePushWithData();
const isMaster = useIsMaster();
const { data: { order } = {} } = useOrderQuery({ documentId });
const { isPending, mutate: updateSlot } = useOrderMutation({ documentId });
if (!order) return null;
const isApproved = order?.state === Enum_Order_State.Approved;
const isCompleted = order?.state === Enum_Order_State.Completed;
const isCancelling = order?.state === Enum_Order_State.Cancelling;
const isCancelled = order?.state === Enum_Order_State.Cancelled;
function handleApprove() {
if (isMaster) {
updateSlot({ data: { state: Enum_Order_State.Approved } });
}
}
function handleCancel() {
if (isMaster) {
updateSlot({ data: { state: Enum_Order_State.Cancelled } });
} else {
updateSlot({ data: { state: Enum_Order_State.Cancelling } });
}
}
function handleOnRepeat() {
push('/orders/add', order);
}
return (
<FloatingActionPanel
isLoading={isPending}
onCancel={
isCancelled || (!isMaster && isCancelling) || isCompleted ? undefined : () => handleCancel()
}
onConfirm={
!isMaster ||
isApproved ||
(!isMaster && isCancelled) ||
(!isMaster && isCancelling) ||
isCompleted
? undefined
: () => handleApprove()
}
onRepeat={isCancelled || isCompleted ? handleOnRepeat : undefined}
/>
);
}

View File

@ -0,0 +1,30 @@
'use client';
import { ContactRow } from '../shared/contact-row';
import { type OrderComponentProps } from './types';
import { useOrderQuery } from '@/hooks/api/orders';
export function OrderContacts({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {} } = useOrderQuery({ documentId });
if (!order) return null;
return (
<div className="flex flex-col space-y-2">
<h1 className="font-bold">Контакты</h1>
<div className="space-y-2">
{order.slot?.master && (
<ContactRow
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
{...order.slot?.master}
/>
)}
{order.client && (
<ContactRow
className="rounded-2xl bg-background p-2 px-4 dark:bg-primary/5"
{...order.client}
/>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,25 @@
'use client';
import { ReadonlyTimeRange } from '../shared/time-range';
import { type OrderComponentProps } from './types';
import { useOrderQuery } from '@/hooks/api/orders';
import { formatDate } from '@repo/utils/datetime-format';
export function OrderDateTime({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {} } = useOrderQuery({ documentId });
if (!order) return null;
return (
<div className="flex flex-col">
<span className="text-sm tracking-wide text-muted-foreground">
{formatDate(order.slot?.date).user()}
</span>
<ReadonlyTimeRange
className="text-3xl"
timeEnd={order.time_end}
timeStart={order.time_start}
/>
</div>
);
}

View File

@ -0,0 +1,26 @@
'use client';
import { useOrderCreate } from '@/hooks/api/orders';
import { useOrderStore } from '@/stores/order';
import { Button } from '@repo/ui/components/ui/button';
export function BackButton() {
const step = useOrderStore((store) => store.step);
const previousStep = useOrderStore((store) => store.prevStep);
const { isPending } = useOrderCreate();
if (['master-select', 'success'].includes(step)) return null;
return (
<Button
className="w-full"
disabled={isPending}
onClick={previousStep}
type="button"
variant="ghost"
>
Назад
</Button>
);
}

View File

@ -0,0 +1,129 @@
'use client';
import { CardSectionHeader } from '@/components/ui';
import { ContactsContextProvider } from '@/context/contacts';
import { useCustomerContacts } from '@/hooks/api/contacts';
// eslint-disable-next-line import/extensions
import AvatarPlaceholder from '@/public/avatar/avatar_placeholder.png';
import { useOrderStore } from '@/stores/order';
import { withContext } from '@/utils/context';
import { type CustomerFieldsFragment } from '@repo/graphql/types';
import { Card } from '@repo/ui/components/ui/card';
import { Label } from '@repo/ui/components/ui/label';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { cn } from '@repo/ui/lib/utils';
import Image from 'next/image';
import { useEffect } from 'react';
type ContactsGridProps = {
readonly contacts: CustomerFieldsFragment[];
readonly onSelect: (contactId: null | string) => void;
readonly selected?: null | string;
readonly title: string;
};
export function ContactsGridBase({ contacts, onSelect, selected, title }: ContactsGridProps) {
return (
<Card className="p-4">
<div className="flex flex-col gap-4">
<CardSectionHeader title={title} />
<div className="grid max-h-screen grid-cols-4 gap-2 overflow-y-auto">
{contacts.map((contact) => {
if (!contact) return null;
const isCurrentUser = contact?.name === 'Я';
return (
<Label
className="flex cursor-pointer flex-col items-center"
key={contact?.documentId}
>
<input
checked={selected === contact?.documentId}
className="hidden"
name="user"
onChange={() => onSelect(contact?.documentId)}
type="radio"
value={contact?.documentId}
/>
<div
className={cn(
'w-20 h-20 rounded-full border-2 transition-all duration-75',
selected === contact?.documentId ? 'border-primary' : 'border-transparent',
)}
>
<div
className={cn(
'size-full rounded-full p-1',
isCurrentUser
? 'bg-gradient-to-r from-purple-500 to-pink-500'
: 'bg-transparent',
)}
>
<Image
alt={contact?.name}
className="size-full rounded-full object-cover"
height={80}
src={contact?.photoUrl || AvatarPlaceholder}
width={80}
/>
</div>
</div>
<span
className={cn(
'mt-2 max-w-20 break-words text-center text-sm font-medium',
isCurrentUser && 'font-bold',
)}
>
{contact?.name}
</span>
</Label>
);
})}
</div>
</div>
</Card>
);
}
export const MastersGrid = withContext(ContactsContextProvider)(function () {
const { contacts, isLoading, setFilter } = useCustomerContacts();
const masterId = useOrderStore((store) => store.masterId);
const setMasterId = useOrderStore((store) => store.setMasterId);
useEffect(() => {
setFilter('masters');
}, [setFilter]);
if (isLoading) return <LoadingSpinner />;
return (
<ContactsGridBase
contacts={contacts}
onSelect={(contactId) => setMasterId(contactId)}
selected={masterId}
title="Мастера"
/>
);
});
export const ClientsGrid = withContext(ContactsContextProvider)(function () {
const { contacts, isLoading, setFilter } = useCustomerContacts();
const clientId = useOrderStore((store) => store.clientId);
const setClientId = useOrderStore((store) => store.setClientId);
useEffect(() => {
setFilter('clients');
}, [setFilter]);
if (isLoading) return <LoadingSpinner />;
return (
<ContactsGridBase
contacts={contacts}
onSelect={(contactId) => setClientId(contactId)}
selected={clientId}
title="Клиенты"
/>
);
});

View File

@ -0,0 +1,123 @@
'use client';
import { useAvailableTimeSlotsQuery } from '@/hooks/api/slots';
import { useOrderStore } from '@/stores/order';
import { Button } from '@repo/ui/components/ui/button';
import { Calendar } from '@repo/ui/components/ui/calendar';
import { formatDate } from '@repo/utils/datetime-format';
import dayjs from 'dayjs';
export function DateSelect() {
const selectedDate = useOrderStore((store) => store.date);
const setDate = useOrderStore((store) => store.setDate);
const setTime = useOrderStore((store) => store.setTime);
const setSlot = useOrderStore((store) => store.setSlotId);
return (
<Calendar
className="bg-background"
disabled={(date) => {
return dayjs().isAfter(dayjs(date), 'day');
}}
mode="single"
onSelect={(date) => {
if (date) setDate(date);
setTime(null);
setSlot(null);
}}
selected={selectedDate}
/>
);
}
export function DateTimeSelect() {
return (
<div className="space-y-4">
<DateSelect />
<TimeSelect />
</div>
);
}
export function TimeSelect() {
const masterId = useOrderStore((store) => store.masterId);
const date = useOrderStore((store) => store.date);
const serviceId = useOrderStore((store) => store.serviceId);
const { data: { times } = {}, isLoading } = useAvailableTimeSlotsQuery(
{
filters: {
date: {
eq: formatDate(date).db(),
},
master: {
documentId: {
eq: masterId,
},
},
},
},
{
service: {
documentId: {
eq: serviceId,
},
},
},
);
if (isLoading || !times) return null;
const morning = times.filter(({ time }) => getHour(time) < 12);
const afternoon = times?.filter(({ time }) => {
const hour = getHour(time);
return hour >= 12 && hour < 18;
});
const evening = times?.filter(({ time }) => getHour(time) >= 18);
return (
<div className="space-y-2">
<TimeSlotsButtons times={morning} title="Утро" />
<TimeSlotsButtons times={afternoon} title="День" />
<TimeSlotsButtons times={evening} title="Вечер" />
</div>
);
}
function getHour(time: string) {
const hour = time.split(':')[0];
if (hour) return Number.parseInt(hour, 10);
return -1;
}
function TimeSlotsButtons({
times,
title,
}: Readonly<{ times: Array<{ slotId: string; time: string }>; title: string }>) {
const setTime = useOrderStore((store) => store.setTime);
const setSlot = useOrderStore((store) => store.setSlotId);
if (!times.length) return null;
return (
<div className="space-y-2">
<h2 className="text-lg font-semibold">{title}</h2>
<div className="grid grid-cols-3 gap-2">
{times.map(({ slotId, time }) => (
<Button
className="mb-2"
key={time.toString()}
onClick={() => {
setTime(time);
setSlot(slotId);
}}
variant="outline"
>
{time}
</Button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,56 @@
'use client';
import { BackButton } from './back-button';
import { ClientsGrid, MastersGrid } from './contacts-grid';
import { DateTimeSelect } from './datetime-select';
import { NextButton } from './next-button';
import { ErrorPage, SuccessPage } from './result';
import { ServiceSelect } from './service-select';
import { SubmitButton } from './submit-button';
import { useGetUrlData } from '@/hooks/url';
import { OrderStoreProvider, useInitOrderStore, useOrderStore } from '@/stores/order';
import { withContext } from '@/utils/context';
import { type OrderFieldsFragment } from '@repo/graphql/types';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { type JSX } from 'react';
const STEP_COMPONENTS: Record<string, JSX.Element> = {
'client-select': <ClientsGrid />,
'datetime-select': <DateTimeSelect />,
error: <ErrorPage />,
'master-select': <MastersGrid />,
'service-select': <ServiceSelect />,
success: <SuccessPage />,
};
function getStepComponent(step: string) {
return STEP_COMPONENTS[step] ?? null;
}
const BUTTON_COMPONENTS: Record<string, JSX.Element> = {
'': <NextButton />,
'datetime-select': <SubmitButton />,
};
function getButtonComponent(step: string) {
return BUTTON_COMPONENTS[step] ?? <NextButton />;
}
export const OrderForm = withContext(OrderStoreProvider)(function () {
const data = useGetUrlData<OrderFieldsFragment>();
useInitOrderStore(data);
const step = useOrderStore((store) => store.step);
if (step === 'loading') return <LoadingSpinner />;
return (
<div className="space-y-4 [&>*]:px-4">
{getStepComponent(step)}
<div className="space-y-2">
{getButtonComponent(step)}
<BackButton />
</div>
</div>
);
});

View File

@ -0,0 +1,22 @@
'use client';
import { useOrderStore } from '@/stores/order';
import { Button } from '@repo/ui/components/ui/button';
export function NextButton() {
const { clientId, date, masterId, nextStep, serviceId, step, time } = useOrderStore(
(store) => store,
);
const isDisabled =
(step === 'master-select' && !masterId) ||
(step === 'client-select' && !clientId) ||
(step === 'service-select' && !serviceId) ||
(step === 'datetime-select' && (!date || !time));
return (
<Button className="w-full" disabled={isDisabled} onClick={nextStep} type="button">
Далее
</Button>
);
}

View File

@ -0,0 +1,59 @@
'use client';
import { useOrderStore } from '@/stores/order';
import { Button } from '@repo/ui/components/ui/button';
import { Card, CardContent } from '@repo/ui/components/ui/card';
import { AlertCircle, CheckCircle2, Home, RefreshCw } from 'lucide-react';
import Link from 'next/link';
export function ErrorPage() {
const setStep = useOrderStore((store) => store.setStep);
const handleRetry = () => {
setStep('datetime-select');
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm border-none bg-card text-card-foreground shadow-none">
<CardContent className="flex flex-col items-center space-y-5 py-8">
<div className="rounded-full bg-red-100 p-3 dark:bg-red-900">
<AlertCircle className="size-12 text-red-600 dark:text-red-400" />
</div>
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">Ошибка!</h1>
<p className="text-muted-foreground">Произошла ошибка при выполнении операции.</p>
</div>
<Button className="w-full" onClick={handleRetry} variant="destructive">
<RefreshCw className="mr-2 size-4" />
Повторить
</Button>
</CardContent>
</Card>
</div>
);
}
export function SuccessPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-sm border-none bg-card text-card-foreground shadow-none">
<CardContent className="flex flex-col items-center space-y-5 py-8">
<div className="rounded-full bg-green-100 p-3 dark:bg-green-900">
<CheckCircle2 className="size-12 text-green-600 dark:text-green-400" />
</div>
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">Готово!</h1>
<p className="text-muted-foreground">Запись успешно создана</p>
</div>
<Button asChild className="w-full">
<Link href="/">
<Home className="mr-2 size-4" />
На главный экран
</Link>
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,61 @@
'use client';
import { useServicesQuery } from '@/hooks/api/services';
import { useOrderStore } from '@/stores/order';
import { type ServiceFieldsFragment } from '@repo/graphql/types';
import { cn } from '@repo/ui/lib/utils';
export function ServiceSelect() {
const masterId = useOrderStore((store) => store.masterId);
const { data: { services } = {} } = useServicesQuery({
filters: {
master: {
documentId: {
eq: masterId,
},
},
},
});
if (!services?.length) return null;
return (
<div>
{services.map((service) => service && <ServiceCard key={service.documentId} {...service} />)}
</div>
);
}
function ServiceCard({ documentId, name }: Readonly<ServiceFieldsFragment>) {
const serviceId = useOrderStore((store) => store.serviceId);
const setServiceId = useOrderStore((store) => store.setServiceId);
const selected = serviceId === documentId;
function handleOnSelect() {
setServiceId(documentId);
}
return (
<label
className={cn(
'flex items-center justify-between border-2 rounded-2xl bg-background p-4 px-6 cursor-pointer dark:bg-primary/5',
selected ? 'border-primary' : 'border-transparent',
)}
>
<input
checked={selected}
className="hidden"
name="service"
onChange={() => handleOnSelect()}
type="radio"
value={documentId}
/>
<div className="flex flex-col gap-2">
{name}
{/* <span className={cn('text-xs font-normal', 'text-muted-foreground')} /> */}
</div>
</label>
);
}

View File

@ -0,0 +1,44 @@
'use client';
import { useOrderCreate } from '@/hooks/api/orders';
import { useOrderStore } from '@/stores/order';
import { Button } from '@repo/ui/components/ui/button';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { formatTime } from '@repo/utils/datetime-format';
import { useEffect } from 'react';
export function SubmitButton() {
const { clientId, date, serviceId, setStep, slotId, time } = useOrderStore((store) => store);
const isDisabled = !clientId || !serviceId || !date || !time || !slotId;
const { isError, isPending, isSuccess, mutate: createOrder } = useOrderCreate();
const handleSubmit = () => {
if (isDisabled) return;
createOrder({
input: {
client: clientId,
services: [serviceId],
slot: slotId,
time_start: formatTime(time).db(),
},
});
};
useEffect(() => {
if (isSuccess) setStep('success');
if (isError) setStep('error');
}, [isError, isSuccess, setStep]);
return (
<Button
className="w-full"
disabled={isPending || isDisabled}
onClick={handleSubmit}
type="button"
>
{isPending ? <LoadingSpinner /> : 'Завершить'}
</Button>
);
}

View File

@ -0,0 +1,29 @@
'use client';
import { type OrderComponentProps } from './types';
import { useOrderQuery } from '@/hooks/api/orders';
import { type ServiceFieldsFragment } from '@repo/graphql/types';
type ServiceCardProps = Pick<ServiceFieldsFragment, 'name'>;
export function OrderServices({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {} } = useOrderQuery({ documentId });
if (!order) return null;
return (
<div className="flex flex-col space-y-2">
<h1 className="font-bold">Услуги</h1>
{order.services?.map(
(service) => service && <ServiceCard key={service.documentId} {...service} />,
)}
</div>
);
}
function ServiceCard({ name }: Readonly<ServiceCardProps>) {
return (
<div className="flex items-center justify-between rounded-2xl border-2 bg-background p-4 px-6">
{name}
</div>
);
}

View File

@ -0,0 +1,11 @@
'use client';
import { type OrderComponentProps } from './types';
import { getAlert } from '@/components/shared/status';
import { useOrderQuery } from '@/hooks/api/orders';
export function OrderStatus({ documentId }: Readonly<OrderComponentProps>) {
const { data: { order } = {} } = useOrderQuery({ documentId });
return order?.state && getAlert(order.state);
}

View File

@ -0,0 +1,49 @@
'use client';
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
import { useOrdersQuery } from '@/hooks/api/orders';
import { useDateTimeStore } from '@/stores/datetime';
import { HorizontalCalendar } from '@repo/ui/components/ui/horizontal-calendar';
import dayjs from 'dayjs';
import { useState } from 'react';
export function DateSelect() {
const { data: { customer } = {} } = useCustomerQuery();
const isMaster = useIsMaster();
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
const { data: { orders } = {} } = useOrdersQuery({
filters: {
client: {
documentId: {
eq: isMaster ? undefined : customer?.documentId,
},
},
slot: {
date: {
gte: dayjs(currentMonthDate).startOf('month').format('YYYY-MM-DD'),
lte: dayjs(currentMonthDate).endOf('month').format('YYYY-MM-DD'),
},
master: {
documentId: {
eq: isMaster ? customer?.documentId : undefined,
},
},
},
},
});
const setSelectedDate = useDateTimeStore((store) => store.setDate);
const selectedDate = useDateTimeStore((store) => store.date);
return (
<HorizontalCalendar
daysWithOrders={orders?.map((order) => new Date(order?.slot?.date))}
onDateChange={setSelectedDate}
onMonthChange={(date) => setCurrentMonthDate(date)}
selectedDate={selectedDate}
/>
);
}

View File

@ -0,0 +1,77 @@
'use client';
import { OrderCard } from '@/components/shared/order-card';
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
import { useOrdersQuery } from '@/hooks/api/orders';
import { useDateTimeStore } from '@/stores/datetime';
import { formatDate } from '@repo/utils/datetime-format';
export function ClientsOrdersList() {
const { data: { customer } = {} } = useCustomerQuery();
const isMaster = useIsMaster();
const selectedDate = useDateTimeStore((store) => store.date);
const { data: { orders } = {}, isLoading } = useOrdersQuery(
{
filters: {
slot: {
date: {
eq: formatDate(selectedDate).db(),
},
master: {
documentId: {
eq: isMaster ? customer?.documentId : undefined,
},
},
},
},
},
Boolean(customer?.documentId) && Boolean(selectedDate) && isMaster,
);
if (!orders?.length || isLoading || !isMaster) return null;
return (
<div className="flex flex-col space-y-2">
<h1 className="font-bold">Записи клиентов</h1>
{orders?.map((order) => order && <OrderCard key={order.documentId} {...order} />)}
</div>
);
}
export function OrdersList() {
const { data: { customer } = {} } = useCustomerQuery();
const selectedDate = useDateTimeStore((store) => store.date);
const { data: { orders } = {}, isLoading } = useOrdersQuery(
{
filters: {
client: {
documentId: {
eq: customer?.documentId,
},
},
slot: {
date: {
eq: formatDate(selectedDate).db(),
},
},
},
},
Boolean(customer?.documentId) && Boolean(selectedDate),
);
if (!orders?.length || isLoading) return null;
return (
<div className="flex flex-col space-y-2">
<h1 className="font-bold">Ваши записи</h1>
{orders?.map(
(order) => order && <OrderCard avatarSource="master" key={order.documentId} {...order} />,
)}
</div>
);
}

View File

@ -0,0 +1,4 @@
import type * as GQL from '@repo/graphql/types';
export type OrderComponentProps = Pick<GQL.OrderFieldsFragment, 'documentId'>;
export type OrderPageParameters = Pick<GQL.OrderFieldsFragment, 'documentId'>;

View File

@ -1,4 +0,0 @@
export * from './card-header';
export * from './checkbox-field';
export * from './link-button';
export * from './text-field';

View File

@ -1,5 +1,6 @@
/* eslint-disable promise/prefer-await-to-then */ /* eslint-disable promise/prefer-await-to-then */
'use client'; 'use client';
import { Checkbox, type CheckboxProps } from '@repo/ui/components/ui/checkbox'; import { Checkbox, type CheckboxProps } from '@repo/ui/components/ui/checkbox';
import { useState } from 'react'; import { useState } from 'react';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';

View File

@ -1,14 +1,17 @@
'use client'; 'use client';
import { CheckboxWithText, DataField, ProfileCardHeader } from './components';
import { type ProfileProps } from './types'; import { type ProfileProps } from '../types';
import { useProfileMutation, useProfileQuery } from '@/hooks/profile'; import { CheckboxWithText } from './checkbox-field';
import { DataField } from './text-field';
import { CardSectionHeader } from '@/components/ui';
import { useCustomerMutation, useCustomerQuery } from '@/hooks/api/customers';
import { Enum_Customer_Role as Role } from '@repo/graphql/types'; import { Enum_Customer_Role as Role } from '@repo/graphql/types';
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 Link from 'next/link'; import Link from 'next/link';
export function ContactDataCard({ telegramId }: Readonly<ProfileProps>) { export function ContactDataCard({ telegramId }: Readonly<ProfileProps>) {
const { data: customer } = useProfileQuery({ telegramId }); const { data: { customer } = {} } = useCustomerQuery({ telegramId });
if (!customer) return null; if (!customer) return null;
@ -29,27 +32,31 @@ export function ContactDataCard({ telegramId }: Readonly<ProfileProps>) {
} }
export function ProfileDataCard() { export function ProfileDataCard() {
const { data: customer } = useProfileQuery({}); const { data: { customer } = {} } = useCustomerQuery();
const { mutate: updateProfile } = useProfileMutation({}); const { mutate: updateCustomer } = useCustomerMutation();
if (!customer) return null; if (!customer) return null;
return ( return (
<Card className="p-4"> <Card className="p-4">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<ProfileCardHeader title="Ваши данные" /> <CardSectionHeader title="Ваши данные" />
<DataField <DataField
fieldName="name" fieldName="name"
id="name" id="name"
label="Имя" label="Имя"
onChange={updateProfile} onChange={({ name }) => updateCustomer({ data: { name } })}
value={customer?.name ?? ''} value={customer?.name ?? ''}
/> />
<DataField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} /> <DataField disabled id="phone" label="Телефон" readOnly value={customer?.phone ?? ''} />
<CheckboxWithText <CheckboxWithText
checked={customer.role !== 'client'} checked={customer.role !== 'client'}
description="Разрешить другим пользователям записываться к вам" description="Разрешить другим пользователям записываться к вам"
onChange={(checked) => updateProfile({ role: checked ? Role.Master : Role.Client })} onChange={(checked) =>
updateCustomer({
data: { role: checked ? Role.Master : Role.Client },
})
}
text="Быть мастером" text="Быть мастером"
/> />
</div> </div>

View File

@ -1,5 +1,6 @@
/* eslint-disable promise/prefer-await-to-then */ /* eslint-disable promise/prefer-await-to-then */
'use client'; 'use client';
import { type CustomerInput } from '@repo/graphql/types'; import { type CustomerInput } from '@repo/graphql/types';
import { Input } from '@repo/ui/components/ui/input'; import { Input } from '@repo/ui/components/ui/input';
import { Label } from '@repo/ui/components/ui/label'; import { Label } from '@repo/ui/components/ui/label';

View File

@ -1,3 +1,4 @@
export * from './data-card'; export * from './data-card';
export * from './links-card'; export * from './links-card';
export * from './orders-list';
export * from './person-card'; export * from './person-card';

View File

@ -1,23 +0,0 @@
/* eslint-disable canonical/id-match */
'use client';
import { LinkButton } from './components';
import { type ProfileProps } from './types';
import { useProfileQuery } from '@/hooks/profile';
import { Enum_Customer_Role } from '@repo/graphql/types';
export function LinksCard({ telegramId }: Readonly<ProfileProps>) {
const { data: customer } = useProfileQuery({ telegramId });
const isMaster = customer?.role === Enum_Customer_Role.Master;
return (
<div className="flex flex-col gap-4 p-4 py-0">
<LinkButton
description="Указать доступные дни и время для записи клиентов"
href="/profile/schedule"
text="График работы"
visible={isMaster}
/>
</div>
);
}

View File

@ -0,0 +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="Указать доступные дни и время для записи клиентов"
href="/profile/schedule"
text="График работы"
visible={isMaster}
/>
</div>
);
}

View File

@ -0,0 +1,45 @@
'use client';
import { OrderCard } from '../shared/order-card';
import { type ProfileProps } from './types';
import { useCustomerQuery, useIsMaster } from '@/hooks/api/customers';
import { useOrdersQuery } from '@/hooks/api/orders';
export function ProfileOrdersList({ telegramId }: Readonly<ProfileProps>) {
const { data: { customer } = {} } = useCustomerQuery();
const isMaster = useIsMaster();
const { data: { customer: profile } = {} } = useCustomerQuery({ telegramId });
const { data: { orders } = {}, isLoading } = useOrdersQuery(
{
filters: {
client: {
documentId: {
eq: isMaster ? profile?.documentId : customer?.documentId,
},
},
slot: {
master: {
documentId: {
eq: isMaster ? customer?.documentId : profile?.documentId,
},
},
},
},
pagination: {
limit: 5,
},
},
Boolean(profile?.documentId) && Boolean(customer?.documentId),
);
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 key={order.documentId} showDate {...order} />)}
</div>
);
}

View File

@ -1,12 +1,13 @@
'use client'; 'use client';
import { LoadingSpinner } from '../common/spinner';
import { type ProfileProps } from './types'; import { type ProfileProps } from './types';
import { useProfileQuery } from '@/hooks/profile'; import { useCustomerQuery } from '@/hooks/api/customers';
import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@repo/ui/components/ui/avatar';
import { Card } from '@repo/ui/components/ui/card'; import { Card } from '@repo/ui/components/ui/card';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
export function PersonCard({ telegramId }: Readonly<ProfileProps>) { export function PersonCard({ telegramId }: Readonly<ProfileProps>) {
const { data: customer, isLoading } = useProfileQuery({ telegramId }); const { data: { customer } = {}, isLoading } = useCustomerQuery({ telegramId });
if (isLoading || !customer) if (isLoading || !customer)
return ( return (

View File

@ -1,3 +1,3 @@
export type ProfileProps = { export type ProfileProps = {
readonly telegramId?: string; readonly telegramId?: number;
}; };

View File

@ -1,11 +1,33 @@
'use client'; 'use client';
import { ScheduleContext } from '@/context/schedule';
import { useCustomerQuery } from '@/hooks/api/customers';
import { useSlotsQuery } from '@/hooks/api/slots';
import { useDateTimeStore } from '@/stores/datetime';
import { Calendar } from '@repo/ui/components/ui/calendar'; import { Calendar } from '@repo/ui/components/ui/calendar';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { use } from 'react'; import { useState } from 'react';
export function ScheduleCalendar() { export function ScheduleCalendar() {
const { selectedDate, setSelectedDate } = use(ScheduleContext); const { data: { customer } = {} } = useCustomerQuery();
const selectedDate = useDateTimeStore((store) => store.date);
const setSelectedDate = useDateTimeStore((store) => store.setDate);
const [currentMonthDate, setCurrentMonthDate] = useState(new Date());
const { data: { slots } = {} } = useSlotsQuery({
filters: {
date: {
gte: dayjs(currentMonthDate).startOf('month').format('YYYY-MM-DD'),
lte: dayjs(currentMonthDate).endOf('month').format('YYYY-MM-DD'),
},
master: {
documentId: {
eq: customer?.documentId,
},
},
},
});
return ( return (
<Calendar <Calendar
@ -14,6 +36,15 @@ export function ScheduleCalendar() {
return dayjs().isAfter(dayjs(date), 'day'); return dayjs().isAfter(dayjs(date), 'day');
}} }}
mode="single" mode="single"
modifiers={{
hasEvent: (date) => {
return slots?.some((slot) => dayjs(slot?.date).isSame(date, 'day')) || false;
},
}}
modifiersClassNames={{
hasEvent: 'border-primary border-2 rounded-xl',
}}
onMonthChange={(date) => setCurrentMonthDate(date)}
onSelect={(date) => { onSelect={(date) => {
if (date) setSelectedDate(date); if (date) setSelectedDate(date);
}} }}

View File

@ -1,76 +0,0 @@
/* eslint-disable canonical/id-match */
'use client';
import { type OrderClient, type OrderComponentProps } from '../types';
import { ReadonlyTimeRange } from './time-range';
import { useOrderQuery } from '@/hooks/orders';
import { Enum_Order_State } 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 Link from 'next/link';
export function OrderCard({ documentId }: Readonly<OrderComponentProps>) {
const { data } = useOrderQuery({ documentId });
const order = data?.data?.order;
if (!order) return null;
const isCompleted = order?.state === Enum_Order_State.Completed;
const isCancelled = order?.state === Enum_Order_State.Cancelled;
const services = order?.services.map((service) => service?.name).join(', ');
return (
<Link href={`/orders/${documentId}`} rel="noopener noreferrer">
<div className="flex items-center justify-between rounded-2xl bg-background p-4 px-6 dark:bg-primary/5">
<div className="flex flex-col">
<div className="flex items-center gap-4">
<ClientAvatar client={order.client} />
<div className="flex flex-col">
<ReadonlyTimeRange time_end={order?.time_end} time_start={order?.time_start} />
<span className="truncate text-xs text-muted-foreground">{services}</span>
</div>
{/* <span className="text-xs text-foreground">{clientName}</span> */}
</div>
</div>
{order?.state && (
<Badge
className={cn(
isCompleted
? 'bg-green-100 text-green-500 dark:bg-green-700 dark:text-green-100'
: '',
isCancelled ? 'bg-red-100 text-red-500 dark:bg-red-700 dark:text-red-100' : '',
)}
>
{getBadgeText(order?.state)}
</Badge>
)}
</div>
</Link>
);
}
function ClientAvatar({ client }: { readonly client: OrderClient }) {
if (!client) return null;
return (
<Avatar>
<AvatarImage alt={client.name} src={client.photoUrl || ''} />
<AvatarFallback>{client.name.charAt(0)}</AvatarFallback>
</Avatar>
);
}
const MAP_BADGE_TEXT: Record<Enum_Order_State, string> = {
approved: 'Подтверждено',
cancelled: 'Отменено',
completed: 'Завершено',
created: 'Создано',
scheduled: 'Запланировано',
};
function getBadgeText(state: Enum_Order_State) {
if (!state) return '';
return MAP_BADGE_TEXT[state];
}

View File

@ -1,66 +0,0 @@
/* eslint-disable canonical/id-match */
'use client';
import { type SlotComponentProps } from '../types';
import { ReadonlyTimeRange } from './time-range';
import { useSlotQuery } from '@/hooks/slots';
import { Enum_Slot_State } from '@repo/graphql/types';
import { Badge } from '@repo/ui/components/ui/badge';
import { cn } from '@repo/ui/lib/utils';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
const MAP_BADGE_TEXT: Record<Enum_Slot_State, string> = {
closed: 'Закрыто',
open: 'Открыто',
reserved: 'Зарезервировано',
};
export function SlotCard(props: Readonly<SlotComponentProps>) {
const pathname = usePathname();
const { documentId } = props;
const { data } = useSlotQuery({ documentId });
const slot = data?.data?.slot;
if (!slot) return null;
const ordersNumber = slot.orders?.length;
const hasOrders = Boolean(ordersNumber);
const isOpened = slot?.state === Enum_Slot_State.Open;
const isClosed = slot?.state === Enum_Slot_State.Closed;
return (
<Link href={`${pathname}/slots/${documentId}`} rel="noopener noreferrer">
<div className="flex items-center justify-between rounded-2xl bg-background p-4 px-6 dark:bg-primary/5">
<div className="flex flex-col">
<ReadonlyTimeRange {...slot} />
<span
className={cn(
'text-xs font-normal',
hasOrders ? 'font-bold text-foreground' : 'text-muted-foreground',
)}
>
{hasOrders ? 'Есть записи' : 'Свободно'}
</span>
</div>
{slot.state && (
<Badge
className={cn(
isOpened ? 'bg-green-100 text-green-500 dark:bg-green-700 dark:text-green-100' : '',
isClosed ? 'bg-red-100 text-red-500 dark:bg-red-700 dark:text-red-100' : '',
)}
>
{getBadgeText(slot.state)}
</Badge>
)}
</div>
</Link>
);
}
function getBadgeText(state: Enum_Slot_State) {
if (!state) return '';
return MAP_BADGE_TEXT[state];
}

View File

@ -1,45 +0,0 @@
'use client';
import {
createContext,
type Dispatch,
type PropsWithChildren,
type SetStateAction,
useMemo,
useState,
} from 'react';
export type ContextType = {
editMode: boolean;
endTime: string;
resetTime: () => void;
setEditMode: Dispatch<SetStateAction<boolean>>;
setEndTime: Dispatch<SetStateAction<string>>;
setStartTime: Dispatch<SetStateAction<string>>;
startTime: string;
};
export const ScheduleTimeContext = createContext<ContextType>({} as ContextType);
export function ScheduleTimeContextProvider({ children }: Readonly<PropsWithChildren>) {
const [editMode, setEditMode] = useState(false);
const [startTime, setStartTime] = useState('');
const [endTime, setEndTime] = useState('');
function resetTime() {
setStartTime('');
setEndTime('');
}
const value = useMemo(() => {
return {
editMode,
endTime,
resetTime,
setEditMode,
setEndTime,
setStartTime,
startTime,
};
}, [editMode, endTime, setEditMode, startTime]);
return <ScheduleTimeContext value={value}>{children}</ScheduleTimeContext>;
}

View File

@ -1,42 +0,0 @@
/* eslint-disable canonical/id-match */
'use client';
import { EditableTimeRangeForm } from './components/time-range';
import { ScheduleTimeContext, ScheduleTimeContextProvider } from './context';
import { ScheduleContext } from '@/context/schedule';
import { useSlotAdd } from '@/hooks/slots';
import { withContext } from '@/utils/context';
import { Enum_Slot_State } from '@repo/graphql/types';
import { Button } from '@repo/ui/components/ui/button';
import { PlusSquare } from 'lucide-react';
import { type FormEvent, use } from 'react';
export const DaySlotAddForm = withContext(ScheduleTimeContextProvider)(function () {
const { endTime, resetTime, startTime } = use(ScheduleTimeContext);
const { selectedDate } = use(ScheduleContext);
const { isPending, mutate: addSlot } = useSlotAdd();
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
if (startTime && endTime) {
addSlot({
date: selectedDate,
state: Enum_Slot_State.Open,
time_end: endTime,
time_start: startTime,
});
resetTime();
}
};
return (
<EditableTimeRangeForm disabled={isPending} onSubmit={handleSubmit}>
<Button className="rounded-full" disabled={isPending} type="submit">
<PlusSquare className="mr-1 size-4" />
Добавить
</Button>
</EditableTimeRangeForm>
);
});

View File

@ -1,20 +0,0 @@
'use client';
import { SlotCard } from './components/slot-card';
import { DaySlotAddForm } from './day-slot-add-form';
import { LoadingSpinner } from '@/components/common/spinner';
import { useSlots } from '@/hooks/slots';
export function DaySlotsList() {
const { data, isLoading } = useSlots();
const slots = data?.data.slots;
if (isLoading) return <LoadingSpinner />;
return (
<div className="flex flex-col space-y-2 px-4">
<h1 className="font-bold">Слоты</h1>
{slots?.map((slot) => slot && <SlotCard key={slot.documentId} {...slot} />)}
<DaySlotAddForm />
</div>
);
}

View File

@ -0,0 +1,47 @@
/* eslint-disable canonical/id-match */
'use client';
import { EditableTimeRangeForm } from '@/components/shared/time-range';
import { useSlotCreate } from '@/hooks/api/slots';
import { useDateTimeStore } from '@/stores/datetime';
import { ScheduleStoreProvider, useScheduleStore } from '@/stores/schedule';
import { withContext } from '@/utils/context';
import { Enum_Slot_State } from '@repo/graphql/types';
import { Button } from '@repo/ui/components/ui/button';
import { formatDate, formatTime } from '@repo/utils/datetime-format';
import { PlusSquare } from 'lucide-react';
import { type FormEvent } from 'react';
export const DaySlotAddForm = withContext(ScheduleStoreProvider)(function () {
const selectedDate = useDateTimeStore((store) => store.date);
const endTime = useScheduleStore((state) => state.endTime);
const resetTime = useScheduleStore((state) => state.resetTime);
const startTime = useScheduleStore((state) => state.startTime);
const { isPending, mutate: addSlot } = useSlotCreate();
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
if (startTime && endTime) {
addSlot({
input: {
date: formatDate(selectedDate).db(),
state: Enum_Slot_State.Open,
time_end: formatTime(endTime).db(),
time_start: formatTime(startTime).db(),
},
});
resetTime();
}
};
return (
<EditableTimeRangeForm disabled={isPending} onSubmit={handleSubmit}>
<Button className="rounded-full" disabled={isPending} type="submit">
<PlusSquare className="mr-1 size-4" />
Добавить
</Button>
</EditableTimeRangeForm>
);
});

View File

@ -0,0 +1,32 @@
'use client';
import { DaySlotAddForm } from './day-slot-add-form';
import { SlotCard } from './slot-card';
import { useCustomerQuery } from '@/hooks/api/customers';
import { useMasterSlotsQuery } from '@/hooks/api/slots';
import { useDateTimeStore } from '@/stores/datetime';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
import { formatDate } from '@repo/utils/datetime-format';
export function DaySlotsList() {
const { data: { customer } = {} } = useCustomerQuery();
const selectedDate = useDateTimeStore((store) => store.date);
const { data: { slots } = {}, isLoading } = useMasterSlotsQuery({
filters: {
date: { eq: formatDate(selectedDate).db() },
master: { documentId: { eq: customer?.documentId } },
},
});
if (isLoading) return <LoadingSpinner />;
return (
<div className="flex flex-col space-y-2 px-4">
<h1 className="font-bold">Слоты</h1>
{slots?.map((slot) => slot && <SlotCard key={slot.documentId} {...slot} />)}
<DaySlotAddForm />
</div>
);
}

View File

@ -0,0 +1,40 @@
'use client';
import { getBadge } from '@/components/shared/status';
import { ReadonlyTimeRange } from '@/components/shared/time-range';
import { useSlotQuery } from '@/hooks/api/slots';
import type * as GQL from '@repo/graphql/types';
import { cn } from '@repo/ui/lib/utils';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
type Props = GQL.SlotFieldsFragment;
export function SlotCard(props: Readonly<Props>) {
const pathname = usePathname();
const { documentId } = props;
const { data: { slot } = {} } = useSlotQuery({ documentId });
const ordersNumber = slot?.orders?.length;
const hasOrders = Boolean(ordersNumber);
return (
<Link href={`${pathname}/slots/${props.documentId}`} rel="noopener noreferrer">
<div className="flex items-center justify-between rounded-2xl bg-background p-4 px-6 dark:bg-primary/5">
<div className="flex flex-col">
<ReadonlyTimeRange timeEnd={props.time_end} timeStart={props.time_start} />
<span
className={cn(
'text-xs font-normal',
hasOrders ? 'font-bold text-foreground' : 'text-muted-foreground',
)}
>
{hasOrders ? 'Есть записи' : 'Свободно'}
</span>
</div>
{slot?.state && getBadge(slot.state)}
</div>
</Link>
);
}

View File

@ -1,5 +1,4 @@
export * from './calendar'; export * from './calendar';
export * from './day-slot-add-form';
export * from './day-slots-list'; export * from './day-slots-list';
export * from './slot-buttons'; export * from './slot-buttons';
export * from './slot-datetime'; export * from './slot-datetime';

View File

@ -1,32 +1,32 @@
/* eslint-disable react/jsx-no-bind */
/* eslint-disable canonical/id-match */ /* eslint-disable canonical/id-match */
'use client'; 'use client';
import FloatingActionPanel from '../shared/action-panel';
import { type SlotComponentProps } from './types'; import { type SlotComponentProps } from './types';
import { useSlotDelete, useSlotMutation, useSlotQuery } from '@/hooks/slots'; import { useSlotDelete, useSlotMutation, useSlotQuery } from '@/hooks/api/slots';
import { Enum_Slot_State } from '@repo/graphql/types'; import { Enum_Slot_State } from '@repo/graphql/types';
import { Button } from '@repo/ui/components/ui/button';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
export function SlotButtons({ documentId }: Readonly<SlotComponentProps>) { export function SlotButtons({ documentId }: Readonly<SlotComponentProps>) {
const { data } = useSlotQuery({ documentId }); const { data: { slot } = {} } = useSlotQuery({ documentId });
const { mutate: updateSlot } = useSlotMutation({ documentId });
const { mutate: deleteSlot } = useSlotDelete({ documentId }); const { isPending: isPendingUpdate, mutate: updateSlot } = useSlotMutation({ documentId });
const { isPending: isPendingDelete, mutate: deleteSlot } = useSlotDelete({ documentId });
const router = useRouter(); const router = useRouter();
const slot = data?.data?.slot;
if (!slot) return null; if (!slot) return null;
const isOpened = slot?.state === Enum_Slot_State.Open; const isOpened = slot?.state === Enum_Slot_State.Open;
const isClosed = slot?.state === Enum_Slot_State.Closed; const isClosed = slot?.state === Enum_Slot_State.Closed;
function handleOpenSlot() { function handleOpenSlot() {
return updateSlot({ data: { state: Enum_Slot_State.Open }, documentId }); return updateSlot({ data: { state: Enum_Slot_State.Open } });
} }
function handleCloseSlot() { function handleCloseSlot() {
return updateSlot({ data: { state: Enum_Slot_State.Closed }, documentId }); return updateSlot({ data: { state: Enum_Slot_State.Closed } });
} }
function handleDeleteSlot() { function handleDeleteSlot() {
@ -37,33 +37,14 @@ export function SlotButtons({ documentId }: Readonly<SlotComponentProps>) {
const hasOrders = Boolean(slot?.orders.length); const hasOrders = Boolean(slot?.orders.length);
return ( return (
<div className="grid grid-cols-[2fr_1fr]"> <FloatingActionPanel
{isOpened && ( isLoading={isPendingUpdate || isPendingDelete}
<Button isOpen={isOpened}
className="rounded-l-2xl rounded-r-none bg-orange-100 p-6 text-orange-500 dark:bg-yellow-700 dark:text-orange-100" onDelete={hasOrders ? undefined : () => handleDeleteSlot()}
onClick={handleCloseSlot} onToggle={() => {
variant="ghost" if (isOpened) handleCloseSlot();
> if (isClosed) handleOpenSlot();
Закрыть }}
</Button> />
)}
{isClosed && (
<Button
className="rounded-l-2xl rounded-r-none bg-green-100 p-6 text-green-500 dark:bg-green-700 dark:text-green-100"
onClick={handleOpenSlot}
variant="ghost"
>
Открыть
</Button>
)}
<Button
className="rounded-l-none rounded-r-2xl bg-red-100 p-6 text-red-500 dark:bg-red-700 dark:text-red-100"
disabled={hasOrders}
onClick={handleDeleteSlot}
variant="ghost"
>
Удалить
</Button>
</div>
); );
} }

View File

@ -1,17 +0,0 @@
'use client';
import { SlotDate } from './components/slot-date';
import { SlotTime } from './components/slot-time';
import { ScheduleTimeContextProvider } from './context';
import { type SlotComponentProps } from './types';
import { withContext } from '@/utils/context';
export const SlotDateTime = withContext(ScheduleTimeContextProvider)(function (
props: Readonly<SlotComponentProps>,
) {
return (
<div className="flex flex-col">
<SlotDate {...props} />
<SlotTime {...props} />
</div>
);
});

View File

@ -0,0 +1,18 @@
'use client';
import { type SlotComponentProps } from '../types';
import { SlotDate } from './slot-date';
import { SlotTime } from './slot-time';
import { ScheduleStoreProvider } from '@/stores/schedule';
import { withContext } from '@/utils/context';
export const SlotDateTime = withContext(ScheduleStoreProvider)(function (
props: Readonly<SlotComponentProps>,
) {
return (
<div className="flex flex-col">
<SlotDate {...props} />
<SlotTime {...props} />
</div>
);
});

View File

@ -1,11 +1,11 @@
'use client'; 'use client';
import { type SlotComponentProps } from '../types'; import { type SlotComponentProps } from '../types';
import { useSlotQuery } from '@/hooks/slots'; import { useSlotQuery } from '@/hooks/api/slots';
import { formatDate } from '@/utils/date'; import { formatDate } from '@repo/utils/datetime-format';
export function SlotDate({ documentId }: Readonly<SlotComponentProps>) { export function SlotDate({ documentId }: Readonly<SlotComponentProps>) {
const { data } = useSlotQuery({ documentId }); const { data: { slot } = {} } = useSlotQuery({ documentId });
const slot = data?.data?.slot;
if (!slot) return null; if (!slot) return null;

View File

@ -1,26 +1,27 @@
/* eslint-disable react/jsx-no-bind */ /* eslint-disable react/jsx-no-bind */
'use client'; 'use client';
import { ScheduleTimeContext } from '../context';
import { type SlotComponentProps } from '../types'; import { type SlotComponentProps } from '../types';
import { EditableTimeRangeForm, ReadonlyTimeRange } from './time-range'; import { EditableTimeRangeForm, ReadonlyTimeRange } from '@/components/shared/time-range';
import { useSlotMutation, useSlotQuery } from '@/hooks/slots'; import { useSlotMutation, useSlotQuery } from '@/hooks/api/slots';
import { useScheduleStore } from '@/stores/schedule';
import { Button } from '@repo/ui/components/ui/button'; import { Button } from '@repo/ui/components/ui/button';
import { formatTime } from '@repo/utils/datetime-format';
import { PencilLine } from 'lucide-react'; import { PencilLine } from 'lucide-react';
import { use, useEffect } from 'react'; import { useEffect } from 'react';
export function SlotTime(props: Readonly<SlotComponentProps>) { export function SlotTime(props: Readonly<SlotComponentProps>) {
const { editMode } = use(ScheduleTimeContext); const editMode = useScheduleStore((state) => state.editMode);
return editMode ? <SlotTimeEditForm {...props} /> : <SlotTimeReadonly {...props} />; return editMode ? <SlotTimeEditForm {...props} /> : <SlotTimeReadonly {...props} />;
} }
function SlotTimeEditForm({ documentId }: Readonly<SlotComponentProps>) { function SlotTimeEditForm({ documentId }: Readonly<SlotComponentProps>) {
const { editMode, endTime, resetTime, setEditMode, setEndTime, setStartTime, startTime } = const { editMode, endTime, resetTime, setEditMode, setEndTime, setStartTime, startTime } =
use(ScheduleTimeContext); useScheduleStore((state) => state);
const { isPending: isMutationPending, mutate: updateSlot } = useSlotMutation({ documentId }); const { isPending: isMutationPending, mutate: updateSlot } = useSlotMutation({ documentId });
const { data, isPending: isQueryPending } = useSlotQuery({ documentId }); const { data: { slot } = {}, isPending: isQueryPending } = useSlotQuery({ documentId });
const slot = data?.data?.slot;
const isPending = isMutationPending || isQueryPending; const isPending = isMutationPending || isQueryPending;
@ -32,7 +33,9 @@ function SlotTimeEditForm({ documentId }: Readonly<SlotComponentProps>) {
}, [editMode, setEndTime, setStartTime, slot]); }, [editMode, setEndTime, setStartTime, slot]);
function handleSubmit() { function handleSubmit() {
updateSlot({ data: { time_end: endTime, time_start: startTime }, documentId }); updateSlot({
data: { time_end: formatTime(endTime).db(), time_start: formatTime(startTime).db() },
});
resetTime(); resetTime();
setEditMode(false); setEditMode(false);
} }
@ -46,11 +49,10 @@ function SlotTimeEditForm({ documentId }: Readonly<SlotComponentProps>) {
); );
} }
function SlotTimeReadonly(props: Readonly<SlotComponentProps>) { function SlotTimeReadonly({ documentId }: Readonly<SlotComponentProps>) {
const { setEditMode } = use(ScheduleTimeContext); const setEditMode = useScheduleStore((state) => state.setEditMode);
const { data } = useSlotQuery(props); const { data: { slot } = {} } = useSlotQuery({ documentId });
const slot = data?.data?.slot;
if (!slot) return null; if (!slot) return null;
@ -58,7 +60,7 @@ function SlotTimeReadonly(props: Readonly<SlotComponentProps>) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ReadonlyTimeRange {...slot} className="text-3xl" /> <ReadonlyTimeRange className="text-3xl" timeEnd={slot.time_end} timeStart={slot.time_start} />
<Button <Button
className="rounded-full text-xs" className="rounded-full text-xs"
disabled={hasOrders} disabled={hasOrders}

View File

@ -1,18 +1,29 @@
'use client'; 'use client';
import { OrderCard } from './components/order-card';
import { type SlotComponentProps } from './types'; import { type SlotComponentProps } from './types';
import { useSlotQuery } from '@/hooks/slots'; import { OrderCard } from '@/components/shared/order-card';
import { useOrdersQuery } from '@/hooks/api/orders';
import { LoadingSpinner } from '@repo/ui/components/ui/spinner';
export function SlotOrdersList({ documentId }: Readonly<SlotComponentProps>) { export function SlotOrdersList({ documentId }: Readonly<SlotComponentProps>) {
const { data } = useSlotQuery({ documentId }); const { data: { orders } = {}, isLoading } = useOrdersQuery({
const slot = data?.data?.slot; filters: {
slot: {
documentId: {
eq: documentId,
},
},
},
});
if (!slot) return null; if (isLoading) return <LoadingSpinner />;
if (!orders?.length) return null;
return ( return (
<div className="flex flex-col space-y-2"> <div className="flex flex-col space-y-2">
<h1 className="font-bold">Записи</h1> <h1 className="font-bold">Записи</h1>
{slot?.orders.map((order) => order && <OrderCard key={order.documentId} {...order} />)} {orders?.map((order) => order && <OrderCard key={order.documentId} {...order} />)}
</div> </div>
); );
} }

View File

@ -1,6 +1,4 @@
import type * as GQL from '@repo/graphql/types'; import type * as GQL from '@repo/graphql/types';
export type OrderClient = NonNullable<GQL.GetOrderQuery['order']>['client'];
export type OrderComponentProps = Pick<GQL.OrderFieldsFragment, 'documentId'>;
export type Slot = NonNullable<GQL.GetSlotQuery['slot']>;
export type SlotComponentProps = Pick<GQL.SlotFieldsFragment, 'documentId'>; export type SlotComponentProps = Pick<GQL.SlotFieldsFragment, 'documentId'>;
export type SlotPageParameters = Pick<GQL.SlotFieldsFragment, 'documentId'>;

View File

@ -0,0 +1,115 @@
'use client';
import { Button } from '@repo/ui/components/ui/button';
import { Card } from '@repo/ui/components/ui/card';
import { Ban, Check, Lock, RotateCcw, Trash2, Unlock } from 'lucide-react';
type FloatingActionPanelProps = {
readonly isLoading?: boolean;
readonly isOpen?: boolean;
readonly onCancel?: () => void;
readonly onConfirm?: () => void;
readonly onDelete?: () => void;
readonly onRepeat?: () => void;
readonly onToggle?: () => void;
};
export default function FloatingActionPanel({
isLoading = false,
isOpen,
onCancel,
onConfirm,
onDelete,
onRepeat,
onToggle,
}: FloatingActionPanelProps) {
// Если не переданы обработчики, скрываем панель
if (!onCancel && !onConfirm && !onDelete && !onRepeat && !onToggle) return null;
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">
<div className="flex flex-col items-center gap-2 sm:flex-row sm:gap-4">
{/* Кнопка закрыть/открыть */}
{onToggle && (
<Button
className={`
w-full rounded-2xl text-sm transition-all duration-200 sm:w-auto
${
isOpen
? 'bg-blue-500 text-white hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700'
: 'bg-gray-500 text-white hover:bg-gray-600 dark:bg-gray-600 dark:hover:bg-gray-700'
}
`}
disabled={isLoading}
onClick={onToggle}
size="sm"
>
{isOpen ? (
<>
<Lock className="mr-2 size-4" />
<span>Закрыть</span>
</>
) : (
<>
<Unlock className="mr-2 size-4" />
<span>Открыть</span>
</>
)}
</Button>
)}
{/* Кнопка повторить */}
{onRepeat && (
<Button
className="w-full rounded-2xl bg-purple-500 text-sm text-white transition-all duration-200 hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700 sm:w-auto"
disabled={isLoading}
onClick={onRepeat}
size="sm"
>
<RotateCcw className="mr-2 size-4" />
<span>Повторить</span>
</Button>
)}
{/* Кнопка удалить */}
{onDelete && (
<Button
className="w-full rounded-2xl bg-orange-500 text-sm text-white transition-all duration-200 hover:bg-orange-600 dark:bg-orange-600 dark:hover:bg-orange-700 sm:w-auto"
disabled={isLoading}
onClick={onDelete}
size="sm"
>
<Trash2 className="mr-2 size-4" />
<span>Удалить</span>
</Button>
)}
{/* Кнопка отменить */}
{onCancel && (
<Button
className="w-full rounded-2xl bg-red-500 text-sm text-white transition-all duration-200 hover:bg-red-600 dark:bg-red-600 dark:hover:bg-red-700 sm:w-auto"
disabled={isLoading}
onClick={onCancel}
size="sm"
>
<Ban className="mr-2 size-4" />
<span>Отменить</span>
</Button>
)}
{/* Кнопка подтвердить */}
{onConfirm && (
<Button
className="w-full rounded-2xl bg-green-600 text-sm text-white shadow-lg transition-all duration-200 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 sm:w-auto"
disabled={isLoading}
onClick={onConfirm}
size="sm"
>
<Check className="mr-2 size-4" />
<span>Подтвердить</span>
</Button>
)}
</div>
</Card>
);
}

View File

@ -0,0 +1,42 @@
import * 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 Link from 'next/link';
import { memo } from 'react';
type ContactRowProps = GQL.CustomerFieldsFragment & {
readonly className?: string;
};
export const ContactRow = memo(function ({ className, ...contact }: ContactRowProps) {
return (
<Link
className="block"
href={contact.active ? `/profile/${contact.telegramId}` : ''}
key={contact.telegramId}
>
<div
className={cn(
'flex items-center justify-between',
contact.active ? 'hover:bg-accent' : 'opacity-50',
className,
)}
>
<div className={cn('flex items-center space-x-4 rounded-lg py-2 transition-colors')}>
<Avatar>
<AvatarImage alt={contact.name} src={contact.photoUrl || ''} />
<AvatarFallback>{contact.name.charAt(0)}</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{contact.name}</p>
<p className="text-sm text-muted-foreground">
{contact.role === GQL.Enum_Customer_Role.Client ? 'Клиент' : 'Мастер'}
</p>
</div>
</div>
{contact.active ? <div /> : <Badge variant="destructive">Неактивен</Badge>}
</div>
</Link>
);
});

View File

@ -0,0 +1,59 @@
'use client';
import { ReadonlyTimeRange } from './time-range/readonly';
import { getBadge } from '@/components/shared/status';
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>) {
const services = order?.services.map((service) => service?.name).join(', ');
const date = order?.slot?.date;
const customer = avatarSource === 'master' ? order?.slot?.master : order?.client;
return (
<Link href={`/orders/${documentId}`} rel="noopener noreferrer">
<div className="flex items-center justify-between rounded-2xl bg-background p-4 px-6 dark:bg-primary/5">
<div className="flex flex-col">
<div className="flex items-center gap-4">
{customer && <CustomerAvatar customer={customer} />}
<div className="flex flex-col">
<ReadonlyTimeRange timeEnd={order?.time_end} timeStart={order?.time_start} />
<span className="truncate text-xs text-muted-foreground">
{showDate ? `${formatDate(date).user('DD.MM.YYYY')}` : ''}
{services}
</span>
</div>
{/* <span className="text-xs text-foreground">{clientName}</span> */}
</div>
</div>
{order?.state && getBadge(order.state)}
</div>
</Link>
);
}
function CustomerAvatar({ customer }: { readonly customer: OrderCustomer }) {
if (!customer) return null;
return (
<Avatar>
<AvatarImage alt={customer.name} src={customer.photoUrl || ''} />
<AvatarFallback>{customer.name.charAt(0)}</AvatarFallback>
</Avatar>
);
}

View File

@ -0,0 +1,168 @@
import { Badge } from '@repo/ui/components/ui/badge';
import { cn } from '@repo/ui/lib/utils';
import { AlertCircle, Calendar, CheckCircle, FileText, Lock, Unlock, XCircle } from 'lucide-react';
import { type JSX } from 'react';
const BADGE_BY_STATE: Record<string, JSX.Element> = {
approved: (
<Badge
className={cn(
'bg-blue-100 text-blue-600 hover:bg-current',
'dark:bg-blue-900 dark:text-blue-100',
'pointer-events-none select-none',
)}
variant="secondary"
>
Подтверждено
</Badge>
),
cancelled: (
<Badge
className={cn(
'bg-red-100 text-red-600 hover:bg-current',
'dark:bg-red-900 dark:text-red-100',
'pointer-events-none select-none',
)}
variant="secondary"
>
Отменено
</Badge>
),
cancelling: (
<Badge
className={cn(
'bg-orange-100 text-orange-600 hover:bg-current',
'dark:bg-orange-900 dark:text-orange-100',
'pointer-events-none select-none',
)}
variant="secondary"
>
Отменяется
</Badge>
),
closed: (
<Badge
className={cn(
'bg-zinc-200 text-zinc-600 hover:bg-current',
'dark:bg-zinc-800 dark:text-zinc-200',
'pointer-events-none select-none',
)}
variant="secondary"
>
Закрыто
</Badge>
),
completed: (
<Badge
className={cn(
'bg-green-100 text-green-600 hover:bg-current',
'dark:bg-green-900 dark:text-green-100',
'pointer-events-none select-none',
)}
variant="secondary"
>
Завершено
</Badge>
),
created: (
<Badge
className={cn('hover:bg-current', 'pointer-events-none select-none')}
variant="secondary"
>
Создано
</Badge>
),
open: (
<Badge
className={cn(
'bg-green-100 text-green-600 hover:bg-current',
'dark:bg-green-700 dark:text-green-100',
'pointer-events-none select-none',
)}
variant="secondary"
>
Открыто
</Badge>
),
scheduled: (
<Badge
className={cn(
'bg-purple-100 text-purple-600 hover:bg-current',
'dark:bg-purple-900 dark:text-purple-100',
'pointer-events-none select-none',
)}
variant="secondary"
>
Запланировано
</Badge>
),
};
export function getBadge(state: string) {
return BADGE_BY_STATE[state] ?? null;
}
function Alert({ className, ...props }: JSX.IntrinsicElements['div']) {
return (
<div className={cn('flex items-center gap-2 rounded-xl px-4 p-2', className)} {...props} />
);
}
function AlertTitle({ className, ...props }: JSX.IntrinsicElements['span']) {
return <span className={cn('line-clamp-1 font-medium tracking-tight', className)} {...props} />;
}
const ALERT_BY_STATE: Record<string, JSX.Element> = {
approved: (
<Alert className="bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200">
<CheckCircle className="size-5 text-blue-600 dark:text-blue-400" />
<AlertTitle>Подтверждено</AlertTitle>
</Alert>
),
cancelled: (
<Alert className="bg-red-50 text-red-800 dark:bg-red-950 dark:text-red-200">
<XCircle className="size-5 text-red-600 dark:text-red-400" />
<AlertTitle>Отменено</AlertTitle>
</Alert>
),
cancelling: (
<Alert className="bg-orange-50 text-orange-800 dark:bg-orange-950 dark:text-orange-200">
<AlertCircle className="size-5 text-orange-600 dark:text-orange-400" />
<AlertTitle>Ожидает отмены</AlertTitle>
</Alert>
),
closed: (
<Alert className="bg-zinc-100 text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200">
<Lock className="size-5 text-zinc-700 dark:text-zinc-300" />
<AlertTitle>Закрыто</AlertTitle>
</Alert>
),
completed: (
<Alert className="bg-green-50 text-green-800 dark:bg-green-950 dark:text-green-200">
<CheckCircle className="size-5 text-green-600 dark:text-green-400" />
<AlertTitle>Завершено</AlertTitle>
</Alert>
),
created: (
<Alert className="bg-gray-50 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
<FileText className="size-5 text-gray-600 dark:text-gray-400" />
<AlertTitle>Создано</AlertTitle>
</Alert>
),
opened: (
<Alert className="bg-cyan-50 text-cyan-800 dark:bg-cyan-950 dark:text-cyan-200">
<Unlock className="size-5 text-cyan-600 dark:text-cyan-400" />
<AlertTitle>Открыто</AlertTitle>
</Alert>
),
scheduled: (
<Alert className="bg-purple-50 text-purple-800 dark:bg-purple-950 dark:text-purple-200">
<Calendar className="size-5 text-purple-600 dark:text-purple-400" />
<AlertTitle>Запланировано</AlertTitle>
</Alert>
),
};
export function getAlert(state: string) {
return ALERT_BY_STATE[state] ?? null;
}

View File

@ -1,28 +1,23 @@
'use client'; 'use client';
import { ScheduleTimeContext } from '../context';
import { formatTime } from '@/utils/date'; import { useScheduleStore } from '@/stores/schedule';
import { Input } from '@repo/ui/components/ui/input'; import { Input } from '@repo/ui/components/ui/input';
import { cn } from '@repo/ui/lib/utils'; import { type FormEvent, type PropsWithChildren } from 'react';
import { type FormEvent, type PropsWithChildren, use } from 'react';
type EditableTimeRangeProps = { type EditableTimeRangeProps = {
readonly disabled?: boolean; readonly disabled?: boolean;
readonly onSubmit: (event: FormEvent) => void; readonly onSubmit: (event: FormEvent) => void;
}; };
type TimeRangeProps = {
readonly className?: string;
readonly delimiter?: boolean;
readonly time_end: string;
readonly time_start: string;
};
export function EditableTimeRangeForm({ export function EditableTimeRangeForm({
children, children,
disabled = false, disabled = false,
onSubmit, onSubmit,
}: PropsWithChildren<EditableTimeRangeProps>) { }: PropsWithChildren<EditableTimeRangeProps>) {
const { endTime, setEndTime, setStartTime, startTime } = use(ScheduleTimeContext); const endTime = useScheduleStore((state) => state.endTime);
const startTime = useScheduleStore((state) => state.startTime);
const setEndTime = useScheduleStore((state) => state.setEndTime);
const setStartTime = useScheduleStore((state) => state.setStartTime);
return ( return (
<form className="flex flex-row items-center gap-2" onSubmit={onSubmit}> <form className="flex flex-row items-center gap-2" onSubmit={onSubmit}>
@ -52,18 +47,3 @@ export function EditableTimeRangeForm({
</form> </form>
); );
} }
export function ReadonlyTimeRange({
className,
delimiter = true,
time_end,
time_start,
}: Readonly<TimeRangeProps>) {
return (
<div className={cn('flex flex-row items-center gap-2 text-lg font-bold', className)}>
<span className="tracking-wider">{formatTime(time_start).user()}</span>
{delimiter && ' - '}
<span className="tracking-wider">{formatTime(time_end).user()}</span>
</div>
);
}

View File

@ -0,0 +1,2 @@
export * from './editable';
export * from './readonly';

View File

@ -0,0 +1,18 @@
import { cn } from '@repo/ui/lib/utils';
import { formatTime } from '@repo/utils/datetime-format';
type TimeRangeProps = {
readonly className?: string;
readonly timeEnd: null | string | undefined;
readonly timeStart: null | string | undefined;
};
export function ReadonlyTimeRange({ className, timeEnd, timeStart }: Readonly<TimeRangeProps>) {
return (
<div className={cn('flex flex-row items-center gap-2 text-lg font-bold', className)}>
<span className="tracking-wider">{timeStart ? formatTime(timeStart).user() : 'xx:xx'}</span>
{' - '}
<span className="tracking-wider">{timeEnd ? formatTime(timeEnd).user() : 'xx:xx'}</span>
</div>
);
}

View File

@ -2,7 +2,7 @@ type Props = {
title: string; title: string;
}; };
export function ProfileCardHeader({ title }: Readonly<Props>) { export function CardSectionHeader({ title }: Readonly<Props>) {
return ( return (
<div className="flex flex-row justify-between"> <div className="flex flex-row justify-between">
<h1 className="font-bold text-primary">{title}</h1> <h1 className="font-bold text-primary">{title}</h1>

View File

@ -0,0 +1 @@
export * from './card-header';

View File

@ -12,7 +12,7 @@ export const authOptions: AuthOptions = {
}, },
async session({ session, token }) { async session({ session, token }) {
if (token?.id && session?.user) { if (token?.id && session?.user) {
session.user.telegramId = token.id as string; session.user.telegramId = token.id as number;
} }
return session; return session;

View File

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

View File

@ -1,15 +0,0 @@
'use client';
import { createContext, useMemo, useState } from 'react';
export type FilterType = 'all' | 'clients' | 'masters';
type ContextType = { filter: FilterType; setFilter: (filter: FilterType) => void };
export const ContactsFilterContext = createContext<ContextType>({} as ContextType);
export function ContactsFilterProvider({ children }: { readonly children: React.ReactNode }) {
const [filter, setFilter] = useState<FilterType>('all');
const value = useMemo(() => ({ filter, setFilter }), [filter, setFilter]);
return <ContactsFilterContext value={value}>{children}</ContactsFilterContext>;
}

View File

@ -0,0 +1,17 @@
'use client';
import { createContext, type PropsWithChildren, useMemo, useState } from 'react';
export type FilterType = 'all' | 'clients' | 'masters';
type ContextType = { filter: FilterType; setFilter: (filter: FilterType) => void };
export const ContactsContext = createContext<ContextType>({} as ContextType);
export function ContactsContextProvider({ children }: Readonly<PropsWithChildren>) {
const [filter, setFilter] = useState<FilterType>('all');
const value = useMemo(() => ({ filter, setFilter }), [filter, setFilter]);
return <ContactsContext value={value}>{children}</ContactsContext>;
}

View File

@ -1,17 +0,0 @@
'use client';
import { createContext, useMemo, useState } from 'react';
type ContextType = {
selectedDate: Date;
setSelectedDate: (date: Date) => void;
};
export const ScheduleContext = createContext<ContextType>({} as ContextType);
export function ScheduleContextProvider({ children }: { readonly children: React.ReactNode }) {
const [selectedDate, setSelectedDate] = useState(new Date());
const value = useMemo(() => ({ selectedDate, setSelectedDate }), [selectedDate]);
return <ScheduleContext value={value}>{children}</ScheduleContext>;
}

View File

@ -0,0 +1,23 @@
import { getClients, getMasters } from '@/actions/api/customers';
import { useQuery } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
export const useClientsQuery = (props?: Parameters<typeof getClients>[0]) => {
const { data: session } = useSession();
const telegramId = props?.telegramId || session?.user?.telegramId;
return useQuery({
queryFn: () => getClients({ telegramId }),
queryKey: ['customer', 'telegramId', telegramId, 'clients'],
});
};
export const useMastersQuery = (props?: Parameters<typeof getMasters>[0]) => {
const { data: session } = useSession();
const telegramId = props?.telegramId || session?.user?.telegramId;
return useQuery({
queryFn: () => getMasters({ telegramId }),
queryKey: ['customer', 'telegramId', telegramId, 'masters'],
});
};

View File

@ -0,0 +1,47 @@
'use client';
import { useClientsQuery, useMastersQuery } from './query';
import { ContactsContext } from '@/context/contacts';
import { sift } from 'radashi';
import { use, useEffect, useMemo } from 'react';
export function useCustomerContacts() {
const { filter, setFilter } = use(ContactsContext);
const {
data: clientsData,
isLoading: isLoadingClients,
refetch: refetchClients,
} = useClientsQuery();
const {
data: mastersData,
isLoading: isLoadingMasters,
refetch: refetchMasters,
} = useMastersQuery();
const clients = clientsData?.customers?.at(0)?.clients || [];
const masters = mastersData?.customers?.at(0)?.masters || [];
const isLoading = isLoadingClients || isLoadingMasters;
useEffect(() => {
if (filter === 'clients') {
refetchClients();
} else if (filter === 'masters') {
refetchMasters();
} else {
refetchClients();
refetchMasters();
}
}, [filter, refetchClients, refetchMasters]);
const contacts = useMemo(() => {
if (filter === 'clients') return sift(clients);
if (filter === 'masters') return sift(masters);
return [...sift(clients), ...sift(masters)];
}, [clients, masters, filter]);
return { contacts, filter, isLoading, setFilter };
}

View File

@ -0,0 +1,44 @@
'use client';
import { getCustomer, updateCustomer } from '@/actions/api/customers';
import { isCustomerMaster } from '@repo/utils/customer';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useSession } from 'next-auth/react';
export const useCustomerQuery = (variables?: Parameters<typeof getCustomer>[0]) => {
const { data: session } = useSession();
const telegramId = variables?.telegramId || session?.user?.telegramId;
return useQuery({
enabled: Boolean(telegramId),
queryFn: () => getCustomer({ telegramId }),
queryKey: ['customer', telegramId],
});
};
export const useIsMaster = () => {
const { data: { customer } = {} } = useCustomerQuery();
if (!customer) return false;
return isCustomerMaster(customer);
};
export const useCustomerMutation = () => {
const { data: session } = useSession();
const telegramId = session?.user?.telegramId;
const queryClient = useQueryClient();
const handleOnSuccess = () => {
if (!telegramId) return;
queryClient.invalidateQueries({
queryKey: ['customer', telegramId],
});
};
return useMutation({
mutationFn: updateCustomer,
onSuccess: handleOnSuccess,
});
};

View File

@ -0,0 +1,61 @@
'use client';
import { createOrder, getOrder, getOrders, updateOrder } from '@/actions/api/orders';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
export const useOrderQuery = ({ documentId }: Parameters<typeof getOrder>[0]) =>
useQuery({
queryFn: () => getOrder({ documentId }),
queryKey: ['order', documentId],
});
export const useOrderCreate = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createOrder,
mutationKey: ['order', 'create'],
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['orders'] });
const documentId = data?.createOrder?.documentId;
if (documentId)
queryClient.prefetchQuery({
queryFn: () => getOrder({ documentId }),
queryKey: ['order', documentId],
});
},
});
};
export const useOrdersQuery = (variables: Parameters<typeof getOrders>[0], enabled?: boolean) =>
useQuery({
enabled,
queryFn: () => getOrders(variables),
queryKey: ['orders', variables],
staleTime: 60 * 1_000,
});
export const useOrderMutation = ({
documentId,
}: Pick<Parameters<typeof updateOrder>[0], 'documentId'>) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ data }: Pick<Parameters<typeof updateOrder>[0], 'data'>) =>
updateOrder({ data, documentId }),
mutationKey: ['order', 'update', documentId],
onSuccess: () => {
if (documentId) {
queryClient.invalidateQueries({
queryKey: ['order', documentId],
});
queryClient.invalidateQueries({
queryKey: ['orders'],
});
}
},
});
};

View File

@ -0,0 +1,18 @@
'use client';
import { getService, getServices } from '@/actions/api/services';
import { useQuery } from '@tanstack/react-query';
export const useServicesQuery = (variables: Parameters<typeof getServices>[0]) => {
return useQuery({
queryFn: () => getServices(variables),
queryKey: ['services', variables],
});
};
export const useServiceQuery = (variables: Parameters<typeof getService>[0]) => {
return useQuery({
queryFn: () => getService(variables),
queryKey: ['service', variables.documentId],
});
};

121
apps/web/hooks/api/slots.ts Normal file
View File

@ -0,0 +1,121 @@
'use client';
import { useCustomerQuery } from './customers';
import {
createSlot,
deleteSlot,
getAvailableTimeSlots,
getSlot,
getSlots,
updateSlot,
} from '@/actions/api/slots';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
type UseMasterSlotsVariables = {
filters: Required<
Pick<NonNullable<Parameters<typeof getSlots>[0]['filters']>, 'date' | 'master'>
>;
};
export const useMasterSlotsQuery = (variables: UseMasterSlotsVariables) => {
const masterId = variables.filters?.master?.documentId?.eq;
const date = variables.filters?.date?.eq;
return useQuery({
queryFn: () => getSlots(variables),
queryKey: ['slots', masterId, dayjs(date).format('YYYY-MM-DD')],
});
};
export const useSlotsQuery = (variables: Parameters<typeof getSlots>[0]) => {
return useQuery({
queryFn: () => getSlots(variables),
queryKey: ['slots', variables],
});
};
export const useSlotQuery = (variables: Parameters<typeof getSlot>[0]) => {
const { documentId } = variables;
return useQuery({
queryFn: () => getSlot(variables),
queryKey: ['slot', documentId],
});
};
export const useAvailableTimeSlotsQuery = (
...variables: Parameters<typeof getAvailableTimeSlots>
) => {
return useQuery({
queryFn: () => getAvailableTimeSlots(...variables),
queryKey: ['available-time-slots', variables],
staleTime: 15 * 1_000,
});
};
export const useSlotMutation = ({
documentId,
}: Pick<Parameters<typeof updateSlot>[0], 'documentId'>) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ data }: Pick<Parameters<typeof updateSlot>[0], 'data'>) =>
updateSlot({ data, documentId }),
mutationKey: ['slot', 'update', documentId],
onSuccess: () => {
if (documentId) {
queryClient.invalidateQueries({
queryKey: ['slot', documentId],
});
queryClient.invalidateQueries({
queryKey: ['slots'],
});
}
},
});
};
export const useSlotCreate = () => {
const { data: { customer } = {} } = useCustomerQuery();
const masterId = customer?.documentId;
const queryClient = useQueryClient();
return useMutation({
mutationFn: createSlot,
mutationKey: ['slot', 'create'],
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: ['slots', masterId],
});
const documentId = data?.createSlot?.documentId;
if (documentId)
queryClient.prefetchQuery({
queryFn: () => getSlot({ documentId }),
queryKey: ['slot', documentId],
});
},
});
};
export const useSlotDelete = ({ documentId }: Parameters<typeof deleteSlot>[0]) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => deleteSlot({ documentId }),
mutationKey: ['slot', 'delete', documentId],
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['slots'],
});
queryClient.invalidateQueries({
queryKey: ['slot', documentId],
});
},
});
};

Some files were not shown because too many files have changed in this diff Show More