Удалены страницы для управления товарами с лучшими ценами и топом продаж. Обновлены компоненты и резолверы для улучшения структуры данных и функциональности. Изменены типы GraphQL и обновлены запросы для повышения гибкости взаимодействия с API. Упрощена навигация в боковом меню, что улучшает пользовательский опыт.
This commit is contained in:
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',
|
||||
|
Reference in New Issue
Block a user