продолжаю работать с оновым сайтом. в заглушку добавлены метрика и информация об ИП
This commit is contained in:
parent
de09de375a
commit
4821780968
107
.cursor/rules/.cursorrules
Normal file
107
.cursor/rules/.cursorrules
Normal file
@ -0,0 +1,107 @@
|
||||
Вы — эксперт в разработке веб-приложений с использованием **Python, FastAPI, SQLAlchemy, Next.js, React, TypeScript, Tailwind CSS** и **Shadcn UI**.
|
||||
|
||||
### Ключевые принципы
|
||||
|
||||
- Пишите лаконичные технические ответы с точными примерами как на Python, так и на TypeScript.
|
||||
- Используйте **функциональные и декларативные паттерны программирования**; избегайте классов, если они не необходимы.
|
||||
- Предпочитайте **итерацию и модуляризацию** вместо дублирования кода.
|
||||
- Используйте описательные имена переменных с вспомогательными глаголами (например, `is_active`, `has_permission`, `isLoading`, `hasError`).
|
||||
- Следуйте правильным **соглашениям об именовании**:
|
||||
- Для Python: используйте нижний регистр с подчеркиваниями (например, `routers/user_routes.py`).
|
||||
- Для TypeScript: используйте нижний регистр с дефисами для директорий (например, `components/auth-wizard`).
|
||||
|
||||
### Структура проекта
|
||||
|
||||
- **Фронтенд**:
|
||||
- **Язык**: TypeScript
|
||||
- **Фреймворк**: Next.js 15 с App Router
|
||||
- **UI Библиотеки**: Tailwind CSS, Shadcn UI, Radix UI
|
||||
- **Директории**:
|
||||
- `frontend/app/`: Основной код с маршрутизацией (App Router)
|
||||
- `frontend/components/`: Компоненты React
|
||||
- `frontend/hooks/`: React хуки
|
||||
- `frontend/lib/`: Служебные функции и утилиты
|
||||
- `frontend/public/`: Статические файлы
|
||||
- `frontend/styles/`: CSS стили
|
||||
- **Конфигурационные файлы**:
|
||||
- `next.config.mjs`
|
||||
- `tsconfig.json`
|
||||
- `tailwind.config.ts`
|
||||
- `postcss.config.mjs`
|
||||
|
||||
- **Бэкенд**:
|
||||
- **Язык**: Python
|
||||
- **Фреймворк**: FastAPI
|
||||
- **База данных**: PostgreSQL
|
||||
- **ORM**: SQLAlchemy 2.0
|
||||
- **Директории**:
|
||||
- `backend/app/`: Основной код
|
||||
- `models/`: Модели базы данных
|
||||
- `repositories/`: Репозитории для работы с данными
|
||||
- `schemas/`: Pydantic схемы
|
||||
- `services/`: Бизнес-логика
|
||||
- `routers/`: Endpoints API
|
||||
- `backend/uploads/`: Загруженные файлы
|
||||
- **Конфигурационные файлы**:
|
||||
- `alembic.ini`: Конфигурация миграций
|
||||
- `.env`: Переменные окружения
|
||||
|
||||
### Стиль кода и структура
|
||||
|
||||
**Бэкенд (Python/FastAPI)**:
|
||||
|
||||
- Используйте `def` для чистых функций и `async def` для асинхронных операций.
|
||||
- **Типизация**: Используйте аннотации типов для всех функций. Предпочитайте Pydantic-модели для валидации данных.
|
||||
- **Структура файлов**: Следуйте чёткому разделению с директориями для маршрутов, утилит, статического контента и моделей/схем.
|
||||
- **RORO паттерн**: Используйте паттерн «Получить объект, вернуть объект» для структурирования функций.
|
||||
- **Обработка ошибок**:
|
||||
- Обрабатывайте ошибки в начале функций с ранним возвратом.
|
||||
- Используйте защитные условия и избегайте глубоко вложенных условий.
|
||||
- Реализуйте правильное логирование и пользовательские типы ошибок.
|
||||
|
||||
**Фронтенд (TypeScript/React/Next.js)**:
|
||||
|
||||
- **TypeScript**: Используйте TypeScript для всего кода. Предпочитайте интерфейсы типам. Избегайте перечислений; используйте объекты вместо них.
|
||||
- **Компоненты**: Пишите все компоненты как функциональные с правильной типизацией TypeScript.
|
||||
- **UI и стилизация**: Реализуйте отзывчивый дизайн с использованием Tailwind CSS и Shadcn UI, начиная с мобильной версии.
|
||||
- **Рендеринг**: Используйте серверные и клиентские компоненты Next.js правильно:
|
||||
- Предпочитайте серверные компоненты, где это возможно
|
||||
- Используйте директиву `"use client"` только для компонентов, требующих клиентских возможностей
|
||||
- Оборачивайте клиентские компоненты в `Suspense` для улучшения производительности
|
||||
|
||||
### Оптимизация производительности
|
||||
|
||||
**Бэкенд**:
|
||||
|
||||
- **Асинхронные операции**: Минимизируйте блокирующие операции ввода-вывода, используя асинхронные функции.
|
||||
- **Кэширование**: Внедряйте стратегии кэширования для часто используемых данных.
|
||||
- **Ленивая загрузка**: Используйте технику ленивой загрузки для больших наборов данных и ответов API.
|
||||
|
||||
**Фронтенд**:
|
||||
|
||||
- **Компоненты React**: Предпочитайте серверный рендеринг и оптимизируйте клиентский рендеринг.
|
||||
- **Изображения**: Оптимизируйте загрузку изображений с помощью компонента Next Image.
|
||||
- **Метрики**: Оптимизируйте Core Web Vitals (LCP, CLS, FID).
|
||||
|
||||
### Проектные соглашения
|
||||
|
||||
**Бэкенд**:
|
||||
|
||||
1. Следуйте **принципам проектирования RESTful API**.
|
||||
2. Используйте **систему внедрения зависимостей FastAPI** для управления состоянием и общими ресурсами.
|
||||
3. Используйте **SQLAlchemy 2.0** для функций ORM.
|
||||
4. Обеспечьте правильную настройку **CORS** для локальной разработки.
|
||||
5. Реализуйте надлежащую аутентификацию и авторизацию для защиты API.
|
||||
|
||||
**Фронтенд**:
|
||||
|
||||
1. Следуйте рекомендациям Next.js по использованию серверных и клиентских компонентов.
|
||||
2. Ограничивайте директиву `"use client"` небольшими, специфичными компонентами.
|
||||
3. Используйте хуки React эффективно, избегая ненужных рендеров.
|
||||
4. Реализуйте интернационализацию для поддержки русского языка.
|
||||
|
||||
### Тестирование и развертывание
|
||||
|
||||
- Реализуйте **юнит-тесты** как для фронтенда, так и для бэкенда.
|
||||
- Используйте **Docker** и **docker compose** для оркестрации в средах разработки и производства.
|
||||
- Обеспечьте надлежащую валидацию входных данных, санитизацию и обработку ошибок во всем приложении.
|
||||
131
.cursor/rules/fastapinextjs.mdc
Normal file
131
.cursor/rules/fastapinextjs.mdc
Normal file
@ -0,0 +1,131 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Вы — эксперт в разработке веб-приложений с использованием **Python, FastAPI, SQLAlchemy, Next.js, React, TypeScript, Tailwind CSS** и **Shadcn UI**.
|
||||
|
||||
### Ключевые принципы
|
||||
|
||||
- Пишите лаконичные технические ответы с точными примерами как на Python, так и на TypeScript.
|
||||
- Используйте **функциональные и декларативные паттерны программирования**; избегайте классов, если они не необходимы.
|
||||
- Предпочитайте **итерацию и модуляризацию** вместо дублирования кода.
|
||||
- Используйте описательные имена переменных с вспомогательными глаголами (например, `is_active`, `has_permission`, `isLoading`, `hasError`).
|
||||
- Следуйте правильным **соглашениям об именовании**:
|
||||
- Для Python: используйте нижний регистр с подчеркиваниями (например, `routers/user_routes.py`).
|
||||
- Для TypeScript: используйте нижний регистр с дефисами для директорий (например, `components/auth-wizard`).
|
||||
|
||||
- никогда не запускай сервера бекенд и фронтенд, я сам это делаю!
|
||||
- комментируй что ты делаешь
|
||||
|
||||
### Структура проекта
|
||||
|
||||
- **Фронтенд**:
|
||||
- **Язык**: TypeScript
|
||||
- **Фреймворк**: Next.js 15 с App Router
|
||||
- **UI Библиотеки**: Tailwind CSS, Shadcn UI, Radix UI
|
||||
- **Директории**:
|
||||
- `frontend/app/`: Основной код с маршрутизацией (App Router)
|
||||
- `frontend/components/`: Компоненты React
|
||||
- `frontend/hooks/`: React хуки
|
||||
- `frontend/lib/`: Служебные функции и утилиты, также работа с api бекенда
|
||||
- `frontend/public/`: Статические файлы
|
||||
- `frontend/styles/`: CSS стили
|
||||
- **Конфигурационные файлы**:
|
||||
- `next.config.mjs`
|
||||
- `tsconfig.json`
|
||||
- `tailwind.config.ts`
|
||||
- `postcss.config.mjs`
|
||||
|
||||
- `frontend/lib/`: Служебные функции и утилиты, также работа с api бекенда
|
||||
api.ts - Основной файл для работы с API. Содержит базовую функцию fetchApi, которая выполняет HTTP-запросы и обрабатывает ответы сервера. Также определяет основные интерфейсы для работы с API.
|
||||
auth.ts - Модуль для аутентификации пользователей. Содержит функции для входа, регистрации, выхода из системы, сброса пароля.
|
||||
users.ts - Модуль для работы с данными пользователей. Содержит функции для получения профиля, обновления данных пользователя, управления адресами.
|
||||
cart.ts - Модуль для работы с корзиной. Позволяет получать состояние корзины, добавлять, обновлять и удалять товары из корзины.
|
||||
catalog.ts - Большой модуль для работы с каталогом товаров на стороне пользователя. Содержит функции для получения товаров, категорий, коллекций и работы с ними.
|
||||
catalog-admin.ts - Модуль для работы с каталогом товаров на стороне администратора. Содержит функции для управления категориями и коллекциями.
|
||||
orders.ts - Модуль для работы с заказами. Содержит функции для получения списка заказов, создания новых заказов, обновления статуса и т.д.
|
||||
analytics.ts - Модуль для работы с аналитикой. Содержит функции для логирования событий, получения отчетов и отслеживания пользовательской активности.
|
||||
utils.ts - Вспомогательные функции для форматирования данных, работы с датами, ценами и другими общими задачами.
|
||||
auth.tsx - React-компоненты для аутентификации (контекст, провайдеры и т.д.).
|
||||
|
||||
|
||||
- **Бэкенд**:
|
||||
- **Язык**: Python
|
||||
- **Фреймворк**: FastAPI
|
||||
- **База данных**: PostgreSQL
|
||||
- **ORM**: SQLAlchemy 2.0
|
||||
- **Директории**:
|
||||
- `backend/app/`: Основной код
|
||||
- `models/`: Модели базы данных
|
||||
- `repositories/`: Репозитории для работы с данными
|
||||
- `schemas/`: Pydantic схемы
|
||||
- `services/`: Бизнес-логика
|
||||
- `routers/`: Endpoints API
|
||||
- `backend/uploads/`: Загруженные файлы
|
||||
- `backend/docs/`: документация проекта
|
||||
|
||||
- **Конфигурационные файлы**:
|
||||
- `alembic.ini`: Конфигурация миграций
|
||||
- `.env`: Переменные окружения
|
||||
|
||||
|
||||
### Стиль кода и структура
|
||||
|
||||
**Бэкенд (Python/FastAPI)**:
|
||||
|
||||
- Используйте `def` для чистых функций и `async def` для асинхронных операций.
|
||||
- **Типизация**: Используйте аннотации типов для всех функций. Предпочитайте Pydantic-модели для валидации данных.
|
||||
- **Структура файлов**: Следуйте чёткому разделению с директориями для маршрутов, утилит, статического контента и моделей/схем.
|
||||
- **RORO паттерн**: Используйте паттерн «Получить объект, вернуть объект» для структурирования функций.
|
||||
- **Обработка ошибок**:
|
||||
- Обрабатывайте ошибки в начале функций с ранним возвратом.
|
||||
- Используйте защитные условия и избегайте глубоко вложенных условий.
|
||||
- Реализуйте правильное логирование и пользовательские типы ошибок.
|
||||
|
||||
**Фронтенд (TypeScript/React/Next.js)**:
|
||||
|
||||
- **TypeScript**: Используйте TypeScript для всего кода. Предпочитайте интерфейсы типам. Избегайте перечислений; используйте объекты вместо них.
|
||||
- **Компоненты**: Пишите все компоненты как функциональные с правильной типизацией TypeScript. Создавай компоненты где они нужны
|
||||
- **UI и стилизация**: Реализуйте отзывчивый дизайн с использованием Tailwind CSS и Shadcn UI, начиная с мобильной версии.
|
||||
- **Рендеринг**: Используйте серверные и клиентские компоненты Next.js правильно:
|
||||
- Предпочитайте серверные компоненты, где это возможно
|
||||
- Используйте директиву `"use client"` только для компонентов, требующих клиентских возможностей
|
||||
- Оборачивайте клиентские компоненты в `Suspense` для улучшения производительности
|
||||
|
||||
### Оптимизация производительности
|
||||
|
||||
**Бэкенд**:
|
||||
|
||||
- **Асинхронные операции**: Минимизируйте блокирующие операции ввода-вывода, используя асинхронные функции.
|
||||
- **Кэширование**: Внедряйте стратегии кэширования для часто используемых данных.
|
||||
- **Ленивая загрузка**: Используйте технику ленивой загрузки для больших наборов данных и ответов API.
|
||||
|
||||
**Фронтенд**:
|
||||
|
||||
- **Компоненты React**: Предпочитайте серверный рендеринг и оптимизируйте клиентский рендеринг.
|
||||
- **Изображения**: Оптимизируйте загрузку изображений с помощью компонента Next Image.
|
||||
- **Метрики**: Оптимизируйте Core Web Vitals (LCP, CLS, FID).
|
||||
|
||||
### Проектные соглашения
|
||||
|
||||
**Бэкенд**:
|
||||
|
||||
1. Следуйте **принципам проектирования RESTful API**.
|
||||
2. Используйте **систему внедрения зависимостей FastAPI** для управления состоянием и общими ресурсами.
|
||||
3. Используйте **SQLAlchemy 2.0** для функций ORM.
|
||||
4. Обеспечьте правильную настройку **CORS** для локальной разработки.
|
||||
5. Реализуйте надлежащую аутентификацию и авторизацию для защиты API.
|
||||
|
||||
**Фронтенд**:
|
||||
|
||||
1. Следуйте рекомендациям Next.js по использованию серверных и клиентских компонентов.
|
||||
2. Ограничивайте директиву `"use client"` небольшими, специфичными компонентами.
|
||||
3. Используйте хуки React эффективно, избегая ненужных рендеров.
|
||||
4. Реализуйте интернационализацию для поддержки русского языка.
|
||||
|
||||
### Тестирование и развертывание
|
||||
|
||||
- Реализуйте **юнит-тесты** как для фронтенда, так и для бэкенда.
|
||||
- Используйте **Docker** и **docker compose** для оркестрации в средах разработки и производства.
|
||||
- Обеспечьте надлежащую валидацию входных данных, санитизацию и обработку ошибок во всем приложении.
|
||||
Binary file not shown.
@ -0,0 +1,57 @@
|
||||
"""Add size table and update product structure
|
||||
|
||||
Revision ID: 9773b0186faa
|
||||
Revises: ef40913679bd
|
||||
Create Date: 2025-03-16 20:30:39.057254
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '9773b0186faa'
|
||||
down_revision: Union[str, None] = 'ef40913679bd'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('sizes',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('code', sa.String(), nullable=False),
|
||||
sa.Column('description', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('code')
|
||||
)
|
||||
op.create_index(op.f('ix_sizes_id'), 'sizes', ['id'], unique=False)
|
||||
op.add_column('product_variants', sa.Column('size_id', sa.Integer(), nullable=False))
|
||||
op.create_foreign_key(None, 'product_variants', 'sizes', ['size_id'], ['id'])
|
||||
op.drop_column('product_variants', 'discount_price')
|
||||
op.drop_column('product_variants', 'name')
|
||||
op.drop_column('product_variants', 'price')
|
||||
op.add_column('products', sa.Column('price', sa.Float(), nullable=False))
|
||||
op.add_column('products', sa.Column('discount_price', sa.Float(), nullable=True))
|
||||
op.add_column('products', sa.Column('care_instructions', sa.JSON(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('products', 'care_instructions')
|
||||
op.drop_column('products', 'discount_price')
|
||||
op.drop_column('products', 'price')
|
||||
op.add_column('product_variants', sa.Column('price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=False))
|
||||
op.add_column('product_variants', sa.Column('name', sa.VARCHAR(), autoincrement=False, nullable=False))
|
||||
op.add_column('product_variants', sa.Column('discount_price', sa.DOUBLE_PRECISION(precision=53), autoincrement=False, nullable=True))
|
||||
op.drop_constraint(None, 'product_variants', type_='foreignkey')
|
||||
op.drop_column('product_variants', 'size_id')
|
||||
op.drop_index(op.f('ix_sizes_id'), table_name='sizes')
|
||||
op.drop_table('sizes')
|
||||
# ### end Alembic commands ###
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
@ -37,6 +37,20 @@ class Category(Base):
|
||||
products = relationship("Product", back_populates="category")
|
||||
|
||||
|
||||
class Size(Base):
|
||||
__tablename__ = "sizes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False) # S, M, L, XL и т.д.
|
||||
code = Column(String, nullable=False, unique=True) # Уникальный код размера
|
||||
description = Column(String, nullable=True) # Дополнительная информация
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Отношения
|
||||
product_variants = relationship("ProductVariant", back_populates="size")
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
@ -44,6 +58,9 @@ class Product(Base):
|
||||
name = Column(String, nullable=False)
|
||||
slug = Column(String, unique=True, index=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
price = Column(Float, nullable=False) # Базовая цена продукта
|
||||
discount_price = Column(Float, nullable=True) # Цена со скидкой
|
||||
care_instructions = Column(JSON, nullable=True) # Инструкции по уходу и другие детали
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
collection_id = Column(Integer, ForeignKey("collections.id"))
|
||||
@ -64,17 +81,16 @@ class ProductVariant(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
product_id = Column(Integer, ForeignKey("products.id"), nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
sku = Column(String, unique=True, nullable=False) # артикул
|
||||
price = Column(Float, nullable=False)
|
||||
discount_price = Column(Float, nullable=True)
|
||||
stock = Column(Integer, default=0)
|
||||
size_id = Column(Integer, ForeignKey("sizes.id"), nullable=False)
|
||||
sku = Column(String, unique=True, nullable=False) # артикул
|
||||
stock = Column(Integer, default=0) # количество на складе
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Отношения
|
||||
product = relationship("Product", back_populates="variants")
|
||||
size = relationship("Size", back_populates="product_variants")
|
||||
|
||||
|
||||
class ProductImage(Base):
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -5,13 +5,14 @@ from typing import List, Optional, Dict, Any
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from app.models.catalog_models import Category, Product, ProductVariant, ProductImage, Collection
|
||||
from app.models.catalog_models import Category, Product, ProductVariant, ProductImage, Collection, Size
|
||||
from app.schemas.catalog_schemas import (
|
||||
CategoryCreate, CategoryUpdate,
|
||||
ProductCreate, ProductUpdate,
|
||||
ProductVariantCreate, ProductVariantUpdate,
|
||||
ProductImageCreate, ProductImageUpdate,
|
||||
CollectionCreate, CollectionUpdate
|
||||
CollectionCreate, CollectionUpdate,
|
||||
SizeCreate, SizeUpdate
|
||||
)
|
||||
|
||||
|
||||
@ -380,32 +381,34 @@ def create_product(db: Session, product: ProductCreate) -> Product:
|
||||
if not product.slug:
|
||||
product.slug = generate_slug(product.name)
|
||||
|
||||
# Проверяем, что продукт с таким slug не существует
|
||||
if get_product_by_slug(db, product.slug):
|
||||
# Проверяем, что slug уникален
|
||||
if db.query(Product).filter(Product.slug == product.slug).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Продукт с таким slug уже существует"
|
||||
detail=f"Продукт с slug '{product.slug}' уже существует"
|
||||
)
|
||||
|
||||
# Проверяем, что категория существует
|
||||
if not get_category(db, product.category_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Категория не найдена"
|
||||
detail=f"Категория с ID {product.category_id} не найдена"
|
||||
)
|
||||
|
||||
# Проверяем, что коллекция существует, если указана
|
||||
# Проверяем, что коллекция существует, если она указана
|
||||
if product.collection_id and not get_collection(db, product.collection_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Коллекция не найдена"
|
||||
detail=f"Коллекция с ID {product.collection_id} не найдена"
|
||||
)
|
||||
|
||||
# Создаем новый продукт
|
||||
db_product = Product(
|
||||
name=product.name,
|
||||
slug=product.slug,
|
||||
description=product.description,
|
||||
price=product.price,
|
||||
discount_price=product.discount_price,
|
||||
care_instructions=product.care_instructions,
|
||||
is_active=product.is_active,
|
||||
category_id=product.category_id,
|
||||
collection_id=product.collection_id
|
||||
@ -416,11 +419,11 @@ def create_product(db: Session, product: ProductCreate) -> Product:
|
||||
db.commit()
|
||||
db.refresh(db_product)
|
||||
return db_product
|
||||
except IntegrityError:
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при создании продукта"
|
||||
detail=f"Ошибка при создании продукта: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@ -429,57 +432,72 @@ def update_product(db: Session, product_id: int, product: ProductUpdate) -> Prod
|
||||
if not db_product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Продукт не найден"
|
||||
detail=f"Продукт с ID {product_id} не найден"
|
||||
)
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = product.dict(exclude_unset=True)
|
||||
|
||||
# Если slug изменяется, проверяем его уникальность
|
||||
if "slug" in update_data and update_data["slug"] != db_product.slug:
|
||||
if get_product_by_slug(db, update_data["slug"]):
|
||||
if product.slug is not None and product.slug != db_product.slug:
|
||||
if db.query(Product).filter(Product.slug == product.slug).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Продукт с таким slug уже существует"
|
||||
detail=f"Продукт с slug '{product.slug}' уже существует"
|
||||
)
|
||||
|
||||
# Если имя изменяется и slug не предоставлен, генерируем новый slug
|
||||
if "name" in update_data and "slug" not in update_data:
|
||||
update_data["slug"] = generate_slug(update_data["name"])
|
||||
# Проверяем уникальность сгенерированного slug
|
||||
if get_product_by_slug(db, update_data["slug"]) and get_product_by_slug(db, update_data["slug"]).id != product_id:
|
||||
# Если имя изменяется и slug не предоставлен, обновляем slug
|
||||
if product.name is not None and product.name != db_product.name and product.slug is None:
|
||||
product.slug = generate_slug(product.name)
|
||||
# Проверяем, что новый slug уникален
|
||||
if db.query(Product).filter(Product.slug == product.slug).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Продукт с таким slug уже существует"
|
||||
detail=f"Продукт с slug '{product.slug}' уже существует"
|
||||
)
|
||||
|
||||
# Проверяем, что категория существует, если указана
|
||||
if "category_id" in update_data and update_data["category_id"] and not get_category(db, update_data["category_id"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Категория не найдена"
|
||||
)
|
||||
# Проверяем, что категория существует, если она изменяется
|
||||
if product.category_id is not None and product.category_id != db_product.category_id:
|
||||
if not get_category(db, product.category_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Категория с ID {product.category_id} не найдена"
|
||||
)
|
||||
|
||||
# Проверяем, что коллекция существует, если указана
|
||||
if "collection_id" in update_data and update_data["collection_id"] and not get_collection(db, update_data["collection_id"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Коллекция не найдена"
|
||||
)
|
||||
# Проверяем, что коллекция существует, если она изменяется
|
||||
if product.collection_id is not None and product.collection_id != db_product.collection_id:
|
||||
if product.collection_id and not get_collection(db, product.collection_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Коллекция с ID {product.collection_id} не найдена"
|
||||
)
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_product, key, value)
|
||||
# Обновляем поля
|
||||
if product.name is not None:
|
||||
db_product.name = product.name
|
||||
if product.slug is not None:
|
||||
db_product.slug = product.slug
|
||||
if product.description is not None:
|
||||
db_product.description = product.description
|
||||
if product.price is not None:
|
||||
db_product.price = product.price
|
||||
if product.discount_price is not None:
|
||||
db_product.discount_price = product.discount_price
|
||||
if product.care_instructions is not None:
|
||||
db_product.care_instructions = product.care_instructions
|
||||
if product.is_active is not None:
|
||||
db_product.is_active = product.is_active
|
||||
if product.category_id is not None:
|
||||
db_product.category_id = product.category_id
|
||||
if product.collection_id is not None:
|
||||
db_product.collection_id = product.collection_id
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_product)
|
||||
return db_product
|
||||
except IntegrityError:
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при обновлении продукта"
|
||||
detail=f"Ошибка при обновлении продукта: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@ -514,26 +532,44 @@ def get_product_variants(db: Session, product_id: int) -> List[ProductVariant]:
|
||||
|
||||
def create_product_variant(db: Session, variant: ProductVariantCreate) -> ProductVariant:
|
||||
# Проверяем, что продукт существует
|
||||
if not get_product(db, variant.product_id):
|
||||
db_product = get_product(db, variant.product_id)
|
||||
if not db_product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Продукт не найден"
|
||||
detail=f"Продукт с ID {variant.product_id} не найден"
|
||||
)
|
||||
|
||||
# Проверяем, что размер существует
|
||||
db_size = get_size(db, variant.size_id)
|
||||
if not db_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {variant.size_id} не найден"
|
||||
)
|
||||
|
||||
# Проверяем, что вариант с таким SKU не существует
|
||||
if db.query(ProductVariant).filter(ProductVariant.sku == variant.sku).first():
|
||||
existing_variant = db.query(ProductVariant).filter(ProductVariant.sku == variant.sku).first()
|
||||
if existing_variant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Вариант с таким SKU уже существует"
|
||||
detail=f"Вариант продукта с SKU {variant.sku} уже существует"
|
||||
)
|
||||
|
||||
# Проверяем, что вариант с таким размером для этого продукта не существует
|
||||
existing_size_variant = db.query(ProductVariant).filter(
|
||||
ProductVariant.product_id == variant.product_id,
|
||||
ProductVariant.size_id == variant.size_id
|
||||
).first()
|
||||
if existing_size_variant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта"
|
||||
)
|
||||
|
||||
# Создаем новый вариант продукта
|
||||
db_variant = ProductVariant(
|
||||
product_id=variant.product_id,
|
||||
name=variant.name,
|
||||
size_id=variant.size_id,
|
||||
sku=variant.sku,
|
||||
price=variant.price,
|
||||
discount_price=variant.discount_price,
|
||||
stock=variant.stock,
|
||||
is_active=variant.is_active
|
||||
)
|
||||
@ -543,11 +579,11 @@ def create_product_variant(db: Session, variant: ProductVariantCreate) -> Produc
|
||||
db.commit()
|
||||
db.refresh(db_variant)
|
||||
return db_variant
|
||||
except IntegrityError:
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при создании варианта продукта"
|
||||
detail=f"Ошибка при создании варианта продукта: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@ -556,41 +592,73 @@ def update_product_variant(db: Session, variant_id: int, variant: ProductVariant
|
||||
if not db_variant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Вариант продукта не найден"
|
||||
detail=f"Вариант продукта с ID {variant_id} не найден"
|
||||
)
|
||||
|
||||
# Обновляем только предоставленные поля
|
||||
update_data = variant.dict(exclude_unset=True)
|
||||
|
||||
# Если product_id изменяется, проверяем, что продукт существует
|
||||
if "product_id" in update_data and update_data["product_id"] != db_variant.product_id:
|
||||
if not get_product(db, update_data["product_id"]):
|
||||
# Проверяем, что продукт существует, если ID продукта изменяется
|
||||
if variant.product_id is not None and variant.product_id != db_variant.product_id:
|
||||
db_product = get_product(db, variant.product_id)
|
||||
if not db_product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Продукт не найден"
|
||||
detail=f"Продукт с ID {variant.product_id} не найден"
|
||||
)
|
||||
|
||||
# Если SKU изменяется, проверяем его уникальность
|
||||
if "sku" in update_data and update_data["sku"] != db_variant.sku:
|
||||
if db.query(ProductVariant).filter(ProductVariant.sku == update_data["sku"]).first():
|
||||
# Проверяем, что размер существует, если ID размера изменяется
|
||||
if variant.size_id is not None and variant.size_id != db_variant.size_id:
|
||||
db_size = get_size(db, variant.size_id)
|
||||
if not db_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {variant.size_id} не найден"
|
||||
)
|
||||
|
||||
# Проверяем, что вариант с таким размером для этого продукта не существует
|
||||
product_id = variant.product_id if variant.product_id is not None else db_variant.product_id
|
||||
existing_size_variant = db.query(ProductVariant).filter(
|
||||
ProductVariant.product_id == product_id,
|
||||
ProductVariant.size_id == variant.size_id,
|
||||
ProductVariant.id != variant_id
|
||||
).first()
|
||||
if existing_size_variant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Вариант с таким SKU уже существует"
|
||||
detail=f"Вариант продукта с размером ID {variant.size_id} уже существует для этого продукта"
|
||||
)
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_variant, key, value)
|
||||
# Проверяем, что SKU уникален, если он изменяется
|
||||
if variant.sku is not None and variant.sku != db_variant.sku:
|
||||
existing_variant = db.query(ProductVariant).filter(
|
||||
ProductVariant.sku == variant.sku,
|
||||
ProductVariant.id != variant_id
|
||||
).first()
|
||||
if existing_variant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Вариант продукта с SKU {variant.sku} уже существует"
|
||||
)
|
||||
|
||||
# Обновляем поля
|
||||
if variant.product_id is not None:
|
||||
db_variant.product_id = variant.product_id
|
||||
if variant.size_id is not None:
|
||||
db_variant.size_id = variant.size_id
|
||||
if variant.sku is not None:
|
||||
db_variant.sku = variant.sku
|
||||
if variant.stock is not None:
|
||||
db_variant.stock = variant.stock
|
||||
if variant.is_active is not None:
|
||||
db_variant.is_active = variant.is_active
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_variant)
|
||||
return db_variant
|
||||
except IntegrityError:
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при обновлении варианта продукта"
|
||||
detail=f"Ошибка при обновлении варианта продукта: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@ -706,4 +774,110 @@ def delete_product_image(db: Session, image_id: int) -> bool:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при удалении изображения продукта"
|
||||
)
|
||||
|
||||
|
||||
# Функции для работы с размерами
|
||||
def get_size(db: Session, size_id: int) -> Optional[Size]:
|
||||
return db.query(Size).filter(Size.id == size_id).first()
|
||||
|
||||
|
||||
def get_size_by_code(db: Session, code: str) -> Optional[Size]:
|
||||
return db.query(Size).filter(Size.code == code).first()
|
||||
|
||||
|
||||
def get_sizes(db: Session, skip: int = 0, limit: int = 100) -> List[Size]:
|
||||
return db.query(Size).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def create_size(db: Session, size: SizeCreate) -> Size:
|
||||
# Проверяем, что размер с таким кодом не существует
|
||||
existing_size = get_size_by_code(db, size.code)
|
||||
if existing_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Размер с кодом {size.code} уже существует"
|
||||
)
|
||||
|
||||
db_size = Size(
|
||||
name=size.name,
|
||||
code=size.code,
|
||||
description=size.description
|
||||
)
|
||||
|
||||
try:
|
||||
db.add(db_size)
|
||||
db.commit()
|
||||
db.refresh(db_size)
|
||||
return db_size
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при создании размера: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size:
|
||||
db_size = get_size(db, size_id)
|
||||
if not db_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {size_id} не найден"
|
||||
)
|
||||
|
||||
# Проверяем, что код размера уникален, если он изменяется
|
||||
if size.code and size.code != db_size.code:
|
||||
existing_size = get_size_by_code(db, size.code)
|
||||
if existing_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Размер с кодом {size.code} уже существует"
|
||||
)
|
||||
|
||||
# Обновляем поля
|
||||
if size.name is not None:
|
||||
db_size.name = size.name
|
||||
if size.code is not None:
|
||||
db_size.code = size.code
|
||||
if size.description is not None:
|
||||
db_size.description = size.description
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_size)
|
||||
return db_size
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при обновлении размера: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def delete_size(db: Session, size_id: int) -> bool:
|
||||
db_size = get_size(db, size_id)
|
||||
if not db_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {size_id} не найден"
|
||||
)
|
||||
|
||||
# Проверяем, используется ли размер в вариантах продуктов
|
||||
variants_with_size = db.query(ProductVariant).filter(ProductVariant.size_id == size_id).count()
|
||||
if variants_with_size > 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Невозможно удалить размер, так как он используется в {variants_with_size} вариантах продуктов"
|
||||
)
|
||||
|
||||
try:
|
||||
db.delete(db_size)
|
||||
db.commit()
|
||||
return True
|
||||
except IntegrityError as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Ошибка при удалении размера: {str(e)}"
|
||||
)
|
||||
@ -419,74 +419,80 @@ def get_cart_with_product_details(db: Session, user_id: int) -> List[Dict[str, A
|
||||
return result
|
||||
|
||||
|
||||
def get_order_with_details(db: Session, order_id: int) -> Dict[str, Any]:
|
||||
order = get_order(db, order_id)
|
||||
def get_order_with_details(db: Session, order_id: int) -> Optional[Dict]:
|
||||
order = db.query(Order).filter(Order.id == order_id).first()
|
||||
if not order:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Заказ не найден"
|
||||
)
|
||||
|
||||
# Получаем пользователя
|
||||
user = db.query(User).filter(User.id == order.user_id).first()
|
||||
|
||||
return None
|
||||
|
||||
# Получаем все товары в заказе с их вариантами
|
||||
order_items = (
|
||||
db.query(OrderItem)
|
||||
.filter(OrderItem.order_id == order_id)
|
||||
.all()
|
||||
)
|
||||
|
||||
items = []
|
||||
for item in order_items:
|
||||
# Получаем информацию о товаре и его варианте
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
|
||||
if not variant:
|
||||
continue
|
||||
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
continue
|
||||
|
||||
# Получаем основное изображение продукта
|
||||
image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id,
|
||||
ProductImage.is_primary == True
|
||||
).first()
|
||||
|
||||
# Если нет основного изображения, берем первое доступное
|
||||
if not image:
|
||||
image = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product.id
|
||||
).first()
|
||||
|
||||
items.append({
|
||||
"id": item.id,
|
||||
"product": {
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"image": image.image_url if image else None,
|
||||
"slug": product.slug
|
||||
},
|
||||
"variant_name": variant.size.name if variant.size else None,
|
||||
"quantity": item.quantity,
|
||||
"price": item.price
|
||||
})
|
||||
|
||||
# Получаем адрес доставки
|
||||
shipping_address = None
|
||||
if order.shipping_address_id:
|
||||
address = db.query(UserAddress).filter(UserAddress.id == order.shipping_address_id).first()
|
||||
if address:
|
||||
shipping_address = {
|
||||
"id": address.id,
|
||||
"address_line1": address.address_line1,
|
||||
"address_line2": address.address_line2,
|
||||
"city": address.city,
|
||||
"state": address.state,
|
||||
"postal_code": address.postal_code,
|
||||
"country": address.country
|
||||
}
|
||||
|
||||
# Получаем элементы заказа с деталями
|
||||
items = []
|
||||
for item in order.items:
|
||||
# Получаем информацию о варианте и продукте
|
||||
variant = db.query(ProductVariant).filter(ProductVariant.id == item.variant_id).first()
|
||||
if not variant:
|
||||
continue
|
||||
|
||||
product = db.query(Product).filter(Product.id == variant.product_id).first()
|
||||
if not product:
|
||||
continue
|
||||
|
||||
# Формируем элемент заказа
|
||||
items.append({
|
||||
"id": item.id,
|
||||
"order_id": item.order_id,
|
||||
"variant_id": item.variant_id,
|
||||
"quantity": item.quantity,
|
||||
"price": item.price,
|
||||
"created_at": item.created_at,
|
||||
"product_id": product.id,
|
||||
"product_name": product.name,
|
||||
"variant_name": variant.name,
|
||||
"total_price": item.price * item.quantity
|
||||
})
|
||||
|
||||
# Формируем результат
|
||||
result = {
|
||||
|
||||
return {
|
||||
"id": order.id,
|
||||
"user_id": order.user_id,
|
||||
"user_name": f"{order.user.first_name} {order.user.last_name}" if order.user else None,
|
||||
"user_email": order.user.email if order.user else None,
|
||||
"status": order.status,
|
||||
"total_amount": order.total_amount,
|
||||
"shipping_address_id": order.shipping_address_id,
|
||||
"payment_method": order.payment_method,
|
||||
"payment_details": order.payment_details,
|
||||
"tracking_number": order.tracking_number,
|
||||
"notes": order.notes,
|
||||
"total": order.total_amount,
|
||||
"created_at": order.created_at,
|
||||
"updated_at": order.updated_at,
|
||||
"user_email": user.email if user else None,
|
||||
"items_count": len(items),
|
||||
"items": items,
|
||||
"shipping_address": shipping_address,
|
||||
"items": items
|
||||
}
|
||||
|
||||
return result
|
||||
"payment_method": order.payment_method,
|
||||
"tracking_number": order.tracking_number,
|
||||
"notes": order.notes
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,15 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Form, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from app.core import get_db, get_current_admin_user
|
||||
from app import services
|
||||
from app.schemas.catalog_schemas import (
|
||||
CategoryCreate, CategoryUpdate, Category,
|
||||
ProductCreate, ProductUpdate, Product,
|
||||
CategoryCreate, CategoryUpdate, Category, CategoryWithSubcategories,
|
||||
ProductCreate, ProductUpdate, Product, ProductWithDetails,
|
||||
ProductVariantCreate, ProductVariantUpdate, ProductVariant,
|
||||
ProductImageCreate, ProductImageUpdate, ProductImage,
|
||||
CollectionCreate, CollectionUpdate, Collection
|
||||
CollectionCreate, CollectionUpdate, Collection,
|
||||
SizeCreate, SizeUpdate, Size
|
||||
)
|
||||
from app.models.user_models import User as UserModel
|
||||
from app.repositories.catalog_repo import get_products, get_product_by_slug
|
||||
@ -90,18 +91,34 @@ async def get_product_by_slug_endpoint(slug: str, db: Session = Depends(get_db))
|
||||
|
||||
|
||||
@catalog_router.post("/products/{product_id}/variants", response_model=Dict[str, Any])
|
||||
async def add_product_variant_endpoint(product_id: int, variant: ProductVariantCreate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
|
||||
variant.product_id = product_id
|
||||
async def add_product_variant_endpoint(
|
||||
product_id: int,
|
||||
variant: ProductVariantCreate,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# Убедимся, что product_id в пути совпадает с product_id в данных
|
||||
if variant.product_id != product_id:
|
||||
variant.product_id = product_id
|
||||
return services.add_product_variant(db, variant)
|
||||
|
||||
|
||||
@catalog_router.put("/variants/{variant_id}", response_model=Dict[str, Any])
|
||||
async def update_product_variant_endpoint(variant_id: int, variant: ProductVariantUpdate, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
|
||||
async def update_product_variant_endpoint(
|
||||
variant_id: int,
|
||||
variant: ProductVariantUpdate,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return services.update_product_variant(db, variant_id, variant)
|
||||
|
||||
|
||||
@catalog_router.delete("/variants/{variant_id}", response_model=Dict[str, Any])
|
||||
async def delete_product_variant_endpoint(variant_id: int, current_user: UserModel = Depends(get_current_admin_user), db: Session = Depends(get_db)):
|
||||
async def delete_product_variant_endpoint(
|
||||
variant_id: int,
|
||||
current_user: UserModel = Depends(get_current_admin_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return services.delete_product_variant(db, variant_id)
|
||||
|
||||
|
||||
@ -141,4 +158,53 @@ async def get_products_endpoint(
|
||||
):
|
||||
products = get_products(db, skip, limit, category_id, collection_id, search, min_price, max_price, is_active, include_variants)
|
||||
# Преобразуем объекты SQLAlchemy в схемы Pydantic
|
||||
return [Product.model_validate(product) for product in products]
|
||||
return [Product.model_validate(product) for product in products]
|
||||
|
||||
|
||||
# Маршруты для размеров
|
||||
@catalog_router.get("/sizes", response_model=List[Size])
|
||||
def get_sizes(
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Получить список всех размеров"""
|
||||
return services.get_sizes(db, skip, limit)
|
||||
|
||||
|
||||
@catalog_router.get("/sizes/{size_id}", response_model=Size)
|
||||
def get_size(
|
||||
size_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Получить размер по ID"""
|
||||
return services.get_size(db, size_id)
|
||||
|
||||
|
||||
@catalog_router.post("/sizes", response_model=Size, status_code=status.HTTP_201_CREATED)
|
||||
def create_size(
|
||||
size: SizeCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Создать новый размер"""
|
||||
return services.create_size(db, size)
|
||||
|
||||
|
||||
@catalog_router.put("/sizes/{size_id}", response_model=Size)
|
||||
def update_size(
|
||||
size_id: int,
|
||||
size: SizeUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Обновить размер"""
|
||||
return services.update_size(db, size_id, size)
|
||||
|
||||
|
||||
@catalog_router.delete("/sizes/{size_id}", response_model=Dict[str, bool])
|
||||
def delete_size(
|
||||
size_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Удалить размер"""
|
||||
success = services.delete_size(db, size_id)
|
||||
return {"success": success}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Optional, List, Union
|
||||
from typing import Optional, List, Union, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@ -62,14 +62,37 @@ class Category(CategoryBase):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Схемы для размеров
|
||||
class SizeBase(BaseModel):
|
||||
name: str
|
||||
code: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class SizeCreate(SizeBase):
|
||||
pass
|
||||
|
||||
|
||||
class SizeUpdate(SizeBase):
|
||||
name: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class Size(SizeBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Схемы для вариантов продуктов
|
||||
class ProductVariantBase(BaseModel):
|
||||
product_id: int
|
||||
name: str
|
||||
size_id: int
|
||||
sku: str
|
||||
price: float
|
||||
discount_price: Optional[float] = None
|
||||
stock: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
@ -78,12 +101,10 @@ class ProductVariantCreate(ProductVariantBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductVariantUpdate(ProductVariantBase):
|
||||
class ProductVariantUpdate(BaseModel):
|
||||
product_id: Optional[int] = None
|
||||
name: Optional[str] = None
|
||||
size_id: Optional[int] = None
|
||||
sku: Optional[str] = None
|
||||
price: Optional[float] = None
|
||||
discount_price: Optional[float] = None
|
||||
stock: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@ -92,6 +113,7 @@ class ProductVariant(ProductVariantBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
size: Optional[Size] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@ -123,12 +145,14 @@ class ProductImage(ProductImageBase):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
|
||||
# Схемы для продуктов
|
||||
class ProductBase(BaseModel):
|
||||
name: str
|
||||
slug: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
price: float
|
||||
discount_price: Optional[float] = None
|
||||
care_instructions: Optional[Dict[str, Any]] = None
|
||||
is_active: bool = True
|
||||
category_id: int
|
||||
collection_id: Optional[int] = None
|
||||
@ -138,10 +162,13 @@ class ProductCreate(ProductBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductUpdate(ProductBase):
|
||||
class ProductUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
price: Optional[float] = None
|
||||
discount_price: Optional[float] = None
|
||||
care_instructions: Optional[Dict[str, Any]] = None
|
||||
is_active: Optional[bool] = None
|
||||
category_id: Optional[int] = None
|
||||
collection_id: Optional[int] = None
|
||||
|
||||
@ -9,7 +9,8 @@ from app.services.catalog_service import (
|
||||
create_product, update_product, delete_product, get_product_details,
|
||||
add_product_variant, update_product_variant, delete_product_variant,
|
||||
upload_product_image, update_product_image, delete_product_image,
|
||||
create_collection, update_collection, delete_collection, get_collections
|
||||
create_collection, update_collection, delete_collection, get_collections,
|
||||
get_size, get_size_by_code, get_sizes, create_size, update_size, delete_size
|
||||
)
|
||||
|
||||
from app.services.order_service import (
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,6 +1,6 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, status, UploadFile
|
||||
from typing import List, Dict, Any
|
||||
from typing import List, Dict, Any, Optional
|
||||
import os
|
||||
import uuid
|
||||
import shutil
|
||||
@ -9,11 +9,12 @@ from pathlib import Path
|
||||
from app.config import settings
|
||||
from app.repositories import catalog_repo, review_repo
|
||||
from app.schemas.catalog_schemas import (
|
||||
CategoryCreate, CategoryUpdate,
|
||||
ProductCreate, ProductUpdate,
|
||||
ProductVariantCreate, ProductVariantUpdate,
|
||||
ProductImageCreate, ProductImageUpdate,
|
||||
CollectionCreate, CollectionUpdate
|
||||
CategoryCreate, CategoryUpdate, Category, CategoryWithSubcategories,
|
||||
ProductCreate, ProductUpdate, Product, ProductWithDetails,
|
||||
ProductVariantCreate, ProductVariantUpdate, ProductVariant,
|
||||
ProductImageCreate, ProductImageUpdate, ProductImage,
|
||||
CollectionCreate, CollectionUpdate, Collection,
|
||||
SizeCreate, SizeUpdate, Size
|
||||
)
|
||||
|
||||
|
||||
@ -137,21 +138,91 @@ def _get_subcategories(db: Session, parent_id: int) -> tuple[List[Dict[str, Any]
|
||||
|
||||
|
||||
def create_product(db: Session, product: ProductCreate) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import Product as ProductSchema
|
||||
|
||||
new_product = catalog_repo.create_product(db, product)
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
product_schema = ProductSchema.model_validate(new_product)
|
||||
return {"product": product_schema}
|
||||
"""Создать новый продукт"""
|
||||
try:
|
||||
# Проверяем, что категория существует
|
||||
category = catalog_repo.get_category(db, product.category_id)
|
||||
if not category:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Категория с ID {product.category_id} не найдена"
|
||||
)
|
||||
|
||||
# Проверяем, что коллекция существует, если она указана
|
||||
if product.collection_id:
|
||||
collection = catalog_repo.get_collection(db, product.collection_id)
|
||||
if not collection:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Коллекция с ID {product.collection_id} не найдена"
|
||||
)
|
||||
|
||||
# Создаем продукт
|
||||
db_product = catalog_repo.create_product(db, product)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"product": Product.from_orm(db_product)
|
||||
}
|
||||
except HTTPException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.detail
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def update_product(db: Session, product_id: int, product: ProductUpdate) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import Product as ProductSchema
|
||||
|
||||
updated_product = catalog_repo.update_product(db, product_id, product)
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
product_schema = ProductSchema.model_validate(updated_product)
|
||||
return {"product": product_schema}
|
||||
"""Обновить продукт"""
|
||||
try:
|
||||
# Проверяем, что продукт существует
|
||||
db_product = catalog_repo.get_product(db, product_id)
|
||||
if not db_product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {product_id} не найден"
|
||||
)
|
||||
|
||||
# Если меняется категория, проверяем, что она существует
|
||||
if product.category_id is not None:
|
||||
category = catalog_repo.get_category(db, product.category_id)
|
||||
if not category:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Категория с ID {product.category_id} не найдена"
|
||||
)
|
||||
|
||||
# Если меняется коллекция, проверяем, что она существует
|
||||
if product.collection_id is not None:
|
||||
if product.collection_id:
|
||||
collection = catalog_repo.get_collection(db, product.collection_id)
|
||||
if not collection:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Коллекция с ID {product.collection_id} не найдена"
|
||||
)
|
||||
|
||||
# Обновляем продукт
|
||||
updated_product = catalog_repo.update_product(db, product_id, product)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"product": Product.from_orm(updated_product)
|
||||
}
|
||||
except HTTPException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.detail
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
@ -160,68 +231,150 @@ def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def get_product_details(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import Product as ProductSchema, Category as CategorySchema
|
||||
from app.schemas.catalog_schemas import ProductVariant as ProductVariantSchema
|
||||
from app.schemas.catalog_schemas import ProductImage as ProductImageSchema
|
||||
from app.schemas.catalog_schemas import Collection as CollectionSchema
|
||||
|
||||
product = catalog_repo.get_product(db, product_id)
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Продукт не найден"
|
||||
"""Получить детальную информацию о продукте"""
|
||||
try:
|
||||
# Получаем продукт
|
||||
product = catalog_repo.get_product(db, product_id)
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {product_id} не найден"
|
||||
)
|
||||
|
||||
# Получаем варианты продукта
|
||||
variants = catalog_repo.get_product_variants(db, product_id)
|
||||
|
||||
# Получаем изображения продукта
|
||||
images = catalog_repo.get_product_images(db, product_id)
|
||||
|
||||
# Получаем категорию
|
||||
category = catalog_repo.get_category(db, product.category_id)
|
||||
|
||||
# Получаем коллекцию, если она указана
|
||||
collection = None
|
||||
if product.collection_id:
|
||||
collection = catalog_repo.get_collection(db, product.collection_id)
|
||||
|
||||
# Создаем детальное представление продукта
|
||||
product_details = ProductWithDetails(
|
||||
id=product.id,
|
||||
name=product.name,
|
||||
slug=product.slug,
|
||||
description=product.description,
|
||||
price=product.price,
|
||||
discount_price=product.discount_price,
|
||||
care_instructions=product.care_instructions,
|
||||
is_active=product.is_active,
|
||||
category_id=product.category_id,
|
||||
collection_id=product.collection_id,
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
category=Category.from_orm(category),
|
||||
collection=Collection.from_orm(collection) if collection else None,
|
||||
variants=[ProductVariant.from_orm(variant) for variant in variants],
|
||||
images=[ProductImage.from_orm(image) for image in images]
|
||||
)
|
||||
|
||||
# Получаем варианты продукта
|
||||
variants = catalog_repo.get_product_variants(db, product_id)
|
||||
|
||||
# Получаем изображения продукта
|
||||
images = catalog_repo.get_product_images(db, product_id)
|
||||
|
||||
# Получаем рейтинг продукта
|
||||
rating = review_repo.get_product_rating(db, product_id)
|
||||
|
||||
# Получаем отзывы продукта
|
||||
reviews = review_repo.get_product_reviews(db, product_id, limit=5)
|
||||
|
||||
# Преобразуем объекты SQLAlchemy в схемы Pydantic
|
||||
product_schema = ProductSchema.model_validate(product)
|
||||
variants_schema = [ProductVariantSchema.model_validate(variant) for variant in variants]
|
||||
images_schema = [ProductImageSchema.model_validate(image) for image in images]
|
||||
|
||||
# Добавляем информацию о коллекции, если она есть
|
||||
collection_schema = None
|
||||
if product.collection_id:
|
||||
collection = catalog_repo.get_collection(db, product.collection_id)
|
||||
if collection:
|
||||
collection_schema = CollectionSchema.model_validate(collection)
|
||||
|
||||
return {
|
||||
"product": product_schema,
|
||||
"variants": variants_schema,
|
||||
"images": images_schema,
|
||||
"collection": collection_schema,
|
||||
"rating": rating,
|
||||
"reviews": reviews
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"product": product_details
|
||||
}
|
||||
except HTTPException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.detail
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def add_product_variant(db: Session, variant: ProductVariantCreate) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import ProductVariant as ProductVariantSchema
|
||||
|
||||
new_variant = catalog_repo.create_product_variant(db, variant)
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
variant_schema = ProductVariantSchema.model_validate(new_variant)
|
||||
return {"variant": variant_schema}
|
||||
"""Добавить вариант продукта"""
|
||||
try:
|
||||
# Проверяем, что продукт существует
|
||||
product = catalog_repo.get_product(db, variant.product_id)
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {variant.product_id} не найден"
|
||||
)
|
||||
|
||||
# Проверяем, что размер существует
|
||||
size = catalog_repo.get_size(db, variant.size_id)
|
||||
if not size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {variant.size_id} не найден"
|
||||
)
|
||||
|
||||
# Создаем вариант продукта
|
||||
db_variant = catalog_repo.create_product_variant(db, variant)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"variant": ProductVariant.from_orm(db_variant)
|
||||
}
|
||||
except HTTPException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.detail
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def update_product_variant(db: Session, variant_id: int, variant: ProductVariantUpdate) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import ProductVariant as ProductVariantSchema
|
||||
|
||||
updated_variant = catalog_repo.update_product_variant(db, variant_id, variant)
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
variant_schema = ProductVariantSchema.model_validate(updated_variant)
|
||||
return {"variant": variant_schema}
|
||||
"""Обновить вариант продукта"""
|
||||
try:
|
||||
# Проверяем, что вариант существует
|
||||
db_variant = catalog_repo.get_product_variant(db, variant_id)
|
||||
if not db_variant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Вариант продукта с ID {variant_id} не найден"
|
||||
)
|
||||
|
||||
# Если меняется продукт, проверяем, что он существует
|
||||
if variant.product_id is not None:
|
||||
product = catalog_repo.get_product(db, variant.product_id)
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Продукт с ID {variant.product_id} не найден"
|
||||
)
|
||||
|
||||
# Если меняется размер, проверяем, что он существует
|
||||
if variant.size_id is not None:
|
||||
size = catalog_repo.get_size(db, variant.size_id)
|
||||
if not size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {variant.size_id} не найден"
|
||||
)
|
||||
|
||||
# Обновляем вариант продукта
|
||||
updated_variant = catalog_repo.update_product_variant(db, variant_id, variant)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"variant": ProductVariant.from_orm(updated_variant)
|
||||
}
|
||||
except HTTPException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.detail
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
|
||||
|
||||
def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]:
|
||||
@ -308,4 +461,42 @@ def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]:
|
||||
# В реальном приложении здесь должно быть логирование
|
||||
pass
|
||||
|
||||
return {"success": success}
|
||||
return {"success": success}
|
||||
|
||||
|
||||
# Функции для работы с размерами
|
||||
def get_size(db: Session, size_id: int) -> Size:
|
||||
db_size = catalog_repo.get_size(db, size_id)
|
||||
if not db_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с ID {size_id} не найден"
|
||||
)
|
||||
return Size.from_orm(db_size)
|
||||
|
||||
|
||||
def get_size_by_code(db: Session, code: str) -> Size:
|
||||
db_size = catalog_repo.get_size_by_code(db, code)
|
||||
if not db_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Размер с кодом {code} не найден"
|
||||
)
|
||||
return Size.from_orm(db_size)
|
||||
|
||||
|
||||
def get_sizes(db: Session, skip: int = 0, limit: int = 100) -> List[Size]:
|
||||
db_sizes = catalog_repo.get_sizes(db, skip, limit)
|
||||
return [Size.from_orm(size) for size in db_sizes]
|
||||
|
||||
|
||||
def create_size(db: Session, size: SizeCreate) -> Size:
|
||||
return Size.from_orm(catalog_repo.create_size(db, size))
|
||||
|
||||
|
||||
def update_size(db: Session, size_id: int, size: SizeUpdate) -> Size:
|
||||
return Size.from_orm(catalog_repo.update_size(db, size_id, size))
|
||||
|
||||
|
||||
def delete_size(db: Session, size_id: int) -> bool:
|
||||
return catalog_repo.delete_size(db, size_id)
|
||||
117
backend/docs/README.md
Normal file
117
backend/docs/README.md
Normal file
@ -0,0 +1,117 @@
|
||||
# Документация бэкенда интернет-магазина одежды
|
||||
|
||||
## Содержание
|
||||
|
||||
1. [API документация](api_documentation.md) - Подробное описание всех API эндпоинтов
|
||||
2. [Структура базы данных](database_structure.md) - Описание таблиц и связей в базе данных
|
||||
|
||||
## Технологический стек
|
||||
|
||||
- **Язык программирования**: Python 3.10+
|
||||
- **Фреймворк**: FastAPI
|
||||
- **ORM**: SQLAlchemy 2.0
|
||||
- **База данных**: PostgreSQL
|
||||
- **Аутентификация**: JWT (JSON Web Tokens)
|
||||
|
||||
## Архитектура приложения
|
||||
|
||||
Приложение построено по принципу многослойной архитектуры:
|
||||
|
||||
1. **Роутеры (Routers)** - Обрабатывают HTTP-запросы и ответы
|
||||
2. **Сервисы (Services)** - Содержат бизнес-логику приложения
|
||||
3. **Репозитории (Repositories)** - Отвечают за взаимодействие с базой данных
|
||||
4. **Модели (Models)** - Описывают структуру таблиц базы данных
|
||||
5. **Схемы (Schemas)** - Описывают структуру данных для API (Pydantic модели)
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
backend/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # Точка входа в приложение
|
||||
│ ├── config.py # Конфигурация приложения
|
||||
│ ├── core.py # Основные функции и зависимости
|
||||
│ ├── models/ # SQLAlchemy модели
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── user_models.py
|
||||
│ │ ├── catalog_models.py
|
||||
│ │ ├── order_models.py
|
||||
│ │ ├── review_models.py
|
||||
│ │ └── content_models.py
|
||||
│ ├── schemas/ # Pydantic схемы
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── user_schemas.py
|
||||
│ │ ├── catalog_schemas.py
|
||||
│ │ ├── order_schemas.py
|
||||
│ │ ├── review_schemas.py
|
||||
│ │ └── content_schemas.py
|
||||
│ ├── repositories/ # Репозитории для работы с БД
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── user_repo.py
|
||||
│ │ ├── catalog_repo.py
|
||||
│ │ ├── order_repo.py
|
||||
│ │ ├── review_repo.py
|
||||
│ │ └── content_repo.py
|
||||
│ ├── services/ # Бизнес-логика
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── user_service.py
|
||||
│ │ ├── catalog_service.py
|
||||
│ │ ├── order_service.py
|
||||
│ │ ├── review_service.py
|
||||
│ │ └── content_service.py
|
||||
│ └── routers/ # API эндпоинты
|
||||
│ ├── __init__.py
|
||||
│ ├── auth_router.py
|
||||
│ ├── user_router.py
|
||||
│ ├── catalog_router.py
|
||||
│ ├── cart_router.py
|
||||
│ ├── order_router.py
|
||||
│ ├── review_router.py
|
||||
│ ├── content_router.py
|
||||
│ └── analytics_router.py
|
||||
├── alembic/ # Миграции базы данных
|
||||
├── tests/ # Тесты
|
||||
└── docs/ # Документация
|
||||
├── README.md
|
||||
├── api_documentation.md
|
||||
└── database_structure.md
|
||||
```
|
||||
|
||||
## Запуск приложения
|
||||
|
||||
### Установка зависимостей
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Настройка переменных окружения
|
||||
|
||||
Создайте файл `.env` в корне проекта со следующими переменными:
|
||||
|
||||
```
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/db_name
|
||||
SECRET_KEY=your_secret_key
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
```
|
||||
|
||||
### Запуск сервера разработки
|
||||
|
||||
```bash
|
||||
uvicorn app.main:app --reload
|
||||
```
|
||||
|
||||
### Запуск миграций
|
||||
|
||||
```bash
|
||||
alembic upgrade head
|
||||
```
|
||||
|
||||
## Документация API
|
||||
|
||||
После запуска приложения, документация API доступна по адресу:
|
||||
|
||||
- Swagger UI: http://localhost:8000/docs
|
||||
- ReDoc: http://localhost:8000/redoc
|
||||
191
backend/docs/api_documentation.md
Normal file
191
backend/docs/api_documentation.md
Normal file
@ -0,0 +1,191 @@
|
||||
# Документация API интернет-магазина одежды
|
||||
|
||||
## Содержание
|
||||
1. [Аутентификация](#аутентификация)
|
||||
2. [Пользователи](#пользователи)
|
||||
3. [Каталог](#каталог)
|
||||
4. [Корзина](#корзина)
|
||||
5. [Заказы](#заказы)
|
||||
6. [Отзывы](#отзывы)
|
||||
7. [Контент](#контент)
|
||||
8. [Аналитика](#аналитика)
|
||||
|
||||
## Аутентификация
|
||||
|
||||
Базовый URL: `/auth`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/register` | Регистрация нового пользователя | `UserCreate` (email, password, first_name, last_name) | Нет |
|
||||
| POST | `/login` | Вход в систему | `username` (email), `password` | Нет |
|
||||
| POST | `/reset-password` | Запрос на сброс пароля | `email` | Нет |
|
||||
| POST | `/set-new-password` | Установка нового пароля по токену | `token`, `password` | Нет |
|
||||
| POST | `/change-password` | Изменение пароля | `current_password`, `new_password` | Да |
|
||||
|
||||
## Пользователи
|
||||
|
||||
Базовый URL: `/users`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/me` | Получение профиля текущего пользователя | - | Да |
|
||||
| PUT | `/me` | Обновление профиля текущего пользователя | `UserUpdate` (first_name, last_name, phone) | Да |
|
||||
| POST | `/me/addresses` | Добавление адреса пользователя | `AddressCreate` (city, street, house, apartment, postal_code, is_default) | Да |
|
||||
| PUT | `/me/addresses/{address_id}` | Обновление адреса пользователя | `AddressUpdate` (city, street, house, apartment, postal_code, is_default) | Да |
|
||||
| DELETE | `/me/addresses/{address_id}` | Удаление адреса пользователя | - | Да |
|
||||
| GET | `/{user_id}` | Получение профиля пользователя по ID (только для админов) | - | Да (админ) |
|
||||
|
||||
## Каталог
|
||||
|
||||
Базовый URL: `/catalog`
|
||||
|
||||
### Коллекции
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/collections` | Получение списка коллекций | `skip`, `limit` | Нет |
|
||||
| POST | `/collections` | Создание новой коллекции | `CollectionCreate` (name, slug, description, is_active) | Да (админ) |
|
||||
| PUT | `/collections/{collection_id}` | Обновление коллекции | `CollectionUpdate` (name, slug, description, is_active) | Да (админ) |
|
||||
| DELETE | `/collections/{collection_id}` | Удаление коллекции | - | Да (админ) |
|
||||
|
||||
### Категории
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/categories` | Получение дерева категорий | - | Нет |
|
||||
| POST | `/categories` | Создание новой категории | `CategoryCreate` (name, slug, description, parent_id, is_active) | Да (админ) |
|
||||
| PUT | `/categories/{category_id}` | Обновление категории | `CategoryUpdate` (name, slug, description, parent_id, is_active) | Да (админ) |
|
||||
| DELETE | `/categories/{category_id}` | Удаление категории | - | Да (админ) |
|
||||
|
||||
### Размеры
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/sizes` | Получение списка размеров | `skip`, `limit` | Нет |
|
||||
| GET | `/sizes/{size_id}` | Получение размера по ID | - | Нет |
|
||||
| POST | `/sizes` | Создание нового размера | `SizeCreate` (name, code, description) | Да (админ) |
|
||||
| PUT | `/sizes/{size_id}` | Обновление размера | `SizeUpdate` (name, code, description) | Да (админ) |
|
||||
| DELETE | `/sizes/{size_id}` | Удаление размера | - | Да (админ) |
|
||||
|
||||
### Продукты
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/products` | Получение списка продуктов | `skip`, `limit`, `category_id`, `collection_id`, `search`, `min_price`, `max_price`, `is_active`, `include_variants` | Нет |
|
||||
| GET | `/products/{product_id}` | Получение детальной информации о продукте | - | Нет |
|
||||
| GET | `/products/slug/{slug}` | Получение продукта по slug | - | Нет |
|
||||
| POST | `/products` | Создание нового продукта | `ProductCreate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) |
|
||||
| PUT | `/products/{product_id}` | Обновление продукта | `ProductUpdate` (name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id) | Да (админ) |
|
||||
| DELETE | `/products/{product_id}` | Удаление продукта | - | Да (админ) |
|
||||
|
||||
### Варианты продуктов
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/products/{product_id}/variants` | Добавление варианта продукта | `ProductVariantCreate` (product_id, size_id, sku, stock, is_active) | Да (админ) |
|
||||
| PUT | `/variants/{variant_id}` | Обновление варианта продукта | `ProductVariantUpdate` (product_id, size_id, sku, stock, is_active) | Да (админ) |
|
||||
| DELETE | `/variants/{variant_id}` | Удаление варианта продукта | - | Да (админ) |
|
||||
|
||||
### Изображения продуктов
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/products/{product_id}/images` | Загрузка изображения продукта | `file`, `is_primary` | Да (админ) |
|
||||
| PUT | `/images/{image_id}` | Обновление изображения продукта | `ProductImageUpdate` (alt_text, is_primary) | Да (админ) |
|
||||
| DELETE | `/images/{image_id}` | Удаление изображения продукта | - | Да (админ) |
|
||||
|
||||
## Корзина
|
||||
|
||||
Базовый URL: `/cart`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/` | Получение корзины пользователя | - | Да |
|
||||
| POST | `/items` | Добавление товара в корзину | `CartItemCreate` (product_variant_id, quantity) | Да |
|
||||
| PUT | `/items/{cart_item_id}` | Обновление товара в корзине | `CartItemUpdate` (quantity) | Да |
|
||||
| DELETE | `/items/{cart_item_id}` | Удаление товара из корзины | - | Да |
|
||||
| DELETE | `/clear` | Очистка корзины | - | Да |
|
||||
|
||||
## Заказы
|
||||
|
||||
Базовый URL: `/orders`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/` | Получение списка заказов | `skip`, `limit`, `status` | Да |
|
||||
| GET | `/{order_id}` | Получение информации о заказе | - | Да |
|
||||
| POST | `/` | Создание нового заказа | `OrderCreate` (shipping_address_id, payment_method) | Да |
|
||||
| PUT | `/{order_id}` | Обновление заказа | `OrderUpdate` (status, tracking_number) | Да (админ) |
|
||||
| POST | `/{order_id}/cancel` | Отмена заказа | - | Да |
|
||||
|
||||
## Отзывы
|
||||
|
||||
Базовый URL: `/reviews`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/products/{product_id}` | Получение отзывов о продукте | `skip`, `limit` | Нет |
|
||||
| POST | `/` | Создание нового отзыва | `ReviewCreate` (product_id, rating, text) | Да |
|
||||
| PUT | `/{review_id}` | Обновление отзыва | `ReviewUpdate` (rating, text) | Да |
|
||||
| DELETE | `/{review_id}` | Удаление отзыва | - | Да |
|
||||
| POST | `/{review_id}/approve` | Одобрение отзыва | - | Да (админ) |
|
||||
|
||||
## Контент
|
||||
|
||||
Базовый URL: `/content`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| GET | `/pages` | Получение списка страниц | `skip`, `limit` | Нет |
|
||||
| GET | `/pages/{page_id}` | Получение страницы по ID | - | Нет |
|
||||
| GET | `/pages/slug/{slug}` | Получение страницы по slug | - | Нет |
|
||||
| POST | `/pages` | Создание новой страницы | `PageCreate` (title, slug, content, is_published) | Да (админ) |
|
||||
| PUT | `/pages/{page_id}` | Обновление страницы | `PageUpdate` (title, slug, content, is_published) | Да (админ) |
|
||||
| DELETE | `/pages/{page_id}` | Удаление страницы | - | Да (админ) |
|
||||
|
||||
## Аналитика
|
||||
|
||||
Базовый URL: `/analytics`
|
||||
|
||||
| Метод | Эндпоинт | Описание | Параметры запроса | Требуется авторизация |
|
||||
|-------|----------|----------|-------------------|------------------------|
|
||||
| POST | `/log` | Логирование события аналитики | `AnalyticsLogCreate` (event_type, event_data, user_id) | Нет |
|
||||
| GET | `/logs` | Получение логов аналитики | `event_type`, `start_date`, `end_date`, `skip`, `limit` | Да (админ) |
|
||||
| GET | `/report` | Получение аналитического отчета | `report_type`, `start_date`, `end_date` | Да (админ) |
|
||||
|
||||
## Модели данных
|
||||
|
||||
### Пользователи
|
||||
- `UserCreate`: email, password, first_name, last_name
|
||||
- `UserUpdate`: first_name, last_name, phone
|
||||
- `AddressCreate`: city, street, house, apartment, postal_code, is_default
|
||||
- `AddressUpdate`: city, street, house, apartment, postal_code, is_default
|
||||
|
||||
### Каталог
|
||||
- `CollectionCreate`: name, slug, description, is_active
|
||||
- `CollectionUpdate`: name, slug, description, is_active
|
||||
- `CategoryCreate`: name, slug, description, parent_id, is_active
|
||||
- `CategoryUpdate`: name, slug, description, parent_id, is_active
|
||||
- `SizeCreate`: name, code, description
|
||||
- `SizeUpdate`: name, code, description
|
||||
- `ProductCreate`: name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id
|
||||
- `ProductUpdate`: name, slug, description, price, discount_price, care_instructions, is_active, category_id, collection_id
|
||||
- `ProductVariantCreate`: product_id, size_id, sku, stock, is_active
|
||||
- `ProductVariantUpdate`: product_id, size_id, sku, stock, is_active
|
||||
- `ProductImageCreate`: product_id, image_url, alt_text, is_primary
|
||||
- `ProductImageUpdate`: alt_text, is_primary
|
||||
|
||||
### Корзина и заказы
|
||||
- `CartItemCreate`: product_variant_id, quantity
|
||||
- `CartItemUpdate`: quantity
|
||||
- `OrderCreate`: shipping_address_id, payment_method
|
||||
- `OrderUpdate`: status, tracking_number
|
||||
|
||||
### Отзывы
|
||||
- `ReviewCreate`: product_id, rating, text
|
||||
- `ReviewUpdate`: rating, text
|
||||
|
||||
### Контент
|
||||
- `PageCreate`: title, slug, content, is_published
|
||||
- `PageUpdate`: title, slug, content, is_published
|
||||
- `AnalyticsLogCreate`: event_type, event_data, user_id
|
||||
210
backend/docs/database_structure.md
Normal file
210
backend/docs/database_structure.md
Normal file
@ -0,0 +1,210 @@
|
||||
# Структура базы данных интернет-магазина одежды
|
||||
|
||||
## Содержание
|
||||
1. [Пользователи](#пользователи)
|
||||
2. [Каталог](#каталог)
|
||||
3. [Заказы](#заказы)
|
||||
4. [Отзывы](#отзывы)
|
||||
5. [Контент](#контент)
|
||||
|
||||
## Пользователи
|
||||
|
||||
### Таблица `users`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| email | String | Email пользователя (уникальный) |
|
||||
| hashed_password | String | Хешированный пароль |
|
||||
| first_name | String | Имя пользователя |
|
||||
| last_name | String | Фамилия пользователя |
|
||||
| phone | String | Телефон пользователя (опционально) |
|
||||
| is_active | Boolean | Активен ли пользователь |
|
||||
| is_admin | Boolean | Является ли пользователь администратором |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
### Таблица `addresses`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| user_id | Integer | Внешний ключ на таблицу users |
|
||||
| city | String | Город |
|
||||
| street | String | Улица |
|
||||
| house | String | Номер дома |
|
||||
| apartment | String | Номер квартиры (опционально) |
|
||||
| postal_code | String | Почтовый индекс |
|
||||
| is_default | Boolean | Является ли адрес адресом по умолчанию |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
## Каталог
|
||||
|
||||
### Таблица `collections`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| name | String | Название коллекции |
|
||||
| slug | String | URL-совместимое имя (уникальное) |
|
||||
| description | Text | Описание коллекции (опционально) |
|
||||
| is_active | Boolean | Активна ли коллекция |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
### Таблица `categories`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| name | String | Название категории |
|
||||
| slug | String | URL-совместимое имя (уникальное) |
|
||||
| description | Text | Описание категории (опционально) |
|
||||
| parent_id | Integer | Внешний ключ на родительскую категорию (опционально) |
|
||||
| is_active | Boolean | Активна ли категория |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
### Таблица `sizes`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| name | String | Название размера (S, M, L, XL и т.д.) |
|
||||
| code | String | Уникальный код размера |
|
||||
| description | String | Дополнительная информация о размере (опционально) |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
### Таблица `products`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| name | String | Название продукта |
|
||||
| slug | String | URL-совместимое имя (уникальное) |
|
||||
| description | Text | Описание продукта (опционально) |
|
||||
| price | Float | Базовая цена продукта |
|
||||
| discount_price | Float | Цена со скидкой (опционально) |
|
||||
| care_instructions | JSON | Инструкции по уходу и другие детали (опционально) |
|
||||
| is_active | Boolean | Активен ли продукт |
|
||||
| category_id | Integer | Внешний ключ на таблицу categories |
|
||||
| collection_id | Integer | Внешний ключ на таблицу collections (опционально) |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
### Таблица `product_variants`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| product_id | Integer | Внешний ключ на таблицу products |
|
||||
| size_id | Integer | Внешний ключ на таблицу sizes |
|
||||
| sku | String | Артикул (уникальный) |
|
||||
| stock | Integer | Количество на складе |
|
||||
| is_active | Boolean | Активен ли вариант |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
### Таблица `product_images`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| product_id | Integer | Внешний ключ на таблицу products |
|
||||
| image_url | String | URL изображения |
|
||||
| alt_text | String | Альтернативный текст (опционально) |
|
||||
| is_primary | Boolean | Является ли изображение основным |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
## Заказы
|
||||
|
||||
### Таблица `cart_items`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| user_id | Integer | Внешний ключ на таблицу users |
|
||||
| product_variant_id | Integer | Внешний ключ на таблицу product_variants |
|
||||
| quantity | Integer | Количество товара |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
### Таблица `orders`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| user_id | Integer | Внешний ключ на таблицу users |
|
||||
| status | String | Статус заказа (новый, оплачен, отправлен, доставлен, отменен) |
|
||||
| total_amount | Float | Общая сумма заказа |
|
||||
| shipping_address_id | Integer | Внешний ключ на таблицу addresses |
|
||||
| payment_method | String | Способ оплаты |
|
||||
| tracking_number | String | Номер отслеживания (опционально) |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
### Таблица `order_items`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| order_id | Integer | Внешний ключ на таблицу orders |
|
||||
| product_variant_id | Integer | Внешний ключ на таблицу product_variants |
|
||||
| quantity | Integer | Количество товара |
|
||||
| price | Float | Цена товара на момент заказа |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
## Отзывы
|
||||
|
||||
### Таблица `reviews`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| product_id | Integer | Внешний ключ на таблицу products |
|
||||
| user_id | Integer | Внешний ключ на таблицу users |
|
||||
| rating | Integer | Оценка (1-5) |
|
||||
| text | Text | Текст отзыва (опционально) |
|
||||
| is_approved | Boolean | Одобрен ли отзыв |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
## Контент
|
||||
|
||||
### Таблица `pages`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| title | String | Заголовок страницы |
|
||||
| slug | String | URL-совместимое имя (уникальное) |
|
||||
| content | Text | Содержимое страницы |
|
||||
| is_published | Boolean | Опубликована ли страница |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
| updated_at | DateTime | Дата и время обновления |
|
||||
|
||||
### Таблица `analytics_logs`
|
||||
| Поле | Тип | Описание |
|
||||
|------|-----|----------|
|
||||
| id | Integer | Первичный ключ |
|
||||
| event_type | String | Тип события |
|
||||
| event_data | JSON | Данные события |
|
||||
| user_id | Integer | Внешний ключ на таблицу users (опционально) |
|
||||
| ip_address | String | IP-адрес (опционально) |
|
||||
| user_agent | String | User-Agent (опционально) |
|
||||
| created_at | DateTime | Дата и время создания |
|
||||
|
||||
## Связи между таблицами
|
||||
|
||||
1. `users` 1:N `addresses` (один пользователь может иметь несколько адресов)
|
||||
2. `users` 1:N `cart_items` (один пользователь может иметь несколько товаров в корзине)
|
||||
3. `users` 1:N `orders` (один пользователь может иметь несколько заказов)
|
||||
4. `users` 1:N `reviews` (один пользователь может оставить несколько отзывов)
|
||||
|
||||
5. `categories` 1:N `categories` (категория может иметь подкатегории)
|
||||
6. `categories` 1:N `products` (категория может содержать несколько продуктов)
|
||||
|
||||
7. `collections` 1:N `products` (коллекция может содержать несколько продуктов)
|
||||
|
||||
8. `sizes` 1:N `product_variants` (размер может быть использован в нескольких вариантах продуктов)
|
||||
|
||||
9. `products` 1:N `product_variants` (продукт может иметь несколько вариантов)
|
||||
10. `products` 1:N `product_images` (продукт может иметь несколько изображений)
|
||||
11. `products` 1:N `reviews` (продукт может иметь несколько отзывов)
|
||||
|
||||
12. `product_variants` 1:N `cart_items` (вариант продукта может быть в нескольких корзинах)
|
||||
13. `product_variants` 1:N `order_items` (вариант продукта может быть в нескольких заказах)
|
||||
|
||||
14. `orders` 1:N `order_items` (заказ может содержать несколько товаров)
|
||||
15. `addresses` 1:N `orders` (адрес может быть использован в нескольких заказах)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 662 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 662 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 662 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 662 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 662 KiB |
BIN
frontend/.DS_Store
vendored
BIN
frontend/.DS_Store
vendored
Binary file not shown.
142
frontend/app/(main)/catalog/[slug]/page.tsx
Normal file
142
frontend/app/(main)/catalog/[slug]/page.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { notFound, useRouter } from "next/navigation"
|
||||
import { Product } from "@/lib/catalog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { fetchProduct } from "@/lib/api"
|
||||
import { ImageSlider } from "@/components/product/ImageSlider"
|
||||
import { ProductDetails } from "@/components/product/ProductDetails"
|
||||
|
||||
export default function ProductPage({ params }: { params: { slug: string } }) {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [product, setProduct] = useState<Product | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
const loadProduct = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// fetchProduct возвращает объект Product напрямую, а не обертку ApiResponse
|
||||
const productData = await fetchProduct(params.slug)
|
||||
|
||||
// Проверяем, что получены данные
|
||||
if (!productData) {
|
||||
setError("Продукт не найден")
|
||||
return
|
||||
}
|
||||
|
||||
// Преобразуем тип API Product в тип из catalog.ts
|
||||
setProduct(productData as unknown as Product)
|
||||
} catch (err) {
|
||||
console.error("Error fetching product:", err)
|
||||
setError("Ошибка при загрузке продукта")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadProduct()
|
||||
}, [params.slug])
|
||||
|
||||
if (error && !loading) {
|
||||
return (
|
||||
<div className="container py-12 text-center">
|
||||
<h1 className="text-2xl font-semibold mb-4">Ошибка</h1>
|
||||
<p>{error}</p>
|
||||
<button
|
||||
onClick={() => router.push("/catalog")}
|
||||
className="mt-4 bg-primary text-white px-4 py-2 rounded hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Вернуться в каталог
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <ProductSkeleton />
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container py-6 md:py-10">
|
||||
<nav className="flex flex-wrap items-center text-sm text-gray-500 mb-4 sm:mb-6 overflow-x-auto whitespace-nowrap pb-2">
|
||||
<a href="/" className="hover:text-primary">
|
||||
Главная
|
||||
</a>
|
||||
<span className="mx-2">›</span>
|
||||
<a href="/catalog" className="hover:text-primary">
|
||||
Каталог
|
||||
</a>
|
||||
{product.category && (
|
||||
<>
|
||||
<span className="mx-2">›</span>
|
||||
<a
|
||||
href={`/catalog?category_id=${product.category.id}`}
|
||||
className="hover:text-primary"
|
||||
>
|
||||
{product.category.name}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
<span className="mx-2">›</span>
|
||||
<span className="text-primary font-medium truncate max-w-[150px] sm:max-w-xs">
|
||||
{product.name}
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
<div>
|
||||
<ImageSlider
|
||||
images={product.images || []}
|
||||
productName={product.name}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<ProductDetails product={product} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductSkeleton() {
|
||||
return (
|
||||
<div className="container py-6 md:py-10">
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<Skeleton className="h-6 w-2/3" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 lg:gap-12">
|
||||
<div>
|
||||
<Skeleton className="aspect-square w-full rounded-lg" />
|
||||
<div className="mt-3 flex gap-2 justify-center">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="w-16 h-16 rounded" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-10 w-4/5" />
|
||||
<Skeleton className="h-6 w-1/3" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<div className="space-y-1 mt-6">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-4/5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
627
frontend/app/(main)/catalog/page.tsx
Normal file
627
frontend/app/(main)/catalog/page.tsx
Normal file
@ -0,0 +1,627 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { ChevronDown, ChevronUp, X, Sliders, Search, ArrowUpRight } from "lucide-react"
|
||||
import { ProductCard } from "@/components/ui/product-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
import { productService, categoryService, Product, Category, Collection } from "@/lib/catalog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export default function CatalogPage() {
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false)
|
||||
const [priceRange, setPriceRange] = useState([0, 15000])
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [sortOption, setSortOption] = useState("popular")
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [heroRef, heroInView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [selectedImage, setSelectedImage] = useState<number | null>(null)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
// Состояния для реальных данных
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [totalProducts, setTotalProducts] = useState(0)
|
||||
const [selectedCategory, setSelectedCategory] = useState<number | null>(null)
|
||||
|
||||
// Цвета (оставляем как есть, так как они не меняются)
|
||||
const colors = [
|
||||
{ id: "black", name: "Черный", hex: "#000000" },
|
||||
{ id: "white", name: "Белый", hex: "#FFFFFF" },
|
||||
{ id: "beige", name: "Бежевый", hex: "#F5F5DC" },
|
||||
{ id: "blue", name: "Синий", hex: "#0000FF" },
|
||||
{ id: "green", name: "Зеленый", hex: "#008000" },
|
||||
{ id: "red", name: "Красный", hex: "#FF0000" },
|
||||
]
|
||||
|
||||
// Размеры (оставляем как есть, так как они не меняются)
|
||||
const sizes = ["XS", "S", "M", "L", "XL", "XXL"]
|
||||
|
||||
// Проверка размера экрана
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
// Автоматически переключаем на сетку на мобильных устройствах
|
||||
if (window.innerWidth < 640 && viewMode === 'list') {
|
||||
setViewMode('grid')
|
||||
}
|
||||
}
|
||||
|
||||
checkIsMobile()
|
||||
window.addEventListener('resize', checkIsMobile)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkIsMobile)
|
||||
}
|
||||
}, [viewMode])
|
||||
|
||||
// Загрузка категорий
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const categoriesData = await categoryService.getCategories();
|
||||
setCategories(categoriesData);
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке категорий:", err);
|
||||
setError("Не удалось загрузить категории");
|
||||
}
|
||||
};
|
||||
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// Загрузка продуктов с учетом фильтров
|
||||
useEffect(() => {
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Формируем параметры запроса
|
||||
const params: any = {
|
||||
limit: 12,
|
||||
skip: (currentPage - 1) * 12,
|
||||
include_variants: true
|
||||
};
|
||||
|
||||
// Добавляем фильтр по категории
|
||||
if (selectedCategory) {
|
||||
params.category_id = selectedCategory;
|
||||
}
|
||||
|
||||
// Добавляем фильтр по цене
|
||||
if (priceRange[0] > 0) {
|
||||
params.min_price = priceRange[0];
|
||||
}
|
||||
if (priceRange[1] < 15000) {
|
||||
params.max_price = priceRange[1];
|
||||
}
|
||||
|
||||
// Добавляем поисковый запрос
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
}
|
||||
|
||||
const productsData = await productService.getProducts(params);
|
||||
|
||||
// Сортировка полученных продуктов
|
||||
let sortedProducts = [...productsData];
|
||||
|
||||
switch (sortOption) {
|
||||
case 'price_asc':
|
||||
sortedProducts.sort((a, b) => {
|
||||
const priceA = a.variants?.[0]?.price || 0;
|
||||
const priceB = b.variants?.[0]?.price || 0;
|
||||
return priceA - priceB;
|
||||
});
|
||||
break;
|
||||
case 'price_desc':
|
||||
sortedProducts.sort((a, b) => {
|
||||
const priceA = a.variants?.[0]?.price || 0;
|
||||
const priceB = b.variants?.[0]?.price || 0;
|
||||
return priceB - priceA;
|
||||
});
|
||||
break;
|
||||
case 'newest':
|
||||
sortedProducts.sort((a, b) => {
|
||||
const dateA = new Date((a as any).created_at || '').getTime();
|
||||
const dateB = new Date((b as any).created_at || '').getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
break;
|
||||
default: // popular - оставляем как есть
|
||||
break;
|
||||
}
|
||||
|
||||
setProducts(sortedProducts);
|
||||
setTotalProducts(productsData.length);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error("Ошибка при загрузке продуктов:", err);
|
||||
setError("Не удалось загрузить продукты");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProducts();
|
||||
}, [currentPage, selectedCategory, priceRange, searchQuery, sortOption]);
|
||||
|
||||
// Функция для переключения фильтров
|
||||
const toggleFilter = (filter: string) => {
|
||||
if (activeFilters.includes(filter)) {
|
||||
setActiveFilters(activeFilters.filter(f => f !== filter))
|
||||
} else {
|
||||
setActiveFilters([...activeFilters, filter])
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для очистки всех фильтров
|
||||
const clearAllFilters = () => {
|
||||
setActiveFilters([])
|
||||
setPriceRange([0, 15000])
|
||||
setSearchQuery("")
|
||||
setSelectedCategory(null)
|
||||
}
|
||||
|
||||
// Обработчик выбора категории
|
||||
const handleCategorySelect = (categoryId: number) => {
|
||||
setSelectedCategory(categoryId === selectedCategory ? null : categoryId);
|
||||
setCurrentPage(1); // Сбрасываем страницу на первую при изменении категории
|
||||
};
|
||||
|
||||
// Компонент боковой панели фильтров
|
||||
const FilterSidebar = () => (
|
||||
<div className="space-y-8">
|
||||
{/* Поиск */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Поиск..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-primary/20 focus:border-primary rounded-none"
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-primary/60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Категории */}
|
||||
<div>
|
||||
<Accordion type="single" collapsible defaultValue="categories">
|
||||
<AccordionItem value="categories" className="border-b-0">
|
||||
<AccordionTrigger className="font-medium text-base py-2">Категории</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="mt-2 space-y-2">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="flex items-center">
|
||||
<Checkbox
|
||||
id={`category-${category.id}`}
|
||||
checked={selectedCategory === category.id}
|
||||
onCheckedChange={() => handleCategorySelect(category.id)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`category-${category.id}`}
|
||||
className="text-sm flex-1 cursor-pointer"
|
||||
>
|
||||
{category.name}
|
||||
</Label>
|
||||
<span className="text-sm text-primary/60">
|
||||
{category.products_count || 0}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* Цена */}
|
||||
<div>
|
||||
<Accordion type="single" collapsible defaultValue="price">
|
||||
<AccordionItem value="price" className="border-b-0">
|
||||
<AccordionTrigger className="font-medium text-base py-2">Цена</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="px-1 py-4">
|
||||
<Slider
|
||||
value={priceRange}
|
||||
min={0}
|
||||
max={15000}
|
||||
step={100}
|
||||
onValueChange={(value) => setPriceRange(value as [number, number])}
|
||||
className="mb-6"
|
||||
/>
|
||||
<div className="flex justify-between text-sm">
|
||||
<div>
|
||||
<span className="text-primary/60 mr-1">от</span>
|
||||
<span className="font-medium">{priceRange[0]} ₽</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-primary/60 mr-1">до</span>
|
||||
<span className="font-medium">{priceRange[1]} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* Цвета */}
|
||||
<div>
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="colors" className="border-b-0">
|
||||
<AccordionTrigger className="font-medium text-base py-2">Цвет</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
{colors.map((color) => (
|
||||
<div key={color.id} className="flex flex-col items-center">
|
||||
<button
|
||||
className={`relative w-8 h-8 rounded-full mb-1 ${
|
||||
activeFilters.includes(color.id)
|
||||
? "ring-2 ring-primary ring-offset-2"
|
||||
: ""
|
||||
}`}
|
||||
style={{ backgroundColor: color.hex }}
|
||||
onClick={() => toggleFilter(color.id)}
|
||||
>
|
||||
{activeFilters.includes(color.id) && (
|
||||
<span
|
||||
className={`absolute inset-0 flex items-center justify-center ${
|
||||
color.id === "white" ? "text-black" : "text-white"
|
||||
}`}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs">{color.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* Размеры */}
|
||||
<div>
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="sizes" className="border-b-0">
|
||||
<AccordionTrigger className="font-medium text-base py-2">Размер</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
||||
{sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
className={`border py-2 text-sm text-center transition-colors ${
|
||||
activeFilters.includes(size)
|
||||
? "bg-primary text-white border-primary"
|
||||
: "border-gray-300 hover:border-primary"
|
||||
}`}
|
||||
onClick={() => toggleFilter(size)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* Кнопка сброса фильтров */}
|
||||
<Button variant="outline" className="w-full" onClick={clearAllFilters}>
|
||||
Сбросить фильтры
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Отображаем загрузку
|
||||
if (loading && currentPage === 1) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-medium">Каталог товаров</h1>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Отображаем ошибку, если она есть
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="bg-red-100 p-4 rounded-md text-red-700">
|
||||
<p className="font-medium">Ошибка загрузки данных</p>
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Попробовать снова
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
{/* Заголовок и фильтры для десктопа */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 space-y-4 md:space-y-0">
|
||||
<div>
|
||||
<h1 className="text-3xl font-medium">Каталог товаров</h1>
|
||||
<p className="text-primary/60">Найдено товаров: {totalProducts}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Сортировка */}
|
||||
<Select value={sortOption} onValueChange={setSortOption}>
|
||||
<SelectTrigger className="w-[180px] text-sm">
|
||||
<SelectValue placeholder="Сортировка" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="popular">По популярности</SelectItem>
|
||||
<SelectItem value="price_asc">По возрастанию цены</SelectItem>
|
||||
<SelectItem value="price_desc">По убыванию цены</SelectItem>
|
||||
<SelectItem value="newest">Сначала новые</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Переключатель вид сетки/списка */}
|
||||
<div className="border rounded-md flex">
|
||||
<button
|
||||
className={`px-3 py-2 ${viewMode === "grid" ? "bg-gray-100" : ""}`}
|
||||
onClick={() => setViewMode("grid")}
|
||||
title="Сетка"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="2" width="9" height="9" rx="1" className={viewMode === "grid" ? "fill-primary" : "fill-gray-500"} />
|
||||
<rect x="13" y="2" width="9" height="9" rx="1" className={viewMode === "grid" ? "fill-primary" : "fill-gray-500"} />
|
||||
<rect x="2" y="13" width="9" height="9" rx="1" className={viewMode === "grid" ? "fill-primary" : "fill-gray-500"} />
|
||||
<rect x="13" y="13" width="9" height="9" rx="1" className={viewMode === "grid" ? "fill-primary" : "fill-gray-500"} />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-2 ${viewMode === "list" ? "bg-gray-100" : ""}`}
|
||||
onClick={() => setViewMode("list")}
|
||||
title="Список"
|
||||
disabled={isMobile}
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="2" y="4" width="20" height="2" rx="1" className={viewMode === "list" ? "fill-primary" : "fill-gray-500"} />
|
||||
<rect x="2" y="11" width="20" height="2" rx="1" className={viewMode === "list" ? "fill-primary" : "fill-gray-500"} />
|
||||
<rect x="2" y="18" width="20" height="2" rx="1" className={viewMode === "list" ? "fill-primary" : "fill-gray-500"} />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Кнопка фильтров для мобильных */}
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" className="md:hidden flex items-center gap-2" onClick={() => setIsFilterOpen(true)}>
|
||||
<Sliders className="h-4 w-4" />
|
||||
Фильтры
|
||||
{activeFilters.length > 0 && (
|
||||
<span className="bg-primary text-white rounded-full w-5 h-5 flex items-center justify-center text-xs">
|
||||
{activeFilters.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Фильтры</SheetTitle>
|
||||
<SheetDescription>
|
||||
Выберите параметры для фильтрации товаров
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="py-4">
|
||||
<FilterSidebar />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Активные фильтры */}
|
||||
{activeFilters.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeFilters.map((filter) => (
|
||||
<Button key={filter} size="sm" variant="outline" className="h-8 flex items-center gap-1" onClick={() => toggleFilter(filter)}>
|
||||
{filter}
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
))}
|
||||
<Button size="sm" variant="ghost" className="h-8" onClick={clearAllFilters}>
|
||||
Очистить все
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{/* Боковая панель с фильтрами - десктоп */}
|
||||
<div className="w-full md:w-64 hidden md:block space-y-6">
|
||||
<FilterSidebar />
|
||||
</div>
|
||||
|
||||
{/* Список товаров */}
|
||||
<div className="flex-1">
|
||||
{/* Сетка товаров */}
|
||||
{products.length > 0 ? (
|
||||
<div
|
||||
className={
|
||||
viewMode === "grid"
|
||||
? "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-3 gap-6"
|
||||
: "flex flex-col space-y-6"
|
||||
}
|
||||
>
|
||||
{products.map((product) => (
|
||||
<AnimatePresence key={product.id}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{viewMode === "grid" ? (
|
||||
// Карточка товара в режиме сетки
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
price={product.variants?.[0]?.price || 0}
|
||||
salePrice={product.variants?.[0]?.discount_price || undefined}
|
||||
image={product.images && product.images.length > 0 ?
|
||||
product.images[0].url : "/placeholder.svg?height=600&width=400"}
|
||||
slug={product.slug}
|
||||
isNew={false} // Нужно добавить поле в API
|
||||
isOnSale={product.variants?.[0]?.discount_price ? true : false}
|
||||
category={product.category?.name || ""}
|
||||
/>
|
||||
) : (
|
||||
// Карточка товара в режиме списка
|
||||
<div className="flex flex-col md:flex-row border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="w-full md:w-1/3 relative overflow-hidden group">
|
||||
<Link href={`/catalog/${product.slug}`}>
|
||||
<div className="aspect-[3/4] relative">
|
||||
<Image
|
||||
src={product.images && product.images.length > 0 ?
|
||||
(product.images[0].url.startsWith('http') ?
|
||||
product.images[0].url :
|
||||
`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:8000'}${product.images[0].url}`) :
|
||||
"/placeholder.svg?height=600&width=400"}
|
||||
alt={product.name}
|
||||
className="object-cover w-full h-full"
|
||||
width={400}
|
||||
height={600}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
{product.variants?.[0]?.discount_price && (
|
||||
<div className="absolute top-2 left-2 bg-red-600 text-white px-2 py-1 text-xs font-medium">
|
||||
Скидка
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full md:w-2/3 p-6 flex flex-col justify-between">
|
||||
<div>
|
||||
<Link href={`/catalog/${product.slug}`}>
|
||||
<h3 className="text-xl font-medium">{product.name}</h3>
|
||||
</Link>
|
||||
<p className="text-primary/60 mt-2">
|
||||
{product.category?.name || ''}
|
||||
</p>
|
||||
<p className="mt-4 text-sm">{product.description.length > 150
|
||||
? `${product.description.substring(0, 150)}...`
|
||||
: product.description}</p>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between">
|
||||
<div>
|
||||
{product.variants?.[0]?.discount_price ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-medium">{product.variants[0]?.discount_price} ₽</span>
|
||||
<span className="text-sm text-primary/60 line-through">{product.variants[0]?.price} ₽</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-lg font-medium">{product.variants?.[0]?.price || 0} ₽</span>
|
||||
)}
|
||||
</div>
|
||||
<Link href={`/catalog/${product.slug}`}>
|
||||
<Button>Подробнее</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
// Если товары не найдены
|
||||
<div className="bg-gray-50 p-8 rounded-lg text-center">
|
||||
<h3 className="text-xl font-medium mb-2">Товары не найдены</h3>
|
||||
<p className="text-primary/60 mb-4">
|
||||
Попробуйте изменить параметры фильтрации или поиска
|
||||
</p>
|
||||
<Button onClick={clearAllFilters}>Сбросить фильтры</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Пагинация */}
|
||||
{totalProducts > 0 && (
|
||||
<div className="mt-12 flex justify-center">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Назад
|
||||
</Button>
|
||||
{/* Номера страниц */}
|
||||
{Array.from({ length: Math.ceil(totalProducts / 12) }).map((_, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant={currentPage === i + 1 ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(i + 1)}
|
||||
className={currentPage === i + 1 ? "bg-primary text-white" : ""}
|
||||
>
|
||||
{i + 1}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(Math.min(Math.ceil(totalProducts / 12), currentPage + 1))}
|
||||
disabled={currentPage === Math.ceil(totalProducts / 12)}
|
||||
>
|
||||
Вперед
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
frontend/app/(main)/layout.tsx
Normal file
16
frontend/app/(main)/layout.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { SiteHeader } from "@/components/layout/site-header"
|
||||
import { SiteFooter } from "@/components/layout/site-footer"
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MainLayout({ children }: MainLayoutProps) {
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<SiteHeader />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -30,87 +30,6 @@ export default function ProductPage({ params }: { params: { id: string } }) {
|
||||
const [isZoomed, setIsZoomed] = useState(false)
|
||||
const [zoomPosition, setZoomPosition] = useState({ x: 0, y: 0 })
|
||||
|
||||
// Мок-данные для продукта
|
||||
const product = {
|
||||
id: params.id,
|
||||
name: "ШЕЛКОВАЯ БЛУЗА С ДЛИННЫМ РУКАВОМ",
|
||||
price: 4990,
|
||||
oldPrice: 6990,
|
||||
discount: 29,
|
||||
rating: 4.8,
|
||||
reviewCount: 24,
|
||||
description:
|
||||
"Элегантная шелковая блуза с длинным рукавом и свободным силуэтом. Идеально подходит для создания как повседневных, так и деловых образов. Изготовлена из 100% натурального шелка высшего качества.",
|
||||
details: [
|
||||
"100% натуральный шелк",
|
||||
"Свободный силуэт",
|
||||
"Длинный рукав с манжетами",
|
||||
"Застежка на пуговицы",
|
||||
"Машинная стирка при 30°C",
|
||||
"Сделано в Италии",
|
||||
],
|
||||
sizes: ["XS", "S", "M", "L", "XL"],
|
||||
colors: [
|
||||
{ name: "Белый", code: "#FFFFFF", border: true },
|
||||
{ name: "Черный", code: "#000000" },
|
||||
{ name: "Бежевый", code: "#F5F5DC", border: true },
|
||||
{ name: "Голубой", code: "#ADD8E6" },
|
||||
],
|
||||
images: [
|
||||
"/placeholder.svg?height=800&width=600",
|
||||
"/placeholder.svg?height=800&width=600&text=Image+2",
|
||||
"/placeholder.svg?height=800&width=600&text=Image+3",
|
||||
"/placeholder.svg?height=800&width=600&text=Image+4",
|
||||
],
|
||||
sku: "BL-12345",
|
||||
inStock: true,
|
||||
category: "Блузы",
|
||||
composition: "100% шелк",
|
||||
care: "Машинная стирка при 30°C, не отбеливать, гладить при низкой температуре, не подвергать химической чистке",
|
||||
delivery: "Доставка по России от 3 до 7 рабочих дней",
|
||||
returns: "Бесплатный возврат в течение 14 дней",
|
||||
}
|
||||
|
||||
// Рекомендуемые товары
|
||||
const recommendedProducts = [
|
||||
{
|
||||
id: 1,
|
||||
name: "БРЮКИ С ВЫСОКОЙ ПОСАДКОЙ",
|
||||
price: 3990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: false,
|
||||
isOnSale: false,
|
||||
category: "Брюки",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "ЖАКЕТ ИЗ ИТАЛЬЯНСКОЙ ШЕРСТИ",
|
||||
price: 12900,
|
||||
salePrice: 9900,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: false,
|
||||
isOnSale: true,
|
||||
category: "Жакеты",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "ЮБКА МИДИ ПЛИССЕ",
|
||||
price: 5290,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: true,
|
||||
isOnSale: false,
|
||||
category: "Юбки",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "ТОПП С ДРАПИРОВКОЙ",
|
||||
price: 3990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: false,
|
||||
isOnSale: false,
|
||||
category: "Топы",
|
||||
},
|
||||
]
|
||||
|
||||
const incrementQuantity = () => {
|
||||
setQuantity(quantity + 1)
|
||||
16
frontend/app/(main)/product/layout.tsx
Normal file
16
frontend/app/(main)/product/layout.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
export const metadata = {
|
||||
title: 'Next.js',
|
||||
description: 'Generated by Next.js',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
235
frontend/app/admin/categories/page.tsx
Normal file
235
frontend/app/admin/categories/page.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Edit, Trash, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
|
||||
// Типы для категорий
|
||||
interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
parent_id: number | null;
|
||||
is_active: boolean;
|
||||
subcategories?: Category[];
|
||||
}
|
||||
|
||||
// Временные данные до реализации API
|
||||
const mockCategories: Category[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Женская одежда',
|
||||
slug: 'womens-clothing',
|
||||
parent_id: null,
|
||||
is_active: true,
|
||||
subcategories: [
|
||||
{ id: 2, name: 'Платья', slug: 'dresses', parent_id: 1, is_active: true },
|
||||
{ id: 3, name: 'Блузки', slug: 'blouses', parent_id: 1, is_active: true },
|
||||
{ id: 4, name: 'Юбки', slug: 'skirts', parent_id: 1, is_active: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Мужская одежда',
|
||||
slug: 'mens-clothing',
|
||||
parent_id: null,
|
||||
is_active: true,
|
||||
subcategories: [
|
||||
{ id: 6, name: 'Рубашки', slug: 'shirts', parent_id: 5, is_active: true },
|
||||
{ id: 7, name: 'Брюки', slug: 'pants', parent_id: 5, is_active: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Аксессуары',
|
||||
slug: 'accessories',
|
||||
parent_id: null,
|
||||
is_active: true,
|
||||
subcategories: []
|
||||
}
|
||||
];
|
||||
|
||||
// Компонент для отображения категории в дереве
|
||||
const CategoryTreeItem = ({
|
||||
category,
|
||||
level = 0,
|
||||
onEdit,
|
||||
onDelete,
|
||||
expandedCategories,
|
||||
toggleCategory
|
||||
}: {
|
||||
category: Category;
|
||||
level?: number;
|
||||
onEdit: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
expandedCategories: number[];
|
||||
toggleCategory: (id: number) => void;
|
||||
}) => {
|
||||
const isExpanded = expandedCategories.includes(category.id);
|
||||
const hasSubcategories = category.subcategories && category.subcategories.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center py-2 px-4 hover:bg-gray-50 border-b">
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={{ paddingLeft: `${level * 20}px` }}
|
||||
>
|
||||
{hasSubcategories && (
|
||||
<button
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
className="w-6 h-6 flex items-center justify-center mr-2 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
)}
|
||||
{!hasSubcategories && <div className="w-6 mr-2"></div>}
|
||||
<span className={`${!category.is_active ? 'text-gray-400' : ''}`}>{category.name}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center">
|
||||
<span className="text-sm text-gray-500 mr-4">{category.slug}</span>
|
||||
<button
|
||||
onClick={() => onEdit(category.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900 p-1"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(category.id)}
|
||||
className="text-red-600 hover:text-red-900 p-1 ml-1"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && hasSubcategories && (
|
||||
<div>
|
||||
{category.subcategories!.map(subcategory => (
|
||||
<CategoryTreeItem
|
||||
key={subcategory.id}
|
||||
category={subcategory}
|
||||
level={level + 1}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
expandedCategories={expandedCategories}
|
||||
toggleCategory={toggleCategory}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [expandedCategories, setExpandedCategories] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка категорий при монтировании
|
||||
useEffect(() => {
|
||||
// Здесь должен быть запрос к API
|
||||
// В будущем заменить на реальный запрос
|
||||
setLoading(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setCategories(mockCategories);
|
||||
setExpandedCategories([1, 5]); // По умолчанию раскрываем первые категории
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Обработчик редактирования категории
|
||||
const handleEdit = (id: number) => {
|
||||
// В будущем реализовать с переходом на страницу редактирования
|
||||
console.log('Редактирование категории:', id);
|
||||
alert(`Редактирование категории с ID: ${id}`);
|
||||
};
|
||||
|
||||
// Обработчик удаления категории
|
||||
const handleDelete = (id: number) => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить эту категорию?')) {
|
||||
console.log('Удаление категории:', id);
|
||||
// В будущем реализовать запрос к API
|
||||
// Временная реализация для демонстрации
|
||||
const removeCategory = (cats: Category[]): Category[] => {
|
||||
return cats.filter(cat => {
|
||||
if (cat.id === id) return false;
|
||||
if (cat.subcategories) {
|
||||
cat.subcategories = removeCategory(cat.subcategories);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
setCategories(removeCategory([...categories]));
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик раскрытия/скрытия категории
|
||||
const toggleCategory = (id: number) => {
|
||||
setExpandedCategories(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(catId => catId !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
||||
<strong className="font-bold">Ошибка!</strong>
|
||||
<span className="block sm:inline"> {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Управление категориями</h2>
|
||||
<Link
|
||||
href="/admin/categories/create"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Добавить категорию
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="border-b px-6 py-3 bg-gray-50">
|
||||
<h3 className="text-base font-medium">Дерево категорий</h3>
|
||||
</div>
|
||||
|
||||
<div className="divide-y">
|
||||
{categories.length === 0 ? (
|
||||
<div className="py-4 px-6 text-center text-gray-500">
|
||||
Категории не найдены
|
||||
</div>
|
||||
) : (
|
||||
categories.map(category => (
|
||||
<CategoryTreeItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
expandedCategories={expandedCategories}
|
||||
toggleCategory={toggleCategory}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
frontend/app/admin/collections/page.tsx
Normal file
235
frontend/app/admin/collections/page.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Edit, Trash, Search } from 'lucide-react';
|
||||
|
||||
// Типы для коллекций
|
||||
interface Collection {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// Временные данные до реализации API
|
||||
const mockCollections: Collection[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Весна-Лето 2023',
|
||||
slug: 'spring-summer-2023',
|
||||
description: 'Коллекция весна-лето 2023 года. Яркие цвета и легкие ткани.',
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Осень-Зима 2022/23',
|
||||
slug: 'autumn-winter-2022-23',
|
||||
description: 'Теплые и стильные вещи для холодного сезона.',
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Базовая коллекция',
|
||||
slug: 'basic-collection',
|
||||
description: 'Базовые модели, которые всегда в тренде.',
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Спортивная линия',
|
||||
slug: 'sport-line',
|
||||
description: 'Спортивная одежда для активного образа жизни.',
|
||||
is_active: false
|
||||
}
|
||||
];
|
||||
|
||||
export default function CollectionsPage() {
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filteredCollections, setFilteredCollections] = useState<Collection[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка коллекций при монтировании
|
||||
useEffect(() => {
|
||||
// Здесь должен быть запрос к API
|
||||
// В будущем заменить на реальный запрос
|
||||
setLoading(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setCollections(mockCollections);
|
||||
setFilteredCollections(mockCollections);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Обработчик поиска
|
||||
useEffect(() => {
|
||||
if (searchTerm.trim() === '') {
|
||||
setFilteredCollections(collections);
|
||||
} else {
|
||||
const filtered = collections.filter(collection =>
|
||||
collection.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
collection.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(collection.description && collection.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
);
|
||||
setFilteredCollections(filtered);
|
||||
}
|
||||
}, [searchTerm, collections]);
|
||||
|
||||
// Обработчик редактирования коллекции
|
||||
const handleEdit = (id: number) => {
|
||||
// В будущем реализовать с переходом на страницу редактирования
|
||||
console.log('Редактирование коллекции:', id);
|
||||
alert(`Редактирование коллекции с ID: ${id}`);
|
||||
};
|
||||
|
||||
// Обработчик удаления коллекции
|
||||
const handleDelete = (id: number) => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить эту коллекцию?')) {
|
||||
console.log('Удаление коллекции:', id);
|
||||
// В будущем реализовать запрос к API
|
||||
// Временная реализация для демонстрации
|
||||
const updatedCollections = collections.filter(collection => collection.id !== id);
|
||||
setCollections(updatedCollections);
|
||||
setFilteredCollections(updatedCollections.filter(collection =>
|
||||
collection.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
collection.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(collection.description && collection.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик изменения статуса коллекции
|
||||
const handleToggleStatus = (id: number) => {
|
||||
const updatedCollections = collections.map(collection =>
|
||||
collection.id === id
|
||||
? { ...collection, is_active: !collection.is_active }
|
||||
: collection
|
||||
);
|
||||
setCollections(updatedCollections);
|
||||
setFilteredCollections(updatedCollections.filter(collection =>
|
||||
collection.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
collection.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(collection.description && collection.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
||||
<strong className="font-bold">Ошибка!</strong>
|
||||
<span className="block sm:inline"> {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Управление коллекциями</h2>
|
||||
<Link
|
||||
href="/admin/collections/create"
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Добавить коллекцию
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск коллекций..."
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Название</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slug</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Описание</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Статус</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredCollections.map((collection) => (
|
||||
<tr key={collection.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{collection.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{collection.slug}</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-500">
|
||||
{collection.description ?
|
||||
(collection.description.length > 60 ?
|
||||
`${collection.description.substring(0, 60)}...` :
|
||||
collection.description) :
|
||||
'—'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
collection.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{collection.is_active ? 'Активна' : 'Неактивна'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => handleToggleStatus(collection.id)}
|
||||
className={`text-sm ${
|
||||
collection.is_active ? 'text-red-600 hover:text-red-900' : 'text-green-600 hover:text-green-900'
|
||||
}`}
|
||||
>
|
||||
{collection.is_active ? 'Деактивировать' : 'Активировать'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(collection.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(collection.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredCollections.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Коллекции не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
243
frontend/app/admin/customers/page.tsx
Normal file
243
frontend/app/admin/customers/page.tsx
Normal file
@ -0,0 +1,243 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Mail, Phone, Calendar, User } from 'lucide-react';
|
||||
|
||||
// Типы для клиентов
|
||||
interface Customer {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone?: string;
|
||||
created_at: string;
|
||||
orders_count: number;
|
||||
total_spent: number;
|
||||
last_order_date?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// Временные данные до реализации API
|
||||
const mockCustomers: Customer[] = [
|
||||
{
|
||||
id: 101,
|
||||
email: 'ivan@example.com',
|
||||
first_name: 'Иван',
|
||||
last_name: 'Иванов',
|
||||
phone: '+7 (999) 123-45-67',
|
||||
created_at: '2023-01-10T14:30:00Z',
|
||||
orders_count: 8,
|
||||
total_spent: 45700,
|
||||
last_order_date: '2023-03-15T14:30:00Z',
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
email: 'anna@example.com',
|
||||
first_name: 'Анна',
|
||||
last_name: 'Петрова',
|
||||
phone: '+7 (999) 765-43-21',
|
||||
created_at: '2023-02-05T10:15:00Z',
|
||||
orders_count: 3,
|
||||
total_spent: 12500,
|
||||
last_order_date: '2023-03-14T10:15:00Z',
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
email: 'sergey@example.com',
|
||||
first_name: 'Сергей',
|
||||
last_name: 'Сидоров',
|
||||
phone: '+7 (999) 555-55-55',
|
||||
created_at: '2023-01-20T18:45:00Z',
|
||||
orders_count: 5,
|
||||
total_spent: 28900,
|
||||
last_order_date: '2023-03-13T18:45:00Z',
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
email: 'elena@example.com',
|
||||
first_name: 'Елена',
|
||||
last_name: 'Смирнова',
|
||||
created_at: '2023-02-15T09:20:00Z',
|
||||
orders_count: 1,
|
||||
total_spent: 6300,
|
||||
last_order_date: '2023-03-12T09:20:00Z',
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
id: 105,
|
||||
email: 'dmitry@example.com',
|
||||
first_name: 'Дмитрий',
|
||||
last_name: 'Кузнецов',
|
||||
phone: '+7 (999) 888-77-66',
|
||||
created_at: '2023-01-05T12:10:00Z',
|
||||
orders_count: 4,
|
||||
total_spent: 19800,
|
||||
last_order_date: '2023-03-11T12:10:00Z',
|
||||
is_active: false
|
||||
}
|
||||
];
|
||||
|
||||
export default function CustomersPage() {
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [filteredCustomers, setFilteredCustomers] = useState<Customer[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка клиентов при монтировании
|
||||
useEffect(() => {
|
||||
// Здесь должен быть запрос к API
|
||||
// В будущем заменить на реальный запрос
|
||||
setLoading(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setCustomers(mockCustomers);
|
||||
setFilteredCustomers(mockCustomers);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
// Обработчик поиска
|
||||
useEffect(() => {
|
||||
if (searchTerm.trim() === '') {
|
||||
setFilteredCustomers(customers);
|
||||
} else {
|
||||
const search = searchTerm.toLowerCase();
|
||||
const filtered = customers.filter(customer =>
|
||||
customer.email.toLowerCase().includes(search) ||
|
||||
customer.first_name.toLowerCase().includes(search) ||
|
||||
customer.last_name.toLowerCase().includes(search) ||
|
||||
(customer.phone && customer.phone.toLowerCase().includes(search))
|
||||
);
|
||||
setFilteredCustomers(filtered);
|
||||
}
|
||||
}, [searchTerm, customers]);
|
||||
|
||||
// Форматирование даты
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
||||
<strong className="font-bold">Ошибка!</strong>
|
||||
<span className="block sm:inline"> {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Управление клиентами</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по имени, email или телефону"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Клиент</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Контакты</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Регистрация</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Заказы</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Сумма</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Последний заказ</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredCustomers.map((customer) => (
|
||||
<tr key={customer.id} className={`${!customer.is_active ? 'bg-gray-50' : ''}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{customer.first_name} {customer.last_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
ID: {customer.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 flex items-center">
|
||||
<Mail className="h-4 w-4 mr-1 text-gray-500" />
|
||||
{customer.email}
|
||||
</div>
|
||||
{customer.phone && (
|
||||
<div className="text-sm text-gray-500 flex items-center mt-1">
|
||||
<Phone className="h-4 w-4 mr-1 text-gray-500" />
|
||||
{customer.phone}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 flex items-center">
|
||||
<Calendar className="h-4 w-4 mr-1 text-gray-500" />
|
||||
{formatDate(customer.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{customer.orders_count}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{customer.total_spent.toLocaleString('ru-RU')} ₽
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{customer.last_order_date ? formatDate(customer.last_order_date) : '—'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/customers/${customer.id}`} className="text-indigo-600 hover:text-indigo-900 mr-4">
|
||||
Профиль
|
||||
</Link>
|
||||
<Link href={`/admin/customers/${customer.id}/orders`} className="text-indigo-600 hover:text-indigo-900">
|
||||
Заказы
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredCustomers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Клиенты не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
522
frontend/app/admin/dashboard/page.tsx
Normal file
522
frontend/app/admin/dashboard/page.tsx
Normal file
@ -0,0 +1,522 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { BarChart3, Package, Tag, Users, ShoppingBag } from 'lucide-react';
|
||||
import { fetchDashboardStats, fetchRecentOrders, fetchPopularProducts, Order, Product } from '@/lib/api';
|
||||
|
||||
// Компонент статистической карточки
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const StatCard = ({ title, value, icon, color }: StatCardProps) => {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6 flex items-center">
|
||||
<div className={`rounded-full p-3 mr-4 ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-gray-500 text-sm font-medium">{title}</h3>
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент последних заказов
|
||||
interface RecentOrdersProps {
|
||||
orders: Order[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const RecentOrders = ({ orders, loading, error }: RecentOrdersProps) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-red-500">Ошибка при загрузке заказов: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium">Последние заказы</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Клиент</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Дата</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Статус</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Сумма</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">#{order.id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{order.user_name || `Пользователь #${order.user_id}`}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(order.created_at).toLocaleDateString('ru-RU')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${order.status === 'delivered' ? 'bg-green-100 text-green-800' :
|
||||
order.status === 'processing' ? 'bg-yellow-100 text-yellow-800' :
|
||||
order.status === 'shipped' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-purple-100 text-purple-800'}`}>
|
||||
{order.status === 'delivered' ? 'Доставлен' :
|
||||
order.status === 'processing' ? 'В обработке' :
|
||||
order.status === 'shipped' ? 'Отправлен' :
|
||||
order.status === 'paid' ? 'Оплачен' :
|
||||
order.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{order.total !== undefined && order.total !== null
|
||||
? `${order.total.toLocaleString('ru-RU')} ₽`
|
||||
: 'Н/Д'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/orders/${order.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
Подробнее
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{orders.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Заказы не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<Link href="/admin/orders" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Посмотреть все заказы →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент популярных товаров
|
||||
interface PopularProductsProps {
|
||||
products: Product[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const PopularProducts = ({ products, loading, error }: PopularProductsProps) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="flex-1 space-y-4 py-1">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="text-red-500">Ошибка при загрузке товаров: {error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-medium">Популярные товары</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Товар</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Категория</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Продажи</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Остаток</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{products.map((product) => (
|
||||
<tr key={product.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{product.name || 'Без названия'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.category?.name || 'Без категории'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{typeof product.sales === 'number' ? product.sales : 0}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${typeof product.stock === 'number' && product.stock > 20 ? 'bg-green-100 text-green-800' :
|
||||
typeof product.stock === 'number' && product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'}`}>
|
||||
{typeof product.stock === 'number' ? product.stock : 'Н/Д'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/catalog/products/${product.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
Редактировать
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{products.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Товары не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200">
|
||||
<Link href="/admin/catalog/products" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Посмотреть все товары →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const [stats, setStats] = useState({
|
||||
ordersCount: 0,
|
||||
totalSales: 0,
|
||||
customersCount: 0,
|
||||
productsCount: 0
|
||||
});
|
||||
const [recentOrders, setRecentOrders] = useState<Order[]>([]);
|
||||
const [popularProducts, setPopularProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState({
|
||||
stats: true,
|
||||
orders: true,
|
||||
products: true
|
||||
});
|
||||
const [error, setError] = useState<{
|
||||
stats: string | null;
|
||||
orders: string | null;
|
||||
products: string | null;
|
||||
}>({
|
||||
stats: null,
|
||||
orders: null,
|
||||
products: null
|
||||
});
|
||||
|
||||
// Загрузка данных при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, stats: true }));
|
||||
let statsData;
|
||||
|
||||
try {
|
||||
// Пытаемся получить данные от API
|
||||
statsData = await fetchDashboardStats();
|
||||
console.log('Полученные данные статистики от API:', JSON.stringify(statsData, null, 2));
|
||||
|
||||
// Проверяем наличие данных
|
||||
if (!statsData.data) {
|
||||
throw new Error('Данные статистики отсутствуют в ответе API');
|
||||
}
|
||||
|
||||
// Проверяем структуру данных
|
||||
let statsObject = statsData.data;
|
||||
if (statsData.data && 'data' in statsData.data) {
|
||||
statsObject = (statsData.data as any).data;
|
||||
}
|
||||
|
||||
// Устанавливаем объект статистики
|
||||
statsData = {
|
||||
data: statsObject,
|
||||
status: statsData.status
|
||||
};
|
||||
} catch (apiError) {
|
||||
console.warn('Ошибка при получении данных от API, используем моковые данные:', apiError);
|
||||
|
||||
// Если API недоступен, используем моковые данные
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
statsData = {
|
||||
data: {
|
||||
ordersCount: 1248,
|
||||
totalSales: 2456789,
|
||||
customersCount: 3456,
|
||||
productsCount: 867
|
||||
},
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
|
||||
if (statsData.data) {
|
||||
// Проверяем, что все нужные поля существуют в данных
|
||||
const safeStats = {
|
||||
ordersCount: typeof statsData.data.ordersCount === 'number' ? statsData.data.ordersCount : 0,
|
||||
totalSales: typeof statsData.data.totalSales === 'number' ? statsData.data.totalSales : 0,
|
||||
customersCount: typeof statsData.data.customersCount === 'number' ? statsData.data.customersCount : 0,
|
||||
productsCount: typeof statsData.data.productsCount === 'number' ? statsData.data.productsCount : 0
|
||||
};
|
||||
setStats(safeStats);
|
||||
console.log('Установленные данные статистики:', safeStats);
|
||||
} else {
|
||||
console.warn('Данные статистики отсутствуют или имеют неверный формат:', statsData);
|
||||
}
|
||||
setLoading(prev => ({ ...prev, stats: false }));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке статистики:', err);
|
||||
setError(prev => ({ ...prev, stats: 'Не удалось загрузить статистику' }));
|
||||
setLoading(prev => ({ ...prev, stats: false }));
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, orders: true }));
|
||||
let ordersData;
|
||||
|
||||
try {
|
||||
ordersData = await fetchRecentOrders({ limit: 4 });
|
||||
console.log('Получены данные заказов:', JSON.stringify(ordersData, null, 2));
|
||||
|
||||
// Проверяем структуру данных
|
||||
if (ordersData.data === null || ordersData.data === undefined) {
|
||||
throw new Error('Данные заказов отсутствуют в ответе API');
|
||||
}
|
||||
|
||||
// Проверяем, если данные приходят в другой структуре
|
||||
let ordersArray: Order[] = [];
|
||||
if (Array.isArray(ordersData.data)) {
|
||||
ordersArray = ordersData.data;
|
||||
} else if (ordersData.data && typeof ordersData.data === 'object' && 'items' in ordersData.data) {
|
||||
ordersArray = (ordersData.data as any).items;
|
||||
}
|
||||
|
||||
console.log('Массив заказов:', ordersArray);
|
||||
|
||||
// Если есть данные, но пустой массив - используем моковые данные
|
||||
if (ordersArray.length === 0) {
|
||||
throw new Error('Массив заказов пуст');
|
||||
}
|
||||
|
||||
// Устанавливаем массив заказов
|
||||
ordersData = {
|
||||
data: ordersArray,
|
||||
status: ordersData.status
|
||||
};
|
||||
} catch (apiError) {
|
||||
console.warn('Ошибка API заказов, используем моковые данные:', apiError);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Моковые данные заказов
|
||||
ordersData = {
|
||||
data: [
|
||||
{ id: 1, user_id: 101, user_name: 'Иван Иванов', created_at: '2023-03-15T14:30:00Z', status: 'delivered', total: 12500 },
|
||||
{ id: 2, user_id: 102, user_name: 'Анна Петрова', created_at: '2023-03-14T10:15:00Z', status: 'shipped', total: 8750 },
|
||||
{ id: 3, user_id: 103, user_name: 'Сергей Сидоров', created_at: '2023-03-13T18:45:00Z', status: 'processing', total: 15200 },
|
||||
{ id: 4, user_id: 104, user_name: 'Елена Смирнова', created_at: '2023-03-12T09:20:00Z', status: 'paid', total: 6300 }
|
||||
],
|
||||
status: 200
|
||||
};
|
||||
}
|
||||
|
||||
if (ordersData && ordersData.data && Array.isArray(ordersData.data)) {
|
||||
// Нормализуем данные заказов, убедившись, что все необходимые поля существуют
|
||||
const normalizedOrders = ordersData.data.map(order => ({
|
||||
id: order.id || 0,
|
||||
user_id: order.user_id || 0,
|
||||
user_name: order.user_name || '',
|
||||
created_at: order.created_at || new Date().toISOString(),
|
||||
status: order.status || 'processing',
|
||||
total: typeof order.total === 'number' ? order.total : 0
|
||||
}));
|
||||
|
||||
setRecentOrders(normalizedOrders);
|
||||
} else {
|
||||
console.warn('Некорректные данные заказов:', ordersData);
|
||||
setRecentOrders([]);
|
||||
}
|
||||
|
||||
setLoading(prev => ({ ...prev, orders: false }));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError(prev => ({ ...prev, orders: 'Не удалось загрузить заказы' }));
|
||||
setLoading(prev => ({ ...prev, orders: false }));
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, products: true }));
|
||||
let productsData;
|
||||
|
||||
try {
|
||||
productsData = await fetchPopularProducts({ limit: 4 });
|
||||
console.log('Получены данные товаров:', JSON.stringify(productsData, null, 2));
|
||||
|
||||
// Проверяем структуру данных
|
||||
if (!productsData.data && !Array.isArray(productsData)) {
|
||||
throw new Error('Данные товаров отсутствуют в ответе API');
|
||||
}
|
||||
|
||||
// Проверяем формат ответа - может быть как data.items, data[] или просто []
|
||||
let productsArray: any[] = [];
|
||||
|
||||
if (Array.isArray(productsData)) {
|
||||
// Ответ сразу в виде массива товаров без обертки
|
||||
productsArray = productsData;
|
||||
} else if (productsData.data) {
|
||||
if (Array.isArray(productsData.data)) {
|
||||
// Ответ в виде { data: [...] }
|
||||
productsArray = productsData.data;
|
||||
} else if (typeof productsData.data === 'object' && productsData.data !== null &&
|
||||
'items' in (productsData.data as Record<string, any>) &&
|
||||
Array.isArray((productsData.data as Record<string, any>).items)) {
|
||||
// Ответ в виде { data: { items: [...] } }
|
||||
productsArray = (productsData.data as Record<string, any>).items;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Обработанный массив товаров:', productsArray);
|
||||
|
||||
// Если массив пустой, используем моковые данные
|
||||
if (!productsArray || productsArray.length === 0) {
|
||||
throw new Error('Массив товаров пуст');
|
||||
}
|
||||
|
||||
// Преобразуем данные в нужный формат для отображения
|
||||
const formattedProducts = productsArray.map(product => {
|
||||
// Определяем stock как сумму остатков всех вариантов, если они есть
|
||||
let totalStock = 0;
|
||||
if (product.variants && Array.isArray(product.variants)) {
|
||||
totalStock = product.variants.reduce((sum: number, variant: any) =>
|
||||
sum + (typeof variant.stock === 'number' ? variant.stock : 0), 0);
|
||||
} else if (typeof product.stock === 'number') {
|
||||
totalStock = product.stock;
|
||||
}
|
||||
|
||||
// Создаем корректный объект Product
|
||||
return {
|
||||
id: product.id || 0,
|
||||
name: product.name || 'Без названия',
|
||||
category: product.category ||
|
||||
(product.category_id ? { id: product.category_id, name: 'Категория ' + product.category_id } :
|
||||
{ id: 0, name: 'Без категории' }),
|
||||
category_id: product.category_id,
|
||||
sales: typeof product.sales === 'number' ? product.sales : 0,
|
||||
stock: totalStock,
|
||||
price: typeof product.price === 'number' ? product.price : 0,
|
||||
images: product.images && Array.isArray(product.images) ?
|
||||
product.images.map((img: any) => img.image_url) : [],
|
||||
description: product.description || ''
|
||||
};
|
||||
});
|
||||
|
||||
setPopularProducts(formattedProducts);
|
||||
} catch (apiError) {
|
||||
console.warn('Ошибка API товаров, используем моковые данные:', apiError);
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Моковые данные товаров
|
||||
const mockProducts = [
|
||||
{ id: 1, name: 'Платье летнее', category: { id: 2, name: 'Платья' }, sales: 42, stock: 28, price: 3500 },
|
||||
{ id: 2, name: 'Брюки классические', category: { id: 7, name: 'Брюки' }, sales: 38, stock: 15, price: 4200 },
|
||||
{ id: 3, name: 'Блузка шелковая', category: { id: 3, name: 'Блузки' }, sales: 35, stock: 10, price: 2800 },
|
||||
{ id: 4, name: 'Рубашка льняная', category: { id: 6, name: 'Рубашки' }, sales: 30, stock: 22, price: 3100 }
|
||||
];
|
||||
|
||||
setPopularProducts(mockProducts);
|
||||
}
|
||||
|
||||
setLoading(prev => ({ ...prev, products: false }));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке товаров:', err);
|
||||
setError(prev => ({ ...prev, products: 'Не удалось загрузить товары' }));
|
||||
setLoading(prev => ({ ...prev, products: false }));
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Дашборд</h1>
|
||||
|
||||
{/* Статистические карточки */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Всего заказов"
|
||||
value={loading.stats ? '...' : (stats.ordersCount !== undefined ? stats.ordersCount.toLocaleString('ru-RU') : '0')}
|
||||
icon={<ShoppingBag size={24} className="text-white" />}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Общие продажи"
|
||||
value={loading.stats ? '...' : (stats.totalSales !== undefined ? `${stats.totalSales.toLocaleString('ru-RU')} ₽` : '0 ₽')}
|
||||
icon={<BarChart3 size={24} className="text-white" />}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Клиенты"
|
||||
value={loading.stats ? '...' : (stats.customersCount !== undefined ? stats.customersCount.toLocaleString('ru-RU') : '0')}
|
||||
icon={<Users size={24} className="text-white" />}
|
||||
color="bg-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Товары"
|
||||
value={loading.stats ? '...' : (stats.productsCount !== undefined ? stats.productsCount.toLocaleString('ru-RU') : '0')}
|
||||
icon={<Package size={24} className="text-white" />}
|
||||
color="bg-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Последние заказы и популярные товары */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<RecentOrders
|
||||
orders={recentOrders}
|
||||
loading={loading.orders}
|
||||
error={error.orders}
|
||||
/>
|
||||
<PopularProducts
|
||||
products={popularProducts}
|
||||
loading={loading.products}
|
||||
error={error.products}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
frontend/app/admin/layout.tsx
Normal file
99
frontend/app/admin/layout.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { BarChart3, Package, Tag, Users, ShoppingBag, FileText, Settings } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Интерфейс для компонента MenuItem
|
||||
interface MenuItemProps {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
// Компонент элемента меню
|
||||
const MenuItem = ({ href, icon, label, active = false }: MenuItemProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex items-center px-4 py-2 rounded-md mb-1 ${
|
||||
active
|
||||
? 'bg-indigo-100 text-indigo-700'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
<span className="ml-3">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
// В реальном приложении здесь будет проверка авторизации
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
{/* Боковое меню */}
|
||||
<div className="w-64 bg-white shadow-md">
|
||||
<div className="p-4 border-b">
|
||||
<h1 className="text-xl font-bold">Админ-панель</h1>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<nav>
|
||||
<MenuItem
|
||||
href="/admin/dashboard"
|
||||
icon={<BarChart3 size={20} />}
|
||||
label="Дашборд"
|
||||
/>
|
||||
<MenuItem
|
||||
href="/admin/products"
|
||||
icon={<Package size={20} />}
|
||||
label="Товары"
|
||||
/>
|
||||
<MenuItem
|
||||
href="/admin/categories"
|
||||
icon={<Tag size={20} />}
|
||||
label="Категории"
|
||||
/>
|
||||
<MenuItem
|
||||
href="/admin/collections"
|
||||
icon={<Tag size={20} />}
|
||||
label="Коллекции"
|
||||
/>
|
||||
<MenuItem
|
||||
href="/admin/orders"
|
||||
icon={<ShoppingBag size={20} />}
|
||||
label="Заказы"
|
||||
/>
|
||||
<MenuItem
|
||||
href="/admin/customers"
|
||||
icon={<Users size={20} />}
|
||||
label="Клиенты"
|
||||
/>
|
||||
<MenuItem
|
||||
href="/admin/settings"
|
||||
icon={<Settings size={20} />}
|
||||
label="Настройки"
|
||||
/>
|
||||
<MenuItem
|
||||
href="/"
|
||||
icon={<Tag size={20} />}
|
||||
label="На сайт"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="px-6 py-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Админ-панель</h2>
|
||||
</div>
|
||||
</header>
|
||||
<main className="p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
159
frontend/app/admin/login/page.tsx
Normal file
159
frontend/app/admin/login/page.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email || !password) {
|
||||
setError('Пожалуйста, заполните все поля');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Здесь должен быть запрос к API для аутентификации
|
||||
// В будущем заменить на реальный запрос
|
||||
|
||||
// Имитация запроса
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Для демо: проверяем email и пароль
|
||||
if (email === 'admin@example.com' && password === 'password') {
|
||||
// Сохраняем токен (в реальном приложении будет получен от сервера)
|
||||
localStorage.setItem('token', 'demo-token');
|
||||
|
||||
// Перенаправляем на дашборд
|
||||
router.push('/admin/dashboard');
|
||||
} else {
|
||||
setError('Неверные учетные данные');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка входа:', err);
|
||||
setError('Ошибка аутентификации. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleShowPassword = () => {
|
||||
setShowPassword(!showPassword);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Вход в админ-панель
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Введите свои учетные данные для входа
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
|
||||
<span className="block sm:inline">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email-address" className="sr-only">Email</label>
|
||||
<input
|
||||
id="email-address"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label htmlFor="password" className="sr-only">Пароль</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm pr-10"
|
||||
placeholder="Пароль"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-500 hover:text-gray-700"
|
||||
onClick={toggleShowPassword}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
|
||||
Запомнить меня
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<a href="#" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Забыли пароль?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className={`group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
|
||||
loading ? 'opacity-70 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="absolute left-0 inset-y-0 flex items-center pl-3">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-300" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
{loading ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center text-sm text-gray-600">
|
||||
<p>Демо-доступ: admin@example.com / password</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
frontend/app/admin/orders/[id]/page.tsx
Normal file
186
frontend/app/admin/orders/[id]/page.tsx
Normal file
@ -0,0 +1,186 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, use } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { fetchOrder, Order } from '@/lib/api';
|
||||
|
||||
// Импортируем компоненты
|
||||
import { OrderHeader } from '@/components/admin/orders/OrderHeader';
|
||||
import { OrderStatus } from '@/components/admin/orders/OrderStatus';
|
||||
import { CustomerInfo } from '@/components/admin/orders/CustomerInfo';
|
||||
import { ShippingAddress } from '@/components/admin/orders/ShippingAddress';
|
||||
import { PaymentShipping } from '@/components/admin/orders/PaymentShipping';
|
||||
import { OrderItems } from '@/components/admin/orders/OrderItems';
|
||||
import { OrderNotes } from '@/components/admin/orders/OrderNotes';
|
||||
import { OrderActions } from '@/components/admin/orders/OrderActions';
|
||||
|
||||
interface OrderDetailsPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function OrderDetailsPage({ params }: OrderDetailsPageProps) {
|
||||
const router = useRouter();
|
||||
const resolvedParams = use(params);
|
||||
const orderId = parseInt(resolvedParams.id);
|
||||
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Проверка токена при монтировании компонента
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// Загрузка данных заказа
|
||||
useEffect(() => {
|
||||
const loadOrder = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (!token) {
|
||||
router.push('/admin/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetchOrder(orderId);
|
||||
|
||||
if (response.data) {
|
||||
// Проверяем, содержит ли ответ вложенное свойство order
|
||||
const orderData = response.data as any;
|
||||
if (orderData.order) {
|
||||
setOrder(orderData.order);
|
||||
} else {
|
||||
setOrder(response.data);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Не удалось получить данные заказа');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказа:', err);
|
||||
if (err instanceof Error && err.message.includes('Not authenticated')) {
|
||||
router.push('/admin/login');
|
||||
} else {
|
||||
setError('Не удалось загрузить заказ');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOrder();
|
||||
}, [orderId, router]);
|
||||
|
||||
// Обработчик изменения статуса заказа
|
||||
const handleStatusChange = (newStatus: string) => {
|
||||
if (order) {
|
||||
setOrder({ ...order, status: newStatus });
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
||||
<strong className="font-bold">Ошибка!</strong>
|
||||
<span className="block sm:inline"> {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Заказ не найден</h2>
|
||||
<p className="mt-2 text-gray-500">Заказ с указанным ID не существует</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<Link
|
||||
href="/admin/orders"
|
||||
className="inline-flex items-center text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Назад к списку заказов
|
||||
</Link>
|
||||
|
||||
<OrderStatus
|
||||
id={order.id}
|
||||
status={order.status}
|
||||
updatedAt={order.updated_at}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{/* Заголовок заказа */}
|
||||
<OrderHeader
|
||||
id={order.id}
|
||||
createdAt={order.created_at}
|
||||
total={order.total}
|
||||
itemsCount={order.items_count}
|
||||
/>
|
||||
|
||||
{/* Информация о заказе */}
|
||||
<div className="px-6 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Информация о клиенте */}
|
||||
<CustomerInfo
|
||||
userId={order.user_id}
|
||||
userName={order.user_name}
|
||||
userEmail={order.user_email}
|
||||
/>
|
||||
|
||||
{/* Адрес доставки */}
|
||||
<ShippingAddress address={order.shipping_address} />
|
||||
|
||||
{/* Информация об оплате и доставке */}
|
||||
<PaymentShipping
|
||||
paymentMethod={order.payment_method || ''}
|
||||
status={order.status}
|
||||
trackingNumber={order.tracking_number}
|
||||
createdAt={order.created_at}
|
||||
updatedAt={order.updated_at}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Товары в заказе */}
|
||||
{order.items && order.items.length > 0 && (
|
||||
<OrderItems items={order.items} total={order.total} />
|
||||
)}
|
||||
|
||||
{/* Примечания к заказу */}
|
||||
{order.notes && <OrderNotes notes={order.notes} />}
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<OrderActions
|
||||
id={order.id}
|
||||
status={order.status}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
452
frontend/app/admin/orders/page.tsx
Normal file
452
frontend/app/admin/orders/page.tsx
Normal file
@ -0,0 +1,452 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Filter, ChevronDown } from 'lucide-react';
|
||||
import { fetchOrders, Order } from '@/lib/api';
|
||||
import { formatDate, formatPrice } from '@/lib/utils';
|
||||
|
||||
// Компонент для фильтрации заказов
|
||||
interface FilterDropdownProps {
|
||||
options: { value: string; label: string }[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const FilterDropdown = ({ options, value, onChange, label }: FilterDropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center px-3 py-2 border border-gray-300 rounded-md bg-white text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{label}: {options.find(option => option.value === value)?.label || 'Все'}
|
||||
<ChevronDown className="ml-2 h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 z-10">
|
||||
<div className="py-1" role="menu" aria-orientation="vertical">
|
||||
{options.map(option => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`block px-4 py-2 text-sm w-full text-left ${
|
||||
value === option.value
|
||||
? 'bg-indigo-100 text-indigo-900'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [filteredOrders, setFilteredOrders] = useState<Order[]>([]);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [dateFilter, setDateFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sortField, setSortField] = useState<string>('created_at');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// Массив статусов заказов
|
||||
const statusOptions = [
|
||||
{ value: '', label: 'Все статусы' },
|
||||
{ value: 'pending', label: 'Ожидает оплаты' },
|
||||
{ value: 'processing', label: 'В обработке' },
|
||||
{ value: 'shipped', label: 'Отправлен' },
|
||||
{ value: 'delivered', label: 'Доставлен' },
|
||||
{ value: 'cancelled', label: 'Отменен' },
|
||||
{ value: 'refunded', label: 'Возвращен' }
|
||||
];
|
||||
|
||||
// Массив фильтров по дате
|
||||
const dateOptions = [
|
||||
{ value: '', label: 'За все время' },
|
||||
{ value: 'today', label: 'Сегодня' },
|
||||
{ value: 'yesterday', label: 'Вчера' },
|
||||
{ value: 'week', label: 'За неделю' },
|
||||
{ value: 'month', label: 'За месяц' }
|
||||
];
|
||||
|
||||
// Функция сортировки заказов
|
||||
const sortOrders = (orders: Order[], field: string, direction: 'asc' | 'desc') => {
|
||||
return [...orders].sort((a, b) => {
|
||||
let valueA, valueB;
|
||||
|
||||
switch (field) {
|
||||
case 'id':
|
||||
valueA = a.id;
|
||||
valueB = b.id;
|
||||
break;
|
||||
case 'user_name':
|
||||
valueA = a.user_name || '';
|
||||
valueB = b.user_name || '';
|
||||
break;
|
||||
case 'created_at':
|
||||
valueA = new Date(a.created_at).getTime();
|
||||
valueB = new Date(b.created_at).getTime();
|
||||
break;
|
||||
case 'items_count':
|
||||
valueA = a.items_count || 0;
|
||||
valueB = b.items_count || 0;
|
||||
break;
|
||||
case 'total':
|
||||
valueA = a.total || 0;
|
||||
valueB = b.total || 0;
|
||||
break;
|
||||
default:
|
||||
valueA = a[field as keyof Order] || '';
|
||||
valueB = b[field as keyof Order] || '';
|
||||
}
|
||||
|
||||
if (valueA < valueB) return direction === 'asc' ? -1 : 1;
|
||||
if (valueA > valueB) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
};
|
||||
|
||||
// Обработчик клика по заголовку таблицы для сортировки
|
||||
const handleSort = (field: string) => {
|
||||
const newDirection = sortField === field && sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
setSortField(field);
|
||||
setSortDirection(newDirection);
|
||||
|
||||
// Сортируем заказы
|
||||
const sortedOrders = sortOrders(filteredOrders, field, newDirection);
|
||||
setFilteredOrders(sortedOrders);
|
||||
};
|
||||
|
||||
// Получить иконку направления сортировки
|
||||
const getSortIcon = (field: string) => {
|
||||
if (sortField !== field) return null;
|
||||
|
||||
return (
|
||||
<span className="ml-1 inline-block">
|
||||
{sortDirection === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Загрузка заказов
|
||||
useEffect(() => {
|
||||
const loadOrders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Формируем параметры запроса
|
||||
const params: any = {
|
||||
limit: 100 // Максимальное количество заказов
|
||||
};
|
||||
|
||||
// Добавляем фильтр по статусу
|
||||
if (statusFilter) {
|
||||
params.status = statusFilter;
|
||||
}
|
||||
|
||||
// Добавляем фильтр по дате
|
||||
if (dateFilter) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const weekAgo = new Date(today);
|
||||
weekAgo.setDate(weekAgo.getDate() - 7);
|
||||
const monthAgo = new Date(today);
|
||||
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
||||
|
||||
switch (dateFilter) {
|
||||
case 'today':
|
||||
params.dateFrom = today.toISOString();
|
||||
break;
|
||||
case 'yesterday':
|
||||
params.dateFrom = yesterday.toISOString();
|
||||
params.dateTo = today.toISOString();
|
||||
break;
|
||||
case 'week':
|
||||
params.dateFrom = weekAgo.toISOString();
|
||||
break;
|
||||
case 'month':
|
||||
params.dateFrom = monthAgo.toISOString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем поисковый запрос
|
||||
if (searchTerm) {
|
||||
params.search = searchTerm;
|
||||
}
|
||||
|
||||
const response = await fetchOrders(params);
|
||||
|
||||
if (response.data) {
|
||||
// Обработка возможных форматов ответа API
|
||||
let orderData: Order[] = [];
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
orderData = response.data;
|
||||
} else {
|
||||
// Если response.data - это объект, проверяем наличие свойства orders
|
||||
const data = response.data as any;
|
||||
if (data.orders && Array.isArray(data.orders)) {
|
||||
orderData = data.orders;
|
||||
}
|
||||
}
|
||||
|
||||
// Применяем сортировку к загруженным данным
|
||||
const sortedOrders = sortOrders(orderData, sortField, sortDirection);
|
||||
|
||||
setOrders(orderData);
|
||||
setFilteredOrders(sortedOrders);
|
||||
} else {
|
||||
throw new Error('Не удалось получить данные заказов');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError('Не удалось загрузить заказы');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOrders();
|
||||
}, [statusFilter, dateFilter, searchTerm, sortField, sortDirection]);
|
||||
|
||||
// Получить строковое представление статуса заказа
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Ожидает оплаты';
|
||||
case 'processing':
|
||||
return 'В обработке';
|
||||
case 'shipped':
|
||||
return 'Отправлен';
|
||||
case 'delivered':
|
||||
return 'Доставлен';
|
||||
case 'cancelled':
|
||||
return 'Отменен';
|
||||
case 'refunded':
|
||||
return 'Возвращен';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
// Получить класс для стиля статуса заказа
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'processing':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'shipped':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
case 'delivered':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'refunded':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6">
|
||||
<strong className="font-bold">Ошибка!</strong>
|
||||
<span className="block sm:inline"> {error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Управление заказами</h2>
|
||||
{filteredOrders.length > 0 && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Найдено заказов: <span className="font-medium">{filteredOrders.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по имени клиента или номеру заказа"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<FilterDropdown
|
||||
options={statusOptions}
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
label="Статус"
|
||||
/>
|
||||
|
||||
<FilterDropdown
|
||||
options={dateOptions}
|
||||
value={dateFilter}
|
||||
onChange={setDateFilter}
|
||||
label="Дата"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('id')}
|
||||
>
|
||||
№ заказа {getSortIcon('id')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('user_name')}
|
||||
>
|
||||
Клиент {getSortIcon('user_name')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('created_at')}
|
||||
>
|
||||
Дата {getSortIcon('created_at')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('items_count')}
|
||||
>
|
||||
Товары {getSortIcon('items_count')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
Статус {getSortIcon('status')}
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100"
|
||||
onClick={() => handleSort('total')}
|
||||
>
|
||||
Сумма {getSortIcon('total')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Действия
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredOrders.length > 0 ? (
|
||||
filteredOrders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">#{order.id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="font-medium">{order.user_name || `Пользователь #${order.user_id}`}</div>
|
||||
{order.user_email && (
|
||||
<div className="text-xs text-gray-400">{order.user_email}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div title={order.created_at}>
|
||||
{formatDate(order.created_at)}
|
||||
</div>
|
||||
{order.updated_at && order.updated_at !== order.created_at && (
|
||||
<div className="text-xs text-gray-400" title={`Обновлен: ${order.updated_at}`}>
|
||||
изменен: {new Date(order.updated_at).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<span className="font-medium">{order.items_count || (order.items && order.items.length) || 0}</span>
|
||||
<span className="ml-1">шт.</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusClass(order.status)}`}
|
||||
title={order.updated_at ? `Обновлено: ${formatDate(order.updated_at)}` : ''}>
|
||||
{getStatusLabel(order.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{formatPrice(order.total || 0)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link
|
||||
href={`/admin/orders/${order.id}`}
|
||||
className="inline-flex items-center justify-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Подробнее
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-sm text-gray-500">
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="text-lg mb-2">Заказы не найдены</p>
|
||||
<p className="text-sm text-gray-400">Попробуйте изменить параметры поиска или фильтрации</p>
|
||||
<button
|
||||
className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
setStatusFilter('');
|
||||
setDateFilter('');
|
||||
}}
|
||||
>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
frontend/app/admin/page.tsx
Normal file
5
frontend/app/admin/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect('/admin/dashboard');
|
||||
}
|
||||
163
frontend/app/admin/products/[id]/page.tsx
Normal file
163
frontend/app/admin/products/[id]/page.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { fetchProduct, updateProduct, deleteProduct, Product, ApiResponse } from '@/lib/api';
|
||||
import { fetchCategories, Category } from '@/lib/catalog-admin';
|
||||
import ProductForm from '@/components/admin/ProductForm';
|
||||
|
||||
export default function EditProductPage({ params }: { params: { id: string } }) {
|
||||
const router = useRouter();
|
||||
const productId = parseInt(params.id);
|
||||
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingCategories, setLoadingCategories] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка данных товара
|
||||
useEffect(() => {
|
||||
const loadProduct = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchProduct(productId);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setProduct(response.data);
|
||||
} else {
|
||||
throw new Error(response.error || 'Не удалось загрузить товар');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке товара:', err);
|
||||
setError('Не удалось загрузить товар');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (productId) {
|
||||
loadProduct();
|
||||
}
|
||||
}, [productId]);
|
||||
|
||||
// Загрузка категорий
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
setLoadingCategories(true);
|
||||
const response = await fetchCategories();
|
||||
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
setCategories(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке категорий:', err);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// Обработчик сохранения товара
|
||||
const handleSubmit = async (formData: Partial<Product>) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Подготавливаем данные для обновления
|
||||
const updateData: Partial<Product> = {
|
||||
name: formData.name,
|
||||
slug: formData.slug || undefined,
|
||||
description: formData.description,
|
||||
price: formData.price,
|
||||
discount_price: formData.discount_price,
|
||||
is_active: formData.is_active,
|
||||
category_id: formData.category_id ? parseInt(formData.category_id.toString()) : undefined,
|
||||
collection_id: formData.collection_id ? parseInt(formData.collection_id.toString()) : undefined,
|
||||
care_instructions: formData.care_instructions
|
||||
};
|
||||
|
||||
console.log('Отправляем данные для обновления:', updateData);
|
||||
|
||||
const response = await updateProduct(productId, updateData);
|
||||
|
||||
if (response.success) {
|
||||
console.log('Товар успешно обновлен');
|
||||
router.push('/admin/products');
|
||||
} else {
|
||||
throw new Error(`Ошибка обновления: ${response.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при сохранении товара:', err);
|
||||
setError('Не удалось сохранить товар');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик удаления товара
|
||||
const handleDelete = async () => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить этот товар?')) {
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await deleteProduct(productId);
|
||||
if (response.success) {
|
||||
router.push('/admin/products');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении товара:', err);
|
||||
setError('Не удалось удалить товар');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Link href="/admin/products" className="mr-4">
|
||||
<ArrowLeft className="h-6 w-6 text-gray-500 hover:text-gray-700" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">
|
||||
{loading ? 'Загрузка...' : `Редактирование: ${product?.name}`}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading || loadingCategories ? (
|
||||
<div className="flex justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
) : product ? (
|
||||
<ProductForm
|
||||
initialData={product}
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
onDelete={handleDelete}
|
||||
isLoading={saving}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-500">Продукт не найден</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
965
frontend/app/admin/products/components/EditProductDialog.tsx
Normal file
965
frontend/app/admin/products/components/EditProductDialog.tsx
Normal file
@ -0,0 +1,965 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger
|
||||
} from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
fetchProduct,
|
||||
updateProduct,
|
||||
createProduct,
|
||||
Product,
|
||||
fetchSizes,
|
||||
Size,
|
||||
getImageUrl,
|
||||
uploadProductImage,
|
||||
deleteProductImage,
|
||||
setProductImageAsPrimary
|
||||
} from '@/lib/api';
|
||||
import { fetchCategories, Category } from '@/lib/catalog-admin';
|
||||
import { Upload, X, Plus, Minus, Star, StarOff } from 'lucide-react';
|
||||
|
||||
// Типы для работы с изображениями
|
||||
interface ProductImage {
|
||||
id: number;
|
||||
image_url: string;
|
||||
is_primary?: boolean;
|
||||
alt_text?: string;
|
||||
}
|
||||
|
||||
// Тип для локального изображения, ожидающего загрузки
|
||||
interface LocalImage {
|
||||
file: File;
|
||||
preview: string;
|
||||
isLocal: true;
|
||||
isPrimary?: boolean;
|
||||
}
|
||||
|
||||
interface EditProductDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
productId?: number; // Если не указан, то это создание нового товара
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export default function EditProductDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
productId,
|
||||
onComplete
|
||||
}: EditProductDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
|
||||
// Данные
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [sizes, setSizes] = useState<Size[]>([]);
|
||||
|
||||
// Основные данные формы
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
discount_price: null as number | null,
|
||||
care_instructions: {} as Record<string, string>,
|
||||
is_active: true,
|
||||
category_id: '',
|
||||
collection_id: '',
|
||||
images: [] as Array<string | ProductImage | LocalImage>
|
||||
});
|
||||
|
||||
// Состояние для загрузки файлов
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Варианты размеров
|
||||
const [variantSizes, setVariantSizes] = useState<{
|
||||
size_id: number;
|
||||
stock: number;
|
||||
checked: boolean;
|
||||
}[]>([]);
|
||||
|
||||
// Загрузка данных товара
|
||||
useEffect(() => {
|
||||
if (isOpen && productId) {
|
||||
const loadProduct = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchProduct(productId);
|
||||
|
||||
// Проверяем структуру ответа
|
||||
console.log('Ответ API при загрузке товара:', response);
|
||||
|
||||
// Получаем данные товара в зависимости от структуры ответа
|
||||
let productData: Product | null = null;
|
||||
if (response.data) {
|
||||
if (typeof response.data === 'object' && 'product' in response.data) {
|
||||
// Новая структура API: { data: { product: {...}, success: true } }
|
||||
productData = response.data.product as Product;
|
||||
} else {
|
||||
// Старая структура API: { data: {...} }
|
||||
productData = response.data as Product;
|
||||
}
|
||||
}
|
||||
|
||||
if (productData) {
|
||||
console.log('Загруженные данные товара:', productData);
|
||||
|
||||
// Обработка изображений
|
||||
let productImages: Array<string | ProductImage> = [];
|
||||
if (productData.images) {
|
||||
productImages = Array.isArray(productData.images)
|
||||
? productData.images.map((img: any) => {
|
||||
// Сохраняем объект изображения как есть
|
||||
if (typeof img === 'object' && img !== null && img.image_url) {
|
||||
return {
|
||||
id: img.id || 0,
|
||||
image_url: img.image_url,
|
||||
is_primary: img.is_primary || false,
|
||||
alt_text: img.alt_text || ''
|
||||
} as ProductImage;
|
||||
}
|
||||
// Для строк возвращаем строку
|
||||
return typeof img === 'string' ? img : '';
|
||||
}).filter(Boolean)
|
||||
: [];
|
||||
}
|
||||
|
||||
// Обработка care_instructions
|
||||
let careInstructions: Record<string, string> = {};
|
||||
if (productData.care_instructions) {
|
||||
if (typeof productData.care_instructions === 'string') {
|
||||
try {
|
||||
// Пробуем распарсить строку как JSON
|
||||
careInstructions = JSON.parse(productData.care_instructions);
|
||||
} catch (e) {
|
||||
// Если не получилось, сохраняем как текст
|
||||
careInstructions = { text: productData.care_instructions };
|
||||
}
|
||||
} else if (typeof productData.care_instructions === 'object') {
|
||||
careInstructions = productData.care_instructions as Record<string, string>;
|
||||
}
|
||||
}
|
||||
|
||||
setProduct(productData);
|
||||
setFormData({
|
||||
name: productData.name || '',
|
||||
slug: productData.slug || '',
|
||||
description: productData.description || '',
|
||||
price: typeof productData.price === 'number' ? productData.price : 0,
|
||||
discount_price: productData.discount_price || null,
|
||||
care_instructions: careInstructions,
|
||||
is_active: productData.is_active !== false, // По умолчанию true
|
||||
category_id: productData.category?.id?.toString() ||
|
||||
(productData.category_id ? productData.category_id.toString() : 'none'),
|
||||
collection_id: productData.collection?.id?.toString() ||
|
||||
(productData.collection_id ? productData.collection_id.toString() : 'none'),
|
||||
images: productImages
|
||||
});
|
||||
|
||||
// Если есть варианты размеров, загрузим их
|
||||
if (productData.variants && Array.isArray(productData.variants)) {
|
||||
// Инициализируем варианты при загрузке размеров
|
||||
console.log('Загружены варианты товара:', productData.variants);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке товара:', err);
|
||||
setError('Не удалось загрузить товар');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadProduct();
|
||||
} else if (isOpen) {
|
||||
// Сброс формы для создания нового товара
|
||||
setFormData({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
discount_price: null,
|
||||
care_instructions: {},
|
||||
is_active: true,
|
||||
category_id: 'none',
|
||||
collection_id: 'none',
|
||||
images: []
|
||||
});
|
||||
setProduct(null);
|
||||
}
|
||||
}, [isOpen, productId]);
|
||||
|
||||
// Загрузка категорий и размеров
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
// Загрузка категорий
|
||||
const categoriesResponse = await fetchCategories();
|
||||
if (categoriesResponse.data && Array.isArray(categoriesResponse.data)) {
|
||||
setCategories(categoriesResponse.data);
|
||||
}
|
||||
|
||||
// Загрузка размеров
|
||||
const sizesResponse = await fetchSizes();
|
||||
if (sizesResponse.data && Array.isArray(sizesResponse.data)) {
|
||||
setSizes(sizesResponse.data);
|
||||
|
||||
// Подготовка вариантов размеров
|
||||
const initialVariants = sizesResponse.data.map(size => {
|
||||
// Если редактируем товар и у него есть варианты
|
||||
let existingVariant = null;
|
||||
if (product?.variants && Array.isArray(product.variants)) {
|
||||
existingVariant = product.variants.find(v => v.size_id === size.id);
|
||||
}
|
||||
|
||||
return {
|
||||
size_id: size.id,
|
||||
stock: existingVariant?.stock || 0,
|
||||
checked: !!existingVariant // Отмечаем, если вариант существует
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Инициализированы варианты размеров:', initialVariants);
|
||||
setVariantSizes(initialVariants);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}
|
||||
}, [isOpen, product]);
|
||||
|
||||
// Обработчик изменения полей формы
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
if (name === 'care_instructions') {
|
||||
try {
|
||||
// Пытаемся распарсить JSON если пользователь ввел валидный JSON
|
||||
const jsonValue = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: jsonValue
|
||||
}));
|
||||
} catch (error) {
|
||||
// Иначе сохраняем как строку в объекте
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: { text: value }
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'number'
|
||||
? (value ? parseFloat(value) : 0)
|
||||
: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик изменения чекбокса
|
||||
const handleCheckboxChange = (name: string, checked: boolean) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
};
|
||||
|
||||
// Обработчик изменения variant size
|
||||
const handleVariantSizeChange = (sizeId: number, checked: boolean) => {
|
||||
setVariantSizes(prev =>
|
||||
prev.map(item =>
|
||||
item.size_id === sizeId
|
||||
? { ...item, checked }
|
||||
: item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Обработчик изменения stock варианта
|
||||
const handleVariantStockChange = (sizeId: number, stock: number) => {
|
||||
setVariantSizes(prev =>
|
||||
prev.map(item =>
|
||||
item.size_id === sizeId
|
||||
? { ...item, stock }
|
||||
: item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// Обработчик загрузки изображения
|
||||
const handleImageUpload = () => {
|
||||
console.log('Открытие диалога выбора файла');
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
} else {
|
||||
console.error('Отсутствует ссылка на элемент для загрузки файла');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик выбора файла - с локальным предпросмотром перед отправкой
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
console.log('Выбраны файлы:', files);
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
console.error('Не выбраны файлы');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
|
||||
// Создаем локальные копии изображений для предпросмотра
|
||||
Array.from(files).forEach((file, index) => {
|
||||
// Создаем URL для предпросмотра
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
|
||||
// Добавляем изображение в состояние с пометкой, что оно локальное
|
||||
const localImage: LocalImage = {
|
||||
file,
|
||||
preview: previewUrl,
|
||||
isLocal: true,
|
||||
isPrimary: formData.images.length === 0 && index === 0 // Первое изображение будет основным, если нет других
|
||||
};
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: [...prev.images, localImage]
|
||||
}));
|
||||
|
||||
console.log(`Добавлен предпросмотр ${index + 1}:`, file.name);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка при добавлении файлов:', error);
|
||||
setError('Не удалось добавить выбранные изображения');
|
||||
} finally {
|
||||
// Сбрасываем input для возможности повторной загрузки тех же файлов
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик удаления изображения
|
||||
const handleRemoveImage = async (index: number) => {
|
||||
const image = formData.images[index];
|
||||
|
||||
// Удаление с сервера если это серверное изображение (ProductImage)
|
||||
if (productId && typeof image === 'object' && 'id' in image) {
|
||||
try {
|
||||
const response = await deleteProductImage(productId, image.id);
|
||||
if (response.status !== 200 && response.status !== 204) {
|
||||
console.error('Ошибка при удалении изображения с сервера');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении изображения:', err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Для локальных изображений освобождаем URL
|
||||
if (typeof image === 'object' && 'isLocal' in image && image.isLocal) {
|
||||
URL.revokeObjectURL(image.preview);
|
||||
}
|
||||
|
||||
// Обновление состояния
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: prev.images.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
// Обработчик установки изображения как основного
|
||||
const handleSetPrimary = async (index: number) => {
|
||||
const image = formData.images[index];
|
||||
|
||||
// Для серверных изображений делаем API-запрос
|
||||
if (productId && typeof image === 'object' && 'id' in image) {
|
||||
try {
|
||||
const response = await setProductImageAsPrimary(productId, image.id);
|
||||
|
||||
if (response.status !== 200 && response.status !== 204) {
|
||||
console.error('Ошибка при установке основного изображения');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при установке основного изображения:', err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем все изображения, устанавливая is_primary/isPrimary только для выбранного
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: prev.images.map((img, i) => {
|
||||
if (typeof img === 'object') {
|
||||
if ('isLocal' in img && img.isLocal) {
|
||||
// Локальное изображение
|
||||
return {
|
||||
...img,
|
||||
isPrimary: i === index
|
||||
};
|
||||
} else if ('id' in img) {
|
||||
// Серверное изображение
|
||||
return {
|
||||
...img,
|
||||
is_primary: i === index
|
||||
};
|
||||
}
|
||||
}
|
||||
return img;
|
||||
})
|
||||
}));
|
||||
};
|
||||
|
||||
// Функция для загрузки локальных файлов на сервер
|
||||
const uploadLocalImages = async (newProductId: number) => {
|
||||
const localImages = formData.images.filter(
|
||||
(img): img is LocalImage => typeof img === 'object' && 'isLocal' in img && img.isLocal
|
||||
);
|
||||
|
||||
if (localImages.length === 0) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
console.log(`Загрузка ${localImages.length} локальных изображений на сервер`);
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
for (const image of localImages) {
|
||||
console.log(`Загрузка файла ${image.file.name}`);
|
||||
|
||||
const response = await uploadProductImage(newProductId, image.file);
|
||||
|
||||
if (response.status !== 201 || !response.data) {
|
||||
console.error('Ошибка при загрузке изображения:', response);
|
||||
return {
|
||||
success: false,
|
||||
error: response.error || `Не удалось загрузить изображение (код ${response.status})`
|
||||
};
|
||||
}
|
||||
|
||||
// Если изображение было отмечено как основное, устанавливаем его основным на сервере
|
||||
const uploadedImageData = response.data;
|
||||
if (image.isPrimary && uploadedImageData && typeof uploadedImageData === 'object') {
|
||||
// Получаем ID изображения в зависимости от структуры ответа
|
||||
const imageId = 'id' in uploadedImageData ?
|
||||
uploadedImageData.id :
|
||||
(uploadedImageData.product && typeof uploadedImageData.product === 'object' && 'id' in uploadedImageData.product ?
|
||||
uploadedImageData.product.id : null);
|
||||
|
||||
if (imageId) {
|
||||
await setProductImageAsPrimary(newProductId, imageId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
console.error('Ошибка при загрузке изображений:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: err.message || 'Ошибка при загрузке изображений'
|
||||
};
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик сохранения
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
// Подготовка данных для API
|
||||
const productData = {
|
||||
name: formData.name,
|
||||
slug: formData.slug || formData.name.toLowerCase().replace(/\s+/g, '-'),
|
||||
description: formData.description,
|
||||
price: formData.price,
|
||||
discount_price: formData.discount_price,
|
||||
care_instructions: formData.care_instructions,
|
||||
is_active: formData.is_active,
|
||||
category_id: formData.category_id && formData.category_id !== "none"
|
||||
? parseInt(formData.category_id)
|
||||
: undefined,
|
||||
collection_id: formData.collection_id && formData.collection_id !== "none"
|
||||
? parseInt(formData.collection_id)
|
||||
: undefined
|
||||
// Изображения обрабатываются отдельно через API загрузки изображений
|
||||
};
|
||||
|
||||
let response;
|
||||
if (productId) {
|
||||
// Обновление существующего товара
|
||||
response = await updateProduct(productId, productData);
|
||||
} else {
|
||||
// Создание нового товара
|
||||
response = await createProduct(productData);
|
||||
}
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
// Успешное сохранение
|
||||
console.log('Товар успешно сохранен:', response.data);
|
||||
|
||||
// Получаем ID созданного или обновленного товара
|
||||
const savedProductId = productId ||
|
||||
(response.data && typeof response.data === 'object' ?
|
||||
('id' in response.data ?
|
||||
(response.data as Product).id :
|
||||
(response.data.product && typeof response.data.product === 'object' && 'id' in response.data.product ?
|
||||
response.data.product.id : null))
|
||||
: null);
|
||||
|
||||
if (savedProductId) {
|
||||
// Загружаем локальные изображения на сервер
|
||||
const uploadResult = await uploadLocalImages(savedProductId);
|
||||
|
||||
if (!uploadResult.success) {
|
||||
setError(uploadResult.error || 'Не удалось загрузить изображения');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Успешно сохранено и загружены изображения
|
||||
onComplete();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
throw new Error(`Ошибка сохранения: ${response.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при сохранении товара:', err);
|
||||
setError('Не удалось сохранить товар');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Очистка локальных превью при закрытии диалога
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Освобождаем URL-ы для предпросмотра при закрытии диалога
|
||||
formData.images.forEach(image => {
|
||||
if (typeof image === 'object' && 'isLocal' in image && image.isLocal) {
|
||||
URL.revokeObjectURL(image.preview);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [isOpen, formData.images]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{productId ? 'Редактирование товара' : 'Создание нового товара'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Заполните информацию о товаре и его вариантах
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-6 text-center">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-gray-500">Загрузка данных...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Tabs defaultValue="general" value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid grid-cols-3 mb-6">
|
||||
<TabsTrigger value="general">Основная информация</TabsTrigger>
|
||||
<TabsTrigger value="variants">Варианты и размеры</TabsTrigger>
|
||||
<TabsTrigger value="images">Изображения</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Основная информация */}
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Название товара*</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="slug">URL-slug (автоматически)</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
name="slug"
|
||||
value={formData.slug}
|
||||
onChange={handleChange}
|
||||
placeholder="auto-generated-from-name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="price">Цена (₽)*</Label>
|
||||
<Input
|
||||
id="price"
|
||||
name="price"
|
||||
type="number"
|
||||
value={formData.price}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="discount_price">Цена со скидкой (₽)</Label>
|
||||
<Input
|
||||
id="discount_price"
|
||||
name="discount_price"
|
||||
type="number"
|
||||
value={formData.discount_price || ''}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="Не указана"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category_id">Категория*</Label>
|
||||
<Select
|
||||
name="category_id"
|
||||
value={formData.category_id}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, category_id: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите категорию" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Не выбрано</SelectItem>
|
||||
{categories.map(category => (
|
||||
<SelectItem key={category.id} value={category.id.toString()}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Описание</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="care_instructions">Инструкции по уходу (JSON)</Label>
|
||||
<Textarea
|
||||
id="care_instructions"
|
||||
name="care_instructions"
|
||||
value={
|
||||
typeof formData.care_instructions === 'string'
|
||||
? formData.care_instructions
|
||||
: formData.care_instructions.text ||
|
||||
JSON.stringify(formData.care_instructions, null, 2)
|
||||
}
|
||||
onChange={handleChange}
|
||||
rows={5}
|
||||
placeholder='Например: {"стирка": "30°C", "глажка": "Средняя температура"}'
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>Введите инструкции по уходу в формате JSON-объекта:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Используйте двойные кавычки для ключей и значений</li>
|
||||
<li>Разделяйте пары ключ-значение запятыми</li>
|
||||
<li>Пример: {`{"стирка": "30°C", "глажка": "Средняя температура"}`}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onCheckedChange={(checked) =>
|
||||
handleCheckboxChange('is_active', !!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="is_active">Товар активен</Label>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Варианты товара */}
|
||||
<TabsContent value="variants" className="space-y-4">
|
||||
<div className="bg-muted/50 p-4 rounded-md">
|
||||
<h3 className="font-medium mb-2">Доступные размеры</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Выберите размеры, в которых доступен товар, и укажите количество.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{variantSizes.map((variant) => {
|
||||
const size = sizes.find(s => s.id === variant.size_id);
|
||||
return (
|
||||
<div key={variant.size_id} className="flex items-center space-x-4 p-2 rounded border bg-card">
|
||||
<Checkbox
|
||||
id={`size-${variant.size_id}`}
|
||||
checked={variant.checked}
|
||||
onCheckedChange={(checked) =>
|
||||
handleVariantSizeChange(variant.size_id, !!checked)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`size-${variant.size_id}`}
|
||||
className="flex-grow font-medium"
|
||||
>
|
||||
{size?.name || `Размер ${variant.size_id}`}
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Label htmlFor={`stock-${variant.size_id}`} className="text-sm">
|
||||
Остаток:
|
||||
</Label>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 rounded-r-none"
|
||||
onClick={() => handleVariantStockChange(
|
||||
variant.size_id,
|
||||
Math.max(0, variant.stock - 1)
|
||||
)}
|
||||
disabled={!variant.checked}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</Button>
|
||||
<Input
|
||||
id={`stock-${variant.size_id}`}
|
||||
type="number"
|
||||
min="0"
|
||||
value={variant.stock}
|
||||
onChange={(e) => handleVariantStockChange(
|
||||
variant.size_id,
|
||||
parseInt(e.target.value) || 0
|
||||
)}
|
||||
className="w-16 h-8 rounded-none text-center"
|
||||
disabled={!variant.checked}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-8 w-8 rounded-l-none"
|
||||
onClick={() => handleVariantStockChange(
|
||||
variant.size_id,
|
||||
variant.stock + 1
|
||||
)}
|
||||
disabled={!variant.checked}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{variantSizes.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Нет доступных размеров. Добавьте размеры в разделе "Размеры".
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Изображения */}
|
||||
<TabsContent value="images" className="space-y-4">
|
||||
<div className="bg-muted/50 p-4 rounded-md">
|
||||
<h3 className="font-medium mb-2">Изображения товара</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Загрузите фотографии товара. Установите основное изображение, которое будет отображаться первым.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||
{formData.images.map((image, index) => {
|
||||
// Определение URL изображения в зависимости от типа
|
||||
let imageUrl = '';
|
||||
let isPrimary = false;
|
||||
let altText = '';
|
||||
|
||||
if (typeof image === 'object' && 'isLocal' in image && image.isLocal) {
|
||||
// Локальный файл с превью
|
||||
imageUrl = image.preview;
|
||||
isPrimary = image.isPrimary || false;
|
||||
altText = image.file.name;
|
||||
} else if (typeof image === 'object' && 'image_url' in image) {
|
||||
// Изображение с сервера
|
||||
imageUrl = getImageUrl(image.image_url);
|
||||
isPrimary = image.is_primary || false;
|
||||
altText = image.alt_text || `Изображение ${index + 1}`;
|
||||
} else if (typeof image === 'string') {
|
||||
// Просто URL строка
|
||||
imageUrl = getImageUrl(image);
|
||||
altText = `Изображение ${index + 1}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className="relative group">
|
||||
<div className={`relative border rounded-md overflow-hidden ${isPrimary ? 'ring-2 ring-primary ring-offset-1' : ''}`}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={altText}
|
||||
className="h-32 w-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<div className="flex gap-2">
|
||||
{isPrimary ? (
|
||||
<span className="p-1.5 bg-primary text-white rounded-full shadow-sm">
|
||||
<Star className="h-4 w-4" />
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetPrimary(index)}
|
||||
className="p-1.5 bg-white/80 hover:bg-white text-gray-700 rounded-full transition-colors shadow-sm"
|
||||
title="Сделать основным"
|
||||
>
|
||||
<StarOff className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveImage(index)}
|
||||
className="p-1.5 bg-red-500/80 hover:bg-red-500 text-white rounded-full transition-colors shadow-sm"
|
||||
title="Удалить"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isPrimary && (
|
||||
<div className="absolute bottom-1 left-1 bg-primary text-white text-xs px-1.5 py-0.5 rounded-sm shadow-sm">
|
||||
Основное
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImageUpload}
|
||||
disabled={isUploading}
|
||||
className="h-32 border-2 border-dashed border-gray-300 rounded-md flex flex-col items-center justify-center text-gray-500 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<div className="h-6 w-6 mb-2 animate-spin border-2 border-current border-t-transparent rounded-full"></div>
|
||||
<span className="text-sm font-medium">Загрузка...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mx-auto h-6 w-6 mb-2" />
|
||||
<span className="text-sm font-medium">Добавить изображение</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Скрытый input для выбора файлов */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.images.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground italic text-center mt-4">
|
||||
Нет изображений. Добавьте хотя бы одно изображение товара.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{error && (
|
||||
<div className="bg-destructive/10 text-destructive p-3 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<div className="h-4 w-4 mr-2 animate-spin border-2 border-current border-t-transparent rounded-full"></div>
|
||||
Сохранение...
|
||||
</>
|
||||
) : (
|
||||
'Сохранить'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
102
frontend/app/admin/products/create/page.tsx
Normal file
102
frontend/app/admin/products/create/page.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { createProduct, ApiResponse, Product } from '@/lib/api';
|
||||
import { fetchCategories, Category } from '@/lib/catalog-admin';
|
||||
import ProductForm from '@/components/admin/ProductForm';
|
||||
|
||||
export default function CreateProductPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loadingCategories, setLoadingCategories] = useState(true);
|
||||
|
||||
// Загрузка категорий
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
setLoadingCategories(true);
|
||||
const response = await fetchCategories();
|
||||
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
setCategories(response.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке категорий:', err);
|
||||
} finally {
|
||||
setLoadingCategories(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// Обработчик сохранения товара
|
||||
const handleSubmit = async (formData: any) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const response = await createProduct({
|
||||
name: formData.name,
|
||||
slug: formData.slug || undefined,
|
||||
description: formData.description,
|
||||
price: formData.price,
|
||||
discount_price: formData.discount_price,
|
||||
is_active: formData.is_active,
|
||||
category_id: parseInt(formData.category_id),
|
||||
collection_id: formData.collection_id ? parseInt(formData.collection_id) : undefined,
|
||||
care_instructions: formData.care_instructions
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
router.push('/admin/products');
|
||||
} else {
|
||||
throw new Error(response.error || 'Не удалось создать товар');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при создании товара:', err);
|
||||
setError('Не удалось создать товар');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Link href="/admin/products" className="mr-4">
|
||||
<ArrowLeft className="h-6 w-6 text-gray-500 hover:text-gray-700" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold">Создание товара</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingCategories ? (
|
||||
<div className="flex justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
) : (
|
||||
<ProductForm
|
||||
categories={categories}
|
||||
onSubmit={handleSubmit}
|
||||
isLoading={saving}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
466
frontend/app/admin/products/page.tsx
Normal file
466
frontend/app/admin/products/page.tsx
Normal file
@ -0,0 +1,466 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Plus, Edit, Trash, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { fetchProducts, deleteProduct, Product, getImageUrl } from '@/lib/api';
|
||||
import EditProductDialog from './components/EditProductDialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Компонент таблицы товаров
|
||||
interface ProductsTableProps {
|
||||
products: Product[];
|
||||
loading: boolean;
|
||||
onDelete: (id: number) => void;
|
||||
onEdit: (id: number) => void;
|
||||
}
|
||||
|
||||
const ProductsTable = ({ products, loading, onDelete, onEdit }: ProductsTableProps) => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Функция для получения URL первого изображения товара
|
||||
const getProductImageUrl = (product: Product): string | null => {
|
||||
if (product.images && Array.isArray(product.images) && product.images.length > 0) {
|
||||
// Если images - массив строк URL
|
||||
if (typeof product.images[0] === 'string') {
|
||||
return getImageUrl(product.images[0]);
|
||||
}
|
||||
// Если images - массив объектов с полем image_url
|
||||
else if (typeof product.images[0] === 'object' && product.images[0] !== null) {
|
||||
const img = product.images[0] as any;
|
||||
return getImageUrl(img.image_url || '');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Функция для получения общего количества товаров в наличии
|
||||
const getProductStock = (product: Product): number => {
|
||||
// Если есть variants - суммируем stock по всем вариантам
|
||||
if (product.variants && Array.isArray(product.variants) && product.variants.length > 0) {
|
||||
return product.variants.reduce((sum: number, variant) =>
|
||||
sum + (typeof variant.stock === 'number' ? variant.stock : 0), 0);
|
||||
}
|
||||
// Иначе используем стандартное поле stock
|
||||
return typeof product.stock === 'number' ? product.stock : 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">Изображение</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">Название</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">Слаг</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">Категория</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">Размеры</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">Цена</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">Остаток</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider whitespace-nowrap">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{products.map((product) => {
|
||||
const imageUrl = getProductImageUrl(product);
|
||||
const stockAmount = getProductStock(product);
|
||||
|
||||
// Получаем строку размеров
|
||||
const sizesString = product.variants && product.variants.length > 0
|
||||
? product.variants
|
||||
.filter(v => v.size)
|
||||
.map(v => v.size?.code || v.size?.name)
|
||||
.join(', ')
|
||||
: 'Не указаны';
|
||||
|
||||
return (
|
||||
<tr key={product.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">#{product.id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
className="h-12 w-12 rounded-md object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-12 w-12 rounded-md bg-gray-200 flex items-center justify-center">
|
||||
<span className="text-xs text-gray-500">Нет</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
<div className="max-w-[150px] truncate" title={product.name}>
|
||||
{product.name || 'Без названия'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="max-w-[100px] truncate" title={product.slug}>
|
||||
{product.slug || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="max-w-[120px] truncate" title={product.category?.name}>
|
||||
{product.category?.name ||
|
||||
(product.category_id ? `ID: ${product.category_id}` : '-')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="max-w-[120px] truncate" title={sizesString}>
|
||||
{sizesString}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{typeof product.price === 'number' && (
|
||||
<div>
|
||||
<span className="font-medium">{product.price.toLocaleString('ru-RU')} ₽</span>
|
||||
{product.discount_price && (
|
||||
<div className="text-xs text-red-600">
|
||||
{product.discount_price.toLocaleString('ru-RU')} ₽
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${stockAmount > 20 ? 'bg-green-100 text-green-800' :
|
||||
stockAmount > 10 ? 'bg-yellow-100 text-yellow-800' :
|
||||
stockAmount > 0 ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'}`}>
|
||||
{stockAmount > 0 ? stockAmount : 'Нет в наличии'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={() => onEdit(product.id)}
|
||||
className="p-1 text-indigo-600 hover:text-indigo-900 hover:bg-indigo-50 rounded"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(product.id)}
|
||||
className="p-1 text-red-600 hover:text-red-900 hover:bg-red-50 rounded"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{products.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Товары не найдены
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент пагинации
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
const Pagination = ({ currentPage, totalPages, onPageChange }: PaginationProps) => {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 sm:px-6">
|
||||
<div className="flex flex-1 justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={`relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium ${
|
||||
currentPage === 1 ? 'text-gray-300' : 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium ${
|
||||
currentPage === totalPages ? 'text-gray-300' : 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Вперед
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex sm:flex-1 sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Страница <span className="font-medium">{currentPage}</span> из{' '}
|
||||
<span className="font-medium">{totalPages}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="isolate inline-flex -space-x-px rounded-md shadow-sm" aria-label="Pagination">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className={`relative inline-flex items-center rounded-l-md px-2 py-2 ${
|
||||
currentPage === 1
|
||||
? 'text-gray-300'
|
||||
: 'text-gray-400 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`relative inline-flex items-center px-4 py-2 text-sm font-semibold ${
|
||||
page === currentPage
|
||||
? 'z-10 bg-indigo-600 text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
|
||||
: 'text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:outline-offset-0'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className={`relative inline-flex items-center rounded-r-md px-2 py-2 ${
|
||||
currentPage === totalPages
|
||||
? 'text-gray-300'
|
||||
: 'text-gray-400 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// Состояние для модального окна редактирования
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editProductId, setEditProductId] = useState<number | undefined>(undefined);
|
||||
|
||||
// Загрузка товаров
|
||||
const loadProducts = async (page = 1, search = '') => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetchProducts({
|
||||
page,
|
||||
search,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
console.log('Полученные товары:', response);
|
||||
|
||||
// Проверяем разные форматы данных
|
||||
let productsData: Product[] = [];
|
||||
|
||||
if (response.data) {
|
||||
// Проверяем, есть ли обертка product в ответе
|
||||
if (typeof response.data === 'object' && 'product' in response.data) {
|
||||
const productData = response.data.product;
|
||||
// Формат { data: { product: { items: [...] } } }
|
||||
if (productData && typeof productData === 'object' && 'items' in productData) {
|
||||
productsData = productData.items as Product[];
|
||||
setTotalPages(productData.total_pages || 1);
|
||||
}
|
||||
// Формат { data: { product: [...] } }
|
||||
else if (Array.isArray(productData)) {
|
||||
productsData = productData;
|
||||
setTotalPages(Math.ceil(productData.length / 10) || 1);
|
||||
}
|
||||
}
|
||||
// Формат { data: { items: [...] } }
|
||||
else if (typeof response.data === 'object' && 'items' in response.data) {
|
||||
productsData = response.data.items as Product[];
|
||||
setTotalPages(response.data.total_pages || 1);
|
||||
}
|
||||
// Формат { data: [...] } - массив товаров
|
||||
else if (Array.isArray(response.data)) {
|
||||
productsData = response.data;
|
||||
setTotalPages(Math.ceil(productsData.length / 10) || 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Отфильтровываем по поиску, если сервер не поддерживает поиск
|
||||
if (search && Array.isArray(productsData)) {
|
||||
productsData = productsData.filter(product =>
|
||||
product.name.toLowerCase().includes(search.toLowerCase()));
|
||||
}
|
||||
|
||||
// Делаем пагинацию на клиенте, если сервер не поддерживает пагинацию
|
||||
if (page > 1 && Array.isArray(productsData) && response.data && !('total_pages' in response.data)) {
|
||||
const startIndex = (page - 1) * 10;
|
||||
productsData = productsData.slice(startIndex, startIndex + 10);
|
||||
}
|
||||
|
||||
setProducts(productsData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке товаров:', err);
|
||||
setError('Не удалось загрузить товары');
|
||||
setProducts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Загрузка при монтировании и изменении страницы/поиска
|
||||
useEffect(() => {
|
||||
loadProducts(currentPage, searchQuery);
|
||||
}, [currentPage]);
|
||||
|
||||
// Обработчик поиска
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setCurrentPage(1);
|
||||
loadProducts(1, searchQuery);
|
||||
};
|
||||
|
||||
// Обработчик удаления товара
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить этот товар?')) {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
const response = await deleteProduct(id);
|
||||
if (response.status === 200 || response.status === 204) {
|
||||
// Обновляем список товаров после удаления
|
||||
loadProducts(currentPage, searchQuery);
|
||||
} else {
|
||||
throw new Error(`Ошибка при удалении: ${response.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении товара:', err);
|
||||
alert('Не удалось удалить товар');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик открытия модального окна для редактирования
|
||||
const handleEdit = (id: number) => {
|
||||
console.log('Открытие модального окна для редактирования товара с ID:', id);
|
||||
setEditProductId(id);
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
// Обработчик открытия модального окна для создания
|
||||
const handleCreate = () => {
|
||||
setEditProductId(undefined);
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Товары</h1>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<Plus size={18} className="mr-1" />
|
||||
Создать товар
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSearch} className="flex gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Поиск товаров..."
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" variant="outline">
|
||||
Найти
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProductsTable
|
||||
products={products}
|
||||
loading={loading}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
/>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm text-gray-500">
|
||||
<div>
|
||||
Показано {products.length} из многих товаров
|
||||
</div>
|
||||
<div>
|
||||
Страница {currentPage} из {totalPages}
|
||||
</div>
|
||||
</div>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Модальное окно редактирования/создания товара */}
|
||||
<EditProductDialog
|
||||
isOpen={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
productId={editProductId}
|
||||
onComplete={() => loadProducts(currentPage, searchQuery)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,700 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { ChevronDown, ChevronUp, X, Sliders, Search, ArrowUpRight } from "lucide-react"
|
||||
import { ProductCard } from "@/components/ui/product-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Slider } from "@/components/ui/slider"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useInView } from "react-intersection-observer"
|
||||
|
||||
export default function CatalogPage() {
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false)
|
||||
const [priceRange, setPriceRange] = useState([0, 15000])
|
||||
const [activeFilters, setActiveFilters] = useState<string[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [sortOption, setSortOption] = useState("popular")
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid")
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [heroRef, heroInView] = useInView({ triggerOnce: true, threshold: 0.1 })
|
||||
const [selectedImage, setSelectedImage] = useState<number | null>(null)
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
|
||||
// Проверка размера экрана
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
setIsMobile(window.innerWidth < 768)
|
||||
// Автоматически переключаем на сетку на мобильных устройствах
|
||||
if (window.innerWidth < 640 && viewMode === 'list') {
|
||||
setViewMode('grid')
|
||||
}
|
||||
}
|
||||
|
||||
checkIsMobile()
|
||||
window.addEventListener('resize', checkIsMobile)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', checkIsMobile)
|
||||
}
|
||||
}, [viewMode])
|
||||
|
||||
// Mock products data
|
||||
const products = [
|
||||
{
|
||||
id: 1,
|
||||
name: "ПЛАТЬЕ С ЦВЕТОЧНЫМ ПРИНТОМ",
|
||||
price: 5990,
|
||||
salePrice: 4490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
images: [
|
||||
"/placeholder.svg?height=600&width=400",
|
||||
"/placeholder.svg?height=600&width=400&text=2",
|
||||
"/placeholder.svg?height=600&width=400&text=3",
|
||||
],
|
||||
isNew: false,
|
||||
isOnSale: true,
|
||||
category: "Платья",
|
||||
colors: ["Черный", "Белый", "Бежевый"],
|
||||
sizes: ["XS", "S", "M", "L", "XL"],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "БЛУЗА ИЗ НАТУРАЛЬНОГО ШЕЛКА",
|
||||
price: 4990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: true,
|
||||
isOnSale: false,
|
||||
category: "Блузы",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "БРЮКИ С ВЫСОКОЙ ПОСАДКОЙ",
|
||||
price: 6490,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: false,
|
||||
isOnSale: false,
|
||||
category: "Брюки",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "ЮБКА МИДИ ПЛИССЕ",
|
||||
price: 5290,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: false,
|
||||
isOnSale: false,
|
||||
category: "Юбки",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "ЖАКЕТ ИЗ ИТАЛЬЯНСКОЙ ШЕРСТИ",
|
||||
price: 12900,
|
||||
salePrice: 9900,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: false,
|
||||
isOnSale: true,
|
||||
category: "Жакеты",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "ПЛАТЬЕ-РУБАШКА ИЗ ЛЬНА",
|
||||
price: 7990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: true,
|
||||
isOnSale: false,
|
||||
category: "Платья",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "ТОПП С ДРАПИРОВКОЙ",
|
||||
price: 3990,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: false,
|
||||
isOnSale: false,
|
||||
category: "Топы",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "ПАЛЬТО ИЗ ШЕРСТИ",
|
||||
price: 18900,
|
||||
image: "/placeholder.svg?height=600&width=400",
|
||||
isNew: true,
|
||||
isOnSale: false,
|
||||
category: "Верхняя одежда",
|
||||
},
|
||||
]
|
||||
|
||||
// Категории
|
||||
const categories = [
|
||||
{ id: "dresses", name: "Платья", count: 42 },
|
||||
{ id: "blouses", name: "Блузы", count: 36 },
|
||||
{ id: "pants", name: "Брюки", count: 28 },
|
||||
{ id: "skirts", name: "Юбки", count: 24 },
|
||||
{ id: "jackets", name: "Жакеты", count: 18 },
|
||||
{ id: "accessories", name: "Аксессуары", count: 15 },
|
||||
]
|
||||
|
||||
// Цвета
|
||||
const colors = [
|
||||
{ id: "black", name: "Черный", hex: "#000000" },
|
||||
{ id: "white", name: "Белый", hex: "#FFFFFF" },
|
||||
{ id: "beige", name: "Бежевый", hex: "#F5F5DC" },
|
||||
{ id: "blue", name: "Синий", hex: "#0000FF" },
|
||||
{ id: "green", name: "Зеленый", hex: "#008000" },
|
||||
{ id: "red", name: "Красный", hex: "#FF0000" },
|
||||
]
|
||||
|
||||
// Размеры
|
||||
const sizes = ["XS", "S", "M", "L", "XL", "XXL"]
|
||||
|
||||
// Функция для переключения фильтров
|
||||
const toggleFilter = (filter: string) => {
|
||||
if (activeFilters.includes(filter)) {
|
||||
setActiveFilters(activeFilters.filter(f => f !== filter))
|
||||
} else {
|
||||
setActiveFilters([...activeFilters, filter])
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для очистки всех фильтров
|
||||
const clearAllFilters = () => {
|
||||
setActiveFilters([])
|
||||
setPriceRange([0, 15000])
|
||||
setSearchQuery("")
|
||||
}
|
||||
|
||||
// Компонент боковой панели фильтров
|
||||
const FilterSidebar = () => (
|
||||
<div className="space-y-8">
|
||||
{/* Поиск */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Поиск..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-primary/20 focus:border-primary rounded-none"
|
||||
/>
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-primary/60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Категории */}
|
||||
<div>
|
||||
<Accordion type="single" collapsible defaultValue="categories">
|
||||
<AccordionItem value="categories" className="border-none">
|
||||
<AccordionTrigger className="text-primary font-medium py-2 hover:no-underline">
|
||||
Категории
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="space-y-2 pt-2">
|
||||
{categories.map((category) => (
|
||||
<div key={category.id} className="flex items-center justify-between group">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`category-${category.id}`}
|
||||
checked={activeFilters.includes(category.id)}
|
||||
onCheckedChange={() => toggleFilter(category.id)}
|
||||
className="text-primary border-primary/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`category-${category.id}`}
|
||||
className="text-sm cursor-pointer group-hover:text-primary transition-colors"
|
||||
>
|
||||
{category.name}
|
||||
</Label>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{category.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* Цвета */}
|
||||
<div>
|
||||
<Accordion type="single" collapsible defaultValue="colors">
|
||||
<AccordionItem value="colors" className="border-none">
|
||||
<AccordionTrigger className="text-primary font-medium py-2 hover:no-underline">
|
||||
Цвета
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-6 gap-2 pt-2">
|
||||
{colors.map((color) => (
|
||||
<div key={color.id} className="flex flex-col items-center space-y-1">
|
||||
<button
|
||||
className={`w-8 h-8 rounded-full border ${
|
||||
activeFilters.includes(color.id)
|
||||
? "ring-2 ring-primary ring-offset-2"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
style={{ backgroundColor: color.hex }}
|
||||
onClick={() => toggleFilter(color.id)}
|
||||
aria-label={color.name}
|
||||
/>
|
||||
<span className="text-xs text-gray-600">{color.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* Размеры */}
|
||||
<div>
|
||||
<Accordion type="single" collapsible defaultValue="sizes">
|
||||
<AccordionItem value="sizes" className="border-none">
|
||||
<AccordionTrigger className="text-primary font-medium py-2 hover:no-underline">
|
||||
Размеры
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="grid grid-cols-4 gap-2 pt-2">
|
||||
{sizes.map((size) => (
|
||||
<button
|
||||
key={size}
|
||||
className={`border py-2 text-sm ${
|
||||
activeFilters.includes(size)
|
||||
? "bg-primary text-white border-primary"
|
||||
: "border-gray-200 hover:border-primary/50 text-gray-700"
|
||||
}`}
|
||||
onClick={() => toggleFilter(size)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* Цена */}
|
||||
<div>
|
||||
<Accordion type="single" collapsible defaultValue="price">
|
||||
<AccordionItem value="price" className="border-none">
|
||||
<AccordionTrigger className="text-primary font-medium py-2 hover:no-underline">
|
||||
Цена
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="pt-2 px-2">
|
||||
<Slider
|
||||
defaultValue={priceRange}
|
||||
max={15000}
|
||||
step={100}
|
||||
onValueChange={setPriceRange}
|
||||
className="my-6"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="border border-gray-200 p-2 w-[45%] text-center text-sm">
|
||||
{priceRange[0].toLocaleString()} ₽
|
||||
</div>
|
||||
<div className="border border-gray-200 p-2 w-[45%] text-center text-sm">
|
||||
{priceRange[1].toLocaleString()} ₽
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="pt-4 space-y-3">
|
||||
<Button
|
||||
className="w-full bg-primary hover:bg-primary/90 rounded-none"
|
||||
onClick={() => setIsFilterOpen(false)}
|
||||
>
|
||||
Применить фильтры
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-primary/30 text-primary hover:bg-primary/5 rounded-none"
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
Сбросить все
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<main className="pb-16">
|
||||
{/* Заголовок страницы */}
|
||||
<section
|
||||
ref={heroRef}
|
||||
className="relative bg-tertiary/10 py-16 md:py-24 overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-full opacity-5">
|
||||
<Image
|
||||
src="/pattern.svg"
|
||||
alt=""
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto px-4 relative">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={heroInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="max-w-2xl mx-auto text-center"
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl lg:text-5xl font-light text-primary mb-4">Каталог</h1>
|
||||
<p className="text-gray-600 mb-6 max-w-xl mx-auto text-sm md:text-base">
|
||||
Откройте для себя нашу коллекцию элегантной одежды, созданной для современных женщин,
|
||||
которые ценят качество, стиль и комфорт.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-3 md:gap-4">
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 rounded-none min-w-[140px] md:min-w-[160px] text-sm md:text-base"
|
||||
asChild
|
||||
>
|
||||
<Link href="#products">
|
||||
Смотреть все
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary/30 text-primary hover:bg-primary/5 rounded-none min-w-[140px] md:min-w-[160px] text-sm md:text-base"
|
||||
asChild
|
||||
>
|
||||
<Link href="/collections">
|
||||
Коллекции
|
||||
<ArrowUpRight className="ml-2 h-3 w-3 md:h-4 md:w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Категории (только для мобильных) */}
|
||||
{isMobile && (
|
||||
<section className="py-6 overflow-hidden">
|
||||
<div className="container px-4 mx-auto">
|
||||
<h2 className="text-lg font-medium text-primary mb-4">Категории</h2>
|
||||
<div className="flex overflow-x-auto pb-4 gap-3 hide-scrollbar">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
className={`flex-shrink-0 px-4 py-2 border text-sm whitespace-nowrap ${
|
||||
activeFilters.includes(category.id)
|
||||
? "bg-primary text-white border-primary"
|
||||
: "border-gray-200 text-gray-700"
|
||||
}`}
|
||||
onClick={() => toggleFilter(category.id)}
|
||||
>
|
||||
{category.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Основной контент */}
|
||||
<section id="products" className="py-8 md:py-16">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Фильтры для десктопа */}
|
||||
<div className="hidden lg:block w-64 flex-shrink-0">
|
||||
<div className="sticky top-24">
|
||||
<FilterSidebar />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фильтры для мобильных */}
|
||||
<div className="lg:hidden mb-6">
|
||||
<Sheet open={isFilterOpen} onOpenChange={setIsFilterOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-primary/30 text-primary hover:bg-primary/5 rounded-none"
|
||||
>
|
||||
<Sliders className="mr-2 h-4 w-4" />
|
||||
Фильтры и сортировка
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-full sm:max-w-md overflow-auto">
|
||||
<SheetHeader className="mb-6">
|
||||
<SheetTitle className="text-primary">Фильтры</SheetTitle>
|
||||
<SheetDescription>
|
||||
Настройте параметры поиска для подбора идеальных товаров
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<FilterSidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
|
||||
{/* Товары */}
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between mb-8 gap-4">
|
||||
<div>
|
||||
<Select value={sortOption} onValueChange={setSortOption}>
|
||||
<SelectTrigger className="w-full sm:w-[180px] border-primary/20 focus:ring-primary rounded-none">
|
||||
<SelectValue placeholder="По популярности" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="popular">По популярности</SelectItem>
|
||||
<SelectItem value="price-asc">По возрастанию цены</SelectItem>
|
||||
<SelectItem value="price-desc">По убыванию цены</SelectItem>
|
||||
<SelectItem value="new">Сначала новинки</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="hidden sm:block text-sm text-gray-500">
|
||||
Показано: <span className="font-medium text-primary">{products.length}</span> из <span className="font-medium text-primary">120</span>
|
||||
</div>
|
||||
|
||||
<div className="flex border border-primary/20 rounded-none">
|
||||
<button
|
||||
className={`p-2 ${viewMode === 'grid' ? 'bg-primary/10' : 'bg-white'}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
aria-label="Сетка"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="6" height="6" stroke="#2B5F47" strokeWidth="1.5"/>
|
||||
<rect x="9" y="1" width="6" height="6" stroke="#2B5F47" strokeWidth="1.5"/>
|
||||
<rect x="1" y="9" width="6" height="6" stroke="#2B5F47" strokeWidth="1.5"/>
|
||||
<rect x="9" y="9" width="6" height="6" stroke="#2B5F47" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className={`p-2 ${viewMode === 'list' ? 'bg-primary/10' : 'bg-white'} ${isMobile ? 'hidden' : 'block'}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
aria-label="Список"
|
||||
disabled={isMobile}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="14" height="3" stroke="#2B5F47" strokeWidth="1.5"/>
|
||||
<rect x="1" y="6" width="14" height="3" stroke="#2B5F47" strokeWidth="1.5"/>
|
||||
<rect x="1" y="11" width="14" height="3" stroke="#2B5F47" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Активные фильтры */}
|
||||
{activeFilters.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{activeFilters.map(filter => {
|
||||
// Найдем название фильтра для отображения
|
||||
let filterName = filter;
|
||||
const category = categories.find(c => c.id === filter);
|
||||
const color = colors.find(c => c.id === filter);
|
||||
|
||||
if (category) filterName = category.name;
|
||||
if (color) filterName = color.name;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={filter}
|
||||
className="flex items-center bg-primary/5 px-3 py-1 text-sm text-primary border border-primary/10"
|
||||
>
|
||||
<span>{filterName}</span>
|
||||
<button
|
||||
onClick={() => toggleFilter(filter)}
|
||||
className="ml-2 text-primary/70 hover:text-primary"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="text-sm text-primary/70 hover:text-primary underline"
|
||||
onClick={clearAllFilters}
|
||||
>
|
||||
Очистить все
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Сетка товаров */}
|
||||
<div className="mb-12">
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-6">
|
||||
{products.map(product => (
|
||||
<ProductCard
|
||||
key={product.id}
|
||||
id={product.id}
|
||||
name={product.name}
|
||||
price={product.price}
|
||||
salePrice={product.salePrice}
|
||||
image={product.image}
|
||||
isNew={product.isNew}
|
||||
isOnSale={product.isOnSale}
|
||||
category={product.category}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{products.map(product => (
|
||||
<div
|
||||
key={product.id}
|
||||
className="flex flex-col sm:flex-row border border-gray-100 hover:border-primary/20 transition-colors"
|
||||
>
|
||||
<div className="w-full sm:w-1/3 relative">
|
||||
<Link href={`/product/${product.id}`} className="block h-full">
|
||||
<Image
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
width={400}
|
||||
height={600}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</Link>
|
||||
{product.isNew && (
|
||||
<div className="absolute top-2 right-2 bg-primary text-white text-xs py-1 px-2">
|
||||
НОВИНКА
|
||||
</div>
|
||||
)}
|
||||
{product.isOnSale && (
|
||||
<div className="absolute top-2 left-2 bg-[#E2725B] text-white text-xs py-1 px-2">
|
||||
СКИДКА
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full sm:w-2/3 p-4 sm:pl-6 sm:pr-4 sm:py-4 flex flex-col">
|
||||
<div className="text-xs text-gray-500 mb-1">{product.category}</div>
|
||||
<h3 className="text-primary font-medium mb-2">{product.name}</h3>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
{product.isOnSale && product.salePrice ? (
|
||||
<>
|
||||
<p className="text-lg font-medium text-secondary">{product.salePrice.toLocaleString()} ₽</p>
|
||||
<p className="text-sm text-gray-500 line-through">{product.price.toLocaleString()} ₽</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-lg font-medium text-secondary">{product.price.toLocaleString()} ₽</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Доступные цвета */}
|
||||
<div className="mb-4">
|
||||
<div className="text-sm text-gray-600 mb-2">Доступные цвета:</div>
|
||||
<div className="flex gap-2">
|
||||
{(product.colors || ["Черный"]).map((color, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
className={`w-6 h-6 rounded-full border ${
|
||||
selectedImage === idx ? "ring-2 ring-primary ring-offset-1" : "border-gray-200"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
color === "Черный" ? "#000" :
|
||||
color === "Белый" ? "#fff" :
|
||||
color === "Бежевый" ? "#F5F5DC" : "#ddd"
|
||||
}}
|
||||
onClick={() => setSelectedImage(idx)}
|
||||
aria-label={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="bg-primary hover:bg-primary/90 text-white rounded-none"
|
||||
>
|
||||
В корзину
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary/30 text-primary hover:bg-primary/5 rounded-none"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/product/${product.id}`}>
|
||||
Подробнее
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Пагинация */}
|
||||
<div className="mt-12 flex justify-center">
|
||||
<nav className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="border-primary/20 text-primary hover:bg-primary/5 w-9 h-9 rounded-none"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4 rotate-90" />
|
||||
</Button>
|
||||
|
||||
{[1, 2, 3, 4, 5].map(page => (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? "default" : "outline"}
|
||||
className={currentPage === page
|
||||
? "bg-primary hover:bg-primary/90 text-white w-9 h-9 rounded-none"
|
||||
: "border-primary/20 text-primary hover:bg-primary/5 w-9 h-9 rounded-none"
|
||||
}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="border-primary/20 text-primary hover:bg-primary/5 w-9 h-9 rounded-none"
|
||||
disabled={currentPage === 5}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4 rotate-90" />
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Стили для скрытия полосы прокрутки */}
|
||||
<style jsx global>{`
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -1,27 +1,16 @@
|
||||
import type React from "react"
|
||||
import type { Metadata } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import { GeistSans } from "geist/font/sans"
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import "./globals.css"
|
||||
import { ThemeProvider } from "@/components/theme-provider"
|
||||
import Header from "@/components/header"
|
||||
import Footer from "@/components/layout/footer"
|
||||
|
||||
// Импортируем шрифт Arimo
|
||||
import { Arimo } from "next/font/google"
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" })
|
||||
|
||||
// Настраиваем шрифт Arimo
|
||||
const arimo = Arimo({
|
||||
subsets: ["latin", "cyrillic"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
variable: "--font-arimo",
|
||||
})
|
||||
import { Toaster } from "@/components/ui/toaster"
|
||||
import { ThemeProvider } from "@/components/providers/theme-provider"
|
||||
import { CartProvider } from "@/hooks/use-cart"
|
||||
import { WishlistProvider } from "@/hooks/use-wishlist"
|
||||
|
||||
// Метаданные сайта
|
||||
export const metadata: Metadata = {
|
||||
title: "Модный магазин одежды",
|
||||
description: "Современный интернет-магазин стильной одежды",
|
||||
generator: 'v0.dev'
|
||||
title: "Одежда для успеха - интернет-магазин стильной одежды",
|
||||
description: "Интернет-магазин стильной одежды. Большой выбор одежды для мужчин и женщин. Доставка по всей России.",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@ -30,18 +19,22 @@ export default function RootLayout({
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body className={`${inter.variable} ${arimo.variable}`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
|
||||
<Header />
|
||||
<div className="pt-20">{children}</div>
|
||||
<Footer />
|
||||
<html lang="ru" suppressHydrationWarning>
|
||||
<body className={`${GeistSans.variable} ${GeistMono.variable} font-sans antialiased`}>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<CartProvider>
|
||||
<WishlistProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</WishlistProvider>
|
||||
</CartProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
import './globals.css'
|
||||
}
|
||||
298
frontend/components/admin/AdminLayout.tsx
Normal file
298
frontend/components/admin/AdminLayout.tsx
Normal file
@ -0,0 +1,298 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Tag,
|
||||
ShoppingBag,
|
||||
Users,
|
||||
FileText,
|
||||
Settings,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Home,
|
||||
Grid
|
||||
} from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import authService from '../../services/auth';
|
||||
import { userService } from '../../services/users';
|
||||
|
||||
// Компонент элемента бокового меню
|
||||
interface SidebarItemProps {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
href: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const SidebarItem = ({ icon, text, href, active }: SidebarItemProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={`flex items-center px-4 py-3 rounded-lg ${active ? 'bg-indigo-50 text-indigo-600' : 'text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
<span className={`${active ? 'text-indigo-600' : 'text-gray-500'}`}>{icon}</span>
|
||||
<span className={`ml-3 font-medium ${active ? 'text-indigo-600' : 'text-gray-700'}`}>{text}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
interface AdminLayoutProps {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children, title }: AdminLayoutProps) {
|
||||
const router = useRouter();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Проверка прав администратора при загрузке компонента
|
||||
useEffect(() => {
|
||||
const checkAdminAccess = async () => {
|
||||
try {
|
||||
if (!authService.isAuthenticated()) {
|
||||
// Если пользователь не авторизован, перенаправляем на страницу входа
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await userService.getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
// Если не удалось получить данные пользователя, перенаправляем на страницу входа
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.is_admin) {
|
||||
// Если пользователь не администратор, перенаправляем на главную страницу
|
||||
router.push('/');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при проверке прав администратора:', error);
|
||||
router.push('/login');
|
||||
}
|
||||
};
|
||||
|
||||
checkAdminAccess();
|
||||
}, [router]);
|
||||
|
||||
// Определяем активный пункт меню на основе текущего пути
|
||||
const currentPath = router.pathname;
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
icon: <Home className="w-5 h-5" />,
|
||||
text: 'Панель управления',
|
||||
href: '/admin',
|
||||
active: currentPath === '/admin'
|
||||
},
|
||||
{
|
||||
icon: <Package className="w-5 h-5" />,
|
||||
text: 'Товары',
|
||||
href: '/admin/products',
|
||||
active: currentPath.startsWith('/admin/products')
|
||||
},
|
||||
{
|
||||
icon: <ShoppingBag className="w-5 h-5" />,
|
||||
text: 'Категории',
|
||||
href: '/admin/categories',
|
||||
active: currentPath.startsWith('/admin/categories')
|
||||
},
|
||||
{
|
||||
icon: <ShoppingBag className="w-5 h-5" />,
|
||||
text: 'Заказы',
|
||||
href: '/admin/orders',
|
||||
active: currentPath.startsWith('/admin/orders')
|
||||
},
|
||||
{
|
||||
icon: <Users className="w-5 h-5" />,
|
||||
text: 'Пользователи',
|
||||
href: '/admin/users',
|
||||
active: currentPath.startsWith('/admin/users')
|
||||
},
|
||||
{
|
||||
icon: <Settings className="w-5 h-5" />,
|
||||
text: 'Настройки',
|
||||
href: '/admin/settings',
|
||||
active: currentPath.startsWith('/admin/settings')
|
||||
},
|
||||
{
|
||||
icon: <Grid className="w-5 h-5" />,
|
||||
text: 'Коллекции',
|
||||
href: '/admin/collections',
|
||||
active: currentPath.startsWith('/admin/collections')
|
||||
}
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-100">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<Head>
|
||||
<title>{title} | Админ-панель</title>
|
||||
</Head>
|
||||
|
||||
{/* Мобильная навигация */}
|
||||
<div className="lg:hidden">
|
||||
<div className="fixed top-0 left-0 right-0 z-30 bg-white shadow-sm px-4 py-2 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
className="p-2 rounded-md text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<Menu className="w-6 h-6" />
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
<div className="relative h-8 w-24">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Brand Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-2 font-semibold text-gray-800">Админ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Боковое меню (мобильное) */}
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 z-40 lg:hidden">
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)}></div>
|
||||
<div className="fixed inset-y-0 left-0 flex flex-col w-64 max-w-xs bg-white shadow-xl">
|
||||
<div className="h-16 flex items-center justify-between px-4 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<div className="relative h-8 w-24">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Brand Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-2 font-semibold text-gray-800">Админ</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="p-2 rounded-md text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<nav className="space-y-1">
|
||||
{menuItems.map((item, index) => (
|
||||
<SidebarItem
|
||||
key={index}
|
||||
icon={item.icon}
|
||||
text={item.text}
|
||||
href={item.href}
|
||||
active={item.active}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center px-4 py-3 text-indigo-600 rounded-lg hover:bg-indigo-50 w-full mb-3"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<span className="ml-3 font-medium">На главную</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
authService.logout();
|
||||
router.push('/login');
|
||||
}}
|
||||
className="flex items-center px-4 py-3 text-red-600 rounded-lg hover:bg-red-50 w-full"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="ml-3 font-medium">Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Боковое меню (десктоп) */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:flex-col lg:w-64 lg:bg-white lg:border-r lg:border-gray-200">
|
||||
<div className="h-16 flex items-center px-6 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<div className="relative h-8 w-24">
|
||||
<Image
|
||||
src="/logo.png"
|
||||
alt="Brand Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-2 font-semibold text-gray-800">Админ</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<nav className="space-y-1">
|
||||
{menuItems.map((item, index) => (
|
||||
<SidebarItem
|
||||
key={index}
|
||||
icon={item.icon}
|
||||
text={item.text}
|
||||
href={item.href}
|
||||
active={item.active}
|
||||
/>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center px-4 py-3 text-indigo-600 rounded-lg hover:bg-indigo-50 w-full mb-3"
|
||||
>
|
||||
<Home className="w-5 h-5" />
|
||||
<span className="ml-3 font-medium">На главную</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
authService.logout();
|
||||
router.push('/login');
|
||||
}}
|
||||
className="flex items-center px-4 py-3 text-red-600 rounded-lg hover:bg-red-50 w-full"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="ml-3 font-medium">Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основной контент */}
|
||||
<div className="lg:pl-64 flex flex-col min-h-screen">
|
||||
<header className="hidden lg:flex h-16 bg-white shadow-sm px-6 items-center">
|
||||
<h1 className="text-2xl font-semibold text-gray-800">{title}</h1>
|
||||
</header>
|
||||
<main className="flex-1 p-6 pt-20 lg:pt-6">
|
||||
{children}
|
||||
</main>
|
||||
<footer className="bg-white border-t border-gray-200 p-4 text-center text-sm text-gray-600">
|
||||
© {new Date().getFullYear()} Dressed for Success. Все права защищены.
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
frontend/components/admin/AdminSidebar.tsx
Normal file
106
frontend/components/admin/AdminSidebar.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
ShoppingBag,
|
||||
Tag,
|
||||
FileText,
|
||||
Settings,
|
||||
Users,
|
||||
ShoppingCart,
|
||||
BarChart,
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
|
||||
// Определение элементов меню
|
||||
const menuItems = [
|
||||
{
|
||||
title: 'Дашборд',
|
||||
icon: <LayoutDashboard className="h-5 w-5" />,
|
||||
href: '/admin/dashboard',
|
||||
active: (path) => path === '/admin/dashboard'
|
||||
},
|
||||
{
|
||||
title: 'Заказы',
|
||||
icon: <ShoppingCart className="h-5 w-5" />,
|
||||
href: '/admin/orders',
|
||||
active: (path) => path.startsWith('/admin/orders')
|
||||
},
|
||||
{
|
||||
title: 'Клиенты',
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
href: '/admin/customers',
|
||||
active: (path) => path.startsWith('/admin/customers')
|
||||
},
|
||||
{
|
||||
title: 'Категории',
|
||||
icon: <Tag className="h-5 w-5" />,
|
||||
href: '/admin/categories',
|
||||
active: (path) => path.startsWith('/admin/categories')
|
||||
},
|
||||
{
|
||||
title: 'Товары',
|
||||
icon: <ShoppingBag className="h-5 w-5" />,
|
||||
href: '/admin/products',
|
||||
active: (path) => path.startsWith('/admin/products')
|
||||
},
|
||||
{
|
||||
title: 'Страницы',
|
||||
icon: <FileText className="h-5 w-5" />,
|
||||
href: '/admin/pages',
|
||||
active: (path) => path.startsWith('/admin/pages')
|
||||
},
|
||||
{
|
||||
title: 'Отзывы',
|
||||
icon: <MessageSquare className="h-5 w-5" />,
|
||||
href: '/admin/reviews',
|
||||
active: (path) => path.startsWith('/admin/reviews')
|
||||
},
|
||||
{
|
||||
title: 'Аналитика',
|
||||
icon: <BarChart className="h-5 w-5" />,
|
||||
href: '/admin/analytics',
|
||||
active: (path) => path.startsWith('/admin/analytics')
|
||||
},
|
||||
{
|
||||
title: 'Настройки',
|
||||
icon: <Settings className="h-5 w-5" />,
|
||||
href: '/admin/settings',
|
||||
active: (path) => path.startsWith('/admin/settings')
|
||||
}
|
||||
];
|
||||
|
||||
export default function AdminSidebar() {
|
||||
const router = useRouter();
|
||||
const currentPath = router.pathname;
|
||||
|
||||
return (
|
||||
<div className="h-full bg-white border-r border-gray-200 w-64 flex-shrink-0">
|
||||
<div className="p-6">
|
||||
<Link href="/admin/dashboard" className="flex items-center">
|
||||
<span className="text-xl font-bold text-indigo-600">DressedForSuccess</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="mt-5 px-3 space-y-1">
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.title}
|
||||
href={item.href}
|
||||
className={`group flex items-center px-3 py-2 text-sm font-medium rounded-md ${
|
||||
item.active(currentPath)
|
||||
? 'bg-indigo-50 text-indigo-600'
|
||||
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<span className={`mr-3 ${
|
||||
item.active(currentPath) ? 'text-indigo-600' : 'text-gray-400 group-hover:text-gray-500'
|
||||
}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.title}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
frontend/components/admin/CategoryForm.tsx
Normal file
242
frontend/components/admin/CategoryForm.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
// Интерфейс для категории
|
||||
export interface Category {
|
||||
id: string | number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
parent_id?: string | number | null;
|
||||
is_active: boolean;
|
||||
children?: Category[];
|
||||
level?: number;
|
||||
}
|
||||
|
||||
// Схема валидации формы
|
||||
export const categoryFormSchema = z.object({
|
||||
name: z.string().min(1, "Название категории обязательно"),
|
||||
slug: z.string().min(1, "URL-адрес обязателен"),
|
||||
description: z.string().optional(),
|
||||
parent_id: z.string().nullable().optional(),
|
||||
is_active: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export type CategoryFormValues = z.infer<typeof categoryFormSchema>;
|
||||
|
||||
interface CategoryFormProps {
|
||||
defaultValues: CategoryFormValues;
|
||||
categories: Category[];
|
||||
onSubmit: (values: CategoryFormValues) => Promise<void>;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
export function CategoryForm({
|
||||
defaultValues,
|
||||
categories,
|
||||
onSubmit,
|
||||
isSaving,
|
||||
onCancel,
|
||||
isEditing = false
|
||||
}: CategoryFormProps) {
|
||||
// Инициализация формы
|
||||
const form = useForm<CategoryFormValues>({
|
||||
resolver: zodResolver(categoryFormSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
// Генерация slug из названия
|
||||
const generateSlug = () => {
|
||||
const name = form.getValues("name");
|
||||
if (name) {
|
||||
const slug = name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-");
|
||||
form.setValue("slug", slug);
|
||||
}
|
||||
};
|
||||
|
||||
// Фильтрация категорий для выбора родительской категории
|
||||
// Исключаем текущую категорию и её дочерние элементы при редактировании
|
||||
const getFilteredCategories = () => {
|
||||
if (!isEditing) return categories;
|
||||
|
||||
const currentCategoryId = defaultValues.parent_id;
|
||||
|
||||
// Функция для проверки, является ли категория потомком текущей
|
||||
const isDescendant = (category: Category, targetId: string | number | null | undefined): boolean => {
|
||||
if (!targetId) return false;
|
||||
if (category.id.toString() === targetId.toString()) return true;
|
||||
if (category.children) {
|
||||
return category.children.some(child => isDescendant(child, targetId));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return categories.filter(category =>
|
||||
!isDescendant(category, currentCategoryId)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Название</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Название категории" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex space-x-2 items-end">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="slug"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>URL-адрес</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="url-адрес-категории" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={generateSlug}
|
||||
className="mb-2"
|
||||
>
|
||||
Сгенерировать
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="parent_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Родительская категория</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value || undefined}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите родительскую категорию" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="">Нет родительской категории</SelectItem>
|
||||
{getFilteredCategories().map((category) => (
|
||||
<SelectItem key={category.id} value={category.id.toString()}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="is_active"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>Активна</FormLabel>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Отображать категорию в каталоге
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Описание</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Описание категории"
|
||||
className="min-h-32"
|
||||
{...field}
|
||||
value={field.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
"Сохранение..."
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Сохранить
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
391
frontend/components/admin/ProductForm copy.tsx
Normal file
391
frontend/components/admin/ProductForm copy.tsx
Normal file
@ -0,0 +1,391 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Save, X } from 'lucide-react';
|
||||
import { Product, ProductVariant, Category, Collection, ProductImage } from '../../services/catalog';
|
||||
import { productService, categoryService, collectionService } from '../../services/catalog';
|
||||
import ProductImageUploader, { ProductImageWithFile } from './ProductImageUploader';
|
||||
import ProductVariantManager from './ProductVariantManager';
|
||||
|
||||
interface ProductFormProps {
|
||||
product?: Product;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const ProductForm: React.FC<ProductFormProps> = ({ product, onSuccess }) => {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [images, setImages] = useState<ProductImageWithFile[]>([]);
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [collections, setCollections] = useState<Collection[]>([]);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
description: '',
|
||||
category_id: '',
|
||||
collection_id: '',
|
||||
is_active: true
|
||||
});
|
||||
const [autoGenerateSlug, setAutoGenerateSlug] = useState(true);
|
||||
|
||||
// Загрузка категорий и коллекций при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [categoriesData, collectionsData] = await Promise.all([
|
||||
categoryService.getCategories(),
|
||||
collectionService.getCollections()
|
||||
]);
|
||||
setCategories(categoriesData);
|
||||
setCollections(collectionsData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные категорий и коллекций.');
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
|
||||
// Если передан продукт, заполняем форму его данными
|
||||
if (product) {
|
||||
setFormData({
|
||||
name: product.name || '',
|
||||
slug: product.slug || '',
|
||||
description: product.description || '',
|
||||
category_id: product.category_id ? String(product.category_id) : '',
|
||||
collection_id: product.collection_id ? String(product.collection_id) : '',
|
||||
is_active: typeof product.is_active === 'boolean' ? product.is_active : true
|
||||
});
|
||||
setAutoGenerateSlug(false);
|
||||
|
||||
// Загружаем изображения
|
||||
if (product.images && Array.isArray(product.images)) {
|
||||
const productImages: ProductImageWithFile[] = product.images.map(img => ({
|
||||
id: img.id,
|
||||
url: img.url,
|
||||
is_primary: img.is_primary,
|
||||
product_id: img.product_id
|
||||
}));
|
||||
setImages(productImages);
|
||||
}
|
||||
|
||||
// Загружаем варианты
|
||||
if (product.variants && Array.isArray(product.variants)) {
|
||||
setVariants(product.variants);
|
||||
}
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
// Автоматическая генерация slug при изменении названия товара
|
||||
useEffect(() => {
|
||||
if (autoGenerateSlug && formData.name) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
slug: generateSlug(formData.name)
|
||||
}));
|
||||
}
|
||||
}, [formData.name, autoGenerateSlug]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
});
|
||||
|
||||
// Если пользователь изменяет slug вручную, отключаем автогенерацию
|
||||
if (name === 'slug') {
|
||||
setAutoGenerateSlug(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.name || !formData.category_id || variants.length === 0) {
|
||||
setError('Пожалуйста, заполните все обязательные поля и добавьте хотя бы один вариант товара.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Используем введенный slug или генерируем новый, если поле пустое
|
||||
const slug = formData.slug || generateSlug(formData.name);
|
||||
|
||||
// Подготавливаем данные продукта
|
||||
const productData = {
|
||||
name: formData.name,
|
||||
slug,
|
||||
description: formData.description,
|
||||
category_id: parseInt(formData.category_id, 10),
|
||||
collection_id: formData.collection_id ? parseInt(formData.collection_id, 10) : null,
|
||||
is_active: formData.is_active
|
||||
};
|
||||
|
||||
console.log('Отправляемые данные продукта:', productData);
|
||||
|
||||
let productId: number;
|
||||
|
||||
if (product) {
|
||||
// Обновляем существующий продукт
|
||||
console.log(`Обновление продукта с ID: ${product.id}`);
|
||||
const updatedProduct = await productService.updateProduct(product.id, productData);
|
||||
console.log('Обновленный продукт:', updatedProduct);
|
||||
productId = updatedProduct.id;
|
||||
} else {
|
||||
// Создаем новый продукт
|
||||
console.log('Создание нового продукта');
|
||||
const newProduct = await productService.createProduct(productData);
|
||||
console.log('Созданный продукт:', newProduct);
|
||||
productId = newProduct.id;
|
||||
}
|
||||
|
||||
// Загружаем изображения
|
||||
console.log(`Загрузка ${images.length} изображений для продукта ${productId}`);
|
||||
|
||||
// Используем Promise.all для параллельной загрузки изображений
|
||||
const imagePromises = images.map(async (image) => {
|
||||
try {
|
||||
if (image.file) {
|
||||
// Загружаем новое изображение
|
||||
console.log(`Загрузка нового изображения: ${image.file.name}`);
|
||||
const uploadedImage = await productService.uploadProductImage(productId, image.file, image.is_primary);
|
||||
console.log('Загруженное изображение:', uploadedImage);
|
||||
return uploadedImage;
|
||||
} else if (image.id && product) {
|
||||
// Обновляем существующее изображение
|
||||
console.log(`Обновление существующего изображения с ID: ${image.id}`);
|
||||
const updatedImage = await productService.updateProductImage(productId, image.id, { is_primary: image.is_primary });
|
||||
console.log('Обновленное изображение:', updatedImage);
|
||||
return updatedImage;
|
||||
}
|
||||
return null;
|
||||
} catch (imgError) {
|
||||
console.error('Ошибка при обработке изображения:', imgError);
|
||||
if (imgError.response) {
|
||||
console.error('Данные ответа:', imgError.response.data);
|
||||
console.error('Статус ответа:', imgError.response.status);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const uploadedImages = await Promise.all(imagePromises);
|
||||
console.log('Все изображения обработаны:', uploadedImages.filter(Boolean));
|
||||
|
||||
// Обрабатываем варианты товара
|
||||
if (product) {
|
||||
// Для существующего продукта варианты уже обработаны через API в компоненте ProductVariantManager
|
||||
console.log('Варианты для существующего продукта уже обработаны');
|
||||
} else {
|
||||
// Для нового продукта создаем варианты
|
||||
console.log(`Создание ${variants.length} вариантов для нового продукта ${productId}`);
|
||||
|
||||
const variantPromises = variants.map(async (variant) => {
|
||||
try {
|
||||
console.log('Отправка данных варианта:', variant);
|
||||
// Явно указываем все поля, чтобы избежать лишних данных
|
||||
const variantData = {
|
||||
name: variant.name,
|
||||
sku: variant.sku,
|
||||
price: Number(variant.price), // Убедимся, что это число
|
||||
discount_price: variant.discount_price ? Number(variant.discount_price) : null,
|
||||
stock: Number(variant.stock),
|
||||
is_active: Boolean(variant.is_active || true)
|
||||
};
|
||||
|
||||
const newVariant = await productService.addProductVariant(productId, variantData);
|
||||
console.log('Созданный вариант:', newVariant);
|
||||
return newVariant;
|
||||
} catch (varError) {
|
||||
console.error('Ошибка при создании варианта:', varError);
|
||||
if (varError.response) {
|
||||
console.error('Ответ сервера:', varError.response.data);
|
||||
console.error('Статус ответа:', varError.response.status);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const createdVariants = await Promise.all(variantPromises);
|
||||
console.log('Все варианты обработаны:', createdVariants.filter(Boolean));
|
||||
}
|
||||
|
||||
// Вызываем колбэк успешного завершения или перенаправляем на страницу списка товаров
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
} else {
|
||||
router.push('/admin/products');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при сохранении товара:', err);
|
||||
if (err.response) {
|
||||
console.error('Ответ сервера:', err.response.data);
|
||||
console.error('Статус ответа:', err.response.status);
|
||||
}
|
||||
setError('Не удалось сохранить товар. Пожалуйста, проверьте введенные данные и попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateSlug = (name) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative">
|
||||
<strong className="font-bold">Ошибка!</strong>
|
||||
<span className="block sm:inline"> {error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">Название товара *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="slug" className="block text-sm font-medium text-gray-700">
|
||||
URL-адрес (slug)
|
||||
<span className="ml-1 text-xs text-gray-500">
|
||||
{autoGenerateSlug ? '(генерируется автоматически)' : ''}
|
||||
</span>
|
||||
</label>
|
||||
<div className="mt-1 flex rounded-md shadow-sm">
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
value={formData.slug}
|
||||
onChange={handleChange}
|
||||
placeholder="url-adres-tovara"
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<label className="inline-flex items-center text-sm text-gray-500">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoGenerateSlug}
|
||||
onChange={() => setAutoGenerateSlug(!autoGenerateSlug)}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded mr-2"
|
||||
/>
|
||||
Генерировать автоматически
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700">Категория *</label>
|
||||
<select
|
||||
id="category_id"
|
||||
name="category_id"
|
||||
value={formData.category_id}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Выберите категорию</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>{category.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="collection_id" className="block text-sm font-medium text-gray-700">Коллекция</label>
|
||||
<select
|
||||
id="collection_id"
|
||||
name="collection_id"
|
||||
value={formData.collection_id}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="">Выберите коллекцию</option>
|
||||
{collections.map(collection => (
|
||||
<option key={collection.id} value={collection.id}>{collection.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="is_active" className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
checked={formData.is_active}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Активный товар</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">Описание</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ProductImageUploader
|
||||
images={images}
|
||||
setImages={setImages}
|
||||
productId={product?.id}
|
||||
/>
|
||||
|
||||
<ProductVariantManager
|
||||
variants={variants}
|
||||
setVariants={setVariants}
|
||||
productId={product?.id}
|
||||
/>
|
||||
|
||||
<div className="mt-8 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/products')}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
{loading ? 'Сохранение...' : product ? 'Обновить товар' : 'Создать товар'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductForm;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user