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

This commit is contained in:
Bivekich
2025-07-11 02:42:44 +03:00
parent 013c05fb02
commit db29525da5
11 changed files with 1628 additions and 1037 deletions

View File

@ -1,390 +0,0 @@
"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

@ -210,6 +210,7 @@ export default function CatalogPage() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onProductEdit={(product: any) => setEditingProduct(product)}
onProductCreated={handleProductCreated}
categories={categories}
/>
{/* Пагинация и информация */}

View File

@ -1,16 +1,21 @@
"use client"
import { useState } from 'react'
import { useState, useEffect } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import {
Calendar,
@ -18,14 +23,33 @@ import {
Search,
Edit,
Trash2,
Package
Package,
Star,
ChevronUp,
ChevronDown
} 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 {
GET_DAILY_PRODUCTS,
GET_BEST_PRICE_PRODUCTS,
GET_TOP_SALES_PRODUCTS,
GET_PRODUCTS
} from '@/lib/graphql/queries'
import {
CREATE_DAILY_PRODUCT,
UPDATE_DAILY_PRODUCT,
DELETE_DAILY_PRODUCT,
CREATE_BEST_PRICE_PRODUCT,
UPDATE_BEST_PRICE_PRODUCT,
DELETE_BEST_PRICE_PRODUCT,
CREATE_TOP_SALES_PRODUCT,
UPDATE_TOP_SALES_PRODUCT,
DELETE_TOP_SALES_PRODUCT
} from '@/lib/graphql/mutations'
import toast from 'react-hot-toast'
// Типы данных
interface DailyProduct {
id: string
productId: string
@ -43,6 +67,39 @@ interface DailyProduct {
}
}
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 TopSalesProduct {
id: string
productId: string
isActive: boolean
sortOrder: number
product: {
id: string
name: string
article?: string
brand?: string
retailPrice?: number
images: { url: string; alt?: string }[]
}
createdAt: string
updatedAt: string
}
interface Product {
id: string
name: string
@ -53,39 +110,83 @@ interface Product {
}
export default function HomepageProductsPage() {
// Состояния для товаров дня
const [selectedDate, setSelectedDate] = useState<string>(format(new Date(), 'yyyy-MM-dd'))
const [showProductSelector, setShowProductSelector] = useState(false)
const [showDailyProductSelector, setShowDailyProductSelector] = useState(false)
const [editingDailyProduct, setEditingDailyProduct] = useState<DailyProduct | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [discount, setDiscount] = useState<number>(0)
const [dailyDiscount, setDailyDiscount] = useState<number>(0)
// Состояния для лучших цен
const [showBestPriceProductSelector, setShowBestPriceProductSelector] = useState(false)
const [editingBestPriceProduct, setEditingBestPriceProduct] = useState<BestPriceProduct | null>(null)
const [bestPriceDiscount, setBestPriceDiscount] = useState<number>(0)
// Состояния для топ продаж
const [showTopSalesProductSelector, setShowTopSalesProductSelector] = useState(false)
const [editingTopSalesProduct, setEditingTopSalesProduct] = useState<TopSalesProduct | null>(null)
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null)
// Общие состояния
const [searchQuery, setSearchQuery] = useState('')
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('')
const [activeTab, setActiveTab] = useState('daily')
// Debounce для поиска
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchQuery(searchQuery)
}, 500)
return () => clearTimeout(timer)
}, [searchQuery])
// Запросы данных
const { data: dailyProductsData, loading: dailyProductsLoading, refetch: refetchDailyProducts } = useQuery(GET_DAILY_PRODUCTS, {
variables: { displayDate: selectedDate }
})
const { data: bestPriceProductsData, loading: bestPriceProductsLoading, refetch: refetchBestPriceProducts } = useQuery(GET_BEST_PRICE_PRODUCTS)
const { data: topSalesProductsData, loading: topSalesProductsLoading, refetch: refetchTopSalesProducts } = useQuery(GET_TOP_SALES_PRODUCTS)
const { data: productsData, loading: productsLoading } = useQuery(GET_PRODUCTS, {
variables: {
search: searchQuery || undefined,
limit: 50
search: debouncedSearchQuery || undefined,
limit: 100
},
skip: !showProductSelector
skip: !showDailyProductSelector && !showBestPriceProductSelector && !showTopSalesProductSelector
})
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 [createDailyProduct, { loading: creatingDaily }] = useMutation(CREATE_DAILY_PRODUCT)
const [updateDailyProduct, { loading: updatingDaily }] = useMutation(UPDATE_DAILY_PRODUCT)
const [deleteDailyProduct, { loading: deletingDaily }] = useMutation(DELETE_DAILY_PRODUCT)
// Мутации для лучших цен
const [createBestPriceProduct, { loading: creatingBestPrice }] = useMutation(CREATE_BEST_PRICE_PRODUCT)
const [updateBestPriceProduct, { loading: updatingBestPrice }] = useMutation(UPDATE_BEST_PRICE_PRODUCT)
const [deleteBestPriceProduct, { loading: deletingBestPrice }] = useMutation(DELETE_BEST_PRICE_PRODUCT)
// Мутации для топ продаж
const [createTopSalesProduct] = useMutation(CREATE_TOP_SALES_PRODUCT)
const [updateTopSalesProduct] = useMutation(UPDATE_TOP_SALES_PRODUCT)
const [deleteTopSalesProduct] = useMutation(DELETE_TOP_SALES_PRODUCT)
// Данные
const dailyProducts: DailyProduct[] = dailyProductsData?.dailyProducts || []
const bestPriceProducts: BestPriceProduct[] = bestPriceProductsData?.bestPriceProducts || []
const topSalesProducts: TopSalesProduct[] = topSalesProductsData?.topSalesProducts || []
const products: Product[] = productsData?.products || []
const handleAddProduct = async (productId: string) => {
// Обработчики для товаров дня
const handleAddDailyProduct = async (productId: string) => {
try {
await createDailyProduct({
variables: {
input: {
productId,
displayDate: selectedDate,
discount: discount || null,
discount: dailyDiscount || null,
isActive: true,
sortOrder: dailyProducts.length
}
@ -93,8 +194,8 @@ export default function HomepageProductsPage() {
})
toast.success('Товар добавлен!')
setShowProductSelector(false)
setDiscount(0)
setShowDailyProductSelector(false)
setDailyDiscount(0)
refetchDailyProducts()
} catch (error) {
console.error('Ошибка добавления товара:', error)
@ -102,12 +203,12 @@ export default function HomepageProductsPage() {
}
}
const handleEditProduct = (dailyProduct: DailyProduct) => {
const handleEditDailyProduct = (dailyProduct: DailyProduct) => {
setEditingDailyProduct(dailyProduct)
setDiscount(dailyProduct.discount || 0)
setDailyDiscount(dailyProduct.discount || 0)
}
const handleUpdateProduct = async () => {
const handleUpdateDailyProduct = async () => {
if (!editingDailyProduct) return
try {
@ -115,7 +216,7 @@ export default function HomepageProductsPage() {
variables: {
id: editingDailyProduct.id,
input: {
discount: discount || null,
discount: dailyDiscount || null,
isActive: editingDailyProduct.isActive
}
}
@ -123,7 +224,7 @@ export default function HomepageProductsPage() {
toast.success('Товар обновлен!')
setEditingDailyProduct(null)
setDiscount(0)
setDailyDiscount(0)
refetchDailyProducts()
} catch (error) {
console.error('Ошибка обновления товара:', error)
@ -131,7 +232,7 @@ export default function HomepageProductsPage() {
}
}
const handleDeleteProduct = async (id: string) => {
const handleDeleteDailyProduct = async (id: string) => {
if (!confirm('Удалить товар из списка товаров дня?')) return
try {
@ -147,6 +248,149 @@ export default function HomepageProductsPage() {
}
}
// Обработчики для лучших цен
const handleAddBestPriceProduct = async (productId: string) => {
try {
await createBestPriceProduct({
variables: {
input: {
productId,
discount: bestPriceDiscount || 0,
isActive: true,
sortOrder: bestPriceProducts.length
}
}
})
toast.success('Товар добавлен в лучшие цены!')
setShowBestPriceProductSelector(false)
setBestPriceDiscount(0)
refetchBestPriceProducts()
} catch (error) {
console.error('Ошибка добавления товара:', error)
toast.error('Не удалось добавить товар')
}
}
const handleEditBestPriceProduct = (bestPriceProduct: BestPriceProduct) => {
setEditingBestPriceProduct(bestPriceProduct)
setBestPriceDiscount(bestPriceProduct.discount || 0)
}
const handleUpdateBestPriceProduct = async () => {
if (!editingBestPriceProduct) return
try {
await updateBestPriceProduct({
variables: {
id: editingBestPriceProduct.id,
input: {
discount: bestPriceDiscount || 0,
isActive: editingBestPriceProduct.isActive
}
}
})
toast.success('Товар обновлен!')
setEditingBestPriceProduct(null)
setBestPriceDiscount(0)
refetchBestPriceProducts()
} catch (error) {
console.error('Ошибка обновления товара:', error)
toast.error('Не удалось обновить товар')
}
}
const handleDeleteBestPriceProduct = async (id: string) => {
if (!confirm('Удалить товар из списка товаров с лучшей ценой?')) return
try {
await deleteBestPriceProduct({
variables: { id }
})
toast.success('Товар удален!')
refetchBestPriceProducts()
} catch (error) {
console.error('Ошибка удаления товара:', error)
toast.error('Не удалось удалить товар')
}
}
// Обработчики для топ продаж
const handleAddTopSalesProduct = () => {
if (!selectedProduct) {
toast.error('Выберите товар')
return
}
createTopSalesProduct({
variables: {
input: {
productId: selectedProduct.id,
isActive: true,
sortOrder: 0
}
},
onCompleted: () => {
toast.success('Товар добавлен в топ продаж')
refetchTopSalesProducts()
setShowTopSalesProductSelector(false)
setSelectedProduct(null)
},
onError: (error) => {
toast.error(`Ошибка: ${error.message}`)
}
})
}
const handleDeleteTopSalesProduct = (id: string) => {
if (confirm('Вы уверены, что хотите удалить этот товар из топ продаж?')) {
deleteTopSalesProduct({
variables: { id },
onCompleted: () => {
toast.success('Товар удален из топ продаж')
refetchTopSalesProducts()
},
onError: (error) => {
toast.error(`Ошибка: ${error.message}`)
}
})
}
}
const handleToggleTopSalesActive = (item: TopSalesProduct) => {
updateTopSalesProduct({
variables: {
id: item.id,
input: {
isActive: !item.isActive,
sortOrder: item.sortOrder
}
},
onCompleted: () => {
refetchTopSalesProducts()
}
})
}
const handleTopSalesSortOrderChange = (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)
}
},
onCompleted: () => {
refetchTopSalesProducts()
}
})
}
// Утилиты
const formatPrice = (price?: number) => {
if (!price) return '—'
return `${price.toLocaleString('ru-RU')}`
@ -160,141 +404,352 @@ export default function HomepageProductsPage() {
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>
<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>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="daily" className="flex items-center">
<Calendar className="w-4 h-4 mr-2" />
Товары дня
</TabsTrigger>
<TabsTrigger value="best-price" className="flex items-center">
<Star className="w-4 h-4 mr-2" />
Лучшие цены
</TabsTrigger>
<TabsTrigger value="top-sales" className="flex items-center">
<Package className="w-4 h-4 mr-2" />
Топ продаж
</TabsTrigger>
</TabsList>
{/* Товары дня */}
<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>
{/* Товары дня */}
<TabsContent value="daily" className="space-y-6">
{/* Выбор даты */}
<Card>
<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>
)}
</CardContent>
</Card>
<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>
{/* Модальное окно выбора товара */}
<Dialog open={showProductSelector} onOpenChange={setShowProductSelector}>
{/* Товары дня */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<Package className="w-5 h-5 mr-2" />
Товары дня
</CardTitle>
<Button
onClick={() => setShowDailyProductSelector(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]?.url ? (
<img
src={dailyProduct.product.images[0].url}
alt={dailyProduct.product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Package className="w-6 h-6 text-gray-400" />
)}
</div>
{/* Информация о товаре */}
<div className="flex-1">
<h3 className="font-medium text-gray-900">{dailyProduct.product.name}</h3>
<div className="text-sm text-gray-500 space-y-1">
{dailyProduct.product.article && (
<p>Артикул: {dailyProduct.product.article}</p>
)}
{dailyProduct.product.brand && (
<p>Бренд: {dailyProduct.product.brand}</p>
)}
<div className="flex items-center space-x-2">
<span>Цена: {formatPrice(dailyProduct.product.retailPrice)}</span>
{dailyProduct.discount && (
<span className="text-green-600 font-medium">
Со скидкой {dailyProduct.discount}%: {formatPrice(calculateDiscountedPrice(dailyProduct.product.retailPrice, dailyProduct.discount))}
</span>
)}
</div>
</div>
</div>
{/* Скидка */}
{dailyProduct.discount && (
<Badge variant="secondary" className="bg-green-100 text-green-800">
-{dailyProduct.discount}%
</Badge>
)}
</div>
{/* Действия */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditDailyProduct(dailyProduct)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteDailyProduct(dailyProduct.id)}
disabled={deletingDaily}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Лучшие цены */}
<TabsContent value="best-price" className="space-y-6">
<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={() => setShowBestPriceProductSelector(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]?.url ? (
<img
src={bestPriceProduct.product.images[0].url}
alt={bestPriceProduct.product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Package className="w-6 h-6 text-gray-400" />
)}
</div>
{/* Информация о товаре */}
<div className="flex-1">
<h3 className="font-medium text-gray-900">{bestPriceProduct.product.name}</h3>
<div className="text-sm text-gray-500 space-y-1">
{bestPriceProduct.product.article && (
<p>Артикул: {bestPriceProduct.product.article}</p>
)}
{bestPriceProduct.product.brand && (
<p>Бренд: {bestPriceProduct.product.brand}</p>
)}
<div className="flex items-center space-x-2">
<span>Цена: {formatPrice(bestPriceProduct.product.retailPrice)}</span>
<span className="text-green-600 font-medium">
Со скидкой {bestPriceProduct.discount}%: {formatPrice(calculateDiscountedPrice(bestPriceProduct.product.retailPrice, bestPriceProduct.discount))}
</span>
</div>
</div>
</div>
{/* Скидка */}
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
-{bestPriceProduct.discount}%
</Badge>
</div>
{/* Действия */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditBestPriceProduct(bestPriceProduct)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteBestPriceProduct(bestPriceProduct.id)}
disabled={deletingBestPrice}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* Топ продаж */}
<TabsContent value="top-sales" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center">
<Package className="w-5 h-5 mr-2" />
Топ продаж
</CardTitle>
<Button
onClick={() => setShowTopSalesProductSelector(true)}
className="flex items-center"
>
<Plus className="w-4 h-4 mr-2" />
Добавить товар
</Button>
</div>
</CardHeader>
<CardContent>
{topSalesProductsLoading ? (
<div className="text-center py-8 text-gray-500">Загрузка товаров...</div>
) : topSalesProducts.length === 0 ? (
<div className="text-center py-8 text-gray-500">
Товары в топ продаж не добавлены
</div>
) : (
<div className="space-y-4">
{topSalesProducts.map((topSalesProduct) => (
<div key={topSalesProduct.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">
{topSalesProduct.product.images?.[0]?.url ? (
<img
src={topSalesProduct.product.images[0].url}
alt={topSalesProduct.product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Package className="w-6 h-6 text-gray-400" />
)}
</div>
{/* Информация о товаре */}
<div className="flex-1">
<h3 className="font-medium text-gray-900">{topSalesProduct.product.name}</h3>
<div className="text-sm text-gray-500 space-y-1">
{topSalesProduct.product.article && (
<p>Артикул: {topSalesProduct.product.article}</p>
)}
{topSalesProduct.product.brand && (
<p>Бренд: {topSalesProduct.product.brand}</p>
)}
<p>Цена: {formatPrice(topSalesProduct.product.retailPrice)}</p>
</div>
</div>
{/* Статус */}
<div className="flex items-center space-x-2">
<Switch
checked={topSalesProduct.isActive}
onCheckedChange={() => handleToggleTopSalesActive(topSalesProduct)}
/>
<span className="text-sm text-gray-500">
{topSalesProduct.isActive ? 'Активен' : 'Неактивен'}
</span>
</div>
</div>
{/* Действия */}
<div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleTopSalesSortOrderChange(topSalesProduct, 'up')}
>
<ChevronUp className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleTopSalesSortOrderChange(topSalesProduct, 'down')}
>
<ChevronDown className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteTopSalesProduct(topSalesProduct.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Диалог добавления товара дня */}
<Dialog open={showDailyProductSelector} onOpenChange={setShowDailyProductSelector}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Добавить товар дня</DialogTitle>
@ -302,75 +757,233 @@ export default function HomepageProductsPage() {
<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" />
<div className="flex items-center space-x-2">
<Search className="w-4 h-4 text-gray-400" />
<Input
placeholder="Поиск товаров по названию, артикулу, бренду..."
placeholder="Поиск товаров..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
className="flex-1"
/>
</div>
{/* Скидка */}
<div>
<Label htmlFor="discount">Скидка (%)</Label>
<Label htmlFor="daily-discount">Скидка (%)</Label>
<Input
id="discount"
id="daily-discount"
type="number"
min="0"
max="99"
value={discount}
onChange={(e) => setDiscount(Number(e.target.value))}
placeholder="Введите размер скидки"
className="w-32"
max="100"
value={dailyDiscount}
onChange={(e) => setDailyDiscount(Number(e.target.value))}
placeholder="Размер скидки"
/>
</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="max-h-96 overflow-y-auto space-y-2">
{productsLoading ? (
<div className="text-center py-4 text-gray-500">Загрузка товаров...</div>
) : products.length === 0 ? (
<div className="text-center py-4 text-gray-500">Товары не найдены</div>
) : (
products.map((product) => (
<div key={product.id} className="border rounded p-3 flex items-center justify-between">
<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] ? (
{product.images?.[0]?.url ? (
<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>
<Package className="w-4 h-4 text-gray-400" />
)}
</div>
<div>
<h4 className="font-medium text-gray-900">{product.name}</h4>
<h4 className="font-medium">{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>
{product.article && <span>Артикул: {product.article} | </span>}
{product.brand && <span>Бренд: {product.brand} | </span>}
<span>Цена: {formatPrice(product.retailPrice)}</span>
</div>
</div>
</div>
<Button
onClick={() => handleAddDailyProduct(product.id)}
disabled={creatingDaily}
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={showBestPriceProductSelector} onOpenChange={setShowBestPriceProductSelector}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Добавить товар с лучшей ценой</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Поиск товаров */}
<div className="flex items-center space-x-2">
<Search className="w-4 h-4 text-gray-400" />
<Input
placeholder="Поиск товаров..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
</div>
{/* Скидка */}
<div>
<Label htmlFor="best-price-discount">Скидка (%)</Label>
<Input
id="best-price-discount"
type="number"
min="0"
max="100"
value={bestPriceDiscount}
onChange={(e) => setBestPriceDiscount(Number(e.target.value))}
placeholder="Размер скидки (необязательно)"
/>
</div>
{/* Список товаров */}
<div className="max-h-96 overflow-y-auto space-y-2">
{productsLoading ? (
<div className="text-center py-4 text-gray-500">Загрузка товаров...</div>
) : products.length === 0 ? (
<div className="text-center py-4 text-gray-500">Товары не найдены</div>
) : (
products.map((product) => (
<div key={product.id} className="border rounded p-3 flex items-center justify-between">
<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]?.url ? (
<img
src={product.images[0].url}
alt={product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Package className="w-4 h-4 text-gray-400" />
)}
</div>
<div>
<h4 className="font-medium">{product.name}</h4>
<div className="text-sm text-gray-500">
{product.article && <span>Артикул: {product.article} | </span>}
{product.brand && <span>Бренд: {product.brand} | </span>}
<span>Цена: {formatPrice(product.retailPrice)}</span>
{bestPriceDiscount > 0 && (
<span className="text-green-600 ml-2">
Со скидкой: {formatPrice(calculateDiscountedPrice(product.retailPrice, bestPriceDiscount))}
</span>
)}
</div>
</div>
</div>
<Button
onClick={() => handleAddBestPriceProduct(product.id)}
disabled={creatingBestPrice}
size="sm"
>
Добавить
</Button>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
{/* Диалог добавления товара в топ продаж */}
<Dialog open={showTopSalesProductSelector} onOpenChange={setShowTopSalesProductSelector}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Добавить товар в топ продаж</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Поиск товаров */}
<div className="flex items-center space-x-2">
<Search className="w-4 h-4 text-gray-400" />
<Input
placeholder="Поиск товаров..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1"
/>
</div>
{/* Список товаров */}
<div className="max-h-96 overflow-y-auto space-y-2">
{productsLoading ? (
<div className="text-center py-4 text-gray-500">Загрузка товаров...</div>
) : products.length === 0 ? (
<div className="text-center py-4 text-gray-500">Товары не найдены</div>
) : (
products.map((product) => (
<div
key={product.id}
className={`border rounded p-3 flex items-center justify-between cursor-pointer transition-colors ${
selectedProduct?.id === product.id ? 'bg-blue-50 border-blue-200' : 'hover:bg-gray-50'
}`}
onClick={() => setSelectedProduct(product)}
>
<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]?.url ? (
<img
src={product.images[0].url}
alt={product.name}
className="w-full h-full object-cover rounded"
/>
) : (
<Package className="w-4 h-4 text-gray-400" />
)}
</div>
<div>
<h4 className="font-medium">{product.name}</h4>
<div className="text-sm text-gray-500">
{product.article && <span>Артикул: {product.article} | </span>}
{product.brand && <span>Бренд: {product.brand} | </span>}
<span>Цена: {formatPrice(product.retailPrice)}</span>
</div>
</div>
</div>
{selectedProduct?.id === product.id && (
<Badge variant="secondary">Выбран</Badge>
)}
</div>
))
)}
</div>
{selectedProduct && (
<div className="pt-4 border-t">
<Button onClick={handleAddTopSalesProduct} className="w-full">
Добавить выбранный товар в топ продаж
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
{/* Модальное окно редактирования */}
{/* Диалог редактирования товара дня */}
<Dialog open={!!editingDailyProduct} onOpenChange={() => setEditingDailyProduct(null)}>
<DialogContent>
<DialogHeader>
@ -380,35 +993,79 @@ export default function HomepageProductsPage() {
{editingDailyProduct && (
<div className="space-y-4">
<div>
<h4 className="font-medium">{editingDailyProduct.product.name}</h4>
<h3 className="font-medium">{editingDailyProduct.product.name}</h3>
<p className="text-sm text-gray-500">
{editingDailyProduct.product.article && `Артикул: ${editingDailyProduct.product.article}`}
{editingDailyProduct.product.brand && `Бренд: ${editingDailyProduct.product.brand}`}
{editingDailyProduct.product.article && `Артикул: ${editingDailyProduct.product.article} | `}
{editingDailyProduct.product.brand && `Бренд: ${editingDailyProduct.product.brand} | `}
Цена: {formatPrice(editingDailyProduct.product.retailPrice)}
</p>
</div>
<div>
<Label htmlFor="edit-discount">Скидка (%)</Label>
<Label htmlFor="edit-daily-discount">Скидка (%)</Label>
<Input
id="edit-discount"
id="edit-daily-discount"
type="number"
min="0"
max="99"
value={discount}
onChange={(e) => setDiscount(Number(e.target.value))}
placeholder="Введите размер скидки"
className="w-32"
max="100"
value={dailyDiscount}
onChange={(e) => setDailyDiscount(Number(e.target.value))}
placeholder="Размер скидки"
/>
</div>
<div className="flex items-center space-x-2 pt-4">
<Button onClick={handleUpdateProduct} disabled={updating}>
{updating ? 'Сохранение...' : 'Сохранить'}
</Button>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingDailyProduct(null)}>
Отмена
</Button>
<Button onClick={handleUpdateDailyProduct} disabled={updatingDaily}>
Сохранить
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
{/* Диалог редактирования товара с лучшей ценой */}
<Dialog open={!!editingBestPriceProduct} onOpenChange={() => setEditingBestPriceProduct(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Редактировать товар с лучшей ценой</DialogTitle>
</DialogHeader>
{editingBestPriceProduct && (
<div className="space-y-4">
<div>
<h3 className="font-medium">{editingBestPriceProduct.product.name}</h3>
<p className="text-sm text-gray-500">
{editingBestPriceProduct.product.article && `Артикул: ${editingBestPriceProduct.product.article} | `}
{editingBestPriceProduct.product.brand && `Бренд: ${editingBestPriceProduct.product.brand} | `}
Цена: {formatPrice(editingBestPriceProduct.product.retailPrice)}
</p>
</div>
<div>
<Label htmlFor="edit-best-price-discount">Скидка (%)</Label>
<Input
id="edit-best-price-discount"
type="number"
min="0"
max="100"
value={bestPriceDiscount}
onChange={(e) => setBestPriceDiscount(Number(e.target.value))}
placeholder="Размер скидки (необязательно)"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingBestPriceProduct(null)}>
Отмена
</Button>
<Button onClick={handleUpdateBestPriceProduct} disabled={updatingBestPrice}>
Сохранить
</Button>
</DialogFooter>
</div>
)}
</DialogContent>

View File

@ -1,436 +0,0 @@
'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

@ -0,0 +1,266 @@
"use client"
import React, { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Search, ChevronRight, ChevronDown, Folder, FolderOpen } from 'lucide-react'
interface Category {
id: string
name: string
slug: string
level?: number
parentId?: string | null
children?: Category[]
_count?: {
products: number
}
}
interface CategorySelectorProps {
open: boolean
onOpenChange: (open: boolean) => void
categories: Category[]
onCategorySelect: (categoryId: string, categoryName: string) => void
title?: string
description?: string
}
export const CategorySelector = ({
open,
onOpenChange,
categories,
onCategorySelect,
title = "Выберите категорию",
description = "Выберите категорию для перемещения товаров"
}: CategorySelectorProps) => {
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
// Функция для построения дерева категорий
const buildCategoryTree = (categories: Category[]): Category[] => {
const categoryMap = new Map<string, Category>()
const rootCategories: Category[] = []
// Создаем карту всех категорий
categories.forEach(category => {
categoryMap.set(category.id, { ...category, children: [] })
})
// Строим дерево
categories.forEach(category => {
const categoryWithChildren = categoryMap.get(category.id)!
if (category.parentId) {
const parent = categoryMap.get(category.parentId)
if (parent) {
parent.children = parent.children || []
parent.children.push(categoryWithChildren)
}
} else {
rootCategories.push(categoryWithChildren)
}
})
return rootCategories
}
// Фильтрация категорий по поисковому запросу
const filterCategories = (categories: Category[], query: string): Category[] => {
if (!query) return categories
const filtered: Category[] = []
categories.forEach(category => {
const matchesQuery = category.name.toLowerCase().includes(query.toLowerCase())
const filteredChildren = category.children ? filterCategories(category.children, query) : []
if (matchesQuery || filteredChildren.length > 0) {
filtered.push({
...category,
children: filteredChildren
})
}
})
return filtered
}
const toggleExpanded = (categoryId: string) => {
const newExpanded = new Set(expandedCategories)
if (newExpanded.has(categoryId)) {
newExpanded.delete(categoryId)
} else {
newExpanded.add(categoryId)
}
setExpandedCategories(newExpanded)
}
const renderCategory = (category: Category, level = 0) => {
const hasChildren = category.children && category.children.length > 0
const isExpanded = expandedCategories.has(category.id)
const isSelected = selectedCategoryId === category.id
return (
<div key={category.id}>
<div
className={`flex items-center py-2 px-3 rounded cursor-pointer hover:bg-gray-50 ${
isSelected ? 'bg-blue-50 border border-blue-200' : ''
}`}
style={{ paddingLeft: `${level * 20 + 12}px` }}
onClick={() => setSelectedCategoryId(category.id)}
>
{hasChildren && (
<button
onClick={(e) => {
e.stopPropagation()
toggleExpanded(category.id)
}}
className="mr-2 p-1 hover:bg-gray-200 rounded"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
)}
{!hasChildren && <div className="w-6 mr-2" />}
<div className="mr-2">
{hasChildren ? (
isExpanded ? <FolderOpen className="w-4 h-4 text-blue-500" /> : <Folder className="w-4 h-4 text-blue-500" />
) : (
<Folder className="w-4 h-4 text-gray-400" />
)}
</div>
<div className="flex-1">
<span className={`text-sm ${isSelected ? 'font-medium text-blue-700' : 'text-gray-900'}`}>
{category.name}
</span>
{category._count && (
<span className="text-xs text-gray-500 ml-2">
({category._count.products} товаров)
</span>
)}
</div>
</div>
{hasChildren && isExpanded && (
<div>
{category.children!.map(child => renderCategory(child, level + 1))}
</div>
)}
</div>
)
}
const handleConfirm = () => {
if (selectedCategoryId) {
const selectedCategory = categories.find(cat => cat.id === selectedCategoryId)
if (selectedCategory) {
onCategorySelect(selectedCategoryId, selectedCategory.name)
onOpenChange(false)
setSelectedCategoryId(null)
setSearchQuery('')
}
}
}
const handleCancel = () => {
onOpenChange(false)
setSelectedCategoryId(null)
setSearchQuery('')
}
const categoryTree = buildCategoryTree(categories)
const filteredCategories = filterCategories(categoryTree, searchQuery)
// Автоматически разворачиваем категории при поиске
React.useEffect(() => {
if (searchQuery) {
const expandAll = (categories: Category[]) => {
categories.forEach(category => {
if (category.children && category.children.length > 0) {
setExpandedCategories(prev => new Set([...prev, category.id]))
expandAll(category.children)
}
})
}
expandAll(filteredCategories)
}
}, [searchQuery, filteredCategories])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && (
<p className="text-sm text-gray-600">{description}</p>
)}
</DialogHeader>
<div className="space-y-4 flex-1 overflow-hidden flex flex-col">
{/* Поиск */}
<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 className="flex-1 overflow-y-auto border rounded-lg">
{filteredCategories.length > 0 ? (
<div className="p-2 space-y-1">
{filteredCategories.map(category => renderCategory(category))}
</div>
) : (
<div className="p-8 text-center text-gray-500">
{searchQuery ? 'Категории не найдены' : 'Нет доступных категорий'}
</div>
)}
</div>
{/* Выбранная категория */}
{selectedCategoryId && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg">
<Label className="text-sm font-medium text-blue-900">Выбранная категория:</Label>
<p className="text-sm text-blue-700 mt-1">
{categories.find(cat => cat.id === selectedCategoryId)?.name}
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
Отмена
</Button>
<Button
onClick={handleConfirm}
disabled={!selectedCategoryId}
style={{ cursor: selectedCategoryId ? 'pointer' : 'not-allowed' }}
>
Переместить товары
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -29,6 +29,7 @@ interface Product {
isVisible: boolean
applyDiscounts: boolean
stock: number
brand?: string
categories: Category[]
images: ProductImage[]
characteristics: ProductCharacteristic[]
@ -188,6 +189,7 @@ export const ProductForm = ({
isVisible: true,
applyDiscounts: true,
stock: '0',
brand: '',
categoryIds: selectedCategoryId ? [selectedCategoryId] : [] as string[]
})
@ -250,6 +252,7 @@ export const ProductForm = ({
isVisible: product.isVisible ?? true,
applyDiscounts: product.applyDiscounts ?? true,
stock: product.stock?.toString() || '0',
brand: product.brand || '',
categoryIds: product.categories?.map(cat => cat.id) || []
})
// Очищаем изображения от лишних полей
@ -474,6 +477,11 @@ export const ProductForm = ({
return
}
if (!formData.brand.trim()) {
alert('Введите бренд товара')
return
}
try {
const dimensions = [formData.dimensionLength, formData.dimensionWidth, formData.dimensionHeight]
.filter(d => d.trim())
@ -493,6 +501,7 @@ export const ProductForm = ({
isVisible: formData.isVisible,
applyDiscounts: formData.applyDiscounts,
stock: parseInt(formData.stock) || 0,
brand: formData.brand.trim(),
categoryIds: formData.categoryIds
}
@ -658,8 +667,8 @@ export const ProductForm = ({
</div>
{/* Основная информация */}
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-3">
<Label htmlFor="name">Наименование *</Label>
<Input
id="name"
@ -680,6 +689,18 @@ export const ProductForm = ({
/>
</div>
<div>
<Label htmlFor="brand">Бренд *</Label>
<Input
id="brand"
value={formData.brand}
onChange={(e) => handleInputChange('brand', e.target.value)}
placeholder="Введите бренд товара"
required
style={{ cursor: 'pointer' }}
/>
</div>
<div>
<Label htmlFor="slug">Адрес (Slug)</Label>
<Input

View File

@ -1,12 +1,13 @@
"use client"
import React, { useState } from 'react'
import { Loader2, Package, Plus, Edit, Trash2 } from 'lucide-react'
import { Loader2, Package, Plus, Edit, Trash2, FolderOpen } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { useMutation } from '@apollo/client'
import { DELETE_PRODUCT, DELETE_PRODUCTS, UPDATE_PRODUCT_VISIBILITY, UPDATE_PRODUCTS_VISIBILITY } from '@/lib/graphql/mutations'
import { DELETE_PRODUCT, DELETE_PRODUCTS, UPDATE_PRODUCT_VISIBILITY, UPDATE_PRODUCTS_VISIBILITY, MOVE_PRODUCTS_TO_CATEGORY } from '@/lib/graphql/mutations'
import { CategorySelector } from './CategorySelector'
interface Product {
id: string
@ -20,22 +21,36 @@ interface Product {
categories: { id: string; name: string }[]
}
interface Category {
id: string
name: string
slug: string
level?: number
parentId?: string | null
_count?: {
products: number
}
}
interface ProductListProps {
products: Product[]
loading?: boolean
onProductEdit: (product: Product) => void
onProductCreated: () => void
categories?: Category[]
}
export const ProductList = ({ products, loading, onProductEdit, onProductCreated }: ProductListProps) => {
export const ProductList = ({ products, loading, onProductEdit, onProductCreated, categories = [] }: ProductListProps) => {
const [selectedProducts, setSelectedProducts] = useState<string[]>([])
const [selectAll, setSelectAll] = useState(false)
const [bulkLoading, setBulkLoading] = useState(false)
const [showCategorySelector, setShowCategorySelector] = useState(false)
const [deleteProduct] = useMutation(DELETE_PRODUCT)
const [deleteProducts] = useMutation(DELETE_PRODUCTS)
const [updateProductVisibility] = useMutation(UPDATE_PRODUCT_VISIBILITY)
const [updateProductsVisibility] = useMutation(UPDATE_PRODUCTS_VISIBILITY)
const [moveProductsToCategory] = useMutation(MOVE_PRODUCTS_TO_CATEGORY)
const handleSelectAll = (checked: boolean) => {
setSelectAll(checked)
@ -120,6 +135,30 @@ export const ProductList = ({ products, loading, onProductEdit, onProductCreated
}
}
const handleMoveToCategory = async (categoryId: string, categoryName: string) => {
if (selectedProducts.length === 0) return
setBulkLoading(true)
try {
const result = await moveProductsToCategory({
variables: {
productIds: selectedProducts,
categoryId
}
})
console.log('Результат перемещения товаров:', result)
alert(`Успешно перемещено ${result.data?.moveProductsToCategory?.count || selectedProducts.length} товаров в категорию "${categoryName}"`)
setSelectedProducts([])
setSelectAll(false)
onProductCreated() // Обновляем список
} catch (error) {
console.error('Ошибка перемещения товаров:', error)
alert('Не удалось переместить товары в категорию')
} finally {
setBulkLoading(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@ -174,6 +213,15 @@ export const ProductList = ({ products, loading, onProductEdit, onProductCreated
{bulkLoading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : null}
Скрыть с сайта
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowCategorySelector(true)}
disabled={bulkLoading}
>
{bulkLoading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <FolderOpen className="w-4 h-4 mr-2" />}
Переместить в категорию
</Button>
<Button
variant="outline"
size="sm"
@ -311,7 +359,15 @@ export const ProductList = ({ products, loading, onProductEdit, onProductCreated
))}
</div>
{/* Модальное окно выбора категории */}
<CategorySelector
open={showCategorySelector}
onOpenChange={setShowCategorySelector}
categories={categories}
onCategorySelect={handleMoveToCategory}
title="Переместить товары в категорию"
description={`Выберите категорию для перемещения ${selectedProducts.length} товаров`}
/>
</div>
)
}

View File

@ -39,16 +39,6 @@ const navigationItems = [
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

@ -17,6 +17,7 @@ export const CREATE_PRODUCT = gql`
isVisible
applyDiscounts
stock
brand
createdAt
updatedAt
categories {
@ -72,6 +73,7 @@ export const UPDATE_PRODUCT = gql`
isVisible
applyDiscounts
stock
brand
createdAt
updatedAt
categories {
@ -193,6 +195,22 @@ export const UPDATE_PRODUCTS_VISIBILITY = gql`
}
`
export const MOVE_PRODUCTS_TO_CATEGORY = gql`
mutation MoveProductsToCategory($productIds: [ID!]!, $categoryId: ID!) {
moveProductsToCategory(productIds: $productIds, categoryId: $categoryId) {
count
movedProducts {
id
name
categories {
id
name
}
}
}
}
`
export const EXPORT_PRODUCTS = gql`
mutation ExportProducts($categoryId: String, $search: String, $format: String) {
exportProducts(categoryId: $categoryId, search: $search, format: $format) {

View File

@ -146,6 +146,7 @@ interface ProductInput {
isVisible?: boolean
applyDiscounts?: boolean
stock?: number
brand: string
categoryIds?: string[]
}
@ -3777,6 +3778,78 @@ export const resolvers = {
console.error('Ошибка получения товара из топ продаж:', error)
throw new Error('Не удалось получить товар из топ продаж')
}
},
// Новые поступления
newArrivals: async (_: unknown, { limit = 8 }: { limit?: number }) => {
try {
const products = await prisma.product.findMany({
where: {
isVisible: true,
AND: [
{
OR: [
{ article: { not: null } },
{ brand: { not: null } }
]
}
]
},
include: {
images: {
orderBy: { order: 'asc' }
},
categories: true
},
orderBy: { createdAt: 'desc' },
take: limit
})
// Для товаров без изображений пытаемся получить их из PartsIndex
const productsWithImages = await Promise.all(
products.map(async (product) => {
// Если у товара уже есть изображения, возвращаем как есть
if (product.images && product.images.length > 0) {
return product
}
// Если нет изображений и есть артикул и бренд, пытаемся получить из 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 {
...product,
images: partsIndexImages
}
}
} catch (error) {
console.error(`Ошибка получения изображений из PartsIndex для товара ${product.id}:`, error)
}
}
return product
})
)
return productsWithImages
} catch (error) {
console.error('Ошибка получения новых поступлений:', error)
throw new Error('Не удалось получить новые поступления')
}
}
},
@ -4561,6 +4634,233 @@ export const resolvers = {
}
},
updateProduct: async (_: unknown, {
id,
input,
images = [],
characteristics = [],
options = []
}: {
id: string;
input: ProductInput;
images?: ProductImageInput[];
characteristics?: CharacteristicInput[];
options?: ProductOptionInput[]
}, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
// Получаем текущий товар для логирования изменений
const existingProduct = await prisma.product.findUnique({
where: { id },
include: {
categories: true,
images: true,
characteristics: { include: { characteristic: true } },
options: { include: { option: true, optionValue: true } }
}
})
if (!existingProduct) {
throw new Error('Товар не найден')
}
// Проверяем уникальность slug если он изменился
if (input.slug && input.slug !== existingProduct.slug) {
const existingBySlug = await prisma.product.findUnique({
where: { slug: input.slug }
})
if (existingBySlug) {
throw new Error('Товар с таким адресом уже существует')
}
}
// Проверяем уникальность артикула если он изменился
if (input.article && input.article !== existingProduct.article) {
const existingByArticle = await prisma.product.findUnique({
where: { article: input.article }
})
if (existingByArticle) {
throw new Error('Товар с таким артикулом уже существует')
}
}
const { categoryIds, ...productData } = input
// Обновляем основные данные товара
await prisma.product.update({
where: { id },
data: {
...productData,
categories: categoryIds ? {
set: categoryIds.map(categoryId => ({ id: categoryId }))
} : undefined
}
})
// Удаляем старые изображения и добавляем новые
await prisma.productImage.deleteMany({
where: { productId: id }
})
if (images.length > 0) {
await prisma.productImage.createMany({
data: images.map((img, index) => ({
productId: id,
url: img.url,
alt: img.alt || '',
order: img.order ?? index
}))
})
}
// Удаляем старые характеристики и добавляем новые
await prisma.productCharacteristic.deleteMany({
where: { productId: id }
})
for (const char of characteristics) {
let characteristic = await prisma.characteristic.findUnique({
where: { name: char.name }
})
if (!characteristic) {
characteristic = await prisma.characteristic.create({
data: { name: char.name }
})
}
await prisma.productCharacteristic.create({
data: {
productId: id,
characteristicId: characteristic.id,
value: char.value
}
})
}
// Удаляем старые опции и добавляем новые
await prisma.productOption.deleteMany({
where: { productId: id }
})
for (const optionInput of options) {
// Создаём или находим опцию
let option = await prisma.option.findUnique({
where: { name: optionInput.name }
})
if (!option) {
option = await prisma.option.create({
data: {
name: optionInput.name,
type: optionInput.type
}
})
}
// Создаём значения опции и связываем с товаром
for (const valueInput of optionInput.values) {
// Создаём или находим значение опции
let optionValue = await prisma.optionValue.findFirst({
where: {
optionId: option.id,
value: valueInput.value
}
})
if (!optionValue) {
optionValue = await prisma.optionValue.create({
data: {
optionId: option.id,
value: valueInput.value,
price: valueInput.price || 0
}
})
}
// Связываем товар с опцией и значением
await prisma.productOption.create({
data: {
productId: id,
optionId: option.id,
optionValueId: optionValue.id
}
})
}
}
// Получаем обновленный товар со всеми связанными данными
const updatedProduct = await prisma.product.findUnique({
where: { id },
include: {
categories: true,
images: { orderBy: { order: 'asc' } },
options: {
include: {
option: { include: { values: true } },
optionValue: true
}
},
characteristics: { include: { characteristic: true } },
products_RelatedProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_RelatedProducts_B: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_A: { include: { images: { orderBy: { order: 'asc' } } } },
products_AccessoryProducts_B: { include: { images: { orderBy: { order: 'asc' } } } }
}
})
// Создаем запись в истории товара
if (context.userId) {
await prisma.productHistory.create({
data: {
productId: id,
action: 'UPDATE',
changes: JSON.stringify({
name: input.name,
article: input.article,
description: input.description,
brand: input.brand,
wholesalePrice: input.wholesalePrice,
retailPrice: input.retailPrice,
stock: input.stock,
isVisible: input.isVisible,
categories: categoryIds,
images: images.length,
characteristics: characteristics.length,
options: options.length
}),
userId: context.userId
}
})
}
// Логируем действие
if (context.headers) {
const { ipAddress, userAgent } = getClientInfo(context.headers)
await createAuditLog({
userId: context.userId,
action: AuditAction.PRODUCT_UPDATE,
details: `Товар "${input.name}"`,
ipAddress,
userAgent
})
}
return updatedProduct
} catch (error) {
console.error('Ошибка обновления товара:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось обновить товар')
}
},
updateProductVisibility: async (_: unknown, { id, isVisible }: { id: string; isVisible: boolean }, context: Context) => {
try {
if (!context.userId) {
@ -4699,6 +4999,105 @@ export const resolvers = {
}
},
moveProductsToCategory: async (_: unknown, { productIds, categoryId }: { productIds: string[]; categoryId: string }, context: Context) => {
try {
if (!context.userId) {
throw new Error('Пользователь не авторизован')
}
if (!productIds || productIds.length === 0) {
throw new Error('Не указаны товары для перемещения')
}
if (!categoryId) {
throw new Error('Не указана целевая категория')
}
// Проверяем существование категории
const targetCategory = await prisma.category.findUnique({
where: { id: categoryId },
select: { id: true, name: true }
})
if (!targetCategory) {
throw new Error('Целевая категория не найдена')
}
// Получаем информацию о товарах для логирования
const products = await prisma.product.findMany({
where: { id: { in: productIds } },
select: {
id: true,
name: true,
categories: { select: { id: true, name: true } }
}
})
if (products.length === 0) {
throw new Error('Товары не найдены')
}
// Обновляем категории для каждого товара в транзакции
const updatePromises = productIds.map(async (productId) => {
// Сначала отключаем товар от всех категорий
await prisma.product.update({
where: { id: productId },
data: {
categories: {
set: []
}
}
})
// Затем подключаем к новой категории
return prisma.product.update({
where: { id: productId },
data: {
categories: {
connect: { id: categoryId }
}
}
})
})
await Promise.all(updatePromises)
// Получаем обновленные товары для ответа
const updatedProducts = await prisma.product.findMany({
where: { id: { in: productIds } },
select: {
id: true,
name: true,
categories: { select: { id: true, name: true } }
}
})
// Логируем действие
if (context.headers) {
const { ipAddress, userAgent } = getClientInfo(context.headers)
await createAuditLog({
userId: context.userId,
action: AuditAction.PRODUCT_UPDATE,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
details: `Массовое перемещение товаров в категорию "${targetCategory.name}": ${products.map((p: any) => p.name).join(', ')} (${products.length} шт.)`,
ipAddress,
userAgent
})
}
return {
count: products.length,
movedProducts: updatedProducts
}
} catch (error) {
console.error('Ошибка перемещения товаров в категорию:', error)
if (error instanceof Error) {
throw error
}
throw new Error('Не удалось переместить товары в категорию')
}
},
exportProducts: async (_: unknown, { categoryId, search }: {
categoryId?: string; search?: string; format?: string
}, context: Context) => {

View File

@ -1025,6 +1025,9 @@ export const typeDefs = gql`
# Топ продаж
topSalesProducts: [TopSalesProduct!]!
topSalesProduct(id: ID!): TopSalesProduct
# Новые поступления
newArrivals(limit: Int = 8): [Product!]!
}
type AuthPayload {
@ -1062,6 +1065,11 @@ export const typeDefs = gql`
count: Int!
}
type MoveProductsResult {
count: Int!
movedProducts: [Product!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
login(input: LoginInput!): AuthPayload!
@ -1089,6 +1097,7 @@ export const typeDefs = gql`
# Массовые операции с товарами
deleteProducts(ids: [ID!]!): BulkOperationResult!
updateProductsVisibility(ids: [ID!]!, isVisible: Boolean!): BulkOperationResult!
moveProductsToCategory(productIds: [ID!]!, categoryId: ID!): MoveProductsResult!
exportProducts(categoryId: String, search: String, format: String): ExportResult!
importProducts(input: ImportProductsInput!): ImportResult!