Добавлены новые модели и мутации для управления товарами дня, лучшими ценами и топом продаж. Обновлены типы GraphQL и резолверы для обработки запросов, что улучшает функциональность и структуру данных. В боковое меню добавлены новые элементы для навигации по товарам. Это повышает удобство работы с приложением и расширяет возможности взаимодействия с API.
This commit is contained in:
1277
prisma/schema.prisma
1277
prisma/schema.prisma
@ -9,21 +9,19 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
firstName String
|
firstName String
|
||||||
lastName String
|
lastName String
|
||||||
email String @unique
|
email String @unique
|
||||||
password String
|
password String
|
||||||
avatar String?
|
avatar String?
|
||||||
role UserRole @default(ADMIN)
|
role UserRole @default(ADMIN)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
auditLogs AuditLog[]
|
||||||
// Связь с логами аудита
|
balanceChanges ClientBalanceHistory[]
|
||||||
auditLogs AuditLog[]
|
managedClients Client[]
|
||||||
productHistory ProductHistory[]
|
productHistory ProductHistory[]
|
||||||
managedClients Client[] // Клиенты, которыми управляет менеджер
|
|
||||||
balanceChanges ClientBalanceHistory[] // История изменений баланса
|
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@ -31,103 +29,103 @@ model User {
|
|||||||
model AuditLog {
|
model AuditLog {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
||||||
action AuditAction
|
action AuditAction
|
||||||
details String?
|
details String?
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("audit_logs")
|
@@map("audit_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модели каталога товаров
|
|
||||||
model Category {
|
model Category {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
description String?
|
description String?
|
||||||
seoTitle String?
|
seoTitle String?
|
||||||
seoDescription String?
|
seoDescription String?
|
||||||
image String?
|
image String?
|
||||||
isHidden Boolean @default(false)
|
isHidden Boolean @default(false)
|
||||||
includeSubcategoryProducts Boolean @default(false)
|
includeSubcategoryProducts Boolean @default(false)
|
||||||
parentId String?
|
parentId String?
|
||||||
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
|
createdAt DateTime @default(now())
|
||||||
children Category[] @relation("CategoryHierarchy")
|
updatedAt DateTime @updatedAt
|
||||||
products Product[]
|
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
children Category[] @relation("CategoryHierarchy")
|
||||||
updatedAt DateTime @updatedAt
|
products Product[] @relation("CategoryToProduct")
|
||||||
|
|
||||||
@@map("categories")
|
@@map("categories")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Product {
|
model Product {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
slug String @unique
|
slug String @unique
|
||||||
article String? @unique
|
article String? @unique
|
||||||
description String?
|
description String?
|
||||||
videoUrl String?
|
videoUrl String?
|
||||||
wholesalePrice Float?
|
wholesalePrice Float?
|
||||||
retailPrice Float?
|
retailPrice Float?
|
||||||
weight Float?
|
weight Float?
|
||||||
dimensions String? // ДхШхВ в формате "10x20x30"
|
dimensions String?
|
||||||
unit String @default("шт")
|
unit String @default("шт")
|
||||||
isVisible Boolean @default(true)
|
isVisible Boolean @default(true)
|
||||||
applyDiscounts Boolean @default(true)
|
applyDiscounts Boolean @default(true)
|
||||||
stock Int @default(0)
|
stock Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
// Связи
|
updatedAt DateTime @updatedAt
|
||||||
categories Category[]
|
brand String?
|
||||||
images ProductImage[]
|
bestPriceProducts BestPriceProduct?
|
||||||
options ProductOption[]
|
dailyProducts DailyProduct[]
|
||||||
characteristics ProductCharacteristic[]
|
topSalesProducts TopSalesProduct?
|
||||||
relatedProducts Product[] @relation("RelatedProducts")
|
orderItems OrderItem[]
|
||||||
relatedTo Product[] @relation("RelatedProducts")
|
characteristics ProductCharacteristic[]
|
||||||
accessoryProducts Product[] @relation("AccessoryProducts")
|
history ProductHistory[]
|
||||||
accessoryTo Product[] @relation("AccessoryProducts")
|
images ProductImage[]
|
||||||
history ProductHistory[]
|
options ProductOption[]
|
||||||
orderItems OrderItem[]
|
products_AccessoryProducts_A Product[] @relation("AccessoryProducts")
|
||||||
|
products_AccessoryProducts_B Product[] @relation("AccessoryProducts")
|
||||||
createdAt DateTime @default(now())
|
categories Category[] @relation("CategoryToProduct")
|
||||||
updatedAt DateTime @updatedAt
|
products_RelatedProducts_A Product[] @relation("RelatedProducts")
|
||||||
|
products_RelatedProducts_B Product[] @relation("RelatedProducts")
|
||||||
|
|
||||||
@@map("products")
|
@@map("products")
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProductImage {
|
model ProductImage {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
url String
|
url String
|
||||||
alt String?
|
alt String?
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
productId String
|
productId String
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("product_images")
|
@@map("product_images")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Option {
|
model Option {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
type OptionType @default(SINGLE)
|
type OptionType @default(SINGLE)
|
||||||
values OptionValue[]
|
createdAt DateTime @default(now())
|
||||||
products ProductOption[]
|
updatedAt DateTime @updatedAt
|
||||||
createdAt DateTime @default(now())
|
values OptionValue[]
|
||||||
updatedAt DateTime @updatedAt
|
products ProductOption[]
|
||||||
|
|
||||||
@@map("options")
|
@@map("options")
|
||||||
}
|
}
|
||||||
|
|
||||||
model OptionValue {
|
model OptionValue {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
value String
|
value String
|
||||||
price Float @default(0)
|
price Float @default(0)
|
||||||
optionId String
|
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[]
|
products ProductOption[]
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
@@map("option_values")
|
@@map("option_values")
|
||||||
}
|
}
|
||||||
@ -135,22 +133,22 @@ model OptionValue {
|
|||||||
model ProductOption {
|
model ProductOption {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
productId String
|
productId String
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
||||||
optionId String
|
optionId String
|
||||||
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
|
||||||
optionValueId String
|
optionValueId String
|
||||||
|
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||||
optionValue OptionValue @relation(fields: [optionValueId], 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])
|
@@unique([productId, optionId, optionValueId])
|
||||||
@@map("product_options")
|
@@map("product_options")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Characteristic {
|
model Characteristic {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String @unique
|
name String @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
products ProductCharacteristic[]
|
products ProductCharacteristic[]
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@map("characteristics")
|
@@map("characteristics")
|
||||||
}
|
}
|
||||||
@ -159,9 +157,9 @@ model ProductCharacteristic {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
value String
|
value String
|
||||||
productId String
|
productId String
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
||||||
characteristicId String
|
characteristicId String
|
||||||
characteristic Characteristic @relation(fields: [characteristicId], references: [id], onDelete: Cascade)
|
characteristic Characteristic @relation(fields: [characteristicId], references: [id], onDelete: Cascade)
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@unique([productId, characteristicId])
|
@@unique([productId, characteristicId])
|
||||||
@@map("product_characteristics")
|
@@map("product_characteristics")
|
||||||
@ -170,16 +168,526 @@ model ProductCharacteristic {
|
|||||||
model ProductHistory {
|
model ProductHistory {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
productId String
|
productId String
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
action String
|
||||||
action String // CREATE, UPDATE, DELETE
|
changes Json?
|
||||||
changes Json? // JSON с изменениями
|
|
||||||
userId String?
|
userId String?
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
user User? @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
@@map("product_history")
|
@@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 {
|
enum UserRole {
|
||||||
ADMIN
|
ADMIN
|
||||||
MODERATOR
|
MODERATOR
|
||||||
@ -208,591 +716,72 @@ enum OptionType {
|
|||||||
MULTIPLE
|
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 {
|
enum MarkupType {
|
||||||
PERCENTAGE // Процентная наценка
|
PERCENTAGE
|
||||||
FIXED_AMOUNT // Фиксированная сумма
|
FIXED_AMOUNT
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PaymentType {
|
enum PaymentType {
|
||||||
CASH // Наличные
|
CASH
|
||||||
CARD // Банковская карта
|
CARD
|
||||||
BANK_TRANSFER // Банковский перевод
|
BANK_TRANSFER
|
||||||
ONLINE // Онлайн платежи
|
ONLINE
|
||||||
CREDIT // В кредит
|
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SearchType {
|
enum SearchType {
|
||||||
TEXT // Текстовый поиск
|
TEXT
|
||||||
ARTICLE // Поиск по артикулу
|
ARTICLE
|
||||||
OEM // Поиск по OEM номеру
|
OEM
|
||||||
VIN // Поиск автомобиля по VIN/Frame
|
VIN
|
||||||
PLATE // Поиск автомобиля по госномеру
|
PLATE
|
||||||
WIZARD // Поиск автомобиля по параметрам
|
WIZARD
|
||||||
PART_VEHICLES // Поиск автомобилей по артикулу детали
|
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum InvoiceStatus {
|
enum InvoiceStatus {
|
||||||
PENDING // Ожидает оплаты
|
PENDING
|
||||||
PAID // Оплачен
|
PAID
|
||||||
EXPIRED // Просрочен
|
EXPIRED
|
||||||
CANCELLED // Отменен
|
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ClientType {
|
enum ClientType {
|
||||||
INDIVIDUAL // Физическое лицо
|
INDIVIDUAL
|
||||||
LEGAL_ENTITY // Юридическое лицо
|
LEGAL_ENTITY
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DiscountType {
|
enum DiscountType {
|
||||||
PERCENTAGE // Процентная скидка
|
PERCENTAGE
|
||||||
FIXED_AMOUNT // Фиксированная сумма
|
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DiscountCodeType {
|
enum DiscountCodeType {
|
||||||
DISCOUNT // Обычная скидка
|
DISCOUNT
|
||||||
PROMOCODE // Промокод
|
PROMOCODE
|
||||||
}
|
}
|
||||||
|
|
||||||
enum DeliveryType {
|
enum DeliveryType {
|
||||||
COURIER // Курьер
|
COURIER
|
||||||
PICKUP // Самовывоз
|
PICKUP
|
||||||
POST // Почта России
|
POST
|
||||||
TRANSPORT // Транспортная компания
|
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")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum OrderStatus {
|
enum OrderStatus {
|
||||||
PENDING // Ожидает оплаты
|
PENDING
|
||||||
PAID // Оплачен
|
PAID
|
||||||
PROCESSING // В обработке
|
PROCESSING
|
||||||
SHIPPED // Отправлен
|
SHIPPED
|
||||||
DELIVERED // Доставлен
|
DELIVERED
|
||||||
CANCELED // Отменен
|
CANCELED
|
||||||
REFUNDED // Возвращен
|
REFUNDED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PaymentStatus {
|
enum PaymentStatus {
|
||||||
PENDING // Ожидает оплаты
|
PENDING
|
||||||
WAITING_FOR_CAPTURE // Ожидает подтверждения
|
WAITING_FOR_CAPTURE
|
||||||
SUCCEEDED // Успешно оплачен
|
SUCCEEDED
|
||||||
CANCELED // Отменен
|
CANCELED
|
||||||
REFUNDED // Возвращен
|
REFUNDED
|
||||||
}
|
}
|
||||||
|
390
src/app/dashboard/best-price-products/page.tsx
Normal file
390
src/app/dashboard/best-price-products/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
418
src/app/dashboard/homepage-products/page.tsx
Normal file
418
src/app/dashboard/homepage-products/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
436
src/app/dashboard/top-sales-products/page.tsx
Normal file
436
src/app/dashboard/top-sales-products/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
@ -13,7 +13,8 @@ import {
|
|||||||
UserCheck,
|
UserCheck,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Receipt,
|
Receipt,
|
||||||
Palette
|
Palette,
|
||||||
|
Star
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useAuth } from '@/components/providers/AuthProvider'
|
import { useAuth } from '@/components/providers/AuthProvider'
|
||||||
@ -33,6 +34,21 @@ const navigationItems = [
|
|||||||
href: '/dashboard/catalog',
|
href: '/dashboard/catalog',
|
||||||
icon: Package,
|
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: 'Заказы',
|
title: 'Заказы',
|
||||||
href: '/dashboard/orders',
|
href: '/dashboard/orders',
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
`
|
@ -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`
|
export const UPLOAD_AVATAR = gql`
|
||||||
mutation UploadAvatar($file: String!) {
|
mutation UploadAvatar($file: String!) {
|
||||||
uploadAvatar(file: $file) {
|
uploadAvatar(file: $file) {
|
||||||
|
@ -408,6 +408,44 @@ interface FavoriteInput {
|
|||||||
image?: string
|
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 => {
|
const createSlug = (text: string): string => {
|
||||||
return text
|
return text
|
||||||
@ -3500,6 +3538,229 @@ export const resolvers = {
|
|||||||
console.error('Ошибка получения предложений адресов:', error)
|
console.error('Ошибка получения предложений адресов:', error)
|
||||||
return []
|
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: {
|
Mutation: {
|
||||||
createUser: async (_: unknown, { input }: { input: CreateUserInput }, context: Context) => {
|
createUser: async (_: unknown, { input }: { input: CreateUserInput }, context: Context) => {
|
||||||
try {
|
try {
|
||||||
@ -7999,6 +8272,380 @@ export const resolvers = {
|
|||||||
offers: fallbackOffers
|
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('Не удалось удалить товар из топ продаж')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -79,6 +79,7 @@ export const typeDefs = gql`
|
|||||||
slug: String!
|
slug: String!
|
||||||
article: String
|
article: String
|
||||||
description: String
|
description: String
|
||||||
|
brand: String
|
||||||
videoUrl: String
|
videoUrl: String
|
||||||
wholesalePrice: Float
|
wholesalePrice: Float
|
||||||
retailPrice: Float
|
retailPrice: Float
|
||||||
@ -604,6 +605,7 @@ export const typeDefs = gql`
|
|||||||
slug: String
|
slug: String
|
||||||
article: String
|
article: String
|
||||||
description: String
|
description: String
|
||||||
|
brand: String
|
||||||
videoUrl: String
|
videoUrl: String
|
||||||
wholesalePrice: Float
|
wholesalePrice: Float
|
||||||
retailPrice: Float
|
retailPrice: Float
|
||||||
@ -1011,6 +1013,18 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
# Автокомплит адресов
|
# Автокомплит адресов
|
||||||
addressSuggestions(query: String!): [String!]!
|
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 {
|
type AuthPayload {
|
||||||
@ -1191,6 +1205,21 @@ export const typeDefs = gql`
|
|||||||
|
|
||||||
# Доставка Яндекс
|
# Доставка Яндекс
|
||||||
getDeliveryOffers(input: DeliveryOffersInput!): DeliveryOffersResponse!
|
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 {
|
input LoginInput {
|
||||||
@ -2157,4 +2186,78 @@ export const typeDefs = gql`
|
|||||||
minPrice: Float
|
minPrice: Float
|
||||||
hasOffers: Boolean!
|
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
|
||||||
|
}
|
||||||
`
|
`
|
Reference in New Issue
Block a user