Добавлены новые модели и мутации для управления товарами дня, лучшими ценами и топом продаж. Обновлены типы GraphQL и резолверы для обработки запросов, что улучшает функциональность и структуру данных. В боковое меню добавлены новые элементы для навигации по товарам. Это повышает удобство работы с приложением и расширяет возможности взаимодействия с API.

This commit is contained in:
Bivekich
2025-07-10 00:11:02 +03:00
parent 2c2ccf8876
commit c7dcb96c05
9 changed files with 2982 additions and 645 deletions

View File

@ -9,21 +9,19 @@ datasource db {
}
model User {
id String @id @default(cuid())
firstName String
lastName String
email String @unique
password String
avatar String?
role UserRole @default(ADMIN)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Связь с логами аудита
auditLogs AuditLog[]
id String @id @default(cuid())
firstName String
lastName String
email String @unique
password String
avatar String?
role UserRole @default(ADMIN)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
auditLogs AuditLog[]
balanceChanges ClientBalanceHistory[]
managedClients Client[]
productHistory ProductHistory[]
managedClients Client[] // Клиенты, которыми управляет менеджер
balanceChanges ClientBalanceHistory[] // История изменений баланса
@@map("users")
}
@ -31,103 +29,103 @@ model User {
model AuditLog {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
action AuditAction
details String?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("audit_logs")
}
// Модели каталога товаров
model Category {
id String @id @default(cuid())
name String
slug String @unique
description String?
seoTitle String?
seoDescription String?
image String?
isHidden Boolean @default(false)
includeSubcategoryProducts Boolean @default(false)
parentId String?
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Category[] @relation("CategoryHierarchy")
products Product[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
slug String @unique
description String?
seoTitle String?
seoDescription String?
image String?
isHidden Boolean @default(false)
includeSubcategoryProducts Boolean @default(false)
parentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
children Category[] @relation("CategoryHierarchy")
products Product[] @relation("CategoryToProduct")
@@map("categories")
}
model Product {
id String @id @default(cuid())
name String
slug String @unique
article String? @unique
description String?
videoUrl String?
wholesalePrice Float?
retailPrice Float?
weight Float?
dimensions String? // ДхШхВ в формате "10x20x30"
unit String @default("шт")
isVisible Boolean @default(true)
applyDiscounts Boolean @default(true)
stock Int @default(0)
// Связи
categories Category[]
images ProductImage[]
options ProductOption[]
characteristics ProductCharacteristic[]
relatedProducts Product[] @relation("RelatedProducts")
relatedTo Product[] @relation("RelatedProducts")
accessoryProducts Product[] @relation("AccessoryProducts")
accessoryTo Product[] @relation("AccessoryProducts")
history ProductHistory[]
orderItems OrderItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
slug String @unique
article String? @unique
description String?
videoUrl String?
wholesalePrice Float?
retailPrice Float?
weight Float?
dimensions String?
unit String @default("шт")
isVisible Boolean @default(true)
applyDiscounts Boolean @default(true)
stock Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
brand String?
bestPriceProducts BestPriceProduct?
dailyProducts DailyProduct[]
topSalesProducts TopSalesProduct?
orderItems OrderItem[]
characteristics ProductCharacteristic[]
history ProductHistory[]
images ProductImage[]
options ProductOption[]
products_AccessoryProducts_A Product[] @relation("AccessoryProducts")
products_AccessoryProducts_B Product[] @relation("AccessoryProducts")
categories Category[] @relation("CategoryToProduct")
products_RelatedProducts_A Product[] @relation("RelatedProducts")
products_RelatedProducts_B Product[] @relation("RelatedProducts")
@@map("products")
}
model ProductImage {
id String @id @default(cuid())
id String @id @default(cuid())
url String
alt String?
order Int @default(0)
order Int @default(0)
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("product_images")
}
model Option {
id String @id @default(cuid())
name String @unique
type OptionType @default(SINGLE)
values OptionValue[]
products ProductOption[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String @unique
type OptionType @default(SINGLE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
values OptionValue[]
products ProductOption[]
@@map("options")
}
model OptionValue {
id String @id @default(cuid())
id String @id @default(cuid())
value String
price Float @default(0)
price Float @default(0)
optionId String
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
products ProductOption[]
createdAt DateTime @default(now())
@@map("option_values")
}
@ -135,22 +133,22 @@ model OptionValue {
model ProductOption {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
optionId String
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
optionValueId String
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
optionValue OptionValue @relation(fields: [optionValueId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([productId, optionId, optionValueId])
@@map("product_options")
}
model Characteristic {
id String @id @default(cuid())
name String @unique
id String @id @default(cuid())
name String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
products ProductCharacteristic[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("characteristics")
}
@ -159,9 +157,9 @@ model ProductCharacteristic {
id String @id @default(cuid())
value String
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
characteristicId String
characteristic Characteristic @relation(fields: [characteristicId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([productId, characteristicId])
@@map("product_characteristics")
@ -170,16 +168,526 @@ model ProductCharacteristic {
model ProductHistory {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
action String // CREATE, UPDATE, DELETE
changes Json? // JSON с изменениями
action String
changes Json?
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id])
@@map("product_history")
}
model Client {
id String @id @default(cuid())
clientNumber String @unique
type ClientType @default(INDIVIDUAL)
name String
email String?
phone String
city String?
markup Float? @default(0)
isConfirmed Boolean @default(false)
profileId String?
managerId String?
balance Float @default(0)
comment String?
emailNotifications Boolean @default(true)
smsNotifications Boolean @default(true)
pushNotifications Boolean @default(false)
legalEntityType String?
legalEntityName String?
inn String?
kpp String?
ogrn String?
okpo String?
legalAddress String?
actualAddress String?
bankAccount String?
bankName String?
bankBik String?
correspondentAccount String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
balanceHistory ClientBalanceHistory[]
bankDetails ClientBankDetails[]
contacts ClientContact[]
contracts ClientContract[]
deliveryAddresses ClientDeliveryAddress[]
discounts ClientDiscount[]
legalEntities ClientLegalEntity[]
vehicles ClientVehicle[]
manager User? @relation(fields: [managerId], references: [id])
profile ClientProfile? @relation(fields: [profileId], references: [id])
favorites Favorite[]
orders Order[]
partsSearchHistory PartsSearchHistory[]
@@map("clients")
}
model Favorite {
id String @id @default(cuid())
clientId String
productId String?
offerKey String?
name String
brand String
article String
price Float?
currency String?
image String?
createdAt DateTime @default(now())
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@unique([clientId, productId, offerKey, article, brand])
@@map("favorites")
}
model ClientProfile {
id String @id @default(cuid())
code String @unique
name String @unique
description String?
baseMarkup Float @default(0)
autoSendInvoice Boolean @default(true)
vinRequestModule Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
clients Client[]
discountProfiles DiscountProfile[]
brandMarkups ProfileBrandMarkup[]
categoryMarkups ProfileCategoryMarkup[]
excludedBrands ProfileExcludedBrand[]
excludedCategories ProfileExcludedCategory[]
orderDiscounts ProfileOrderDiscount[]
paymentTypes ProfilePaymentType[]
priceRangeMarkups ProfilePriceRangeMarkup[]
supplierMarkups ProfileSupplierMarkup[]
@@map("client_profiles")
}
model ProfilePriceRangeMarkup {
id String @id @default(cuid())
profileId String
priceFrom Float
priceTo Float
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_price_range_markups")
}
model ProfileOrderDiscount {
id String @id @default(cuid())
profileId String
minOrderSum Float
discountType DiscountType @default(PERCENTAGE)
discountValue Float
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_order_discounts")
}
model ProfileSupplierMarkup {
id String @id @default(cuid())
profileId String
supplierName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_supplier_markups")
}
model ProfileBrandMarkup {
id String @id @default(cuid())
profileId String
brandName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_brand_markups")
}
model ProfileCategoryMarkup {
id String @id @default(cuid())
profileId String
categoryName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_category_markups")
}
model ProfileExcludedBrand {
id String @id @default(cuid())
profileId String
brandName String
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_excluded_brands")
}
model ProfileExcludedCategory {
id String @id @default(cuid())
profileId String
categoryName String
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_excluded_categories")
}
model ProfilePaymentType {
id String @id @default(cuid())
profileId String
paymentType PaymentType
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@map("profile_payment_types")
}
model ClientVehicle {
id String @id @default(cuid())
clientId String
name String
vin String?
frame String?
licensePlate String?
brand String?
model String?
modification String?
year Int?
mileage Int?
comment String?
createdAt DateTime @default(now())
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_vehicles")
}
model PartsSearchHistory {
id String @id @default(cuid())
clientId String
searchQuery String
searchType SearchType
brand String?
articleNumber String?
vehicleBrand String?
vehicleModel String?
vehicleYear Int?
resultCount Int @default(0)
createdAt DateTime @default(now())
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("parts_search_history")
}
model ClientDeliveryAddress {
id String @id @default(cuid())
clientId String
name String
address String
deliveryType DeliveryType @default(COURIER)
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
apartment String?
contactPhone String?
deliveryTime String?
entrance String?
floor String?
intercom String?
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_delivery_addresses")
}
model ClientContact {
id String @id @default(cuid())
clientId String
phone String?
email String?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_contacts")
}
model ClientContract {
id String @id @default(cuid())
clientId String
contractNumber String
contractDate DateTime
name String
ourLegalEntity String
clientLegalEntity String
balance Float @default(0)
currency String @default("RUB")
isActive Boolean @default(true)
isDefault Boolean @default(false)
contractType String
relationship String
paymentDelay Boolean @default(false)
creditLimit Float?
delayDays Int?
fileUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
balanceInvoices BalanceInvoice[]
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_contracts")
}
model BalanceInvoice {
id String @id @default(cuid())
contractId String
amount Float
currency String @default("RUB")
status InvoiceStatus @default(PENDING)
invoiceNumber String @unique
qrCode String
pdfUrl String?
paymentUrl String?
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
contract ClientContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
@@map("balance_invoices")
}
model ClientLegalEntity {
id String @id @default(cuid())
clientId String
shortName String
fullName String
form String
legalAddress String
actualAddress String?
taxSystem String
responsiblePhone String?
responsiblePosition String?
responsibleName String?
accountant String?
signatory String?
registrationReasonCode String?
ogrn String?
inn String
vatPercent Float @default(20)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bankDetails ClientBankDetails[]
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_legal_entities")
}
model ClientBankDetails {
id String @id @default(cuid())
clientId String
legalEntityId String?
name String
accountNumber String
bankName String
bik String
correspondentAccount String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
legalEntity ClientLegalEntity? @relation(fields: [legalEntityId], references: [id])
@@map("client_bank_details")
}
model ClientBalanceHistory {
id String @id @default(cuid())
clientId String
userId String?
oldValue Float
newValue Float
comment String?
createdAt DateTime @default(now())
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id])
@@map("client_balance_history")
}
model ClientDiscount {
id String @id @default(cuid())
clientId String
name String
type DiscountType
value Float
isActive Boolean @default(true)
validFrom DateTime?
validTo DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
@@map("client_discounts")
}
model ClientStatus {
id String @id @default(cuid())
name String @unique
color String @default("#6B7280")
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_statuses")
}
model Discount {
id String @id @default(cuid())
name String
type DiscountCodeType @default(DISCOUNT)
code String? @unique
minOrderAmount Float? @default(0)
discountType DiscountType @default(PERCENTAGE)
discountValue Float
isActive Boolean @default(true)
validFrom DateTime?
validTo DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profiles DiscountProfile[]
@@map("discounts")
}
model DiscountProfile {
id String @id @default(cuid())
discountId String
profileId String
createdAt DateTime @default(now())
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@unique([discountId, profileId])
@@map("discount_profiles")
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
clientId String?
clientEmail String?
clientPhone String?
clientName String?
status OrderStatus @default(PENDING)
totalAmount Float
discountAmount Float @default(0)
finalAmount Float
currency String @default("RUB")
deliveryAddress String?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items OrderItem[]
client Client? @relation(fields: [clientId], references: [id])
payments Payment[]
@@map("orders")
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String?
externalId String?
name String
article String?
brand String?
price Float
quantity Int
totalPrice Float
createdAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id])
@@map("order_items")
}
model Payment {
id String @id @default(cuid())
orderId String
yookassaPaymentId String @unique
status PaymentStatus @default(PENDING)
amount Float
currency String @default("RUB")
paymentMethod String?
description String?
confirmationUrl String?
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
paidAt DateTime?
canceledAt DateTime?
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@map("payments")
}
model DailyProduct {
id String @id @default(cuid())
productId String
displayDate DateTime
discount Float?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([productId, displayDate])
@@map("daily_products")
}
model BestPriceProduct {
id String @id @default(cuid())
productId String @unique
discount Float?
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("best_price_products")
}
model TopSalesProduct {
id String @id @default(cuid())
productId String @unique
isActive Boolean @default(true)
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@map("top_sales_products")
}
enum UserRole {
ADMIN
MODERATOR
@ -208,591 +716,72 @@ enum OptionType {
MULTIPLE
}
// Модели для клиентов
model Client {
id String @id @default(cuid())
clientNumber String @unique
type ClientType @default(INDIVIDUAL)
name String
email String?
phone String
city String?
markup Float? @default(0)
isConfirmed Boolean @default(false)
profileId String?
profile ClientProfile? @relation(fields: [profileId], references: [id])
managerId String? // Личный менеджер
manager User? @relation(fields: [managerId], references: [id])
balance Float @default(0)
comment String?
// Уведомления
emailNotifications Boolean @default(true)
smsNotifications Boolean @default(true)
pushNotifications Boolean @default(false)
// Поля для юридических лиц
legalEntityType String? // ООО, ИП, АО и т.д.
legalEntityName String? // Наименование юрлица
inn String?
kpp String?
ogrn String?
okpo String?
legalAddress String?
actualAddress String?
bankAccount String?
bankName String?
bankBik String?
correspondentAccount String?
// Связи
vehicles ClientVehicle[]
discounts ClientDiscount[]
deliveryAddresses ClientDeliveryAddress[]
contacts ClientContact[]
contracts ClientContract[]
legalEntities ClientLegalEntity[]
bankDetails ClientBankDetails[]
balanceHistory ClientBalanceHistory[]
orders Order[]
partsSearchHistory PartsSearchHistory[]
favorites Favorite[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("clients")
}
// Модель для избранных товаров
model Favorite {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
// Данные о товаре - для внешних товаров (AutoEuro, PartsAPI)
productId String? // ID товара во внешней системе или внутренний ID
offerKey String? // Ключ предложения (для AutoEuro)
name String // Название товара
brand String // Бренд
article String // Артикул
price Float? // Цена (может отсутствовать)
currency String? // Валюта
image String? // URL изображения
createdAt DateTime @default(now())
// Уникальность по клиенту и комбинации идентификаторов товара
@@unique([clientId, productId, offerKey, article, brand])
@@map("favorites")
}
model ClientProfile {
id String @id @default(cuid())
code String @unique
name String @unique
description String?
baseMarkup Float @default(0)
autoSendInvoice Boolean @default(true)
vinRequestModule Boolean @default(false)
clients Client[]
// Связи с дополнительными настройками
priceRangeMarkups ProfilePriceRangeMarkup[]
orderDiscounts ProfileOrderDiscount[]
supplierMarkups ProfileSupplierMarkup[]
brandMarkups ProfileBrandMarkup[]
categoryMarkups ProfileCategoryMarkup[]
excludedBrands ProfileExcludedBrand[]
excludedCategories ProfileExcludedCategory[]
paymentTypes ProfilePaymentType[]
discountProfiles DiscountProfile[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_profiles")
}
// Наценки от стоимости товара
model ProfilePriceRangeMarkup {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
priceFrom Float
priceTo Float
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
@@map("profile_price_range_markups")
}
// Скидки от суммы заказа
model ProfileOrderDiscount {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
minOrderSum Float
discountType DiscountType @default(PERCENTAGE)
discountValue Float
createdAt DateTime @default(now())
@@map("profile_order_discounts")
}
// Наценки на поставщиков
model ProfileSupplierMarkup {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
supplierName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
@@map("profile_supplier_markups")
}
// Наценки на бренды
model ProfileBrandMarkup {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
brandName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
@@map("profile_brand_markups")
}
// Наценки на категории товаров
model ProfileCategoryMarkup {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
categoryName String
markupType MarkupType @default(PERCENTAGE)
markupValue Float
createdAt DateTime @default(now())
@@map("profile_category_markups")
}
// Исключенные бренды
model ProfileExcludedBrand {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
brandName String
createdAt DateTime @default(now())
@@map("profile_excluded_brands")
}
// Исключенные категории
model ProfileExcludedCategory {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
categoryName String
createdAt DateTime @default(now())
@@map("profile_excluded_categories")
}
// Типы платежей для профиля
model ProfilePaymentType {
id String @id @default(cuid())
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
paymentType PaymentType
isEnabled Boolean @default(true)
createdAt DateTime @default(now())
@@map("profile_payment_types")
}
enum MarkupType {
PERCENTAGE // Процентная наценка
FIXED_AMOUNT // Фиксированная сумма
PERCENTAGE
FIXED_AMOUNT
}
enum PaymentType {
CASH // Наличные
CARD // Банковская карта
BANK_TRANSFER // Банковский перевод
ONLINE // Онлайн платежи
CREDIT // В кредит
}
model ClientVehicle {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
name String // Название авто
vin String?
frame String?
licensePlate String?
brand String?
model String?
modification String?
year Int?
mileage Int?
comment String?
createdAt DateTime @default(now())
@@map("client_vehicles")
}
// История поиска запчастей
model PartsSearchHistory {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
searchQuery String // Поисковый запрос
searchType SearchType // Тип поиска
brand String? // Бренд (если искали по бренду)
articleNumber String? // Артикул (если искали по артикулу)
// Информация об автомобиле (если поиск был для конкретного авто)
vehicleBrand String?
vehicleModel String?
vehicleYear Int?
resultCount Int @default(0) // Количество найденных результатов
createdAt DateTime @default(now())
@@map("parts_search_history")
CASH
CARD
BANK_TRANSFER
ONLINE
CREDIT
}
enum SearchType {
TEXT // Текстовый поиск
ARTICLE // Поиск по артикулу
OEM // Поиск по OEM номеру
VIN // Поиск автомобиля по VIN/Frame
PLATE // Поиск автомобиля по госномеру
WIZARD // Поиск автомобиля по параметрам
PART_VEHICLES // Поиск автомобилей по артикулу детали
}
// Адреса доставки
model ClientDeliveryAddress {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
name String // Название адреса
address String // Полный адрес
deliveryType DeliveryType @default(COURIER)
comment String?
// Дополнительные поля для курьерской доставки
entrance String? // Подъезд
floor String? // Этаж
apartment String? // Квартира/офис
intercom String? // Домофон
deliveryTime String? // Желаемое время доставки
contactPhone String? // Контактный телефон
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_delivery_addresses")
}
// Контакты
model ClientContact {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
phone String?
email String?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_contacts")
}
// Договоры
model ClientContract {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
contractNumber String
contractDate DateTime
name String
ourLegalEntity String // Наше ЮЛ
clientLegalEntity String // ЮЛ клиента
balance Float @default(0)
currency String @default("RUB")
isActive Boolean @default(true)
isDefault Boolean @default(false)
contractType String // Тип договора
relationship String // Отношение
paymentDelay Boolean @default(false)
creditLimit Float?
delayDays Int?
fileUrl String? // Ссылка на файл договора
balanceInvoices BalanceInvoice[] // Счета на пополнение баланса
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_contracts")
}
// Счета на пополнение баланса
model BalanceInvoice {
id String @id @default(cuid())
contractId String
contract ClientContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
amount Float
currency String @default("RUB")
status InvoiceStatus @default(PENDING)
invoiceNumber String @unique
qrCode String // QR код для оплаты
pdfUrl String? // Ссылка на PDF счета
paymentUrl String? // Ссылка на оплату
expiresAt DateTime // Срок действия счета
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("balance_invoices")
TEXT
ARTICLE
OEM
VIN
PLATE
WIZARD
PART_VEHICLES
}
enum InvoiceStatus {
PENDING // Ожидает оплаты
PAID // Оплачен
EXPIRED // Просрочен
CANCELLED // Отменен
}
// Юридические лица клиента
model ClientLegalEntity {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
shortName String // Короткое наименование
fullName String // Полное наименование
form String // Форма (ООО, ИП и т.д.)
legalAddress String // Юридический адрес
actualAddress String? // Фактический адрес
taxSystem String // Система налогообложения
responsiblePhone String? // Телефон ответственного
responsiblePosition String? // Должность ответственного
responsibleName String? // ФИО ответственного
accountant String? // Бухгалтер
signatory String? // Подписант
registrationReasonCode String? // Код причины постановки на учет
ogrn String? // ОГРН
inn String // ИНН
vatPercent Float @default(20) // НДС в процентах
bankDetails ClientBankDetails[] // Банковские реквизиты
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_legal_entities")
}
// Банковские реквизиты
model ClientBankDetails {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
legalEntityId String?
legalEntity ClientLegalEntity? @relation(fields: [legalEntityId], references: [id])
name String // Название реквизитов
accountNumber String // Расчетный счет
bankName String // Наименование банка
bik String // БИК
correspondentAccount String // Корреспондентский счет
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_bank_details")
}
// История изменения баланса
model ClientBalanceHistory {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
oldValue Float
newValue Float
comment String?
createdAt DateTime @default(now())
@@map("client_balance_history")
}
model ClientDiscount {
id String @id @default(cuid())
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
name String
type DiscountType
value Float // процент или фиксированная сумма
isActive Boolean @default(true)
validFrom DateTime?
validTo DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_discounts")
}
model ClientStatus {
id String @id @default(cuid())
name String @unique
color String @default("#6B7280")
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("client_statuses")
PENDING
PAID
EXPIRED
CANCELLED
}
enum ClientType {
INDIVIDUAL // Физическое лицо
LEGAL_ENTITY // Юридическое лицо
INDIVIDUAL
LEGAL_ENTITY
}
enum DiscountType {
PERCENTAGE // Процентная скидка
FIXED_AMOUNT // Фиксированная сумма
}
// Модели для скидок и промокодов
model Discount {
id String @id @default(cuid())
name String
type DiscountCodeType @default(DISCOUNT)
code String? @unique // Промокод (если есть)
minOrderAmount Float? @default(0)
discountType DiscountType @default(PERCENTAGE)
discountValue Float
isActive Boolean @default(true)
validFrom DateTime?
validTo DateTime?
// Связи с профилями
profiles DiscountProfile[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("discounts")
}
// Связь скидок с профилями клиентов
model DiscountProfile {
id String @id @default(cuid())
discountId String
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
profileId String
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([discountId, profileId])
@@map("discount_profiles")
PERCENTAGE
FIXED_AMOUNT
}
enum DiscountCodeType {
DISCOUNT // Обычная скидка
PROMOCODE // Промокод
DISCOUNT
PROMOCODE
}
enum DeliveryType {
COURIER // Курьер
PICKUP // Самовывоз
POST // Почта России
TRANSPORT // Транспортная компания
}
// Модели для заказов и платежей
model Order {
id String @id @default(cuid())
orderNumber String @unique
clientId String?
client Client? @relation(fields: [clientId], references: [id], onDelete: SetNull)
clientEmail String? // Для гостевых заказов
clientPhone String? // Для гостевых заказов
clientName String? // Для гостевых заказов
status OrderStatus @default(PENDING)
totalAmount Float
discountAmount Float @default(0)
finalAmount Float // totalAmount - discountAmount
currency String @default("RUB")
items OrderItem[]
payments Payment[]
deliveryAddress String?
comment String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("orders")
}
model OrderItem {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
productId String? // Для внутренних товаров
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
// Для внешних товаров (AutoEuro)
externalId String? // ID товара во внешней системе
name String // Название товара
article String? // Артикул
brand String? // Бренд
price Float // Цена за единицу
quantity Int // Количество
totalPrice Float // price * quantity
createdAt DateTime @default(now())
@@map("order_items")
}
model Payment {
id String @id @default(cuid())
orderId String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
yookassaPaymentId String @unique // ID платежа в YooKassa
status PaymentStatus @default(PENDING)
amount Float
currency String @default("RUB")
paymentMethod String? // Способ оплаты
description String?
confirmationUrl String? // URL для подтверждения платежа
// Метаданные от YooKassa
metadata Json?
// Даты
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
paidAt DateTime? // Дата успешной оплаты
canceledAt DateTime? // Дата отмены
@@map("payments")
COURIER
PICKUP
POST
TRANSPORT
}
enum OrderStatus {
PENDING // Ожидает оплаты
PAID // Оплачен
PROCESSING // В обработке
SHIPPED // Отправлен
DELIVERED // Доставлен
CANCELED // Отменен
REFUNDED // Возвращен
PENDING
PAID
PROCESSING
SHIPPED
DELIVERED
CANCELED
REFUNDED
}
enum PaymentStatus {
PENDING // Ожидает оплаты
WAITING_FOR_CAPTURE // Ожидает подтверждения
SUCCEEDED // Успешно оплачен
CANCELED // Отменен
REFUNDED // Возвращен
PENDING
WAITING_FOR_CAPTURE
SUCCEEDED
CANCELED
REFUNDED
}

View File

@ -0,0 +1,390 @@
"use client"
import { useState } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Plus,
Search,
Edit,
Trash2,
Package,
Star
} from 'lucide-react'
import { GET_BEST_PRICE_PRODUCTS, GET_PRODUCTS } from '@/lib/graphql/queries'
import { CREATE_BEST_PRICE_PRODUCT, UPDATE_BEST_PRICE_PRODUCT, DELETE_BEST_PRICE_PRODUCT } from '@/lib/graphql/mutations'
import toast from 'react-hot-toast'
interface BestPriceProduct {
id: string
productId: string
discount: number
isActive: boolean
sortOrder: number
product: {
id: string
name: string
article?: string
brand?: string
retailPrice?: number
images: { url: string; alt?: string }[]
}
}
interface Product {
id: string
name: string
article?: string
brand?: string
retailPrice?: number
images: { url: string; alt?: string }[]
}
export default function BestPriceProductsPage() {
const [showProductSelector, setShowProductSelector] = useState(false)
const [editingBestPriceProduct, setEditingBestPriceProduct] = useState<BestPriceProduct | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [discount, setDiscount] = useState<number>(0)
const { data: bestPriceProductsData, loading: bestPriceProductsLoading, refetch: refetchBestPriceProducts } = useQuery(GET_BEST_PRICE_PRODUCTS)
const { data: productsData, loading: productsLoading } = useQuery(GET_PRODUCTS, {
variables: {
search: searchQuery || undefined,
limit: 50
},
skip: !showProductSelector
})
const [createBestPriceProduct, { loading: creating }] = useMutation(CREATE_BEST_PRICE_PRODUCT)
const [updateBestPriceProduct, { loading: updating }] = useMutation(UPDATE_BEST_PRICE_PRODUCT)
const [deleteBestPriceProduct, { loading: deleting }] = useMutation(DELETE_BEST_PRICE_PRODUCT)
const bestPriceProducts: BestPriceProduct[] = bestPriceProductsData?.bestPriceProducts || []
const products: Product[] = productsData?.products || []
const handleAddProduct = async (productId: string) => {
if (!discount || discount <= 0) {
toast.error('Укажите размер скидки больше 0%')
return
}
try {
await createBestPriceProduct({
variables: {
input: {
productId,
discount,
isActive: true,
sortOrder: bestPriceProducts.length
}
}
})
toast.success('Товар добавлен в лучшие цены!')
setShowProductSelector(false)
setDiscount(0)
refetchBestPriceProducts()
} catch (error) {
console.error('Ошибка добавления товара:', error)
toast.error('Не удалось добавить товар')
}
}
const handleEditProduct = (bestPriceProduct: BestPriceProduct) => {
setEditingBestPriceProduct(bestPriceProduct)
setDiscount(bestPriceProduct.discount || 0)
}
const handleUpdateProduct = async () => {
if (!editingBestPriceProduct) return
if (!discount || discount <= 0) {
toast.error('Укажите размер скидки больше 0%')
return
}
try {
await updateBestPriceProduct({
variables: {
id: editingBestPriceProduct.id,
input: {
discount,
isActive: editingBestPriceProduct.isActive
}
}
})
toast.success('Товар обновлен!')
setEditingBestPriceProduct(null)
setDiscount(0)
refetchBestPriceProducts()
} catch (error) {
console.error('Ошибка обновления товара:', error)
toast.error('Не удалось обновить товар')
}
}
const handleDeleteProduct = async (id: string) => {
if (!confirm('Удалить товар из списка товаров с лучшей ценой?')) return
try {
await deleteBestPriceProduct({
variables: { id }
})
toast.success('Товар удален!')
refetchBestPriceProducts()
} catch (error) {
console.error('Ошибка удаления товара:', error)
toast.error('Не удалось удалить товар')
}
}
const formatPrice = (price?: number) => {
if (!price) return '—'
return `${price.toLocaleString('ru-RU')}`
}
const calculateDiscountedPrice = (price?: number, discount?: number) => {
if (!price || !discount) return price
return price * (1 - discount / 100)
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Товары с лучшей ценой</h1>
<p className="text-gray-600">Управление товарами с лучшими ценами, которые показываются на главной странице сайта</p>
</div>
{/* Товары с лучшей ценой */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<Star className="w-5 h-5 mr-2 text-yellow-500" />
Товары с лучшей ценой
</CardTitle>
<Button
onClick={() => setShowProductSelector(true)}
className="flex items-center"
>
<Plus className="w-4 h-4 mr-2" />
Добавить товар
</Button>
</div>
</CardHeader>
<CardContent>
{bestPriceProductsLoading ? (
<div className="text-center py-8 text-gray-500">Загрузка товаров...</div>
) : bestPriceProducts.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Товары с лучшей ценой не добавлены
</div>
) : (
<div className="space-y-4">
{bestPriceProducts.map((bestPriceProduct) => (
<div key={bestPriceProduct.id} className="border rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
{/* Изображение товара */}
<div className="w-16 h-16 bg-gray-100 rounded border flex items-center justify-center">
{bestPriceProduct.product.images?.[0] ? (
<img
src={bestPriceProduct.product.images[0].url}
alt={bestPriceProduct.product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<span className="text-xs text-gray-400">Нет фото</span>
)}
</div>
{/* Информация о товаре */}
<div className="flex-1">
<h3 className="font-medium text-gray-900">{bestPriceProduct.product.name}</h3>
<div className="text-sm text-gray-500 space-x-4">
{bestPriceProduct.product.article && (
<span>Артикул: {bestPriceProduct.product.article}</span>
)}
{bestPriceProduct.product.brand && (
<span>Бренд: {bestPriceProduct.product.brand}</span>
)}
</div>
<div className="flex items-center space-x-2 mt-1">
<span className="text-lg font-medium text-green-600">
от {formatPrice(calculateDiscountedPrice(bestPriceProduct.product.retailPrice, bestPriceProduct.discount))}
</span>
<span className="text-sm text-gray-500 line-through">
{formatPrice(bestPriceProduct.product.retailPrice)}
</span>
<span className="bg-red-100 text-red-800 text-xs px-2 py-1 rounded">
-{bestPriceProduct.discount}%
</span>
</div>
</div>
</div>
{/* Действия */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditProduct(bestPriceProduct)}
style={{ cursor: 'pointer' }}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteProduct(bestPriceProduct.id)}
disabled={deleting}
style={{ cursor: 'pointer' }}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Модальное окно выбора товара */}
<Dialog open={showProductSelector} onOpenChange={setShowProductSelector}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Добавить товар с лучшей ценой</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Поиск товаров */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Поиск товаров по названию, артикулу, бренду..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Скидка */}
<div>
<Label htmlFor="discount">Скидка (%) *</Label>
<Input
id="discount"
type="number"
min="1"
max="99"
value={discount}
onChange={(e) => setDiscount(Number(e.target.value))}
placeholder="Введите размер скидки"
className="w-32"
required
/>
<p className="text-sm text-gray-500 mt-1">Обязательное поле для товаров с лучшей ценой</p>
</div>
{/* Список товаров */}
{productsLoading ? (
<div className="text-center py-8 text-gray-500">Загрузка товаров...</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{products.map((product) => (
<div key={product.id} className="border rounded-lg p-3 flex items-center justify-between hover:bg-gray-50">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-gray-100 rounded border flex items-center justify-center">
{product.images?.[0] ? (
<img
src={product.images[0].url}
alt={product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<span className="text-xs text-gray-400">Нет фото</span>
)}
</div>
<div>
<h4 className="font-medium text-gray-900">{product.name}</h4>
<div className="text-sm text-gray-500">
{product.article && <span>Артикул: {product.article}</span>}
{product.brand && <span className="ml-2">Бренд: {product.brand}</span>}
<span className="ml-2">Цена: {formatPrice(product.retailPrice)}</span>
</div>
</div>
</div>
<Button
size="sm"
onClick={() => handleAddProduct(product.id)}
disabled={creating || bestPriceProducts.some(bp => bp.productId === product.id)}
style={{ cursor: 'pointer' }}
>
{bestPriceProducts.some(bp => bp.productId === product.id) ? 'Уже добавлен' : 'Добавить'}
</Button>
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Модальное окно редактирования */}
<Dialog open={!!editingBestPriceProduct} onOpenChange={() => setEditingBestPriceProduct(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Редактировать товар с лучшей ценой</DialogTitle>
</DialogHeader>
{editingBestPriceProduct && (
<div className="space-y-4">
<div>
<h4 className="font-medium">{editingBestPriceProduct.product.name}</h4>
<p className="text-sm text-gray-500">
{editingBestPriceProduct.product.article && `Артикул: ${editingBestPriceProduct.product.article}`}
{editingBestPriceProduct.product.brand && ` • Бренд: ${editingBestPriceProduct.product.brand}`}
</p>
</div>
<div>
<Label htmlFor="edit-discount">Скидка (%) *</Label>
<Input
id="edit-discount"
type="number"
min="1"
max="99"
value={discount}
onChange={(e) => setDiscount(Number(e.target.value))}
placeholder="Введите размер скидки"
className="w-32"
required
/>
</div>
<div className="flex items-center space-x-2 pt-4">
<Button onClick={handleUpdateProduct} disabled={updating} style={{ cursor: 'pointer' }}>
{updating ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button variant="outline" onClick={() => setEditingBestPriceProduct(null)} style={{ cursor: 'pointer' }}>
Отмена
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,418 @@
"use client"
import { useState } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Calendar,
Plus,
Search,
Edit,
Trash2,
Package
} from 'lucide-react'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { GET_DAILY_PRODUCTS, GET_PRODUCTS } from '@/lib/graphql/queries'
import { CREATE_DAILY_PRODUCT, UPDATE_DAILY_PRODUCT, DELETE_DAILY_PRODUCT } from '@/lib/graphql/mutations'
import toast from 'react-hot-toast'
interface DailyProduct {
id: string
productId: string
displayDate: string
discount?: number
isActive: boolean
sortOrder: number
product: {
id: string
name: string
article?: string
brand?: string
retailPrice?: number
images: { url: string; alt?: string }[]
}
}
interface Product {
id: string
name: string
article?: string
brand?: string
retailPrice?: number
images: { url: string; alt?: string }[]
}
export default function HomepageProductsPage() {
const [selectedDate, setSelectedDate] = useState<string>(format(new Date(), 'yyyy-MM-dd'))
const [showProductSelector, setShowProductSelector] = useState(false)
const [editingDailyProduct, setEditingDailyProduct] = useState<DailyProduct | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [discount, setDiscount] = useState<number>(0)
const { data: dailyProductsData, loading: dailyProductsLoading, refetch: refetchDailyProducts } = useQuery(GET_DAILY_PRODUCTS, {
variables: { displayDate: selectedDate }
})
const { data: productsData, loading: productsLoading } = useQuery(GET_PRODUCTS, {
variables: {
search: searchQuery || undefined,
limit: 50
},
skip: !showProductSelector
})
const [createDailyProduct, { loading: creating }] = useMutation(CREATE_DAILY_PRODUCT)
const [updateDailyProduct, { loading: updating }] = useMutation(UPDATE_DAILY_PRODUCT)
const [deleteDailyProduct, { loading: deleting }] = useMutation(DELETE_DAILY_PRODUCT)
const dailyProducts: DailyProduct[] = dailyProductsData?.dailyProducts || []
const products: Product[] = productsData?.products || []
const handleAddProduct = async (productId: string) => {
try {
await createDailyProduct({
variables: {
input: {
productId,
displayDate: selectedDate,
discount: discount || null,
isActive: true,
sortOrder: dailyProducts.length
}
}
})
toast.success('Товар добавлен!')
setShowProductSelector(false)
setDiscount(0)
refetchDailyProducts()
} catch (error) {
console.error('Ошибка добавления товара:', error)
toast.error('Не удалось добавить товар')
}
}
const handleEditProduct = (dailyProduct: DailyProduct) => {
setEditingDailyProduct(dailyProduct)
setDiscount(dailyProduct.discount || 0)
}
const handleUpdateProduct = async () => {
if (!editingDailyProduct) return
try {
await updateDailyProduct({
variables: {
id: editingDailyProduct.id,
input: {
discount: discount || null,
isActive: editingDailyProduct.isActive
}
}
})
toast.success('Товар обновлен!')
setEditingDailyProduct(null)
setDiscount(0)
refetchDailyProducts()
} catch (error) {
console.error('Ошибка обновления товара:', error)
toast.error('Не удалось обновить товар')
}
}
const handleDeleteProduct = async (id: string) => {
if (!confirm('Удалить товар из списка товаров дня?')) return
try {
await deleteDailyProduct({
variables: { id }
})
toast.success('Товар удален!')
refetchDailyProducts()
} catch (error) {
console.error('Ошибка удаления товара:', error)
toast.error('Не удалось удалить товар')
}
}
const formatPrice = (price?: number) => {
if (!price) return '—'
return `${price.toLocaleString('ru-RU')}`
}
const calculateDiscountedPrice = (price?: number, discount?: number) => {
if (!price || !discount) return price
return price * (1 - discount / 100)
}
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Товары главной страницы</h1>
<p className="text-gray-600">Управление товарами дня, которые показываются на главной странице сайта</p>
</div>
{/* Выбор даты */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="flex items-center">
<Calendar className="w-5 h-5 mr-2" />
Выбор даты показа
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-4">
<div>
<Label htmlFor="date">Дата показа товаров</Label>
<Input
id="date"
type="date"
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
className="w-48"
/>
</div>
<div className="pt-6">
<p className="text-sm text-gray-500">
Выбранная дата: {format(new Date(selectedDate), 'dd MMMM yyyy', { locale: ru })}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Товары дня */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<Package className="w-5 h-5 mr-2" />
Товары дня на {format(new Date(selectedDate), 'dd.MM.yyyy')}
</CardTitle>
<Button
onClick={() => setShowProductSelector(true)}
className="flex items-center"
>
<Plus className="w-4 h-4 mr-2" />
Добавить товар
</Button>
</div>
</CardHeader>
<CardContent>
{dailyProductsLoading ? (
<div className="text-center py-8 text-gray-500">Загрузка товаров...</div>
) : dailyProducts.length === 0 ? (
<div className="text-center py-8 text-gray-500">
На выбранную дату товары не добавлены
</div>
) : (
<div className="space-y-4">
{dailyProducts.map((dailyProduct) => (
<div key={dailyProduct.id} className="border rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
{/* Изображение товара */}
<div className="w-16 h-16 bg-gray-100 rounded border flex items-center justify-center">
{dailyProduct.product.images?.[0] ? (
<img
src={dailyProduct.product.images[0].url}
alt={dailyProduct.product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<span className="text-xs text-gray-400">Нет фото</span>
)}
</div>
{/* Информация о товаре */}
<div className="flex-1">
<h3 className="font-medium text-gray-900">{dailyProduct.product.name}</h3>
<div className="text-sm text-gray-500 space-x-4">
{dailyProduct.product.article && (
<span>Артикул: {dailyProduct.product.article}</span>
)}
{dailyProduct.product.brand && (
<span>Бренд: {dailyProduct.product.brand}</span>
)}
</div>
<div className="flex items-center space-x-2 mt-1">
{dailyProduct.discount ? (
<>
<span className="text-lg font-medium text-green-600">
от {formatPrice(calculateDiscountedPrice(dailyProduct.product.retailPrice, dailyProduct.discount))}
</span>
<span className="text-sm text-gray-500 line-through">
{formatPrice(dailyProduct.product.retailPrice)}
</span>
<span className="bg-red-100 text-red-800 text-xs px-2 py-1 rounded">
-{dailyProduct.discount}%
</span>
</>
) : (
<span className="text-lg font-medium">
от {formatPrice(dailyProduct.product.retailPrice)}
</span>
)}
</div>
</div>
</div>
{/* Действия */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditProduct(dailyProduct)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteProduct(dailyProduct.id)}
disabled={deleting}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Модальное окно выбора товара */}
<Dialog open={showProductSelector} onOpenChange={setShowProductSelector}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Добавить товар дня</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Поиск товаров */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Поиск товаров по названию, артикулу, бренду..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* Скидка */}
<div>
<Label htmlFor="discount">Скидка (%)</Label>
<Input
id="discount"
type="number"
min="0"
max="99"
value={discount}
onChange={(e) => setDiscount(Number(e.target.value))}
placeholder="Введите размер скидки"
className="w-32"
/>
</div>
{/* Список товаров */}
{productsLoading ? (
<div className="text-center py-8 text-gray-500">Загрузка товаров...</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{products.map((product) => (
<div key={product.id} className="border rounded-lg p-3 flex items-center justify-between hover:bg-gray-50">
<div className="flex items-center space-x-3">
<div className="w-12 h-12 bg-gray-100 rounded border flex items-center justify-center">
{product.images?.[0] ? (
<img
src={product.images[0].url}
alt={product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<span className="text-xs text-gray-400">Нет фото</span>
)}
</div>
<div>
<h4 className="font-medium text-gray-900">{product.name}</h4>
<div className="text-sm text-gray-500">
{product.article && <span>Артикул: {product.article}</span>}
{product.brand && <span className="ml-2">Бренд: {product.brand}</span>}
<span className="ml-2">Цена: {formatPrice(product.retailPrice)}</span>
</div>
</div>
</div>
<Button
size="sm"
onClick={() => handleAddProduct(product.id)}
disabled={creating || dailyProducts.some(dp => dp.productId === product.id)}
>
{dailyProducts.some(dp => dp.productId === product.id) ? 'Уже добавлен' : 'Добавить'}
</Button>
</div>
))}
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Модальное окно редактирования */}
<Dialog open={!!editingDailyProduct} onOpenChange={() => setEditingDailyProduct(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Редактировать товар дня</DialogTitle>
</DialogHeader>
{editingDailyProduct && (
<div className="space-y-4">
<div>
<h4 className="font-medium">{editingDailyProduct.product.name}</h4>
<p className="text-sm text-gray-500">
{editingDailyProduct.product.article && `Артикул: ${editingDailyProduct.product.article}`}
{editingDailyProduct.product.brand && ` • Бренд: ${editingDailyProduct.product.brand}`}
</p>
</div>
<div>
<Label htmlFor="edit-discount">Скидка (%)</Label>
<Input
id="edit-discount"
type="number"
min="0"
max="99"
value={discount}
onChange={(e) => setDiscount(Number(e.target.value))}
placeholder="Введите размер скидки"
className="w-32"
/>
</div>
<div className="flex items-center space-x-2 pt-4">
<Button onClick={handleUpdateProduct} disabled={updating}>
{updating ? 'Сохранение...' : 'Сохранить'}
</Button>
<Button variant="outline" onClick={() => setEditingDailyProduct(null)}>
Отмена
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,436 @@
'use client'
import React, { useState } from 'react'
import { useQuery, useMutation } from '@apollo/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Trash2, Plus, Search, Edit, ChevronUp, ChevronDown } from 'lucide-react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { GET_TOP_SALES_PRODUCTS, GET_PRODUCTS } from '@/lib/graphql/queries'
import {
CREATE_TOP_SALES_PRODUCT,
UPDATE_TOP_SALES_PRODUCT,
DELETE_TOP_SALES_PRODUCT,
} from '@/lib/graphql/mutations'
interface Product {
id: string
name: string
article?: string
brand?: string
retailPrice?: number
images: { url: string; alt?: string }[]
}
interface TopSalesProduct {
id: string
productId: string
isActive: boolean
sortOrder: number
product: Product
createdAt: string
updatedAt: string
}
interface TopSalesProductInput {
productId: string
isActive?: boolean
sortOrder?: number
}
interface TopSalesProductUpdateInput {
isActive?: boolean
sortOrder?: number
}
export default function TopSalesProductsPage() {
const [searchTerm, setSearchTerm] = useState('')
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
const [editingItem, setEditingItem] = useState<TopSalesProduct | null>(null)
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
// Загружаем топ продаж
const { data: topSalesData, loading: topSalesLoading, refetch: refetchTopSales } = useQuery<{
topSalesProducts: TopSalesProduct[]
}>(GET_TOP_SALES_PRODUCTS)
// Загружаем все товары для поиска
const { data: productsData, loading: productsLoading } = useQuery<{
products: Product[]
}>(GET_PRODUCTS, {
variables: { limit: 100 }
})
// Мутации
const [createTopSalesProduct] = useMutation<
{ createTopSalesProduct: TopSalesProduct },
{ input: TopSalesProductInput }
>(CREATE_TOP_SALES_PRODUCT, {
onCompleted: () => {
toast.success('Товар добавлен в топ продаж')
refetchTopSales()
setIsAddDialogOpen(false)
setSelectedProduct(null)
},
onError: (error) => {
toast.error(`Ошибка: ${error.message}`)
}
})
const [updateTopSalesProduct] = useMutation<
{ updateTopSalesProduct: TopSalesProduct },
{ id: string; input: TopSalesProductUpdateInput }
>(UPDATE_TOP_SALES_PRODUCT, {
onCompleted: () => {
toast.success('Товар обновлен')
refetchTopSales()
setIsEditDialogOpen(false)
setEditingItem(null)
},
onError: (error) => {
toast.error(`Ошибка: ${error.message}`)
}
})
const [deleteTopSalesProduct] = useMutation<
{ deleteTopSalesProduct: boolean },
{ id: string }
>(DELETE_TOP_SALES_PRODUCT, {
onCompleted: () => {
toast.success('Товар удален из топ продаж')
refetchTopSales()
},
onError: (error) => {
toast.error(`Ошибка: ${error.message}`)
}
})
// Фильтрация товаров для поиска
const filteredProducts = productsData?.products?.filter(product =>
product.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.article?.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.brand?.toLowerCase().includes(searchTerm.toLowerCase())
) || []
// Обработчики
const handleAddProduct = () => {
if (!selectedProduct) {
toast.error('Выберите товар')
return
}
createTopSalesProduct({
variables: {
input: {
productId: selectedProduct.id,
isActive: true,
sortOrder: 0
}
}
})
}
const handleUpdateProduct = (isActive: boolean, sortOrder: number) => {
if (!editingItem) return
updateTopSalesProduct({
variables: {
id: editingItem.id,
input: {
isActive,
sortOrder
}
}
})
}
const handleDeleteProduct = (id: string) => {
if (confirm('Вы уверены, что хотите удалить этот товар из топ продаж?')) {
deleteTopSalesProduct({
variables: { id }
})
}
}
const handleToggleActive = (item: TopSalesProduct) => {
updateTopSalesProduct({
variables: {
id: item.id,
input: {
isActive: !item.isActive,
sortOrder: item.sortOrder
}
}
})
}
const handleSortOrderChange = (item: TopSalesProduct, direction: 'up' | 'down') => {
const newSortOrder = direction === 'up' ? item.sortOrder - 1 : item.sortOrder + 1
updateTopSalesProduct({
variables: {
id: item.id,
input: {
isActive: item.isActive,
sortOrder: Math.max(0, newSortOrder)
}
}
})
}
if (topSalesLoading) {
return <div className="p-6">Загрузка...</div>
}
const topSalesProducts = topSalesData?.topSalesProducts || []
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Топ продаж</h1>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" />
Добавить товар
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Добавить товар в топ продаж</DialogTitle>
<DialogDescription>
Найдите и выберите товар для добавления в топ продаж
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<Input
placeholder="Поиск товаров..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{productsLoading ? (
<div>Загрузка товаров...</div>
) : (
<div className="max-h-96 overflow-y-auto space-y-2">
{filteredProducts.map((product) => (
<div
key={product.id}
className={`p-3 border rounded cursor-pointer transition-colors ${
selectedProduct?.id === product.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
onClick={() => setSelectedProduct(product)}
>
<div className="flex items-center space-x-3">
{product.images?.[0] && (
<img
src={product.images[0].url}
alt={product.images[0].alt || product.name}
className="w-12 h-12 object-cover rounded"
/>
)}
<div className="flex-1">
<h3 className="font-medium">{product.name}</h3>
<p className="text-sm text-gray-500">
{product.brand} {product.article}
</p>
{product.retailPrice && (
<p className="text-sm font-medium">
{product.retailPrice.toLocaleString('ru-RU')}
</p>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
Отмена
</Button>
<Button onClick={handleAddProduct} disabled={!selectedProduct}>
Добавить в топ продаж
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Список топ продаж */}
<div className="space-y-4">
{topSalesProducts.length === 0 ? (
<Card>
<CardContent className="p-6 text-center">
<p className="text-gray-500">Нет товаров в топ продаж</p>
</CardContent>
</Card>
) : (
[...topSalesProducts]
.sort((a, b) => a.sortOrder - b.sortOrder)
.map((item) => (
<Card key={item.id}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{item.product.images?.[0] && (
<img
src={item.product.images[0].url}
alt={item.product.images[0].alt || item.product.name}
className="w-16 h-16 object-cover rounded"
/>
)}
<div>
<h3 className="font-medium text-lg">{item.product.name}</h3>
<p className="text-gray-600">
{item.product.brand} {item.product.article}
</p>
{item.product.retailPrice && (
<p className="font-medium">
{item.product.retailPrice.toLocaleString('ru-RU')}
</p>
)}
<div className="flex items-center space-x-2 mt-2">
<Badge variant={item.isActive ? 'default' : 'secondary'}>
{item.isActive ? 'Активен' : 'Неактивен'}
</Badge>
<Badge variant="outline">Порядок: {item.sortOrder}</Badge>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
{/* Управление порядком */}
<div className="flex flex-col">
<Button
variant="outline"
size="sm"
onClick={() => handleSortOrderChange(item, 'up')}
disabled={item.sortOrder === 0}
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleSortOrderChange(item, 'down')}
>
<ChevronDown className="w-4 h-4" />
</Button>
</div>
{/* Переключатель активности */}
<div className="flex items-center space-x-2">
<Label htmlFor={`active-${item.id}`}>Активен</Label>
<Switch
id={`active-${item.id}`}
checked={item.isActive}
onCheckedChange={() => handleToggleActive(item)}
/>
</div>
{/* Кнопка редактирования */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => setEditingItem(item)}
>
<Edit className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Редактировать товар</DialogTitle>
</DialogHeader>
{editingItem && (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Label htmlFor="edit-active">Активен</Label>
<Switch
id="edit-active"
checked={editingItem.isActive}
onCheckedChange={(checked) =>
setEditingItem({ ...editingItem, isActive: checked })
}
/>
</div>
<div>
<Label htmlFor="edit-sort-order">Порядок сортировки</Label>
<Input
id="edit-sort-order"
type="number"
min="0"
value={editingItem.sortOrder}
onChange={(e) =>
setEditingItem({
...editingItem,
sortOrder: parseInt(e.target.value) || 0
})
}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>
Отмена
</Button>
<Button
onClick={() => {
if (editingItem) {
handleUpdateProduct(editingItem.isActive, editingItem.sortOrder)
}
}}
>
Сохранить
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Кнопка удаления */}
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteProduct(item.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
)
}

View File

@ -13,7 +13,8 @@ import {
UserCheck,
ShoppingCart,
Receipt,
Palette
Palette,
Star
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/components/providers/AuthProvider'
@ -33,6 +34,21 @@ const navigationItems = [
href: '/dashboard/catalog',
icon: Package,
},
{
title: 'Товары главной',
href: '/dashboard/homepage-products',
icon: Star,
},
{
title: 'Лучшие цены',
href: '/dashboard/best-price-products',
icon: Star,
},
{
title: 'Топ продаж',
href: '/dashboard/top-sales-products',
icon: Star,
},
{
title: 'Заказы',
href: '/dashboard/orders',

View File

@ -1166,3 +1166,178 @@ export const GET_DELIVERY_OFFERS = gql`
}
}
`
// Daily Products mutations
export const CREATE_DAILY_PRODUCT = gql`
mutation CreateDailyProduct($input: DailyProductInput!) {
createDailyProduct(input: $input) {
id
productId
displayDate
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const UPDATE_DAILY_PRODUCT = gql`
mutation UpdateDailyProduct($id: ID!, $input: DailyProductUpdateInput!) {
updateDailyProduct(id: $id, input: $input) {
id
productId
displayDate
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const DELETE_DAILY_PRODUCT = gql`
mutation DeleteDailyProduct($id: ID!) {
deleteDailyProduct(id: $id)
}
`
export const CREATE_BEST_PRICE_PRODUCT = gql`
mutation CreateBestPriceProduct($input: BestPriceProductInput!) {
createBestPriceProduct(input: $input) {
id
productId
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const UPDATE_BEST_PRICE_PRODUCT = gql`
mutation UpdateBestPriceProduct($id: ID!, $input: BestPriceProductInput!) {
updateBestPriceProduct(id: $id, input: $input) {
id
productId
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const DELETE_BEST_PRICE_PRODUCT = gql`
mutation DeleteBestPriceProduct($id: ID!) {
deleteBestPriceProduct(id: $id)
}
`
export const CREATE_TOP_SALES_PRODUCT = gql`
mutation CreateTopSalesProduct($input: TopSalesProductInput!) {
createTopSalesProduct(input: $input) {
id
productId
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const UPDATE_TOP_SALES_PRODUCT = gql`
mutation UpdateTopSalesProduct($id: ID!, $input: TopSalesProductUpdateInput!) {
updateTopSalesProduct(id: $id, input: $input) {
id
productId
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const DELETE_TOP_SALES_PRODUCT = gql`
mutation DeleteTopSalesProduct($id: ID!) {
deleteTopSalesProduct(id: $id)
}
`

View File

@ -200,6 +200,169 @@ export const ADMIN_CHANGE_PASSWORD = gql`
}
`
// Daily Products queries
export const GET_DAILY_PRODUCTS = gql`
query GetDailyProducts($displayDate: String!) {
dailyProducts(displayDate: $displayDate) {
id
productId
displayDate
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const GET_DAILY_PRODUCT = gql`
query GetDailyProduct($id: ID!) {
dailyProduct(id: $id) {
id
productId
displayDate
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const GET_BEST_PRICE_PRODUCTS = gql`
query GetBestPriceProducts {
bestPriceProducts {
id
productId
discount
isActive
sortOrder
product {
id
name
slug
article
brand
retailPrice
wholesalePrice
images {
id
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const GET_BEST_PRICE_PRODUCT = gql`
query GetBestPriceProduct($id: ID!) {
bestPriceProduct(id: $id) {
id
productId
discount
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const GET_TOP_SALES_PRODUCTS = gql`
query GetTopSalesProducts {
topSalesProducts {
id
productId
isActive
sortOrder
product {
id
name
slug
article
brand
retailPrice
wholesalePrice
images {
id
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const GET_TOP_SALES_PRODUCT = gql`
query GetTopSalesProduct($id: ID!) {
topSalesProduct(id: $id) {
id
productId
isActive
sortOrder
product {
id
name
article
brand
retailPrice
images {
url
alt
order
}
}
createdAt
updatedAt
}
}
`
export const UPLOAD_AVATAR = gql`
mutation UploadAvatar($file: String!) {
uploadAvatar(file: $file) {

View File

@ -408,6 +408,44 @@ interface FavoriteInput {
image?: string
}
interface DailyProductInput {
productId: string
displayDate: string
discount?: number
isActive?: boolean
sortOrder?: number
}
interface DailyProductUpdateInput {
discount?: number
isActive?: boolean
sortOrder?: number
}
interface BestPriceProductInput {
productId: string
discount?: number
isActive?: boolean
sortOrder?: number
}
interface BestPriceProductUpdateInput {
discount?: number
isActive?: boolean
sortOrder?: number
}
interface TopSalesProductInput {
productId: string
isActive?: boolean
sortOrder?: number
}
interface TopSalesProductUpdateInput {
isActive?: boolean
sortOrder?: number
}
// Утилиты
const createSlug = (text: string): string => {
return text
@ -3500,6 +3538,229 @@ export const resolvers = {
console.error('Ошибка получения предложений адресов:', error)
return []
}
},
// Daily Products queries
dailyProducts: async (_: unknown, { displayDate }: { displayDate: string }) => {
try {
return await prisma.dailyProduct.findMany({
where: {
displayDate: new Date(displayDate),
isActive: true
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
},
orderBy: { sortOrder: 'asc' }
})
} catch (error) {
console.error('Ошибка получения товаров дня:', error)
throw new Error('Не удалось получить товары дня')
}
},
dailyProduct: async (_: unknown, { id }: { id: string }) => {
try {
return await prisma.dailyProduct.findUnique({
where: { id },
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
} catch (error) {
console.error('Ошибка получения товара дня:', error)
throw new Error('Не удалось получить товар дня')
}
},
// Best Price Products queries
bestPriceProducts: async () => {
try {
const bestPriceProducts = await prisma.bestPriceProduct.findMany({
where: { isActive: true },
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
},
orderBy: { sortOrder: 'asc' }
})
// Для товаров без изображений пытаемся получить их из PartsIndex
const productsWithImages = await Promise.all(
bestPriceProducts.map(async (bestPriceProduct) => {
const product = bestPriceProduct.product
// Если у товара уже есть изображения, возвращаем как есть
if (product.images && product.images.length > 0) {
return bestPriceProduct
}
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
if (product.article && product.brand) {
try {
const partsIndexEntity = await partsIndexService.searchEntityByCode(
product.article,
product.brand
)
if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) {
// Создаем временные изображения для отображения (не сохраняем в БД)
const partsIndexImages = partsIndexEntity.images.slice(0, 3).map((imageUrl, index) => ({
id: `partsindex-${product.id}-${index}`,
url: imageUrl,
alt: product.name,
order: index,
productId: product.id
}))
return {
...bestPriceProduct,
product: {
...product,
images: partsIndexImages
}
}
}
} catch (error) {
console.error(`Ошибка получения изображений из PartsIndex для товара ${product.id}:`, error)
}
}
return bestPriceProduct
})
)
return productsWithImages
} catch (error) {
console.error('Ошибка получения товаров с лучшей ценой:', error)
throw new Error('Не удалось получить товары с лучшей ценой')
}
},
bestPriceProduct: async (_: unknown, { id }: { id: string }) => {
try {
return await prisma.bestPriceProduct.findUnique({
where: { id },
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
} catch (error) {
console.error('Ошибка получения товара с лучшей ценой:', error)
throw new Error('Не удалось получить товар с лучшей ценой')
}
},
// Top Sales Products queries
topSalesProducts: async () => {
try {
const topSalesProducts = await prisma.topSalesProduct.findMany({
where: { isActive: true },
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
},
orderBy: { sortOrder: 'asc' }
})
// Для товаров без изображений пытаемся получить их из PartsIndex
const productsWithImages = await Promise.all(
topSalesProducts.map(async (topSalesProduct) => {
const product = topSalesProduct.product
// Если у товара уже есть изображения, возвращаем как есть
if (product.images && product.images.length > 0) {
return topSalesProduct
}
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
if (product.article && product.brand) {
try {
const partsIndexEntity = await partsIndexService.searchEntityByCode(
product.article,
product.brand
)
if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) {
// Создаем временные изображения для отображения (не сохраняем в БД)
const partsIndexImages = partsIndexEntity.images.slice(0, 3).map((imageUrl, index) => ({
id: `partsindex-${product.id}-${index}`,
url: imageUrl,
alt: product.name,
order: index,
productId: product.id
}))
return {
...topSalesProduct,
product: {
...product,
images: partsIndexImages
}
}
}
} catch (error) {
console.error(`Ошибка получения изображений из PartsIndex для товара ${product.id}:`, error)
}
}
return topSalesProduct
})
)
return productsWithImages
} catch (error) {
console.error('Ошибка получения топ продаж:', error)
throw new Error('Не удалось получить топ продаж')
}
},
topSalesProduct: async (_: unknown, { id }: { id: string }) => {
try {
return await prisma.topSalesProduct.findUnique({
where: { id },
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
} catch (error) {
console.error('Ошибка получения товара из топ продаж:', error)
throw new Error('Не удалось получить товар из топ продаж')
}
}
},
@ -3522,6 +3783,18 @@ export const resolvers = {
}
},
DailyProduct: {
product: async (parent: { productId: string }) => {
return await prisma.product.findUnique({
where: { id: parent.productId },
include: {
images: { orderBy: { order: 'asc' } },
categories: true
}
})
}
},
Mutation: {
createUser: async (_: unknown, { input }: { input: CreateUserInput }, context: Context) => {
try {
@ -7999,6 +8272,380 @@ export const resolvers = {
offers: fallbackOffers
}
}
},
// Daily Products mutations
createDailyProduct: async (_: unknown, { input }: { input: DailyProductInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар
const product = await prisma.product.findUnique({
where: { id: input.productId }
})
if (!product) {
throw new Error('Товар не найден')
}
// Создаем товар дня
const dailyProduct = await prisma.dailyProduct.create({
data: {
productId: input.productId,
displayDate: new Date(input.displayDate),
discount: input.discount,
isActive: input.isActive ?? true,
sortOrder: input.sortOrder ?? 0
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return dailyProduct
} catch (error) {
console.error('Ошибка создания товара дня:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось создать товар дня')
}
},
updateDailyProduct: async (_: unknown, { id, input }: { id: string; input: DailyProductUpdateInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар дня
const existingDailyProduct = await prisma.dailyProduct.findUnique({
where: { id }
})
if (!existingDailyProduct) {
throw new Error('Товар дня не найден')
}
// Обновляем товар дня
const dailyProduct = await prisma.dailyProduct.update({
where: { id },
data: {
...(input.discount !== undefined && { discount: input.discount }),
...(input.isActive !== undefined && { isActive: input.isActive }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder })
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return dailyProduct
} catch (error) {
console.error('Ошибка обновления товара дня:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось обновить товар дня')
}
},
deleteDailyProduct: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар дня
const existingDailyProduct = await prisma.dailyProduct.findUnique({
where: { id }
})
if (!existingDailyProduct) {
throw new Error('Товар дня не найден')
}
// Удаляем товар дня
await prisma.dailyProduct.delete({
where: { id }
})
return true
} catch (error) {
console.error('Ошибка удаления товара дня:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить товар дня')
}
},
// Best Price Products mutations
createBestPriceProduct: async (_: unknown, { input }: { input: BestPriceProductInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар
const product = await prisma.product.findUnique({
where: { id: input.productId }
})
if (!product) {
throw new Error('Товар не найден')
}
// Проверяем, что товар еще не добавлен в список лучших цен
const existingBestPriceProduct = await prisma.bestPriceProduct.findUnique({
where: { productId: input.productId }
})
if (existingBestPriceProduct) {
throw new Error('Товар уже добавлен в список лучших цен')
}
// Создаем товар с лучшей ценой
const bestPriceProduct = await prisma.bestPriceProduct.create({
data: {
productId: input.productId,
discount: input.discount || 0,
isActive: input.isActive ?? true,
sortOrder: input.sortOrder ?? 0
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return bestPriceProduct
} catch (error) {
console.error('Ошибка создания товара с лучшей ценой:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось создать товар с лучшей ценой')
}
},
updateBestPriceProduct: async (_: unknown, { id, input }: { id: string; input: BestPriceProductUpdateInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар с лучшей ценой
const existingBestPriceProduct = await prisma.bestPriceProduct.findUnique({
where: { id }
})
if (!existingBestPriceProduct) {
throw new Error('Товар с лучшей ценой не найден')
}
// Обновляем товар с лучшей ценой
const bestPriceProduct = await prisma.bestPriceProduct.update({
where: { id },
data: {
...(input.discount !== undefined && { discount: input.discount }),
...(input.isActive !== undefined && { isActive: input.isActive }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder })
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return bestPriceProduct
} catch (error) {
console.error('Ошибка обновления товара с лучшей ценой:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось обновить товар с лучшей ценой')
}
},
deleteBestPriceProduct: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар с лучшей ценой
const existingBestPriceProduct = await prisma.bestPriceProduct.findUnique({
where: { id }
})
if (!existingBestPriceProduct) {
throw new Error('Товар с лучшей ценой не найден')
}
// Удаляем товар с лучшей ценой
await prisma.bestPriceProduct.delete({
where: { id }
})
return true
} catch (error) {
console.error('Ошибка удаления товара с лучшей ценой:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить товар с лучшей ценой')
}
},
// Top Sales Products mutations
createTopSalesProduct: async (_: unknown, { input }: { input: TopSalesProductInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар
const product = await prisma.product.findUnique({
where: { id: input.productId }
})
if (!product) {
throw new Error('Товар не найден')
}
// Проверяем, что товар еще не добавлен в топ продаж
const existingTopSalesProduct = await prisma.topSalesProduct.findUnique({
where: { productId: input.productId }
})
if (existingTopSalesProduct) {
throw new Error('Товар уже добавлен в топ продаж')
}
// Создаем товар в топ продаж
const topSalesProduct = await prisma.topSalesProduct.create({
data: {
productId: input.productId,
isActive: input.isActive ?? true,
sortOrder: input.sortOrder ?? 0
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return topSalesProduct
} catch (error) {
console.error('Ошибка создания товара в топ продаж:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось создать товар в топ продаж')
}
},
updateTopSalesProduct: async (_: unknown, { id, input }: { id: string; input: TopSalesProductUpdateInput }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар в топ продаж
const existingTopSalesProduct = await prisma.topSalesProduct.findUnique({
where: { id }
})
if (!existingTopSalesProduct) {
throw new Error('Товар в топ продаж не найден')
}
// Обновляем товар в топ продаж
const topSalesProduct = await prisma.topSalesProduct.update({
where: { id },
data: {
...(input.isActive !== undefined && { isActive: input.isActive }),
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder })
},
include: {
product: {
include: {
images: {
orderBy: { order: 'asc' }
}
}
}
}
})
return topSalesProduct
} catch (error) {
console.error('Ошибка обновления товара в топ продаж:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось обновить товар в топ продаж')
}
},
deleteTopSalesProduct: async (_: unknown, { id }: { id: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Проверяем, существует ли товар в топ продаж
const existingTopSalesProduct = await prisma.topSalesProduct.findUnique({
where: { id }
})
if (!existingTopSalesProduct) {
throw new Error('Товар в топ продаж не найден')
}
// Удаляем товар из топ продаж
await prisma.topSalesProduct.delete({
where: { id }
})
return true
} catch (error) {
console.error('Ошибка удаления товара из топ продаж:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось удалить товар из топ продаж')
}
}
}
}

View File

@ -79,6 +79,7 @@ export const typeDefs = gql`
slug: String!
article: String
description: String
brand: String
videoUrl: String
wholesalePrice: Float
retailPrice: Float
@ -604,6 +605,7 @@ export const typeDefs = gql`
slug: String
article: String
description: String
brand: String
videoUrl: String
wholesalePrice: Float
retailPrice: Float
@ -1011,6 +1013,18 @@ export const typeDefs = gql`
# Автокомплит адресов
addressSuggestions(query: String!): [String!]!
# Товары дня
dailyProducts(displayDate: String!): [DailyProduct!]!
dailyProduct(id: ID!): DailyProduct
# Товары с лучшей ценой
bestPriceProducts: [BestPriceProduct!]!
bestPriceProduct(id: ID!): BestPriceProduct
# Топ продаж
topSalesProducts: [TopSalesProduct!]!
topSalesProduct(id: ID!): TopSalesProduct
}
type AuthPayload {
@ -1191,6 +1205,21 @@ export const typeDefs = gql`
# Доставка Яндекс
getDeliveryOffers(input: DeliveryOffersInput!): DeliveryOffersResponse!
# Товары дня
createDailyProduct(input: DailyProductInput!): DailyProduct!
updateDailyProduct(id: ID!, input: DailyProductUpdateInput!): DailyProduct!
deleteDailyProduct(id: ID!): Boolean!
# Товары с лучшей ценой
createBestPriceProduct(input: BestPriceProductInput!): BestPriceProduct!
updateBestPriceProduct(id: ID!, input: BestPriceProductUpdateInput!): BestPriceProduct!
deleteBestPriceProduct(id: ID!): Boolean!
# Топ продаж
createTopSalesProduct(input: TopSalesProductInput!): TopSalesProduct!
updateTopSalesProduct(id: ID!, input: TopSalesProductUpdateInput!): TopSalesProduct!
deleteTopSalesProduct(id: ID!): Boolean!
}
input LoginInput {
@ -2157,4 +2186,78 @@ export const typeDefs = gql`
minPrice: Float
hasOffers: Boolean!
}
# Типы для товаров дня
type DailyProduct {
id: ID!
productId: String!
product: Product!
displayDate: String!
discount: Float
isActive: Boolean!
sortOrder: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
input DailyProductInput {
productId: String!
displayDate: String!
discount: Float
isActive: Boolean
sortOrder: Int
}
input DailyProductUpdateInput {
discount: Float
isActive: Boolean
sortOrder: Int
}
# Типы для товаров с лучшей ценой
type BestPriceProduct {
id: ID!
productId: String!
product: Product!
discount: Float!
isActive: Boolean!
sortOrder: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
input BestPriceProductInput {
productId: String!
discount: Float!
isActive: Boolean
sortOrder: Int
}
input BestPriceProductUpdateInput {
discount: Float
isActive: Boolean
sortOrder: Int
}
# Типы для топ продаж
type TopSalesProduct {
id: ID!
productId: String!
product: Product!
isActive: Boolean!
sortOrder: Int!
createdAt: DateTime!
updatedAt: DateTime!
}
input TopSalesProductInput {
productId: String!
isActive: Boolean
sortOrder: Int
}
input TopSalesProductUpdateInput {
isActive: Boolean
sortOrder: Int
}
`