diff --git a/src/app/dashboard/best-price-products/page.tsx b/src/app/dashboard/best-price-products/page.tsx deleted file mode 100644 index 7ce3267..0000000 --- a/src/app/dashboard/best-price-products/page.tsx +++ /dev/null @@ -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(null) - const [searchQuery, setSearchQuery] = useState('') - const [discount, setDiscount] = useState(0) - - const { data: bestPriceProductsData, loading: bestPriceProductsLoading, refetch: refetchBestPriceProducts } = useQuery(GET_BEST_PRICE_PRODUCTS) - - const { data: productsData, loading: productsLoading } = useQuery(GET_PRODUCTS, { - variables: { - search: searchQuery || undefined, - limit: 50 - }, - skip: !showProductSelector - }) - - const [createBestPriceProduct, { loading: creating }] = useMutation(CREATE_BEST_PRICE_PRODUCT) - const [updateBestPriceProduct, { loading: updating }] = useMutation(UPDATE_BEST_PRICE_PRODUCT) - const [deleteBestPriceProduct, { loading: deleting }] = useMutation(DELETE_BEST_PRICE_PRODUCT) - - const bestPriceProducts: BestPriceProduct[] = bestPriceProductsData?.bestPriceProducts || [] - const products: Product[] = productsData?.products || [] - - const handleAddProduct = async (productId: string) => { - if (!discount || discount <= 0) { - toast.error('Укажите размер скидки больше 0%') - return - } - - try { - await createBestPriceProduct({ - variables: { - input: { - productId, - discount, - isActive: true, - sortOrder: bestPriceProducts.length - } - } - }) - - toast.success('Товар добавлен в лучшие цены!') - setShowProductSelector(false) - setDiscount(0) - refetchBestPriceProducts() - } catch (error) { - console.error('Ошибка добавления товара:', error) - toast.error('Не удалось добавить товар') - } - } - - const handleEditProduct = (bestPriceProduct: BestPriceProduct) => { - setEditingBestPriceProduct(bestPriceProduct) - setDiscount(bestPriceProduct.discount || 0) - } - - const handleUpdateProduct = async () => { - if (!editingBestPriceProduct) return - - if (!discount || discount <= 0) { - toast.error('Укажите размер скидки больше 0%') - return - } - - try { - await updateBestPriceProduct({ - variables: { - id: editingBestPriceProduct.id, - input: { - discount, - isActive: editingBestPriceProduct.isActive - } - } - }) - - toast.success('Товар обновлен!') - setEditingBestPriceProduct(null) - setDiscount(0) - refetchBestPriceProducts() - } catch (error) { - console.error('Ошибка обновления товара:', error) - toast.error('Не удалось обновить товар') - } - } - - const handleDeleteProduct = async (id: string) => { - if (!confirm('Удалить товар из списка товаров с лучшей ценой?')) return - - try { - await deleteBestPriceProduct({ - variables: { id } - }) - - toast.success('Товар удален!') - refetchBestPriceProducts() - } catch (error) { - console.error('Ошибка удаления товара:', error) - toast.error('Не удалось удалить товар') - } - } - - const formatPrice = (price?: number) => { - if (!price) return '—' - return `${price.toLocaleString('ru-RU')} ₽` - } - - const calculateDiscountedPrice = (price?: number, discount?: number) => { - if (!price || !discount) return price - return price * (1 - discount / 100) - } - - return ( -
-
-

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

-

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

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

{bestPriceProduct.product.name}

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

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

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

{product.name}

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

{editingBestPriceProduct.product.name}

-

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

-
- -
- - setDiscount(Number(e.target.value))} - placeholder="Введите размер скидки" - className="w-32" - required - /> -
- -
- - -
-
- )} -
-
-
- ) -} \ No newline at end of file diff --git a/src/app/dashboard/catalog/page.tsx b/src/app/dashboard/catalog/page.tsx index 8bb2403..257118f 100644 --- a/src/app/dashboard/catalog/page.tsx +++ b/src/app/dashboard/catalog/page.tsx @@ -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} /> {/* Пагинация и информация */} diff --git a/src/app/dashboard/homepage-products/page.tsx b/src/app/dashboard/homepage-products/page.tsx index 666fa95..0cfcf8f 100644 --- a/src/app/dashboard/homepage-products/page.tsx +++ b/src/app/dashboard/homepage-products/page.tsx @@ -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(format(new Date(), 'yyyy-MM-dd')) - const [showProductSelector, setShowProductSelector] = useState(false) + const [showDailyProductSelector, setShowDailyProductSelector] = useState(false) const [editingDailyProduct, setEditingDailyProduct] = useState(null) - const [searchQuery, setSearchQuery] = useState('') - const [discount, setDiscount] = useState(0) + const [dailyDiscount, setDailyDiscount] = useState(0) + // Состояния для лучших цен + const [showBestPriceProductSelector, setShowBestPriceProductSelector] = useState(false) + const [editingBestPriceProduct, setEditingBestPriceProduct] = useState(null) + const [bestPriceDiscount, setBestPriceDiscount] = useState(0) + + // Состояния для топ продаж + const [showTopSalesProductSelector, setShowTopSalesProductSelector] = useState(false) + const [editingTopSalesProduct, setEditingTopSalesProduct] = useState(null) + const [selectedProduct, setSelectedProduct] = useState(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 (
-

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

-

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

+

Управление товарами главной страницы

+

Управление товарами дня, лучшими ценами и топ продажами на главной странице сайта

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

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

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

{dailyProduct.product.name}

-
- {dailyProduct.product.article && ( - Артикул: {dailyProduct.product.article} - )} - {dailyProduct.product.brand && ( - Бренд: {dailyProduct.product.brand} - )} -
-
- {dailyProduct.discount ? ( - <> - - от {formatPrice(calculateDiscountedPrice(dailyProduct.product.retailPrice, dailyProduct.discount))} - - - {formatPrice(dailyProduct.product.retailPrice)} - - - -{dailyProduct.discount}% - - - ) : ( - - от {formatPrice(dailyProduct.product.retailPrice)} - - )} -
-
-
- - {/* Действия */} -
- - -
+ {/* Товары дня */} + + {/* Выбор даты */} + + + + + Выбор даты показа + + + +
+
+ + setSelectedDate(e.target.value)} + className="w-48" + />
- ))} -
- )} -
-
+
+

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

+
+
+ + - {/* Модальное окно выбора товара */} - + {/* Товары дня */} + + +
+ + + Товары дня + + +
+
+ + {dailyProductsLoading ? ( +
Загрузка товаров...
+ ) : dailyProducts.length === 0 ? ( +
+ Товары дня не добавлены на выбранную дату +
+ ) : ( +
+ {dailyProducts.map((dailyProduct) => ( +
+
+ {/* Изображение товара */} +
+ {dailyProduct.product.images?.[0]?.url ? ( + {dailyProduct.product.name} + ) : ( + + )} +
+ + {/* Информация о товаре */} +
+

{dailyProduct.product.name}

+
+ {dailyProduct.product.article && ( +

Артикул: {dailyProduct.product.article}

+ )} + {dailyProduct.product.brand && ( +

Бренд: {dailyProduct.product.brand}

+ )} +
+ Цена: {formatPrice(dailyProduct.product.retailPrice)} + {dailyProduct.discount && ( + + Со скидкой {dailyProduct.discount}%: {formatPrice(calculateDiscountedPrice(dailyProduct.product.retailPrice, dailyProduct.discount))} + + )} +
+
+
+ + {/* Скидка */} + {dailyProduct.discount && ( + + -{dailyProduct.discount}% + + )} +
+ + {/* Действия */} +
+ + +
+
+ ))} +
+ )} +
+
+ + + {/* Лучшие цены */} + + + +
+ + + Товары с лучшей ценой + + +
+
+ + {bestPriceProductsLoading ? ( +
Загрузка товаров...
+ ) : bestPriceProducts.length === 0 ? ( +
+ Товары с лучшей ценой не добавлены +
+ ) : ( +
+ {bestPriceProducts.map((bestPriceProduct) => ( +
+
+ {/* Изображение товара */} +
+ {bestPriceProduct.product.images?.[0]?.url ? ( + {bestPriceProduct.product.name} + ) : ( + + )} +
+ + {/* Информация о товаре */} +
+

{bestPriceProduct.product.name}

+
+ {bestPriceProduct.product.article && ( +

Артикул: {bestPriceProduct.product.article}

+ )} + {bestPriceProduct.product.brand && ( +

Бренд: {bestPriceProduct.product.brand}

+ )} +
+ Цена: {formatPrice(bestPriceProduct.product.retailPrice)} + + Со скидкой {bestPriceProduct.discount}%: {formatPrice(calculateDiscountedPrice(bestPriceProduct.product.retailPrice, bestPriceProduct.discount))} + +
+
+
+ + {/* Скидка */} + + -{bestPriceProduct.discount}% + +
+ + {/* Действия */} +
+ + +
+
+ ))} +
+ )} +
+
+
+ + {/* Топ продаж */} + + + +
+ + + Топ продаж + + +
+
+ + {topSalesProductsLoading ? ( +
Загрузка товаров...
+ ) : topSalesProducts.length === 0 ? ( +
+ Товары в топ продаж не добавлены +
+ ) : ( +
+ {topSalesProducts.map((topSalesProduct) => ( +
+
+ {/* Изображение товара */} +
+ {topSalesProduct.product.images?.[0]?.url ? ( + {topSalesProduct.product.name} + ) : ( + + )} +
+ + {/* Информация о товаре */} +
+

{topSalesProduct.product.name}

+
+ {topSalesProduct.product.article && ( +

Артикул: {topSalesProduct.product.article}

+ )} + {topSalesProduct.product.brand && ( +

Бренд: {topSalesProduct.product.brand}

+ )} +

Цена: {formatPrice(topSalesProduct.product.retailPrice)}

+
+
+ + {/* Статус */} +
+ handleToggleTopSalesActive(topSalesProduct)} + /> + + {topSalesProduct.isActive ? 'Активен' : 'Неактивен'} + +
+
+ + {/* Действия */} +
+ + + +
+
+ ))} +
+ )} +
+
+
+ + + {/* Диалог добавления товара дня */} + Добавить товар дня @@ -302,75 +757,233 @@ export default function HomepageProductsPage() {
{/* Поиск товаров */} -
- +
+ setSearchQuery(e.target.value)} - className="pl-10" + className="flex-1" />
{/* Скидка */}
- + setDiscount(Number(e.target.value))} - placeholder="Введите размер скидки" - className="w-32" + max="100" + value={dailyDiscount} + onChange={(e) => setDailyDiscount(Number(e.target.value))} + placeholder="Размер скидки" />
{/* Список товаров */} - {productsLoading ? ( -
Загрузка товаров...
- ) : ( -
- {products.map((product) => ( -
+
+ {productsLoading ? ( +
Загрузка товаров...
+ ) : products.length === 0 ? ( +
Товары не найдены
+ ) : ( + products.map((product) => ( +
- {product.images?.[0] ? ( + {product.images?.[0]?.url ? ( {product.name} ) : ( - Нет фото + )}
-

{product.name}

+

{product.name}

- {product.article && Артикул: {product.article}} - {product.brand && Бренд: {product.brand}} - Цена: {formatPrice(product.retailPrice)} + {product.article && Артикул: {product.article} | } + {product.brand && Бренд: {product.brand} | } + Цена: {formatPrice(product.retailPrice)}
- ))} + )) + )} +
+
+ +
+ + {/* Диалог добавления товара с лучшей ценой */} + + + + Добавить товар с лучшей ценой + + +
+ {/* Поиск товаров */} +
+ + setSearchQuery(e.target.value)} + className="flex-1" + /> +
+ + {/* Скидка */} +
+ + setBestPriceDiscount(Number(e.target.value))} + placeholder="Размер скидки (необязательно)" + /> +
+ + {/* Список товаров */} +
+ {productsLoading ? ( +
Загрузка товаров...
+ ) : products.length === 0 ? ( +
Товары не найдены
+ ) : ( + products.map((product) => ( +
+
+
+ {product.images?.[0]?.url ? ( + {product.name} + ) : ( + + )} +
+
+

{product.name}

+
+ {product.article && Артикул: {product.article} | } + {product.brand && Бренд: {product.brand} | } + Цена: {formatPrice(product.retailPrice)} + {bestPriceDiscount > 0 && ( + + Со скидкой: {formatPrice(calculateDiscountedPrice(product.retailPrice, bestPriceDiscount))} + + )} +
+
+
+ +
+ )) + )} +
+
+
+
+ + {/* Диалог добавления товара в топ продаж */} + + + + Добавить товар в топ продаж + + +
+ {/* Поиск товаров */} +
+ + setSearchQuery(e.target.value)} + className="flex-1" + /> +
+ + {/* Список товаров */} +
+ {productsLoading ? ( +
Загрузка товаров...
+ ) : products.length === 0 ? ( +
Товары не найдены
+ ) : ( + products.map((product) => ( +
setSelectedProduct(product)} + > +
+
+ {product.images?.[0]?.url ? ( + {product.name} + ) : ( + + )} +
+
+

{product.name}

+
+ {product.article && Артикул: {product.article} | } + {product.brand && Бренд: {product.brand} | } + Цена: {formatPrice(product.retailPrice)} +
+
+
+ {selectedProduct?.id === product.id && ( + Выбран + )} +
+ )) + )} +
+ + {selectedProduct && ( +
+
)}
- {/* Модальное окно редактирования */} + {/* Диалог редактирования товара дня */} setEditingDailyProduct(null)}> @@ -380,35 +993,79 @@ export default function HomepageProductsPage() { {editingDailyProduct && (
-

{editingDailyProduct.product.name}

+

{editingDailyProduct.product.name}

- {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)}

- + setDiscount(Number(e.target.value))} - placeholder="Введите размер скидки" - className="w-32" + max="100" + value={dailyDiscount} + onChange={(e) => setDailyDiscount(Number(e.target.value))} + placeholder="Размер скидки" />
-
- + + + +
+ )} + +
+ + {/* Диалог редактирования товара с лучшей ценой */} + setEditingBestPriceProduct(null)}> + + + Редактировать товар с лучшей ценой + + + {editingBestPriceProduct && ( +
+
+

{editingBestPriceProduct.product.name}

+

+ {editingBestPriceProduct.product.article && `Артикул: ${editingBestPriceProduct.product.article} | `} + {editingBestPriceProduct.product.brand && `Бренд: ${editingBestPriceProduct.product.brand} | `} + Цена: {formatPrice(editingBestPriceProduct.product.retailPrice)} +

+ +
+ + setBestPriceDiscount(Number(e.target.value))} + placeholder="Размер скидки (необязательно)" + /> +
+ + + + +
)}
diff --git a/src/app/dashboard/top-sales-products/page.tsx b/src/app/dashboard/top-sales-products/page.tsx deleted file mode 100644 index b97b6e1..0000000 --- a/src/app/dashboard/top-sales-products/page.tsx +++ /dev/null @@ -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(null) - const [editingItem, setEditingItem] = useState(null) - const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) - - // Загружаем топ продаж - const { data: topSalesData, loading: topSalesLoading, refetch: refetchTopSales } = useQuery<{ - topSalesProducts: TopSalesProduct[] - }>(GET_TOP_SALES_PRODUCTS) - - // Загружаем все товары для поиска - const { data: productsData, loading: productsLoading } = useQuery<{ - products: Product[] - }>(GET_PRODUCTS, { - variables: { limit: 100 } - }) - - // Мутации - const [createTopSalesProduct] = useMutation< - { createTopSalesProduct: TopSalesProduct }, - { input: TopSalesProductInput } - >(CREATE_TOP_SALES_PRODUCT, { - onCompleted: () => { - toast.success('Товар добавлен в топ продаж') - refetchTopSales() - setIsAddDialogOpen(false) - setSelectedProduct(null) - }, - onError: (error) => { - toast.error(`Ошибка: ${error.message}`) - } - }) - - const [updateTopSalesProduct] = useMutation< - { updateTopSalesProduct: TopSalesProduct }, - { id: string; input: TopSalesProductUpdateInput } - >(UPDATE_TOP_SALES_PRODUCT, { - onCompleted: () => { - toast.success('Товар обновлен') - refetchTopSales() - setIsEditDialogOpen(false) - setEditingItem(null) - }, - onError: (error) => { - toast.error(`Ошибка: ${error.message}`) - } - }) - - const [deleteTopSalesProduct] = useMutation< - { deleteTopSalesProduct: boolean }, - { id: string } - >(DELETE_TOP_SALES_PRODUCT, { - onCompleted: () => { - toast.success('Товар удален из топ продаж') - refetchTopSales() - }, - onError: (error) => { - toast.error(`Ошибка: ${error.message}`) - } - }) - - // Фильтрация товаров для поиска - const filteredProducts = productsData?.products?.filter(product => - product.name.toLowerCase().includes(searchTerm.toLowerCase()) || - product.article?.toLowerCase().includes(searchTerm.toLowerCase()) || - product.brand?.toLowerCase().includes(searchTerm.toLowerCase()) - ) || [] - - // Обработчики - const handleAddProduct = () => { - if (!selectedProduct) { - toast.error('Выберите товар') - return - } - - createTopSalesProduct({ - variables: { - input: { - productId: selectedProduct.id, - isActive: true, - sortOrder: 0 - } - } - }) - } - - const handleUpdateProduct = (isActive: boolean, sortOrder: number) => { - if (!editingItem) return - - updateTopSalesProduct({ - variables: { - id: editingItem.id, - input: { - isActive, - sortOrder - } - } - }) - } - - const handleDeleteProduct = (id: string) => { - if (confirm('Вы уверены, что хотите удалить этот товар из топ продаж?')) { - deleteTopSalesProduct({ - variables: { id } - }) - } - } - - const handleToggleActive = (item: TopSalesProduct) => { - updateTopSalesProduct({ - variables: { - id: item.id, - input: { - isActive: !item.isActive, - sortOrder: item.sortOrder - } - } - }) - } - - const handleSortOrderChange = (item: TopSalesProduct, direction: 'up' | 'down') => { - const newSortOrder = direction === 'up' ? item.sortOrder - 1 : item.sortOrder + 1 - updateTopSalesProduct({ - variables: { - id: item.id, - input: { - isActive: item.isActive, - sortOrder: Math.max(0, newSortOrder) - } - } - }) - } - - if (topSalesLoading) { - return
Загрузка...
- } - - const topSalesProducts = topSalesData?.topSalesProducts || [] - - return ( -
-
-

Топ продаж

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

{product.name}

-

- {product.brand} • {product.article} -

- {product.retailPrice && ( -

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

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

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

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

{item.product.name}

-

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

- {item.product.retailPrice && ( -

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

- )} -
- - {item.isActive ? 'Активен' : 'Неактивен'} - - Порядок: {item.sortOrder} -
-
-
- -
- {/* Управление порядком */} -
- - -
- - {/* Переключатель активности */} -
- - handleToggleActive(item)} - /> -
- - {/* Кнопка редактирования */} - - - - - - - Редактировать товар - - - {editingItem && ( -
-
- - - setEditingItem({ ...editingItem, isActive: checked }) - } - /> -
- -
- - - setEditingItem({ - ...editingItem, - sortOrder: parseInt(e.target.value) || 0 - }) - } - /> -
-
- )} - - - - - -
-
- - {/* Кнопка удаления */} - -
-
-
-
- )) - )} -
-
- ) -} \ No newline at end of file diff --git a/src/components/catalog/CategorySelector.tsx b/src/components/catalog/CategorySelector.tsx new file mode 100644 index 0000000..8cbc3be --- /dev/null +++ b/src/components/catalog/CategorySelector.tsx @@ -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(null) + const [expandedCategories, setExpandedCategories] = useState>(new Set()) + + // Функция для построения дерева категорий + const buildCategoryTree = (categories: Category[]): Category[] => { + const categoryMap = new Map() + 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 ( +
+
setSelectedCategoryId(category.id)} + > + {hasChildren && ( + + )} + + {!hasChildren &&
} + +
+ {hasChildren ? ( + isExpanded ? : + ) : ( + + )} +
+ +
+ + {category.name} + + {category._count && ( + + ({category._count.products} товаров) + + )} +
+
+ + {hasChildren && isExpanded && ( +
+ {category.children!.map(child => renderCategory(child, level + 1))} +
+ )} +
+ ) + } + + 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 ( + + + + {title} + {description && ( +

{description}

+ )} +
+ +
+ {/* Поиск */} +
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ + {/* Список категорий */} +
+ {filteredCategories.length > 0 ? ( +
+ {filteredCategories.map(category => renderCategory(category))} +
+ ) : ( +
+ {searchQuery ? 'Категории не найдены' : 'Нет доступных категорий'} +
+ )} +
+ + {/* Выбранная категория */} + {selectedCategoryId && ( +
+ +

+ {categories.find(cat => cat.id === selectedCategoryId)?.name} +

+
+ )} +
+ + + + + +
+
+ ) +} \ No newline at end of file diff --git a/src/components/catalog/ProductForm.tsx b/src/components/catalog/ProductForm.tsx index e244a1e..cc3f533 100644 --- a/src/components/catalog/ProductForm.tsx +++ b/src/components/catalog/ProductForm.tsx @@ -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 = ({
{/* Основная информация */} -
-
+
+
+
+ + handleInputChange('brand', e.target.value)} + placeholder="Введите бренд товара" + required + style={{ cursor: 'pointer' }} + /> +
+
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([]) 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 (
@@ -174,6 +213,15 @@ export const ProductList = ({ products, loading, onProductEdit, onProductCreated {bulkLoading ? : null} Скрыть с сайта +
) } \ No newline at end of file diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 43a40ba..34c9725 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -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', diff --git a/src/lib/graphql/mutations.ts b/src/lib/graphql/mutations.ts index 9a2441f..01f637d 100644 --- a/src/lib/graphql/mutations.ts +++ b/src/lib/graphql/mutations.ts @@ -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) { diff --git a/src/lib/graphql/resolvers.ts b/src/lib/graphql/resolvers.ts index 279b921..7ef2fc2 100644 --- a/src/lib/graphql/resolvers.ts +++ b/src/lib/graphql/resolvers.ts @@ -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) => { diff --git a/src/lib/graphql/typeDefs.ts b/src/lib/graphql/typeDefs.ts index 04fcf21..35c7e91 100644 --- a/src/lib/graphql/typeDefs.ts +++ b/src/lib/graphql/typeDefs.ts @@ -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!