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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
}
`

View File

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

View File

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

View File

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