Compare commits
115 Commits
main
...
feature/or
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74fdefe65a | ||
|
|
bb65863f81 | ||
|
|
1e3ef51eb0 | ||
|
|
7f3b38fa48 | ||
|
|
9beee9902e | ||
|
|
1fb3b67d79 | ||
|
|
7f6539d10a | ||
|
|
aa347fb032 | ||
|
|
7dbc08f1d1 | ||
|
|
f305987f68 | ||
|
|
8cc7c37f18 | ||
|
|
46ab2d67dc | ||
|
|
fead5353e7 | ||
|
|
f47ec19551 | ||
|
|
98e0c33424 | ||
|
|
5c89d41f2f | ||
|
|
46f60d969d | ||
|
|
a831aeb212 | ||
|
|
2ca11832a9 | ||
|
|
37e7a01ef2 | ||
|
|
28bceab36d | ||
|
|
a3fe14a53c | ||
|
|
24f71a9b66 | ||
|
|
f6154d5fc2 | ||
|
|
e861e6e917 | ||
|
|
d7acd1ef9c | ||
|
|
832f65714d | ||
|
|
5641a13890 | ||
|
|
25f6e26901 | ||
|
|
efebc9d8ef | ||
|
|
f69aeb2353 | ||
|
|
ff68ffbb6a | ||
|
|
c8ea506dc5 | ||
|
|
4ed010056a | ||
|
|
2ba56c5949 | ||
|
|
6a2678653c | ||
|
|
085263654f | ||
|
|
a7f00a3811 | ||
|
|
25c90984dc | ||
|
|
d15dd9ada7 | ||
|
|
8242d186fe | ||
|
|
1b1963e5d9 | ||
|
|
f6285d6ebf | ||
|
|
07d878214c | ||
|
|
16d448bab6 | ||
|
|
ca8d88bfc3 | ||
|
|
d085a3d24d | ||
|
|
0cb9e6b6ee | ||
|
|
e4ec942a9c | ||
|
|
f63ca6d93e | ||
|
|
a9efcfccf2 | ||
|
|
1e84b4ec0e | ||
|
|
f4609eb8d1 | ||
|
|
d0efd133f2 | ||
|
|
3589ab974a | ||
|
|
2510e0bcae | ||
|
|
5e13deecf0 | ||
|
|
d0e67a0f8a | ||
|
|
a4608ead43 | ||
|
|
c710537727 | ||
|
|
2bb85af46b | ||
|
|
1b99f7f18d | ||
|
|
4160ed4540 | ||
|
|
d8f853180b | ||
|
|
ebe8ee5437 | ||
|
|
0698242257 | ||
|
|
f0b63a5e7e | ||
|
|
52d68964f1 | ||
|
|
0b867a9136 | ||
|
|
b8880eedee | ||
|
|
9314cdd1cb | ||
|
|
fda1a0a531 | ||
|
|
f2f7138c67 | ||
|
|
0ed90d5451 | ||
|
|
1528cc25b8 | ||
|
|
24fb2103f7 | ||
|
|
3738c4e2a9 | ||
|
|
7fcf67eece | ||
|
|
b5306357c8 | ||
|
|
7e886172f2 | ||
|
|
e6f2e6ccaf | ||
|
|
2bc7607800 | ||
|
|
7e143b3054 | ||
|
|
1e6718508a | ||
|
|
68d2343e98 | ||
|
|
47144e8126 | ||
|
|
1883280dca | ||
|
|
db9af07dab | ||
|
|
bc974ffc40 | ||
|
|
c09e79b024 | ||
|
|
2676e40df6 | ||
|
|
dd99e7d984 | ||
|
|
ec32f56f8b | ||
|
|
8c8a588dfc | ||
|
|
4143151cbb | ||
|
|
8eece70ff4 | ||
|
|
2a830ceffb | ||
|
|
0281e99403 | ||
|
|
461bca0a0b | ||
|
|
1e69802b82 | ||
|
|
cc81a9a504 | ||
|
|
687a5b66c0 | ||
|
|
4f87d17e8e | ||
|
|
aacb7fa998 | ||
|
|
5f0d707884 | ||
|
|
79570efe1a | ||
|
|
cab23ac932 | ||
|
|
2bbe9731b1 | ||
|
|
3a649e5825 | ||
|
|
cf5ceae115 | ||
|
|
8931dfc69f | ||
|
|
d5d07d7b2f | ||
|
|
4db10a7f63 | ||
|
|
b6d7fabba1 | ||
|
|
fbc682b41f |
34
.vscode/launch.json
vendored
Normal file
34
.vscode/launch.json
vendored
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -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": {
|
||||||
|
|||||||
@ -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' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { type ReplyKeyboardRemove } from 'telegraf/types';
|
import { type ReplyKeyboardRemove } from 'telegraf/types';
|
||||||
|
|
||||||
export const commandsList = `
|
export const commandsList = `
|
||||||
\nДоступные команды:
|
\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>\nПригласите пользователя в приложение, чтобы вы могли добавлять записи с этим контактом.`;
|
||||||
|
|||||||
42
apps/web/actions/api/customers.ts
Normal file
42
apps/web/actions/api/customers.ts
Normal 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);
|
||||||
|
}
|
||||||
14
apps/web/actions/api/lib/service.ts
Normal file
14
apps/web/actions/api/lib/service.ts
Normal 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>;
|
||||||
|
};
|
||||||
|
}
|
||||||
30
apps/web/actions/api/orders.ts
Normal file
30
apps/web/actions/api/orders.ts
Normal 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);
|
||||||
|
}
|
||||||
18
apps/web/actions/api/services.ts
Normal file
18
apps/web/actions/api/services.ts
Normal 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);
|
||||||
|
}
|
||||||
44
apps/web/actions/api/slots.ts
Normal file
44
apps/web/actions/api/slots.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
'use server';
|
|
||||||
import * as api from '@repo/graphql/api';
|
|
||||||
|
|
||||||
export const getOrder = api.getOrder;
|
|
||||||
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
13
apps/web/actions/session.ts
Normal file
13
apps/web/actions/session.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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]);
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
apps/web/app/(main)/orders/[documentId]/page.tsx
Normal file
40
apps/web/app/(main)/orders/[documentId]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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="Контакты" />
|
||||||
37
apps/web/components/navigation/bottom-nav/nav-button.tsx
Normal file
37
apps/web/components/navigation/bottom-nav/nav-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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';
|
||||||
|
|
||||||
@ -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 };
|
||||||
|
|
||||||
8
apps/web/components/orders/index.ts
Normal file
8
apps/web/components/orders/index.ts
Normal 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';
|
||||||
63
apps/web/components/orders/order-buttons.tsx
Normal file
63
apps/web/components/orders/order-buttons.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/web/components/orders/order-contacts.tsx
Normal file
30
apps/web/components/orders/order-contacts.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/web/components/orders/order-datetime.tsx
Normal file
25
apps/web/components/orders/order-datetime.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
apps/web/components/orders/order-form/back-button.tsx
Normal file
26
apps/web/components/orders/order-form/back-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
129
apps/web/components/orders/order-form/contacts-grid.tsx
Normal file
129
apps/web/components/orders/order-form/contacts-grid.tsx
Normal 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="Клиенты"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
123
apps/web/components/orders/order-form/datetime-select.tsx
Normal file
123
apps/web/components/orders/order-form/datetime-select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/web/components/orders/order-form/index.tsx
Normal file
56
apps/web/components/orders/order-form/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
22
apps/web/components/orders/order-form/next-button.tsx
Normal file
22
apps/web/components/orders/order-form/next-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
apps/web/components/orders/order-form/result.tsx
Normal file
59
apps/web/components/orders/order-form/result.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
apps/web/components/orders/order-form/service-select.tsx
Normal file
61
apps/web/components/orders/order-form/service-select.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/web/components/orders/order-form/submit-button.tsx
Normal file
44
apps/web/components/orders/order-form/submit-button.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
apps/web/components/orders/order-services.tsx
Normal file
29
apps/web/components/orders/order-services.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/web/components/orders/order-status.tsx
Normal file
11
apps/web/components/orders/order-status.tsx
Normal 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);
|
||||||
|
}
|
||||||
49
apps/web/components/orders/orders-list/date-select.tsx
Normal file
49
apps/web/components/orders/orders-list/date-select.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
apps/web/components/orders/orders-list/index.tsx
Normal file
77
apps/web/components/orders/orders-list/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
apps/web/components/orders/types/index.tsx
Normal file
4
apps/web/components/orders/types/index.tsx
Normal 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'>;
|
||||||
@ -1,4 +0,0 @@
|
|||||||
export * from './card-header';
|
|
||||||
export * from './checkbox-field';
|
|
||||||
export * from './link-button';
|
|
||||||
export * from './text-field';
|
|
||||||
@ -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';
|
||||||
@ -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>
|
||||||
@ -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';
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
19
apps/web/components/profile/links-card/index.tsx
Normal file
19
apps/web/components/profile/links-card/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/web/components/profile/orders-list.tsx
Normal file
45
apps/web/components/profile/orders-list.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
|||||||
@ -1,3 +1,3 @@
|
|||||||
export type ProfileProps = {
|
export type ProfileProps = {
|
||||||
readonly telegramId?: string;
|
readonly telegramId?: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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];
|
|
||||||
}
|
|
||||||
@ -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];
|
|
||||||
}
|
|
||||||
@ -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>;
|
|
||||||
}
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
});
|
||||||
32
apps/web/components/schedule/day-slots-list/index.tsx
Normal file
32
apps/web/components/schedule/day-slots-list/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
apps/web/components/schedule/day-slots-list/slot-card.tsx
Normal file
40
apps/web/components/schedule/day-slots-list/slot-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
18
apps/web/components/schedule/slot-datetime/index.tsx
Normal file
18
apps/web/components/schedule/slot-datetime/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -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;
|
||||||
|
|
||||||
@ -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}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'>;
|
||||||
|
|||||||
115
apps/web/components/shared/action-panel.tsx
Normal file
115
apps/web/components/shared/action-panel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
apps/web/components/shared/contact-row.tsx
Normal file
42
apps/web/components/shared/contact-row.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
59
apps/web/components/shared/order-card.tsx
Normal file
59
apps/web/components/shared/order-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
168
apps/web/components/shared/status.tsx
Normal file
168
apps/web/components/shared/status.tsx
Normal 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;
|
||||||
|
}
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
2
apps/web/components/shared/time-range/index.ts
Normal file
2
apps/web/components/shared/time-range/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './editable';
|
||||||
|
export * from './readonly';
|
||||||
18
apps/web/components/shared/time-range/readonly.tsx
Normal file
18
apps/web/components/shared/time-range/readonly.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
1
apps/web/components/ui/index.ts
Normal file
1
apps/web/components/ui/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './card-header';
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>;
|
|
||||||
}
|
|
||||||
17
apps/web/context/contacts.tsx
Normal file
17
apps/web/context/contacts.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@ -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>;
|
|
||||||
}
|
|
||||||
23
apps/web/hooks/api/contacts/query.ts
Normal file
23
apps/web/hooks/api/contacts/query.ts
Normal 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'],
|
||||||
|
});
|
||||||
|
};
|
||||||
47
apps/web/hooks/api/contacts/use-customer-contacts.ts
Normal file
47
apps/web/hooks/api/contacts/use-customer-contacts.ts
Normal 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 };
|
||||||
|
}
|
||||||
44
apps/web/hooks/api/customers.ts
Normal file
44
apps/web/hooks/api/customers.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
61
apps/web/hooks/api/orders.ts
Normal file
61
apps/web/hooks/api/orders.ts
Normal 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'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
18
apps/web/hooks/api/services.ts
Normal file
18
apps/web/hooks/api/services.ts
Normal 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
121
apps/web/hooks/api/slots.ts
Normal 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
Loading…
x
Reference in New Issue
Block a user