Удалены страницы для управления товарами с лучшими ценами и топом продаж. Обновлены компоненты и резолверы для улучшения структуры данных и функциональности. Изменены типы GraphQL и обновлены запросы для повышения гибкости взаимодействия с API. Упрощена навигация в боковом меню, что улучшает пользовательский опыт.
This commit is contained in:
@ -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>
|
||||
)
|
||||
}
|
@ -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}
|
||||
/>
|
||||
|
||||
{/* Пагинация и информация */}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
266
src/components/catalog/CategorySelector.tsx
Normal file
266
src/components/catalog/CategorySelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
@ -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',
|
||||
|
@ -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) {
|
||||
|
@ -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) => {
|
||||
|
@ -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!
|
||||
|
||||
|
Reference in New Issue
Block a user