Compare commits

..

2 Commits

11 changed files with 400 additions and 375 deletions

27
package-lock.json generated
View File

@ -61,6 +61,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"react-hot-toast": "^2.5.2",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"tailwindcss": "^4", "tailwindcss": "^4",
@ -8428,6 +8429,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/goober": {
"version": "2.1.16",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
"license": "MIT",
"peerDependencies": {
"csstype": "^3.0.10"
}
},
"node_modules/gopd": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -11166,6 +11176,23 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-hot-toast": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz",
"integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.3",
"goober": "^2.1.16"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">=16",
"react-dom": ">=16"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@ -71,6 +71,7 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.57.0", "react-hook-form": "^7.57.0",
"react-hot-toast": "^2.5.2",
"sonner": "^2.0.5", "sonner": "^2.0.5",
"tailwind-merge": "^3.3.0", "tailwind-merge": "^3.3.0",
"tailwindcss": "^4", "tailwindcss": "^4",

View File

@ -1,4 +1,4 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('postcss-load-config').Config} */
export default { export default {
plugins: { plugins: {
'@tailwindcss/postcss': {}, '@tailwindcss/postcss': {},

View File

@ -24,6 +24,7 @@ import { ImportProductsModal } from '@/components/catalog/ImportProductsModal'
import { Pagination } from '@/components/ui/pagination' import { Pagination } from '@/components/ui/pagination'
import { GET_CATEGORIES, GET_PRODUCTS, GET_PRODUCTS_COUNT } from '@/lib/graphql/queries' import { GET_CATEGORIES, GET_PRODUCTS, GET_PRODUCTS_COUNT } from '@/lib/graphql/queries'
import { EXPORT_PRODUCTS } from '@/lib/graphql/mutations' import { EXPORT_PRODUCTS } from '@/lib/graphql/mutations'
import toast from 'react-hot-toast'
@ -106,7 +107,7 @@ export default function CatalogPage() {
} }
} catch (error) { } catch (error) {
console.error('Ошибка экспорта:', error) console.error('Ошибка экспорта:', error)
alert('Не удалось экспортировать товары') toast.error('Не удалось экспортировать товары')
} finally { } finally {
setExportLoading(false) setExportLoading(false)
} }

View File

@ -31,6 +31,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
AlertDialogTrigger, AlertDialogTrigger,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import toast from 'react-hot-toast'
const GET_BALANCE_INVOICES = gql` const GET_BALANCE_INVOICES = gql`
query GetBalanceInvoices { query GetBalanceInvoices {
@ -127,7 +128,7 @@ export default function InvoicesPage() {
}, },
onError: (error) => { onError: (error) => {
console.error('Ошибка обновления статуса счета:', error) console.error('Ошибка обновления статуса счета:', error)
alert('Ошибка обновления статуса: ' + error.message) toast.error('Ошибка обновления статуса: ' + error.message)
} }
}) })
@ -175,11 +176,11 @@ export default function InvoicesPage() {
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
document.body.removeChild(a) document.body.removeChild(a)
} else { } else {
alert('Ошибка получения PDF: ' + (data?.getInvoicePDF?.error || 'Неизвестная ошибка')) toast.error('Ошибка получения PDF: ' + (data?.getInvoicePDF?.error || 'Неизвестная ошибка'))
} }
} catch (error) { } catch (error) {
console.error('Ошибка скачивания PDF:', error) console.error('Ошибка скачивания PDF:', error)
alert('Ошибка скачивания PDF: ' + (error as Error).message) toast.error('Ошибка скачивания PDF: ' + (error as Error).message)
} }
} }

View File

@ -1,8 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);

View File

@ -1,15 +1,32 @@
"use client" "use client"
import { Toaster } from 'sonner' import { Toaster } from 'react-hot-toast'
export const ToastProvider = () => { export const ToastProvider = () => {
return ( return (
<Toaster <Toaster
position="top-right" position="top-right"
richColors toastOptions={{
closeButton duration: 4000,
expand={false} style: {
duration={4000} background: '#fff',
color: '#333',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
},
success: {
iconTheme: {
primary: '#10B981',
secondary: '#fff',
},
},
error: {
iconTheme: {
primary: '#EF4444',
secondary: '#fff',
},
},
}}
/> />
) )
} }

View File

@ -377,6 +377,8 @@ interface CreateOrderInput {
items: OrderItemInput[] items: OrderItemInput[]
deliveryAddress?: string deliveryAddress?: string
comment?: string comment?: string
paymentMethod?: string
legalEntityId?: string
} }
interface OrderItemInput { interface OrderItemInput {
@ -2754,66 +2756,6 @@ export const resolvers = {
} }
}, },
partsIndexCatalogParams: async (_: unknown, {
catalogId,
groupId,
lang = 'ru',
engineId,
generationId,
params,
q
}: {
catalogId: string;
groupId: string;
lang?: 'ru' | 'en';
engineId?: string;
generationId?: string;
params?: string;
q?: string;
}) => {
try {
console.log('🔍 GraphQL resolver partsIndexCatalogParams вызван с параметрами:', {
catalogId,
groupId,
lang,
q
})
// Преобразуем строку params в объект если передан
let parsedParams: Record<string, any> | undefined;
if (params) {
try {
parsedParams = JSON.parse(params);
} catch (error) {
console.warn('⚠️ Не удалось разобрать параметры фильтрации:', params);
}
}
const parameters = await partsIndexService.getCatalogParams(catalogId, groupId, {
lang,
engineId,
generationId,
params: parsedParams,
q
})
if (!parameters) {
console.warn('⚠️ Не удалось получить параметры каталога')
return {
list: [],
paramsQuery: {}
}
}
console.log('✅ Получены параметры каталога:', parameters.list.length)
return parameters
} catch (error) {
console.error('❌ Ошибка в GraphQL resolver partsIndexCatalogParams:', error)
throw new Error('Не удалось получить параметры каталога')
}
},
partsIndexSearchByArticle: async (_: unknown, { partsIndexSearchByArticle: async (_: unknown, {
articleNumber, articleNumber,
brandName, brandName,
@ -4178,248 +4120,6 @@ export const resolvers = {
} }
}, },
updateProduct: async (_: unknown, {
id,
input,
images = [],
characteristics = [],
options = []
}: {
id: string;
input: ProductInput;
images?: ProductImageInput[];
characteristics?: CharacteristicInput[];
options?: ProductOptionInput[]
}, context: Context) => {
try {
console.log('updateProduct вызван с опциями:', JSON.stringify(options, null, 2))
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
const { categoryIds, ...productData } = input
// Удаляем старые изображения, характеристики и опции
await prisma.productImage.deleteMany({
where: { productId: id }
})
await prisma.productCharacteristic.deleteMany({
where: { productId: id }
})
await prisma.productOption.deleteMany({
where: { productId: id }
})
// Обновляем основные данные товара
const finalData = { ...productData }
if (input.name && !input.slug) {
finalData.slug = createSlug(input.name)
}
await prisma.product.update({
where: { id },
data: {
...finalData,
categories: categoryIds ? {
set: categoryIds.map((catId: string) => ({ id: catId }))
} : undefined,
images: {
create: images.map((img, index) => ({
...img,
order: img.order ?? index
}))
}
}
})
// Добавляем новые характеристики
for (const char of characteristics) {
let characteristic = await prisma.characteristic.findUnique({
where: { name: char.name }
})
if (!characteristic) {
characteristic = await prisma.characteristic.create({
data: { name: char.name }
})
}
await prisma.productCharacteristic.create({
data: {
productId: id,
characteristicId: characteristic.id,
value: char.value
}
})
}
// Добавляем новые опции
console.log(`Обрабатываем ${options.length} опций для товара ${id}`)
for (const optionInput of options) {
console.log('Обрабатываем опцию:', optionInput)
// Создаём или находим опцию
let option = await prisma.option.findUnique({
where: { name: optionInput.name }
})
if (!option) {
console.log('Создаем новую опцию:', optionInput.name)
option = await prisma.option.create({
data: {
name: optionInput.name,
type: optionInput.type
}
})
} else {
console.log('Найдена существующая опция:', option.id)
}
// Создаём значения опции и связываем с товаром
for (const valueInput of optionInput.values) {
console.log('Обрабатываем значение опции:', valueInput)
// Создаём или находим значение опции
let optionValue = await prisma.optionValue.findFirst({
where: {
optionId: option.id,
value: valueInput.value
}
})
if (!optionValue) {
console.log('Создаем новое значение опции')
optionValue = await prisma.optionValue.create({
data: {
optionId: option.id,
value: valueInput.value,
price: valueInput.price || 0
}
})
} else {
console.log('Найдено существующее значение опции:', optionValue.id)
}
// Связываем товар с опцией и значением
console.log('Создаем связь товар-опция-значение')
await prisma.productOption.create({
data: {
productId: id,
optionId: option.id,
optionValueId: optionValue.id
}
})
}
}
console.log('Все опции обработаны')
// Получаем обновленный товар со всеми связанными данными
const product = await prisma.product.findUnique({
where: { id },
include: {
categories: true,
images: { orderBy: { order: 'asc' } },
options: {
include: {
option: { include: { values: true } },
optionValue: true
}
},
characteristics: { include: { characteristic: true } },
relatedProducts: { include: { images: { orderBy: { order: 'asc' } } } },
accessoryProducts: { include: { images: { orderBy: { order: 'asc' } } } }
}
})
// Создаем запись в истории товара
if (context.userId && product) {
await prisma.productHistory.create({
data: {
productId: id,
action: 'UPDATE',
changes: JSON.stringify({
name: input.name,
article: input.article,
description: input.description,
wholesalePrice: input.wholesalePrice,
retailPrice: input.retailPrice,
stock: input.stock,
isVisible: input.isVisible,
categories: categoryIds,
images: images.length,
characteristics: characteristics.length,
options: options.length
}),
userId: context.userId
}
})
}
// Логируем действие
if (context.headers) {
const { ipAddress, userAgent } = getClientInfo(context.headers)
await createAuditLog({
userId: context.userId,
action: AuditAction.PRODUCT_UPDATE,
details: `Товар "${product?.name}"`,
ipAddress,
userAgent
})
}
return product
} catch (error) {
console.error('Ошибка обновления товара:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось обновить товар')
}
},
deleteProduct: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
const product = await prisma.product.findUnique({
where: { id }
})
if (!product) {
throw new Error('Товар не найден')
}
await prisma.product.delete({
where: { id }
})
// Логируем действие
if (context.headers) {
const { ipAddress, userAgent } = getClientInfo(context.headers)
await createAuditLog({
userId: context.userId,
action: AuditAction.PRODUCT_DELETE,
details: `Товар "${product.name}"`,
ipAddress,
userAgent
})
}
return true
} catch (error) {
console.error('Ошибка удаления товара:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить товар')
}
},
updateProductVisibility: async (_: unknown, { id, isVisible }: { id: string; isVisible: boolean }, context: Context) => { updateProductVisibility: async (_: unknown, { id, isVisible }: { id: string; isVisible: boolean }, context: Context) => {
try { try {
if (!context.userId) { if (!context.userId) {
@ -4965,26 +4665,6 @@ export const resolvers = {
} }
}, },
deleteOption: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
await prisma.option.delete({
where: { id }
})
return true
} catch (error) {
console.error('Ошибка удаления опции:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить опцию')
}
},
// Клиенты // Клиенты
createClient: async (_: unknown, { input, vehicles = [], discounts = [] }: { createClient: async (_: unknown, { input, vehicles = [], discounts = [] }: {
input: ClientInput; vehicles?: ClientVehicleInput[]; discounts?: ClientDiscountInput[] input: ClientInput; vehicles?: ClientVehicleInput[]; discounts?: ClientDiscountInput[]
@ -5755,26 +5435,6 @@ export const resolvers = {
} }
}, },
deleteClientVehicle: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
await prisma.clientVehicle.delete({
where: { id }
})
return true
} catch (error) {
console.error('Ошибка удаления транспорта:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить транспорт')
}
},
// Адреса доставки // Адреса доставки
createClientDeliveryAddress: async (_: unknown, { clientId, input }: { clientId: string; input: ClientDeliveryAddressInput }, context: Context) => { createClientDeliveryAddress: async (_: unknown, { clientId, input }: { clientId: string; input: ClientDeliveryAddressInput }, context: Context) => {
try { try {
@ -7448,6 +7108,41 @@ export const resolvers = {
? clientId.substring(7) ? clientId.substring(7)
: clientId : clientId
// Проверяем баланс для оплаты с баланса
if (input.paymentMethod === 'balance') {
console.log('createOrder: проверяем баланс для оплаты с баланса')
// Сначала ищем дефолтный активный контракт, если нет - любой активный
let contract = await prisma.clientContract.findFirst({
where: {
clientId: cleanClientId,
isActive: true,
isDefault: true
}
})
if (!contract) {
// Если дефолтного нет, ищем любой активный
contract = await prisma.clientContract.findFirst({
where: {
clientId: cleanClientId,
isActive: true
}
})
}
if (!contract) {
throw new Error('Активный контракт не найден')
}
const availableBalance = (contract.balance || 0) + (contract.creditLimit || 0)
console.log(`createOrder: доступный баланс: ${availableBalance}, сумма заказа: ${totalAmount}`)
if (availableBalance < totalAmount) {
throw new Error('Недостаточно средств на балансе для оплаты заказа')
}
}
const order = await prisma.order.create({ const order = await prisma.order.create({
data: { data: {
orderNumber, orderNumber,
@ -7458,7 +7153,7 @@ export const resolvers = {
totalAmount, totalAmount,
finalAmount: totalAmount, // Пока без скидок finalAmount: totalAmount, // Пока без скидок
deliveryAddress: input.deliveryAddress, deliveryAddress: input.deliveryAddress,
comment: input.comment, comment: `${input.comment || ''}${input.paymentMethod ? ` | Способ оплаты: ${input.paymentMethod}` : ''}${input.legalEntityId ? ` | ЮЛ ID: ${input.legalEntityId}` : ''}`,
items: { items: {
create: input.items.map(item => ({ create: input.items.map(item => ({
productId: item.productId, productId: item.productId,
@ -7483,6 +7178,52 @@ export const resolvers = {
} }
}) })
// Если оплата с баланса, списываем средства и устанавливаем статус "Оплачен"
if (input.paymentMethod === 'balance') {
console.log('createOrder: списываем средства с баланса')
// Ищем тот же контракт, который использовали для проверки баланса
let contractToUpdate = await prisma.clientContract.findFirst({
where: {
clientId: cleanClientId,
isActive: true,
isDefault: true
}
})
if (!contractToUpdate) {
contractToUpdate = await prisma.clientContract.findFirst({
where: {
clientId: cleanClientId,
isActive: true
}
})
}
if (contractToUpdate) {
await prisma.clientContract.update({
where: {
id: contractToUpdate.id
},
data: {
balance: {
decrement: totalAmount
}
}
})
console.log(`createOrder: списано ${totalAmount}с баланса контракта ${contractToUpdate.contractNumber}`)
// Обновляем статус заказа на "Оплачен"
await prisma.order.update({
where: { id: order.id },
data: { status: 'PAID' }
})
console.log('createOrder: статус заказа изменен на PAID')
}
}
console.log('createOrder: заказ создан:', order.orderNumber) console.log('createOrder: заказ создан:', order.orderNumber)
return order return order
} catch (error) { } catch (error) {
@ -7609,6 +7350,139 @@ export const resolvers = {
} }
}, },
// Resolver для подтверждения платежа
confirmPayment: async (_: unknown, { orderId }: { orderId: string }, context: Context) => {
try {
console.log('confirmPayment: подтверждение платежа для заказа:', orderId)
// Находим заказ
const order = await prisma.order.findUnique({
where: { id: orderId },
include: {
client: true,
items: {
include: {
product: true
}
},
payments: true
}
})
if (!order) {
throw new Error('Заказ не найден')
}
// Если заказ уже оплачен, просто возвращаем его
if (order.status === 'PAID') {
console.log('confirmPayment: заказ уже оплачен')
return order
}
// Обновляем статус заказа на "Оплачен"
const updatedOrder = await prisma.order.update({
where: { id: orderId },
data: { status: 'PAID' },
include: {
client: true,
items: {
include: {
product: true
}
},
payments: true
}
})
console.log('confirmPayment: статус заказа изменен на PAID')
return updatedOrder
} catch (error) {
console.error('Ошибка подтверждения платежа:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось подтвердить платеж')
}
},
// Resolver для создания платежа
createPayment: async (_: unknown, { input }: { input: CreatePaymentInput }, context: Context) => {
try {
console.log('createPayment: создание платежа для заказа:', input.orderId)
// Находим заказ
const order = await prisma.order.findUnique({
where: { id: input.orderId },
include: { items: true }
})
if (!order) {
throw new Error('Заказ не найден')
}
// Если заказ уже оплачен с баланса, не создаем платеж в ЮКассе
if (order.status === 'PAID') {
console.log('createPayment: заказ уже оплачен с баланса')
// Возвращаем успешный результат без создания платежа в ЮКассе
return {
payment: null,
confirmationUrl: null,
success: true,
message: 'Заказ уже оплачен с баланса'
}
}
// Создаем платеж в ЮКассе
const { yooKassaService } = await import('../yookassa-service')
const payment = await yooKassaService.createPayment({
amount: order.finalAmount,
currency: 'RUB',
description: input.description || `Оплата заказа ${order.orderNumber}`,
returnUrl: input.returnUrl,
metadata: { orderId: order.id }
})
console.log('createPayment: платеж создан в ЮКассе:', payment.id)
// Маппинг статусов YooKassa на GraphQL enum
const mapYooKassaStatus = (status: string) => {
switch (status) {
case 'pending': return 'PENDING'
case 'waiting_for_capture': return 'WAITING_FOR_CAPTURE'
case 'succeeded': return 'SUCCEEDED'
case 'canceled': return 'CANCELED'
default: return 'PENDING'
}
}
return {
payment: {
id: payment.id,
orderId: order.id,
yookassaPaymentId: payment.id,
status: mapYooKassaStatus(payment.status),
amount: parseFloat(payment.amount.value),
currency: payment.amount.currency,
description: payment.description,
confirmationUrl: payment.confirmation?.confirmation_url || null,
createdAt: new Date().toISOString()
},
confirmationUrl: payment.confirmation?.confirmation_url || null,
success: true,
message: 'Платеж успешно создан'
}
} catch (error) {
console.error('Ошибка создания платежа:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось создать платеж')
}
},
// Мутация для получения офферов доставки // Мутация для получения офферов доставки
getDeliveryOffers: async (_: unknown, { input }: { getDeliveryOffers: async (_: unknown, { input }: {
input: { input: {

View File

@ -1777,6 +1777,8 @@ export const typeDefs = gql`
items: [OrderItemInput!]! items: [OrderItemInput!]!
deliveryAddress: String deliveryAddress: String
comment: String comment: String
paymentMethod: String
legalEntityId: String
} }
input OrderItemInput { input OrderItemInput {
@ -1798,8 +1800,10 @@ export const typeDefs = gql`
# Результат создания платежа # Результат создания платежа
type CreatePaymentResult { type CreatePaymentResult {
payment: Payment! payment: Payment
confirmationUrl: String! confirmationUrl: String
success: Boolean!
message: String
} }
# PartsAPI типы для категорий автозапчастей # PartsAPI типы для категорий автозапчастей

View File

@ -205,7 +205,7 @@ class YandexDeliveryService {
} }
/** /**
* Форматирование расписания работы ПВЗ * Форматирование расписания работы ПВЗ с группировкой дней
*/ */
formatSchedule(schedule: YandexPickupPoint['schedule']): string { formatSchedule(schedule: YandexPickupPoint['schedule']): string {
if (!schedule.restrictions || schedule.restrictions.length === 0) { if (!schedule.restrictions || schedule.restrictions.length === 0) {
@ -214,12 +214,57 @@ class YandexDeliveryService {
const dayNames = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']; const dayNames = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
return schedule.restrictions.map(restriction => { // Группируем ограничения по времени
const days = restriction.days.map(day => dayNames[day - 1]).join(', '); const timeGroups: Record<string, number[]> = {};
schedule.restrictions.forEach(restriction => {
const timeFrom = `${restriction.time_from.hours.toString().padStart(2, '0')}:${restriction.time_from.minutes.toString().padStart(2, '0')}`; const timeFrom = `${restriction.time_from.hours.toString().padStart(2, '0')}:${restriction.time_from.minutes.toString().padStart(2, '0')}`;
const timeTo = `${restriction.time_to.hours.toString().padStart(2, '0')}:${restriction.time_to.minutes.toString().padStart(2, '0')}`; const timeTo = `${restriction.time_to.hours.toString().padStart(2, '0')}:${restriction.time_to.minutes.toString().padStart(2, '0')}`;
const timeRange = `${timeFrom}-${timeTo}`;
return `${days}: ${timeFrom}-${timeTo}`; if (!timeGroups[timeRange]) {
timeGroups[timeRange] = [];
}
timeGroups[timeRange].push(...restriction.days);
});
// Форматируем каждую группу времени
return Object.entries(timeGroups).map(([timeRange, days]) => {
// Убираем дубликаты и сортируем дни
const uniqueDays = [...new Set(days)].sort((a, b) => a - b);
// Группируем последовательные дни в диапазоны
const dayRanges: string[] = [];
let rangeStart = uniqueDays[0];
let rangeEnd = uniqueDays[0];
for (let i = 1; i < uniqueDays.length; i++) {
const currentDay = uniqueDays[i];
const prevDay = uniqueDays[i - 1];
// Если дни идут подряд, расширяем диапазон
if (currentDay === prevDay + 1) {
rangeEnd = currentDay;
} else {
// Завершаем текущий диапазон и начинаем новый
if (rangeStart === rangeEnd) {
dayRanges.push(dayNames[rangeStart - 1]);
} else {
dayRanges.push(`${dayNames[rangeStart - 1]}-${dayNames[rangeEnd - 1]}`);
}
rangeStart = currentDay;
rangeEnd = currentDay;
}
}
// Добавляем последний диапазон
if (rangeStart === rangeEnd) {
dayRanges.push(dayNames[rangeStart - 1]);
} else {
dayRanges.push(`${dayNames[rangeStart - 1]}-${dayNames[rangeEnd - 1]}`);
}
return `${dayRanges.join(', ')}: ${timeRange}`;
}).join('; '); }).join('; ');
} }

57
tailwind.config.js Normal file
View File

@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
sans: ["var(--font-geist-sans)", "sans-serif"],
mono: ["var(--font-geist-mono)", "monospace"],
},
},
},
plugins: [],
}