From c7dcb96c05d70ec64183f0e7ad8a1f026efe1315 Mon Sep 17 00:00:00 2001 From: Bivekich Date: Thu, 10 Jul 2025 00:11:02 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B8=20=D0=B8=20=D0=BC=D1=83=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D0=B4=D0=BB=D1=8F=20=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=80=D0=B0=D0=BC=D0=B8=20=D0=B4=D0=BD=D1=8F,=20=D0=BB?= =?UTF-8?q?=D1=83=D1=87=D1=88=D0=B8=D0=BC=D0=B8=20=D1=86=D0=B5=D0=BD=D0=B0?= =?UTF-8?q?=D0=BC=D0=B8=20=D0=B8=20=D1=82=D0=BE=D0=BF=D0=BE=D0=BC=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=B0=D0=B6.=20=D0=9E=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=82=D0=B8=D0=BF=D1=8B=20Gra?= =?UTF-8?q?phQL=20=D0=B8=20=D1=80=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=BE=D0=B2,=20=D1=87=D1=82=D0=BE=20=D1=83=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D1=88=D0=B0=D0=B5=D1=82=20=D1=84=D1=83=D0=BD=D0=BA=D1=86?= =?UTF-8?q?=D0=B8=D0=BE=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B8=20=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83?= =?UTF-8?q?=D1=80=D1=83=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B=D1=85.=20=D0=92?= =?UTF-8?q?=20=D0=B1=D0=BE=D0=BA=D0=BE=D0=B2=D0=BE=D0=B5=20=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8E=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=B5=20=D1=8D=D0=BB=D0=B5?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D0=BD?= =?UTF-8?q?=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=B0=D0=BC.=20=D0=AD=D1=82?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=D0=B2=D1=8B=D1=88=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D1=83=D0=B4=D0=BE=D0=B1=D1=81=D1=82=D0=B2=D0=BE=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D1=8B=20=D1=81=20=D0=BF=D1=80=D0=B8=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=B8=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D1=88=D0=B8=D1=80=D1=8F=D0=B5=D1=82=20=D0=B2=D0=BE?= =?UTF-8?q?=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD=D0=BE=D1=81=D1=82=D0=B8=20=D0=B2?= =?UTF-8?q?=D0=B7=D0=B0=D0=B8=D0=BC=D0=BE=D0=B4=D0=B5=D0=B9=D1=81=D1=82?= =?UTF-8?q?=D0=B2=D0=B8=D1=8F=20=D1=81=20API.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 1277 ++++++++--------- .../dashboard/best-price-products/page.tsx | 390 +++++ src/app/dashboard/homepage-products/page.tsx | 418 ++++++ src/app/dashboard/top-sales-products/page.tsx | 436 ++++++ src/components/ui/sidebar.tsx | 18 +- src/lib/graphql/mutations.ts | 175 +++ src/lib/graphql/queries.ts | 163 +++ src/lib/graphql/resolvers.ts | 647 +++++++++ src/lib/graphql/typeDefs.ts | 103 ++ 9 files changed, 2982 insertions(+), 645 deletions(-) create mode 100644 src/app/dashboard/best-price-products/page.tsx create mode 100644 src/app/dashboard/homepage-products/page.tsx create mode 100644 src/app/dashboard/top-sales-products/page.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 40cea94..d299e01 100644 --- a/prisma/schema.prisma +++ b/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 } diff --git a/src/app/dashboard/best-price-products/page.tsx b/src/app/dashboard/best-price-products/page.tsx new file mode 100644 index 0000000..7ce3267 --- /dev/null +++ b/src/app/dashboard/best-price-products/page.tsx @@ -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(null) + const [searchQuery, setSearchQuery] = useState('') + const [discount, setDiscount] = useState(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 ( +
+
+

Товары с лучшей ценой

+

Управление товарами с лучшими ценами, которые показываются на главной странице сайта

+
+ + {/* Товары с лучшей ценой */} + + +
+ + + Товары с лучшей ценой + + +
+
+ + {bestPriceProductsLoading ? ( +
Загрузка товаров...
+ ) : bestPriceProducts.length === 0 ? ( +
+ Товары с лучшей ценой не добавлены +
+ ) : ( +
+ {bestPriceProducts.map((bestPriceProduct) => ( +
+
+ {/* Изображение товара */} +
+ {bestPriceProduct.product.images?.[0] ? ( + {bestPriceProduct.product.name} + ) : ( + Нет фото + )} +
+ + {/* Информация о товаре */} +
+

{bestPriceProduct.product.name}

+
+ {bestPriceProduct.product.article && ( + Артикул: {bestPriceProduct.product.article} + )} + {bestPriceProduct.product.brand && ( + Бренд: {bestPriceProduct.product.brand} + )} +
+
+ + от {formatPrice(calculateDiscountedPrice(bestPriceProduct.product.retailPrice, bestPriceProduct.discount))} + + + {formatPrice(bestPriceProduct.product.retailPrice)} + + + -{bestPriceProduct.discount}% + +
+
+
+ + {/* Действия */} +
+ + +
+
+ ))} +
+ )} +
+
+ + {/* Модальное окно выбора товара */} + + + + Добавить товар с лучшей ценой + + +
+ {/* Поиск товаров */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + {/* Скидка */} +
+ + setDiscount(Number(e.target.value))} + placeholder="Введите размер скидки" + className="w-32" + required + /> +

Обязательное поле для товаров с лучшей ценой

+
+ + {/* Список товаров */} + {productsLoading ? ( +
Загрузка товаров...
+ ) : ( +
+ {products.map((product) => ( +
+
+
+ {product.images?.[0] ? ( + {product.name} + ) : ( + Нет фото + )} +
+
+

{product.name}

+
+ {product.article && Артикул: {product.article}} + {product.brand && Бренд: {product.brand}} + Цена: {formatPrice(product.retailPrice)} +
+
+
+ +
+ ))} +
+ )} +
+
+
+ + {/* Модальное окно редактирования */} + setEditingBestPriceProduct(null)}> + + + Редактировать товар с лучшей ценой + + + {editingBestPriceProduct && ( +
+
+

{editingBestPriceProduct.product.name}

+

+ {editingBestPriceProduct.product.article && `Артикул: ${editingBestPriceProduct.product.article}`} + {editingBestPriceProduct.product.brand && ` • Бренд: ${editingBestPriceProduct.product.brand}`} +

+
+ +
+ + setDiscount(Number(e.target.value))} + placeholder="Введите размер скидки" + className="w-32" + required + /> +
+ +
+ + +
+
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/dashboard/homepage-products/page.tsx b/src/app/dashboard/homepage-products/page.tsx new file mode 100644 index 0000000..666fa95 --- /dev/null +++ b/src/app/dashboard/homepage-products/page.tsx @@ -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(format(new Date(), 'yyyy-MM-dd')) + const [showProductSelector, setShowProductSelector] = useState(false) + const [editingDailyProduct, setEditingDailyProduct] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [discount, setDiscount] = useState(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 ( +
+
+

Товары главной страницы

+

Управление товарами дня, которые показываются на главной странице сайта

+
+ + {/* Выбор даты */} + + + + + Выбор даты показа + + + +
+
+ + setSelectedDate(e.target.value)} + className="w-48" + /> +
+
+

+ Выбранная дата: {format(new Date(selectedDate), 'dd MMMM yyyy', { locale: ru })} +

+
+
+
+
+ + {/* Товары дня */} + + +
+ + + Товары дня на {format(new Date(selectedDate), 'dd.MM.yyyy')} + + +
+
+ + {dailyProductsLoading ? ( +
Загрузка товаров...
+ ) : dailyProducts.length === 0 ? ( +
+ На выбранную дату товары не добавлены +
+ ) : ( +
+ {dailyProducts.map((dailyProduct) => ( +
+
+ {/* Изображение товара */} +
+ {dailyProduct.product.images?.[0] ? ( + {dailyProduct.product.name} + ) : ( + Нет фото + )} +
+ + {/* Информация о товаре */} +
+

{dailyProduct.product.name}

+
+ {dailyProduct.product.article && ( + Артикул: {dailyProduct.product.article} + )} + {dailyProduct.product.brand && ( + Бренд: {dailyProduct.product.brand} + )} +
+
+ {dailyProduct.discount ? ( + <> + + от {formatPrice(calculateDiscountedPrice(dailyProduct.product.retailPrice, dailyProduct.discount))} + + + {formatPrice(dailyProduct.product.retailPrice)} + + + -{dailyProduct.discount}% + + + ) : ( + + от {formatPrice(dailyProduct.product.retailPrice)} + + )} +
+
+
+ + {/* Действия */} +
+ + +
+
+ ))} +
+ )} +
+
+ + {/* Модальное окно выбора товара */} + + + + Добавить товар дня + + +
+ {/* Поиск товаров */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + {/* Скидка */} +
+ + setDiscount(Number(e.target.value))} + placeholder="Введите размер скидки" + className="w-32" + /> +
+ + {/* Список товаров */} + {productsLoading ? ( +
Загрузка товаров...
+ ) : ( +
+ {products.map((product) => ( +
+
+
+ {product.images?.[0] ? ( + {product.name} + ) : ( + Нет фото + )} +
+
+

{product.name}

+
+ {product.article && Артикул: {product.article}} + {product.brand && Бренд: {product.brand}} + Цена: {formatPrice(product.retailPrice)} +
+
+
+ +
+ ))} +
+ )} +
+
+
+ + {/* Модальное окно редактирования */} + setEditingDailyProduct(null)}> + + + Редактировать товар дня + + + {editingDailyProduct && ( +
+
+

{editingDailyProduct.product.name}

+

+ {editingDailyProduct.product.article && `Артикул: ${editingDailyProduct.product.article}`} + {editingDailyProduct.product.brand && ` • Бренд: ${editingDailyProduct.product.brand}`} +

+
+ +
+ + setDiscount(Number(e.target.value))} + placeholder="Введите размер скидки" + className="w-32" + /> +
+ +
+ + +
+
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/dashboard/top-sales-products/page.tsx b/src/app/dashboard/top-sales-products/page.tsx new file mode 100644 index 0000000..b97b6e1 --- /dev/null +++ b/src/app/dashboard/top-sales-products/page.tsx @@ -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(null) + const [editingItem, setEditingItem] = useState(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
Загрузка...
+ } + + const topSalesProducts = topSalesData?.topSalesProducts || [] + + return ( +
+
+

Топ продаж

+ + + + + + + + Добавить товар в топ продаж + + Найдите и выберите товар для добавления в топ продаж + + + +
+
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+ + {productsLoading ? ( +
Загрузка товаров...
+ ) : ( +
+ {filteredProducts.map((product) => ( +
setSelectedProduct(product)} + > +
+ {product.images?.[0] && ( + {product.images[0].alt + )} +
+

{product.name}

+

+ {product.brand} • {product.article} +

+ {product.retailPrice && ( +

+ {product.retailPrice.toLocaleString('ru-RU')} ₽ +

+ )} +
+
+
+ ))} +
+ )} +
+ + + + + +
+
+
+ + {/* Список топ продаж */} +
+ {topSalesProducts.length === 0 ? ( + + +

Нет товаров в топ продаж

+
+
+ ) : ( + [...topSalesProducts] + .sort((a, b) => a.sortOrder - b.sortOrder) + .map((item) => ( + + +
+
+ {item.product.images?.[0] && ( + {item.product.images[0].alt + )} +
+

{item.product.name}

+

+ {item.product.brand} • {item.product.article} +

+ {item.product.retailPrice && ( +

+ {item.product.retailPrice.toLocaleString('ru-RU')} ₽ +

+ )} +
+ + {item.isActive ? 'Активен' : 'Неактивен'} + + Порядок: {item.sortOrder} +
+
+
+ +
+ {/* Управление порядком */} +
+ + +
+ + {/* Переключатель активности */} +
+ + handleToggleActive(item)} + /> +
+ + {/* Кнопка редактирования */} + + + + + + + Редактировать товар + + + {editingItem && ( +
+
+ + + setEditingItem({ ...editingItem, isActive: checked }) + } + /> +
+ +
+ + + setEditingItem({ + ...editingItem, + sortOrder: parseInt(e.target.value) || 0 + }) + } + /> +
+
+ )} + + + + + +
+
+ + {/* Кнопка удаления */} + +
+
+
+
+ )) + )} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 0f86529..43a40ba 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -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', diff --git a/src/lib/graphql/mutations.ts b/src/lib/graphql/mutations.ts index 55ee85d..9a2441f 100644 --- a/src/lib/graphql/mutations.ts +++ b/src/lib/graphql/mutations.ts @@ -1165,4 +1165,179 @@ 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) + } ` \ No newline at end of file diff --git a/src/lib/graphql/queries.ts b/src/lib/graphql/queries.ts index 68b51e3..84cb795 100644 --- a/src/lib/graphql/queries.ts +++ b/src/lib/graphql/queries.ts @@ -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) { diff --git a/src/lib/graphql/resolvers.ts b/src/lib/graphql/resolvers.ts index c3a359f..51fa1df 100644 --- a/src/lib/graphql/resolvers.ts +++ b/src/lib/graphql/resolvers.ts @@ -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('Не удалось удалить товар из топ продаж') + } } } } \ No newline at end of file diff --git a/src/lib/graphql/typeDefs.ts b/src/lib/graphql/typeDefs.ts index 29adde7..04fcf21 100644 --- a/src/lib/graphql/typeDefs.ts +++ b/src/lib/graphql/typeDefs.ts @@ -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 + } ` \ No newline at end of file