Добавлены новые модели и мутации для управления товарами дня, лучшими ценами и топом продаж. Обновлены типы GraphQL и резолверы для обработки запросов, что улучшает функциональность и структуру данных. В боковое меню добавлены новые элементы для навигации по товарам. Это повышает удобство работы с приложением и расширяет возможности взаимодействия с API.
This commit is contained in:
1277
prisma/schema.prisma
1277
prisma/schema.prisma
@ -9,21 +9,19 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
firstName String
|
||||
lastName String
|
||||
email String @unique
|
||||
password String
|
||||
avatar String?
|
||||
role UserRole @default(ADMIN)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Связь с логами аудита
|
||||
auditLogs AuditLog[]
|
||||
id String @id @default(cuid())
|
||||
firstName String
|
||||
lastName String
|
||||
email String @unique
|
||||
password String
|
||||
avatar String?
|
||||
role UserRole @default(ADMIN)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
auditLogs AuditLog[]
|
||||
balanceChanges ClientBalanceHistory[]
|
||||
managedClients Client[]
|
||||
productHistory ProductHistory[]
|
||||
managedClients Client[] // Клиенты, которыми управляет менеджер
|
||||
balanceChanges ClientBalanceHistory[] // История изменений баланса
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@ -31,103 +29,103 @@ model User {
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
action AuditAction
|
||||
details String?
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
// Модели каталога товаров
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String?
|
||||
seoTitle String?
|
||||
seoDescription String?
|
||||
image String?
|
||||
isHidden Boolean @default(false)
|
||||
includeSubcategoryProducts Boolean @default(false)
|
||||
parentId String?
|
||||
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
children Category[] @relation("CategoryHierarchy")
|
||||
products Product[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
description String?
|
||||
seoTitle String?
|
||||
seoDescription String?
|
||||
image String?
|
||||
isHidden Boolean @default(false)
|
||||
includeSubcategoryProducts Boolean @default(false)
|
||||
parentId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id], onDelete: Cascade)
|
||||
children Category[] @relation("CategoryHierarchy")
|
||||
products Product[] @relation("CategoryToProduct")
|
||||
|
||||
@@map("categories")
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
article String? @unique
|
||||
description String?
|
||||
videoUrl String?
|
||||
wholesalePrice Float?
|
||||
retailPrice Float?
|
||||
weight Float?
|
||||
dimensions String? // ДхШхВ в формате "10x20x30"
|
||||
unit String @default("шт")
|
||||
isVisible Boolean @default(true)
|
||||
applyDiscounts Boolean @default(true)
|
||||
stock Int @default(0)
|
||||
|
||||
// Связи
|
||||
categories Category[]
|
||||
images ProductImage[]
|
||||
options ProductOption[]
|
||||
characteristics ProductCharacteristic[]
|
||||
relatedProducts Product[] @relation("RelatedProducts")
|
||||
relatedTo Product[] @relation("RelatedProducts")
|
||||
accessoryProducts Product[] @relation("AccessoryProducts")
|
||||
accessoryTo Product[] @relation("AccessoryProducts")
|
||||
history ProductHistory[]
|
||||
orderItems OrderItem[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
slug String @unique
|
||||
article String? @unique
|
||||
description String?
|
||||
videoUrl String?
|
||||
wholesalePrice Float?
|
||||
retailPrice Float?
|
||||
weight Float?
|
||||
dimensions String?
|
||||
unit String @default("шт")
|
||||
isVisible Boolean @default(true)
|
||||
applyDiscounts Boolean @default(true)
|
||||
stock Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
brand String?
|
||||
bestPriceProducts BestPriceProduct?
|
||||
dailyProducts DailyProduct[]
|
||||
topSalesProducts TopSalesProduct?
|
||||
orderItems OrderItem[]
|
||||
characteristics ProductCharacteristic[]
|
||||
history ProductHistory[]
|
||||
images ProductImage[]
|
||||
options ProductOption[]
|
||||
products_AccessoryProducts_A Product[] @relation("AccessoryProducts")
|
||||
products_AccessoryProducts_B Product[] @relation("AccessoryProducts")
|
||||
categories Category[] @relation("CategoryToProduct")
|
||||
products_RelatedProducts_A Product[] @relation("RelatedProducts")
|
||||
products_RelatedProducts_B Product[] @relation("RelatedProducts")
|
||||
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
url String
|
||||
alt String?
|
||||
order Int @default(0)
|
||||
order Int @default(0)
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("product_images")
|
||||
}
|
||||
|
||||
model Option {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
type OptionType @default(SINGLE)
|
||||
values OptionValue[]
|
||||
products ProductOption[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
type OptionType @default(SINGLE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
values OptionValue[]
|
||||
products ProductOption[]
|
||||
|
||||
@@map("options")
|
||||
}
|
||||
|
||||
model OptionValue {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
value String
|
||||
price Float @default(0)
|
||||
price Float @default(0)
|
||||
optionId String
|
||||
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
products ProductOption[]
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("option_values")
|
||||
}
|
||||
@ -135,22 +133,22 @@ model OptionValue {
|
||||
model ProductOption {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
optionId String
|
||||
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
optionValueId String
|
||||
option Option @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||
optionValue OptionValue @relation(fields: [optionValueId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([productId, optionId, optionValueId])
|
||||
@@map("product_options")
|
||||
}
|
||||
|
||||
model Characteristic {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
products ProductCharacteristic[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("characteristics")
|
||||
}
|
||||
@ -159,9 +157,9 @@ model ProductCharacteristic {
|
||||
id String @id @default(cuid())
|
||||
value String
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
characteristicId String
|
||||
characteristic Characteristic @relation(fields: [characteristicId], references: [id], onDelete: Cascade)
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([productId, characteristicId])
|
||||
@@map("product_characteristics")
|
||||
@ -170,16 +168,526 @@ model ProductCharacteristic {
|
||||
model ProductHistory {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
action String // CREATE, UPDATE, DELETE
|
||||
changes Json? // JSON с изменениями
|
||||
action String
|
||||
changes Json?
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("product_history")
|
||||
}
|
||||
|
||||
model Client {
|
||||
id String @id @default(cuid())
|
||||
clientNumber String @unique
|
||||
type ClientType @default(INDIVIDUAL)
|
||||
name String
|
||||
email String?
|
||||
phone String
|
||||
city String?
|
||||
markup Float? @default(0)
|
||||
isConfirmed Boolean @default(false)
|
||||
profileId String?
|
||||
managerId String?
|
||||
balance Float @default(0)
|
||||
comment String?
|
||||
emailNotifications Boolean @default(true)
|
||||
smsNotifications Boolean @default(true)
|
||||
pushNotifications Boolean @default(false)
|
||||
legalEntityType String?
|
||||
legalEntityName String?
|
||||
inn String?
|
||||
kpp String?
|
||||
ogrn String?
|
||||
okpo String?
|
||||
legalAddress String?
|
||||
actualAddress String?
|
||||
bankAccount String?
|
||||
bankName String?
|
||||
bankBik String?
|
||||
correspondentAccount String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
balanceHistory ClientBalanceHistory[]
|
||||
bankDetails ClientBankDetails[]
|
||||
contacts ClientContact[]
|
||||
contracts ClientContract[]
|
||||
deliveryAddresses ClientDeliveryAddress[]
|
||||
discounts ClientDiscount[]
|
||||
legalEntities ClientLegalEntity[]
|
||||
vehicles ClientVehicle[]
|
||||
manager User? @relation(fields: [managerId], references: [id])
|
||||
profile ClientProfile? @relation(fields: [profileId], references: [id])
|
||||
favorites Favorite[]
|
||||
orders Order[]
|
||||
partsSearchHistory PartsSearchHistory[]
|
||||
|
||||
@@map("clients")
|
||||
}
|
||||
|
||||
model Favorite {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
productId String?
|
||||
offerKey String?
|
||||
name String
|
||||
brand String
|
||||
article String
|
||||
price Float?
|
||||
currency String?
|
||||
image String?
|
||||
createdAt DateTime @default(now())
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([clientId, productId, offerKey, article, brand])
|
||||
@@map("favorites")
|
||||
}
|
||||
|
||||
model ClientProfile {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String @unique
|
||||
description String?
|
||||
baseMarkup Float @default(0)
|
||||
autoSendInvoice Boolean @default(true)
|
||||
vinRequestModule Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
clients Client[]
|
||||
discountProfiles DiscountProfile[]
|
||||
brandMarkups ProfileBrandMarkup[]
|
||||
categoryMarkups ProfileCategoryMarkup[]
|
||||
excludedBrands ProfileExcludedBrand[]
|
||||
excludedCategories ProfileExcludedCategory[]
|
||||
orderDiscounts ProfileOrderDiscount[]
|
||||
paymentTypes ProfilePaymentType[]
|
||||
priceRangeMarkups ProfilePriceRangeMarkup[]
|
||||
supplierMarkups ProfileSupplierMarkup[]
|
||||
|
||||
@@map("client_profiles")
|
||||
}
|
||||
|
||||
model ProfilePriceRangeMarkup {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
priceFrom Float
|
||||
priceTo Float
|
||||
markupType MarkupType @default(PERCENTAGE)
|
||||
markupValue Float
|
||||
createdAt DateTime @default(now())
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("profile_price_range_markups")
|
||||
}
|
||||
|
||||
model ProfileOrderDiscount {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
minOrderSum Float
|
||||
discountType DiscountType @default(PERCENTAGE)
|
||||
discountValue Float
|
||||
createdAt DateTime @default(now())
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("profile_order_discounts")
|
||||
}
|
||||
|
||||
model ProfileSupplierMarkup {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
supplierName String
|
||||
markupType MarkupType @default(PERCENTAGE)
|
||||
markupValue Float
|
||||
createdAt DateTime @default(now())
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("profile_supplier_markups")
|
||||
}
|
||||
|
||||
model ProfileBrandMarkup {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
brandName String
|
||||
markupType MarkupType @default(PERCENTAGE)
|
||||
markupValue Float
|
||||
createdAt DateTime @default(now())
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("profile_brand_markups")
|
||||
}
|
||||
|
||||
model ProfileCategoryMarkup {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
categoryName String
|
||||
markupType MarkupType @default(PERCENTAGE)
|
||||
markupValue Float
|
||||
createdAt DateTime @default(now())
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("profile_category_markups")
|
||||
}
|
||||
|
||||
model ProfileExcludedBrand {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
brandName String
|
||||
createdAt DateTime @default(now())
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("profile_excluded_brands")
|
||||
}
|
||||
|
||||
model ProfileExcludedCategory {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
categoryName String
|
||||
createdAt DateTime @default(now())
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("profile_excluded_categories")
|
||||
}
|
||||
|
||||
model ProfilePaymentType {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
paymentType PaymentType
|
||||
isEnabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("profile_payment_types")
|
||||
}
|
||||
|
||||
model ClientVehicle {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
name String
|
||||
vin String?
|
||||
frame String?
|
||||
licensePlate String?
|
||||
brand String?
|
||||
model String?
|
||||
modification String?
|
||||
year Int?
|
||||
mileage Int?
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("client_vehicles")
|
||||
}
|
||||
|
||||
model PartsSearchHistory {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
searchQuery String
|
||||
searchType SearchType
|
||||
brand String?
|
||||
articleNumber String?
|
||||
vehicleBrand String?
|
||||
vehicleModel String?
|
||||
vehicleYear Int?
|
||||
resultCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("parts_search_history")
|
||||
}
|
||||
|
||||
model ClientDeliveryAddress {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
name String
|
||||
address String
|
||||
deliveryType DeliveryType @default(COURIER)
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
apartment String?
|
||||
contactPhone String?
|
||||
deliveryTime String?
|
||||
entrance String?
|
||||
floor String?
|
||||
intercom String?
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("client_delivery_addresses")
|
||||
}
|
||||
|
||||
model ClientContact {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
phone String?
|
||||
email String?
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("client_contacts")
|
||||
}
|
||||
|
||||
model ClientContract {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
contractNumber String
|
||||
contractDate DateTime
|
||||
name String
|
||||
ourLegalEntity String
|
||||
clientLegalEntity String
|
||||
balance Float @default(0)
|
||||
currency String @default("RUB")
|
||||
isActive Boolean @default(true)
|
||||
isDefault Boolean @default(false)
|
||||
contractType String
|
||||
relationship String
|
||||
paymentDelay Boolean @default(false)
|
||||
creditLimit Float?
|
||||
delayDays Int?
|
||||
fileUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
balanceInvoices BalanceInvoice[]
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("client_contracts")
|
||||
}
|
||||
|
||||
model BalanceInvoice {
|
||||
id String @id @default(cuid())
|
||||
contractId String
|
||||
amount Float
|
||||
currency String @default("RUB")
|
||||
status InvoiceStatus @default(PENDING)
|
||||
invoiceNumber String @unique
|
||||
qrCode String
|
||||
pdfUrl String?
|
||||
paymentUrl String?
|
||||
expiresAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
contract ClientContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("balance_invoices")
|
||||
}
|
||||
|
||||
model ClientLegalEntity {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
shortName String
|
||||
fullName String
|
||||
form String
|
||||
legalAddress String
|
||||
actualAddress String?
|
||||
taxSystem String
|
||||
responsiblePhone String?
|
||||
responsiblePosition String?
|
||||
responsibleName String?
|
||||
accountant String?
|
||||
signatory String?
|
||||
registrationReasonCode String?
|
||||
ogrn String?
|
||||
inn String
|
||||
vatPercent Float @default(20)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
bankDetails ClientBankDetails[]
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("client_legal_entities")
|
||||
}
|
||||
|
||||
model ClientBankDetails {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
legalEntityId String?
|
||||
name String
|
||||
accountNumber String
|
||||
bankName String
|
||||
bik String
|
||||
correspondentAccount String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
legalEntity ClientLegalEntity? @relation(fields: [legalEntityId], references: [id])
|
||||
|
||||
@@map("client_bank_details")
|
||||
}
|
||||
|
||||
model ClientBalanceHistory {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
userId String?
|
||||
oldValue Float
|
||||
newValue Float
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("client_balance_history")
|
||||
}
|
||||
|
||||
model ClientDiscount {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
name String
|
||||
type DiscountType
|
||||
value Float
|
||||
isActive Boolean @default(true)
|
||||
validFrom DateTime?
|
||||
validTo DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("client_discounts")
|
||||
}
|
||||
|
||||
model ClientStatus {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
color String @default("#6B7280")
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("client_statuses")
|
||||
}
|
||||
|
||||
model Discount {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
type DiscountCodeType @default(DISCOUNT)
|
||||
code String? @unique
|
||||
minOrderAmount Float? @default(0)
|
||||
discountType DiscountType @default(PERCENTAGE)
|
||||
discountValue Float
|
||||
isActive Boolean @default(true)
|
||||
validFrom DateTime?
|
||||
validTo DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
profiles DiscountProfile[]
|
||||
|
||||
@@map("discounts")
|
||||
}
|
||||
|
||||
model DiscountProfile {
|
||||
id String @id @default(cuid())
|
||||
discountId String
|
||||
profileId String
|
||||
createdAt DateTime @default(now())
|
||||
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([discountId, profileId])
|
||||
@@map("discount_profiles")
|
||||
}
|
||||
|
||||
model Order {
|
||||
id String @id @default(cuid())
|
||||
orderNumber String @unique
|
||||
clientId String?
|
||||
clientEmail String?
|
||||
clientPhone String?
|
||||
clientName String?
|
||||
status OrderStatus @default(PENDING)
|
||||
totalAmount Float
|
||||
discountAmount Float @default(0)
|
||||
finalAmount Float
|
||||
currency String @default("RUB")
|
||||
deliveryAddress String?
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
items OrderItem[]
|
||||
client Client? @relation(fields: [clientId], references: [id])
|
||||
payments Payment[]
|
||||
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id String @id @default(cuid())
|
||||
orderId String
|
||||
productId String?
|
||||
externalId String?
|
||||
name String
|
||||
article String?
|
||||
brand String?
|
||||
price Float
|
||||
quantity Int
|
||||
totalPrice Float
|
||||
createdAt DateTime @default(now())
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
product Product? @relation(fields: [productId], references: [id])
|
||||
|
||||
@@map("order_items")
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(cuid())
|
||||
orderId String
|
||||
yookassaPaymentId String @unique
|
||||
status PaymentStatus @default(PENDING)
|
||||
amount Float
|
||||
currency String @default("RUB")
|
||||
paymentMethod String?
|
||||
description String?
|
||||
confirmationUrl String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
paidAt DateTime?
|
||||
canceledAt DateTime?
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("payments")
|
||||
}
|
||||
|
||||
model DailyProduct {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
displayDate DateTime
|
||||
discount Float?
|
||||
isActive Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([productId, displayDate])
|
||||
@@map("daily_products")
|
||||
}
|
||||
|
||||
model BestPriceProduct {
|
||||
id String @id @default(cuid())
|
||||
productId String @unique
|
||||
discount Float?
|
||||
isActive Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("best_price_products")
|
||||
}
|
||||
|
||||
model TopSalesProduct {
|
||||
id String @id @default(cuid())
|
||||
productId String @unique
|
||||
isActive Boolean @default(true)
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("top_sales_products")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
ADMIN
|
||||
MODERATOR
|
||||
@ -208,591 +716,72 @@ enum OptionType {
|
||||
MULTIPLE
|
||||
}
|
||||
|
||||
// Модели для клиентов
|
||||
model Client {
|
||||
id String @id @default(cuid())
|
||||
clientNumber String @unique
|
||||
type ClientType @default(INDIVIDUAL)
|
||||
name String
|
||||
email String?
|
||||
phone String
|
||||
city String?
|
||||
markup Float? @default(0)
|
||||
isConfirmed Boolean @default(false)
|
||||
profileId String?
|
||||
profile ClientProfile? @relation(fields: [profileId], references: [id])
|
||||
managerId String? // Личный менеджер
|
||||
manager User? @relation(fields: [managerId], references: [id])
|
||||
balance Float @default(0)
|
||||
comment String?
|
||||
|
||||
// Уведомления
|
||||
emailNotifications Boolean @default(true)
|
||||
smsNotifications Boolean @default(true)
|
||||
pushNotifications Boolean @default(false)
|
||||
|
||||
// Поля для юридических лиц
|
||||
legalEntityType String? // ООО, ИП, АО и т.д.
|
||||
legalEntityName String? // Наименование юрлица
|
||||
inn String?
|
||||
kpp String?
|
||||
ogrn String?
|
||||
okpo String?
|
||||
legalAddress String?
|
||||
actualAddress String?
|
||||
bankAccount String?
|
||||
bankName String?
|
||||
bankBik String?
|
||||
correspondentAccount String?
|
||||
|
||||
// Связи
|
||||
vehicles ClientVehicle[]
|
||||
discounts ClientDiscount[]
|
||||
deliveryAddresses ClientDeliveryAddress[]
|
||||
contacts ClientContact[]
|
||||
contracts ClientContract[]
|
||||
legalEntities ClientLegalEntity[]
|
||||
bankDetails ClientBankDetails[]
|
||||
balanceHistory ClientBalanceHistory[]
|
||||
orders Order[]
|
||||
partsSearchHistory PartsSearchHistory[]
|
||||
favorites Favorite[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("clients")
|
||||
}
|
||||
|
||||
// Модель для избранных товаров
|
||||
model Favorite {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Данные о товаре - для внешних товаров (AutoEuro, PartsAPI)
|
||||
productId String? // ID товара во внешней системе или внутренний ID
|
||||
offerKey String? // Ключ предложения (для AutoEuro)
|
||||
name String // Название товара
|
||||
brand String // Бренд
|
||||
article String // Артикул
|
||||
price Float? // Цена (может отсутствовать)
|
||||
currency String? // Валюта
|
||||
image String? // URL изображения
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
// Уникальность по клиенту и комбинации идентификаторов товара
|
||||
@@unique([clientId, productId, offerKey, article, brand])
|
||||
@@map("favorites")
|
||||
}
|
||||
|
||||
model ClientProfile {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String @unique
|
||||
description String?
|
||||
baseMarkup Float @default(0)
|
||||
autoSendInvoice Boolean @default(true)
|
||||
vinRequestModule Boolean @default(false)
|
||||
clients Client[]
|
||||
|
||||
// Связи с дополнительными настройками
|
||||
priceRangeMarkups ProfilePriceRangeMarkup[]
|
||||
orderDiscounts ProfileOrderDiscount[]
|
||||
supplierMarkups ProfileSupplierMarkup[]
|
||||
brandMarkups ProfileBrandMarkup[]
|
||||
categoryMarkups ProfileCategoryMarkup[]
|
||||
excludedBrands ProfileExcludedBrand[]
|
||||
excludedCategories ProfileExcludedCategory[]
|
||||
paymentTypes ProfilePaymentType[]
|
||||
discountProfiles DiscountProfile[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("client_profiles")
|
||||
}
|
||||
|
||||
// Наценки от стоимости товара
|
||||
model ProfilePriceRangeMarkup {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
priceFrom Float
|
||||
priceTo Float
|
||||
markupType MarkupType @default(PERCENTAGE)
|
||||
markupValue Float
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("profile_price_range_markups")
|
||||
}
|
||||
|
||||
// Скидки от суммы заказа
|
||||
model ProfileOrderDiscount {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
minOrderSum Float
|
||||
discountType DiscountType @default(PERCENTAGE)
|
||||
discountValue Float
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("profile_order_discounts")
|
||||
}
|
||||
|
||||
// Наценки на поставщиков
|
||||
model ProfileSupplierMarkup {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
supplierName String
|
||||
markupType MarkupType @default(PERCENTAGE)
|
||||
markupValue Float
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("profile_supplier_markups")
|
||||
}
|
||||
|
||||
// Наценки на бренды
|
||||
model ProfileBrandMarkup {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
brandName String
|
||||
markupType MarkupType @default(PERCENTAGE)
|
||||
markupValue Float
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("profile_brand_markups")
|
||||
}
|
||||
|
||||
// Наценки на категории товаров
|
||||
model ProfileCategoryMarkup {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
categoryName String
|
||||
markupType MarkupType @default(PERCENTAGE)
|
||||
markupValue Float
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("profile_category_markups")
|
||||
}
|
||||
|
||||
// Исключенные бренды
|
||||
model ProfileExcludedBrand {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
brandName String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("profile_excluded_brands")
|
||||
}
|
||||
|
||||
// Исключенные категории
|
||||
model ProfileExcludedCategory {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
categoryName String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("profile_excluded_categories")
|
||||
}
|
||||
|
||||
// Типы платежей для профиля
|
||||
model ProfilePaymentType {
|
||||
id String @id @default(cuid())
|
||||
profileId String
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
paymentType PaymentType
|
||||
isEnabled Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("profile_payment_types")
|
||||
}
|
||||
|
||||
enum MarkupType {
|
||||
PERCENTAGE // Процентная наценка
|
||||
FIXED_AMOUNT // Фиксированная сумма
|
||||
PERCENTAGE
|
||||
FIXED_AMOUNT
|
||||
}
|
||||
|
||||
enum PaymentType {
|
||||
CASH // Наличные
|
||||
CARD // Банковская карта
|
||||
BANK_TRANSFER // Банковский перевод
|
||||
ONLINE // Онлайн платежи
|
||||
CREDIT // В кредит
|
||||
}
|
||||
|
||||
model ClientVehicle {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
name String // Название авто
|
||||
vin String?
|
||||
frame String?
|
||||
licensePlate String?
|
||||
brand String?
|
||||
model String?
|
||||
modification String?
|
||||
year Int?
|
||||
mileage Int?
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("client_vehicles")
|
||||
}
|
||||
|
||||
// История поиска запчастей
|
||||
model PartsSearchHistory {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
searchQuery String // Поисковый запрос
|
||||
searchType SearchType // Тип поиска
|
||||
brand String? // Бренд (если искали по бренду)
|
||||
articleNumber String? // Артикул (если искали по артикулу)
|
||||
|
||||
// Информация об автомобиле (если поиск был для конкретного авто)
|
||||
vehicleBrand String?
|
||||
vehicleModel String?
|
||||
vehicleYear Int?
|
||||
|
||||
resultCount Int @default(0) // Количество найденных результатов
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("parts_search_history")
|
||||
CASH
|
||||
CARD
|
||||
BANK_TRANSFER
|
||||
ONLINE
|
||||
CREDIT
|
||||
}
|
||||
|
||||
enum SearchType {
|
||||
TEXT // Текстовый поиск
|
||||
ARTICLE // Поиск по артикулу
|
||||
OEM // Поиск по OEM номеру
|
||||
VIN // Поиск автомобиля по VIN/Frame
|
||||
PLATE // Поиск автомобиля по госномеру
|
||||
WIZARD // Поиск автомобиля по параметрам
|
||||
PART_VEHICLES // Поиск автомобилей по артикулу детали
|
||||
}
|
||||
|
||||
// Адреса доставки
|
||||
model ClientDeliveryAddress {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
name String // Название адреса
|
||||
address String // Полный адрес
|
||||
deliveryType DeliveryType @default(COURIER)
|
||||
comment String?
|
||||
// Дополнительные поля для курьерской доставки
|
||||
entrance String? // Подъезд
|
||||
floor String? // Этаж
|
||||
apartment String? // Квартира/офис
|
||||
intercom String? // Домофон
|
||||
deliveryTime String? // Желаемое время доставки
|
||||
contactPhone String? // Контактный телефон
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("client_delivery_addresses")
|
||||
}
|
||||
|
||||
// Контакты
|
||||
model ClientContact {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
phone String?
|
||||
email String?
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("client_contacts")
|
||||
}
|
||||
|
||||
// Договоры
|
||||
model ClientContract {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
contractNumber String
|
||||
contractDate DateTime
|
||||
name String
|
||||
ourLegalEntity String // Наше ЮЛ
|
||||
clientLegalEntity String // ЮЛ клиента
|
||||
balance Float @default(0)
|
||||
currency String @default("RUB")
|
||||
isActive Boolean @default(true)
|
||||
isDefault Boolean @default(false)
|
||||
contractType String // Тип договора
|
||||
relationship String // Отношение
|
||||
paymentDelay Boolean @default(false)
|
||||
creditLimit Float?
|
||||
delayDays Int?
|
||||
fileUrl String? // Ссылка на файл договора
|
||||
balanceInvoices BalanceInvoice[] // Счета на пополнение баланса
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("client_contracts")
|
||||
}
|
||||
|
||||
// Счета на пополнение баланса
|
||||
model BalanceInvoice {
|
||||
id String @id @default(cuid())
|
||||
contractId String
|
||||
contract ClientContract @relation(fields: [contractId], references: [id], onDelete: Cascade)
|
||||
amount Float
|
||||
currency String @default("RUB")
|
||||
status InvoiceStatus @default(PENDING)
|
||||
invoiceNumber String @unique
|
||||
qrCode String // QR код для оплаты
|
||||
pdfUrl String? // Ссылка на PDF счета
|
||||
paymentUrl String? // Ссылка на оплату
|
||||
expiresAt DateTime // Срок действия счета
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("balance_invoices")
|
||||
TEXT
|
||||
ARTICLE
|
||||
OEM
|
||||
VIN
|
||||
PLATE
|
||||
WIZARD
|
||||
PART_VEHICLES
|
||||
}
|
||||
|
||||
enum InvoiceStatus {
|
||||
PENDING // Ожидает оплаты
|
||||
PAID // Оплачен
|
||||
EXPIRED // Просрочен
|
||||
CANCELLED // Отменен
|
||||
}
|
||||
|
||||
// Юридические лица клиента
|
||||
model ClientLegalEntity {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
shortName String // Короткое наименование
|
||||
fullName String // Полное наименование
|
||||
form String // Форма (ООО, ИП и т.д.)
|
||||
legalAddress String // Юридический адрес
|
||||
actualAddress String? // Фактический адрес
|
||||
taxSystem String // Система налогообложения
|
||||
responsiblePhone String? // Телефон ответственного
|
||||
responsiblePosition String? // Должность ответственного
|
||||
responsibleName String? // ФИО ответственного
|
||||
accountant String? // Бухгалтер
|
||||
signatory String? // Подписант
|
||||
registrationReasonCode String? // Код причины постановки на учет
|
||||
ogrn String? // ОГРН
|
||||
inn String // ИНН
|
||||
vatPercent Float @default(20) // НДС в процентах
|
||||
bankDetails ClientBankDetails[] // Банковские реквизиты
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("client_legal_entities")
|
||||
}
|
||||
|
||||
// Банковские реквизиты
|
||||
model ClientBankDetails {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
legalEntityId String?
|
||||
legalEntity ClientLegalEntity? @relation(fields: [legalEntityId], references: [id])
|
||||
name String // Название реквизитов
|
||||
accountNumber String // Расчетный счет
|
||||
bankName String // Наименование банка
|
||||
bik String // БИК
|
||||
correspondentAccount String // Корреспондентский счет
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("client_bank_details")
|
||||
}
|
||||
|
||||
// История изменения баланса
|
||||
model ClientBalanceHistory {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
oldValue Float
|
||||
newValue Float
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("client_balance_history")
|
||||
}
|
||||
|
||||
model ClientDiscount {
|
||||
id String @id @default(cuid())
|
||||
clientId String
|
||||
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
|
||||
name String
|
||||
type DiscountType
|
||||
value Float // процент или фиксированная сумма
|
||||
isActive Boolean @default(true)
|
||||
validFrom DateTime?
|
||||
validTo DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("client_discounts")
|
||||
}
|
||||
|
||||
model ClientStatus {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
color String @default("#6B7280")
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("client_statuses")
|
||||
PENDING
|
||||
PAID
|
||||
EXPIRED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum ClientType {
|
||||
INDIVIDUAL // Физическое лицо
|
||||
LEGAL_ENTITY // Юридическое лицо
|
||||
INDIVIDUAL
|
||||
LEGAL_ENTITY
|
||||
}
|
||||
|
||||
enum DiscountType {
|
||||
PERCENTAGE // Процентная скидка
|
||||
FIXED_AMOUNT // Фиксированная сумма
|
||||
}
|
||||
|
||||
// Модели для скидок и промокодов
|
||||
model Discount {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
type DiscountCodeType @default(DISCOUNT)
|
||||
code String? @unique // Промокод (если есть)
|
||||
minOrderAmount Float? @default(0)
|
||||
discountType DiscountType @default(PERCENTAGE)
|
||||
discountValue Float
|
||||
isActive Boolean @default(true)
|
||||
validFrom DateTime?
|
||||
validTo DateTime?
|
||||
|
||||
// Связи с профилями
|
||||
profiles DiscountProfile[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("discounts")
|
||||
}
|
||||
|
||||
// Связь скидок с профилями клиентов
|
||||
model DiscountProfile {
|
||||
id String @id @default(cuid())
|
||||
discountId String
|
||||
discount Discount @relation(fields: [discountId], references: [id], onDelete: Cascade)
|
||||
profileId String
|
||||
profile ClientProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([discountId, profileId])
|
||||
@@map("discount_profiles")
|
||||
PERCENTAGE
|
||||
FIXED_AMOUNT
|
||||
}
|
||||
|
||||
enum DiscountCodeType {
|
||||
DISCOUNT // Обычная скидка
|
||||
PROMOCODE // Промокод
|
||||
DISCOUNT
|
||||
PROMOCODE
|
||||
}
|
||||
|
||||
enum DeliveryType {
|
||||
COURIER // Курьер
|
||||
PICKUP // Самовывоз
|
||||
POST // Почта России
|
||||
TRANSPORT // Транспортная компания
|
||||
}
|
||||
|
||||
// Модели для заказов и платежей
|
||||
model Order {
|
||||
id String @id @default(cuid())
|
||||
orderNumber String @unique
|
||||
clientId String?
|
||||
client Client? @relation(fields: [clientId], references: [id], onDelete: SetNull)
|
||||
clientEmail String? // Для гостевых заказов
|
||||
clientPhone String? // Для гостевых заказов
|
||||
clientName String? // Для гостевых заказов
|
||||
status OrderStatus @default(PENDING)
|
||||
totalAmount Float
|
||||
discountAmount Float @default(0)
|
||||
finalAmount Float // totalAmount - discountAmount
|
||||
currency String @default("RUB")
|
||||
items OrderItem[]
|
||||
payments Payment[]
|
||||
deliveryAddress String?
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id String @id @default(cuid())
|
||||
orderId String
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
productId String? // Для внутренних товаров
|
||||
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||
|
||||
// Для внешних товаров (AutoEuro)
|
||||
externalId String? // ID товара во внешней системе
|
||||
name String // Название товара
|
||||
article String? // Артикул
|
||||
brand String? // Бренд
|
||||
price Float // Цена за единицу
|
||||
quantity Int // Количество
|
||||
totalPrice Float // price * quantity
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("order_items")
|
||||
}
|
||||
|
||||
model Payment {
|
||||
id String @id @default(cuid())
|
||||
orderId String
|
||||
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||
yookassaPaymentId String @unique // ID платежа в YooKassa
|
||||
status PaymentStatus @default(PENDING)
|
||||
amount Float
|
||||
currency String @default("RUB")
|
||||
paymentMethod String? // Способ оплаты
|
||||
description String?
|
||||
confirmationUrl String? // URL для подтверждения платежа
|
||||
|
||||
// Метаданные от YooKassa
|
||||
metadata Json?
|
||||
|
||||
// Даты
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
paidAt DateTime? // Дата успешной оплаты
|
||||
canceledAt DateTime? // Дата отмены
|
||||
|
||||
@@map("payments")
|
||||
COURIER
|
||||
PICKUP
|
||||
POST
|
||||
TRANSPORT
|
||||
}
|
||||
|
||||
enum OrderStatus {
|
||||
PENDING // Ожидает оплаты
|
||||
PAID // Оплачен
|
||||
PROCESSING // В обработке
|
||||
SHIPPED // Отправлен
|
||||
DELIVERED // Доставлен
|
||||
CANCELED // Отменен
|
||||
REFUNDED // Возвращен
|
||||
PENDING
|
||||
PAID
|
||||
PROCESSING
|
||||
SHIPPED
|
||||
DELIVERED
|
||||
CANCELED
|
||||
REFUNDED
|
||||
}
|
||||
|
||||
enum PaymentStatus {
|
||||
PENDING // Ожидает оплаты
|
||||
WAITING_FOR_CAPTURE // Ожидает подтверждения
|
||||
SUCCEEDED // Успешно оплачен
|
||||
CANCELED // Отменен
|
||||
REFUNDED // Возвращен
|
||||
PENDING
|
||||
WAITING_FOR_CAPTURE
|
||||
SUCCEEDED
|
||||
CANCELED
|
||||
REFUNDED
|
||||
}
|
||||
|
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,
|
||||
ShoppingCart,
|
||||
Receipt,
|
||||
Palette
|
||||
Palette,
|
||||
Star
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAuth } from '@/components/providers/AuthProvider'
|
||||
@ -33,6 +34,21 @@ const navigationItems = [
|
||||
href: '/dashboard/catalog',
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: 'Товары главной',
|
||||
href: '/dashboard/homepage-products',
|
||||
icon: Star,
|
||||
},
|
||||
{
|
||||
title: 'Лучшие цены',
|
||||
href: '/dashboard/best-price-products',
|
||||
icon: Star,
|
||||
},
|
||||
{
|
||||
title: 'Топ продаж',
|
||||
href: '/dashboard/top-sales-products',
|
||||
icon: Star,
|
||||
},
|
||||
{
|
||||
title: 'Заказы',
|
||||
href: '/dashboard/orders',
|
||||
|
@ -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`
|
||||
mutation UploadAvatar($file: String!) {
|
||||
uploadAvatar(file: $file) {
|
||||
|
@ -408,6 +408,44 @@ interface FavoriteInput {
|
||||
image?: string
|
||||
}
|
||||
|
||||
interface DailyProductInput {
|
||||
productId: string
|
||||
displayDate: string
|
||||
discount?: number
|
||||
isActive?: boolean
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface DailyProductUpdateInput {
|
||||
discount?: number
|
||||
isActive?: boolean
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface BestPriceProductInput {
|
||||
productId: string
|
||||
discount?: number
|
||||
isActive?: boolean
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface BestPriceProductUpdateInput {
|
||||
discount?: number
|
||||
isActive?: boolean
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface TopSalesProductInput {
|
||||
productId: string
|
||||
isActive?: boolean
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface TopSalesProductUpdateInput {
|
||||
isActive?: boolean
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
// Утилиты
|
||||
const createSlug = (text: string): string => {
|
||||
return text
|
||||
@ -3500,6 +3538,229 @@ export const resolvers = {
|
||||
console.error('Ошибка получения предложений адресов:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
|
||||
// Daily Products queries
|
||||
dailyProducts: async (_: unknown, { displayDate }: { displayDate: string }) => {
|
||||
try {
|
||||
return await prisma.dailyProduct.findMany({
|
||||
where: {
|
||||
displayDate: new Date(displayDate),
|
||||
isActive: true
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения товаров дня:', error)
|
||||
throw new Error('Не удалось получить товары дня')
|
||||
}
|
||||
},
|
||||
|
||||
dailyProduct: async (_: unknown, { id }: { id: string }) => {
|
||||
try {
|
||||
return await prisma.dailyProduct.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения товара дня:', error)
|
||||
throw new Error('Не удалось получить товар дня')
|
||||
}
|
||||
},
|
||||
|
||||
// Best Price Products queries
|
||||
bestPriceProducts: async () => {
|
||||
try {
|
||||
const bestPriceProducts = await prisma.bestPriceProduct.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
})
|
||||
|
||||
// Для товаров без изображений пытаемся получить их из PartsIndex
|
||||
const productsWithImages = await Promise.all(
|
||||
bestPriceProducts.map(async (bestPriceProduct) => {
|
||||
const product = bestPriceProduct.product
|
||||
|
||||
// Если у товара уже есть изображения, возвращаем как есть
|
||||
if (product.images && product.images.length > 0) {
|
||||
return bestPriceProduct
|
||||
}
|
||||
|
||||
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
|
||||
if (product.article && product.brand) {
|
||||
try {
|
||||
const partsIndexEntity = await partsIndexService.searchEntityByCode(
|
||||
product.article,
|
||||
product.brand
|
||||
)
|
||||
|
||||
if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) {
|
||||
// Создаем временные изображения для отображения (не сохраняем в БД)
|
||||
const partsIndexImages = partsIndexEntity.images.slice(0, 3).map((imageUrl, index) => ({
|
||||
id: `partsindex-${product.id}-${index}`,
|
||||
url: imageUrl,
|
||||
alt: product.name,
|
||||
order: index,
|
||||
productId: product.id
|
||||
}))
|
||||
|
||||
return {
|
||||
...bestPriceProduct,
|
||||
product: {
|
||||
...product,
|
||||
images: partsIndexImages
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Ошибка получения изображений из PartsIndex для товара ${product.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return bestPriceProduct
|
||||
})
|
||||
)
|
||||
|
||||
return productsWithImages
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения товаров с лучшей ценой:', error)
|
||||
throw new Error('Не удалось получить товары с лучшей ценой')
|
||||
}
|
||||
},
|
||||
|
||||
bestPriceProduct: async (_: unknown, { id }: { id: string }) => {
|
||||
try {
|
||||
return await prisma.bestPriceProduct.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения товара с лучшей ценой:', error)
|
||||
throw new Error('Не удалось получить товар с лучшей ценой')
|
||||
}
|
||||
},
|
||||
|
||||
// Top Sales Products queries
|
||||
topSalesProducts: async () => {
|
||||
try {
|
||||
const topSalesProducts = await prisma.topSalesProduct.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
})
|
||||
|
||||
// Для товаров без изображений пытаемся получить их из PartsIndex
|
||||
const productsWithImages = await Promise.all(
|
||||
topSalesProducts.map(async (topSalesProduct) => {
|
||||
const product = topSalesProduct.product
|
||||
|
||||
// Если у товара уже есть изображения, возвращаем как есть
|
||||
if (product.images && product.images.length > 0) {
|
||||
return topSalesProduct
|
||||
}
|
||||
|
||||
// Если нет изображений и есть артикул и бренд, пытаемся получить из PartsIndex
|
||||
if (product.article && product.brand) {
|
||||
try {
|
||||
const partsIndexEntity = await partsIndexService.searchEntityByCode(
|
||||
product.article,
|
||||
product.brand
|
||||
)
|
||||
|
||||
if (partsIndexEntity && partsIndexEntity.images && partsIndexEntity.images.length > 0) {
|
||||
// Создаем временные изображения для отображения (не сохраняем в БД)
|
||||
const partsIndexImages = partsIndexEntity.images.slice(0, 3).map((imageUrl, index) => ({
|
||||
id: `partsindex-${product.id}-${index}`,
|
||||
url: imageUrl,
|
||||
alt: product.name,
|
||||
order: index,
|
||||
productId: product.id
|
||||
}))
|
||||
|
||||
return {
|
||||
...topSalesProduct,
|
||||
product: {
|
||||
...product,
|
||||
images: partsIndexImages
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Ошибка получения изображений из PartsIndex для товара ${product.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return topSalesProduct
|
||||
})
|
||||
)
|
||||
|
||||
return productsWithImages
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения топ продаж:', error)
|
||||
throw new Error('Не удалось получить топ продаж')
|
||||
}
|
||||
},
|
||||
|
||||
topSalesProduct: async (_: unknown, { id }: { id: string }) => {
|
||||
try {
|
||||
return await prisma.topSalesProduct.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения товара из топ продаж:', error)
|
||||
throw new Error('Не удалось получить товар из топ продаж')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -3522,6 +3783,18 @@ export const resolvers = {
|
||||
}
|
||||
},
|
||||
|
||||
DailyProduct: {
|
||||
product: async (parent: { productId: string }) => {
|
||||
return await prisma.product.findUnique({
|
||||
where: { id: parent.productId },
|
||||
include: {
|
||||
images: { orderBy: { order: 'asc' } },
|
||||
categories: true
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
Mutation: {
|
||||
createUser: async (_: unknown, { input }: { input: CreateUserInput }, context: Context) => {
|
||||
try {
|
||||
@ -7999,6 +8272,380 @@ export const resolvers = {
|
||||
offers: fallbackOffers
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Daily Products mutations
|
||||
createDailyProduct: async (_: unknown, { input }: { input: DailyProductInput }, context: Context) => {
|
||||
try {
|
||||
if (!context.userId) {
|
||||
throw new Error('Пользователь не авторизован')
|
||||
}
|
||||
|
||||
// Проверяем, существует ли товар
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: input.productId }
|
||||
})
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Товар не найден')
|
||||
}
|
||||
|
||||
// Создаем товар дня
|
||||
const dailyProduct = await prisma.dailyProduct.create({
|
||||
data: {
|
||||
productId: input.productId,
|
||||
displayDate: new Date(input.displayDate),
|
||||
discount: input.discount,
|
||||
isActive: input.isActive ?? true,
|
||||
sortOrder: input.sortOrder ?? 0
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return dailyProduct
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания товара дня:', error)
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Не удалось создать товар дня')
|
||||
}
|
||||
},
|
||||
|
||||
updateDailyProduct: async (_: unknown, { id, input }: { id: string; input: DailyProductUpdateInput }, context: Context) => {
|
||||
try {
|
||||
if (!context.userId) {
|
||||
throw new Error('Пользователь не авторизован')
|
||||
}
|
||||
|
||||
// Проверяем, существует ли товар дня
|
||||
const existingDailyProduct = await prisma.dailyProduct.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingDailyProduct) {
|
||||
throw new Error('Товар дня не найден')
|
||||
}
|
||||
|
||||
// Обновляем товар дня
|
||||
const dailyProduct = await prisma.dailyProduct.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.discount !== undefined && { discount: input.discount }),
|
||||
...(input.isActive !== undefined && { isActive: input.isActive }),
|
||||
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder })
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return dailyProduct
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления товара дня:', error)
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Не удалось обновить товар дня')
|
||||
}
|
||||
},
|
||||
|
||||
deleteDailyProduct: async (_: unknown, { id }: { id: string }, context: Context) => {
|
||||
try {
|
||||
if (!context.userId) {
|
||||
throw new Error('Пользователь не авторизован')
|
||||
}
|
||||
|
||||
// Проверяем, существует ли товар дня
|
||||
const existingDailyProduct = await prisma.dailyProduct.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingDailyProduct) {
|
||||
throw new Error('Товар дня не найден')
|
||||
}
|
||||
|
||||
// Удаляем товар дня
|
||||
await prisma.dailyProduct.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления товара дня:', error)
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Не удалось удалить товар дня')
|
||||
}
|
||||
},
|
||||
|
||||
// Best Price Products mutations
|
||||
createBestPriceProduct: async (_: unknown, { input }: { input: BestPriceProductInput }, context: Context) => {
|
||||
try {
|
||||
if (!context.userId) {
|
||||
throw new Error('Пользователь не авторизован')
|
||||
}
|
||||
|
||||
// Проверяем, существует ли товар
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: input.productId }
|
||||
})
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Товар не найден')
|
||||
}
|
||||
|
||||
// Проверяем, что товар еще не добавлен в список лучших цен
|
||||
const existingBestPriceProduct = await prisma.bestPriceProduct.findUnique({
|
||||
where: { productId: input.productId }
|
||||
})
|
||||
|
||||
if (existingBestPriceProduct) {
|
||||
throw new Error('Товар уже добавлен в список лучших цен')
|
||||
}
|
||||
|
||||
// Создаем товар с лучшей ценой
|
||||
const bestPriceProduct = await prisma.bestPriceProduct.create({
|
||||
data: {
|
||||
productId: input.productId,
|
||||
discount: input.discount || 0,
|
||||
isActive: input.isActive ?? true,
|
||||
sortOrder: input.sortOrder ?? 0
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return bestPriceProduct
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания товара с лучшей ценой:', error)
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Не удалось создать товар с лучшей ценой')
|
||||
}
|
||||
},
|
||||
|
||||
updateBestPriceProduct: async (_: unknown, { id, input }: { id: string; input: BestPriceProductUpdateInput }, context: Context) => {
|
||||
try {
|
||||
if (!context.userId) {
|
||||
throw new Error('Пользователь не авторизован')
|
||||
}
|
||||
|
||||
// Проверяем, существует ли товар с лучшей ценой
|
||||
const existingBestPriceProduct = await prisma.bestPriceProduct.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingBestPriceProduct) {
|
||||
throw new Error('Товар с лучшей ценой не найден')
|
||||
}
|
||||
|
||||
// Обновляем товар с лучшей ценой
|
||||
const bestPriceProduct = await prisma.bestPriceProduct.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.discount !== undefined && { discount: input.discount }),
|
||||
...(input.isActive !== undefined && { isActive: input.isActive }),
|
||||
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder })
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return bestPriceProduct
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления товара с лучшей ценой:', error)
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Не удалось обновить товар с лучшей ценой')
|
||||
}
|
||||
},
|
||||
|
||||
deleteBestPriceProduct: async (_: unknown, { id }: { id: string }, context: Context) => {
|
||||
try {
|
||||
if (!context.userId) {
|
||||
throw new Error('Пользователь не авторизован')
|
||||
}
|
||||
|
||||
// Проверяем, существует ли товар с лучшей ценой
|
||||
const existingBestPriceProduct = await prisma.bestPriceProduct.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingBestPriceProduct) {
|
||||
throw new Error('Товар с лучшей ценой не найден')
|
||||
}
|
||||
|
||||
// Удаляем товар с лучшей ценой
|
||||
await prisma.bestPriceProduct.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления товара с лучшей ценой:', error)
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Не удалось удалить товар с лучшей ценой')
|
||||
}
|
||||
},
|
||||
|
||||
// Top Sales Products mutations
|
||||
createTopSalesProduct: async (_: unknown, { input }: { input: TopSalesProductInput }, context: Context) => {
|
||||
try {
|
||||
if (!context.userId) {
|
||||
throw new Error('Пользователь не авторизован')
|
||||
}
|
||||
|
||||
// Проверяем, существует ли товар
|
||||
const product = await prisma.product.findUnique({
|
||||
where: { id: input.productId }
|
||||
})
|
||||
|
||||
if (!product) {
|
||||
throw new Error('Товар не найден')
|
||||
}
|
||||
|
||||
// Проверяем, что товар еще не добавлен в топ продаж
|
||||
const existingTopSalesProduct = await prisma.topSalesProduct.findUnique({
|
||||
where: { productId: input.productId }
|
||||
})
|
||||
|
||||
if (existingTopSalesProduct) {
|
||||
throw new Error('Товар уже добавлен в топ продаж')
|
||||
}
|
||||
|
||||
// Создаем товар в топ продаж
|
||||
const topSalesProduct = await prisma.topSalesProduct.create({
|
||||
data: {
|
||||
productId: input.productId,
|
||||
isActive: input.isActive ?? true,
|
||||
sortOrder: input.sortOrder ?? 0
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return topSalesProduct
|
||||
} catch (error) {
|
||||
console.error('Ошибка создания товара в топ продаж:', error)
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Не удалось создать товар в топ продаж')
|
||||
}
|
||||
},
|
||||
|
||||
updateTopSalesProduct: async (_: unknown, { id, input }: { id: string; input: TopSalesProductUpdateInput }, context: Context) => {
|
||||
try {
|
||||
if (!context.userId) {
|
||||
throw new Error('Пользователь не авторизован')
|
||||
}
|
||||
|
||||
// Проверяем, существует ли товар в топ продаж
|
||||
const existingTopSalesProduct = await prisma.topSalesProduct.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingTopSalesProduct) {
|
||||
throw new Error('Товар в топ продаж не найден')
|
||||
}
|
||||
|
||||
// Обновляем товар в топ продаж
|
||||
const topSalesProduct = await prisma.topSalesProduct.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.isActive !== undefined && { isActive: input.isActive }),
|
||||
...(input.sortOrder !== undefined && { sortOrder: input.sortOrder })
|
||||
},
|
||||
include: {
|
||||
product: {
|
||||
include: {
|
||||
images: {
|
||||
orderBy: { order: 'asc' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return topSalesProduct
|
||||
} catch (error) {
|
||||
console.error('Ошибка обновления товара в топ продаж:', error)
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Не удалось обновить товар в топ продаж')
|
||||
}
|
||||
},
|
||||
|
||||
deleteTopSalesProduct: async (_: unknown, { id }: { id: string }, context: Context) => {
|
||||
try {
|
||||
if (!context.userId) {
|
||||
throw new Error('Пользователь не авторизован')
|
||||
}
|
||||
|
||||
// Проверяем, существует ли товар в топ продаж
|
||||
const existingTopSalesProduct = await prisma.topSalesProduct.findUnique({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
if (!existingTopSalesProduct) {
|
||||
throw new Error('Товар в топ продаж не найден')
|
||||
}
|
||||
|
||||
// Удаляем товар из топ продаж
|
||||
await prisma.topSalesProduct.delete({
|
||||
where: { id }
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Ошибка удаления товара из топ продаж:', error)
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Не удалось удалить товар из топ продаж')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -79,6 +79,7 @@ export const typeDefs = gql`
|
||||
slug: String!
|
||||
article: String
|
||||
description: String
|
||||
brand: String
|
||||
videoUrl: String
|
||||
wholesalePrice: Float
|
||||
retailPrice: Float
|
||||
@ -604,6 +605,7 @@ export const typeDefs = gql`
|
||||
slug: String
|
||||
article: String
|
||||
description: String
|
||||
brand: String
|
||||
videoUrl: String
|
||||
wholesalePrice: Float
|
||||
retailPrice: Float
|
||||
@ -1011,6 +1013,18 @@ export const typeDefs = gql`
|
||||
|
||||
# Автокомплит адресов
|
||||
addressSuggestions(query: String!): [String!]!
|
||||
|
||||
# Товары дня
|
||||
dailyProducts(displayDate: String!): [DailyProduct!]!
|
||||
dailyProduct(id: ID!): DailyProduct
|
||||
|
||||
# Товары с лучшей ценой
|
||||
bestPriceProducts: [BestPriceProduct!]!
|
||||
bestPriceProduct(id: ID!): BestPriceProduct
|
||||
|
||||
# Топ продаж
|
||||
topSalesProducts: [TopSalesProduct!]!
|
||||
topSalesProduct(id: ID!): TopSalesProduct
|
||||
}
|
||||
|
||||
type AuthPayload {
|
||||
@ -1191,6 +1205,21 @@ export const typeDefs = gql`
|
||||
|
||||
# Доставка Яндекс
|
||||
getDeliveryOffers(input: DeliveryOffersInput!): DeliveryOffersResponse!
|
||||
|
||||
# Товары дня
|
||||
createDailyProduct(input: DailyProductInput!): DailyProduct!
|
||||
updateDailyProduct(id: ID!, input: DailyProductUpdateInput!): DailyProduct!
|
||||
deleteDailyProduct(id: ID!): Boolean!
|
||||
|
||||
# Товары с лучшей ценой
|
||||
createBestPriceProduct(input: BestPriceProductInput!): BestPriceProduct!
|
||||
updateBestPriceProduct(id: ID!, input: BestPriceProductUpdateInput!): BestPriceProduct!
|
||||
deleteBestPriceProduct(id: ID!): Boolean!
|
||||
|
||||
# Топ продаж
|
||||
createTopSalesProduct(input: TopSalesProductInput!): TopSalesProduct!
|
||||
updateTopSalesProduct(id: ID!, input: TopSalesProductUpdateInput!): TopSalesProduct!
|
||||
deleteTopSalesProduct(id: ID!): Boolean!
|
||||
}
|
||||
|
||||
input LoginInput {
|
||||
@ -2157,4 +2186,78 @@ export const typeDefs = gql`
|
||||
minPrice: Float
|
||||
hasOffers: Boolean!
|
||||
}
|
||||
|
||||
# Типы для товаров дня
|
||||
type DailyProduct {
|
||||
id: ID!
|
||||
productId: String!
|
||||
product: Product!
|
||||
displayDate: String!
|
||||
discount: Float
|
||||
isActive: Boolean!
|
||||
sortOrder: Int!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
input DailyProductInput {
|
||||
productId: String!
|
||||
displayDate: String!
|
||||
discount: Float
|
||||
isActive: Boolean
|
||||
sortOrder: Int
|
||||
}
|
||||
|
||||
input DailyProductUpdateInput {
|
||||
discount: Float
|
||||
isActive: Boolean
|
||||
sortOrder: Int
|
||||
}
|
||||
|
||||
# Типы для товаров с лучшей ценой
|
||||
type BestPriceProduct {
|
||||
id: ID!
|
||||
productId: String!
|
||||
product: Product!
|
||||
discount: Float!
|
||||
isActive: Boolean!
|
||||
sortOrder: Int!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
input BestPriceProductInput {
|
||||
productId: String!
|
||||
discount: Float!
|
||||
isActive: Boolean
|
||||
sortOrder: Int
|
||||
}
|
||||
|
||||
input BestPriceProductUpdateInput {
|
||||
discount: Float
|
||||
isActive: Boolean
|
||||
sortOrder: Int
|
||||
}
|
||||
|
||||
# Типы для топ продаж
|
||||
type TopSalesProduct {
|
||||
id: ID!
|
||||
productId: String!
|
||||
product: Product!
|
||||
isActive: Boolean!
|
||||
sortOrder: Int!
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
input TopSalesProductInput {
|
||||
productId: String!
|
||||
isActive: Boolean
|
||||
sortOrder: Int
|
||||
}
|
||||
|
||||
input TopSalesProductUpdateInput {
|
||||
isActive: Boolean
|
||||
sortOrder: Int
|
||||
}
|
||||
`
|
Reference in New Issue
Block a user