Админка, не доделана
This commit is contained in:
parent
bfc77be0d6
commit
834d7f59da
BIN
backend/.DS_Store
vendored
BIN
backend/.DS_Store
vendored
Binary file not shown.
BIN
backend/app/.DS_Store
vendored
BIN
backend/app/.DS_Store
vendored
Binary file not shown.
Binary file not shown.
@ -72,14 +72,14 @@ async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = De
|
||||
|
||||
try:
|
||||
payload = verify_token(token)
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
email: str = payload.get("sub")
|
||||
if email is None:
|
||||
raise credentials_exception
|
||||
except JWTError:
|
||||
raise credentials_exception
|
||||
|
||||
from app.repositories.user_repo import get_user_by_username
|
||||
user = get_user_by_username(db, username)
|
||||
from app.repositories.user_repo import get_user_by_email
|
||||
user = get_user_by_email(db, email)
|
||||
|
||||
if user is None:
|
||||
raise credentials_exception
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -205,7 +205,7 @@ def get_review_with_user(db: Session, review_id: int) -> Dict[str, Any]:
|
||||
"is_approved": review.is_approved,
|
||||
"created_at": review.created_at,
|
||||
"updated_at": review.updated_at,
|
||||
"user_username": user.username if user else "Неизвестный пользователь"
|
||||
"user_email": user.email if user else "Неизвестный пользователь"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -17,34 +17,26 @@ def get_user_by_email(db: Session, email: str) -> Optional[User]:
|
||||
return db.query(User).filter(User.email == email).first()
|
||||
|
||||
|
||||
def get_user_by_username(db: Session, username: str) -> Optional[User]:
|
||||
return db.query(User).filter(User.username == username).first()
|
||||
|
||||
|
||||
def get_users(db: Session, skip: int = 0, limit: int = 100) -> List[User]:
|
||||
return db.query(User).offset(skip).limit(limit).all()
|
||||
|
||||
|
||||
def create_user(db: Session, user: UserCreate) -> User:
|
||||
# Проверяем, что пользователь с таким email или username не существует
|
||||
# Проверяем, что пользователь с таким email не существует
|
||||
if get_user_by_email(db, user.email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пользователь с таким email уже существует"
|
||||
)
|
||||
|
||||
if get_user_by_username(db, user.username):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пользователь с таким username уже существует"
|
||||
)
|
||||
|
||||
# Создаем нового пользователя
|
||||
hashed_password = get_password_hash(user.password)
|
||||
db_user = User(
|
||||
email=user.email,
|
||||
username=user.username,
|
||||
hashed_password=hashed_password,
|
||||
password=hashed_password,
|
||||
phone=user.phone,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin
|
||||
)
|
||||
@ -75,12 +67,12 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
|
||||
|
||||
# Если предоставлен новый пароль, хешируем его
|
||||
if "password" in update_data and update_data["password"]:
|
||||
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
|
||||
update_data["password"] = get_password_hash(update_data.pop("password"))
|
||||
|
||||
# Удаляем поле password_confirm, если оно есть
|
||||
update_data.pop("password_confirm", None)
|
||||
|
||||
# Проверяем уникальность email и username, если они изменяются
|
||||
# Проверяем уникальность email, если он изменяется
|
||||
if "email" in update_data and update_data["email"] != db_user.email:
|
||||
if get_user_by_email(db, update_data["email"]):
|
||||
raise HTTPException(
|
||||
@ -88,13 +80,6 @@ def update_user(db: Session, user_id: int, user: UserUpdate) -> User:
|
||||
detail="Пользователь с таким email уже существует"
|
||||
)
|
||||
|
||||
if "username" in update_data and update_data["username"] != db_user.username:
|
||||
if get_user_by_username(db, update_data["username"]):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Пользователь с таким username уже существует"
|
||||
)
|
||||
|
||||
# Применяем обновления
|
||||
for key, value in update_data.items():
|
||||
setattr(db_user, key, value)
|
||||
@ -131,11 +116,11 @@ def delete_user(db: Session, user_id: int) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
|
||||
user = get_user_by_username(db, username)
|
||||
def authenticate_user(db: Session, email: str, password: str) -> Optional[User]:
|
||||
user = get_user_by_email(db, email)
|
||||
if not user:
|
||||
return None
|
||||
if not verify_password(password, user.hashed_password):
|
||||
if not verify_password(password, user.password):
|
||||
return None
|
||||
return user
|
||||
|
||||
@ -241,4 +226,53 @@ def delete_address(db: Session, address_id: int, user_id: int) -> bool:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при удалении адреса"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Функции для работы с паролями и токенами сброса
|
||||
def update_password(db: Session, user_id: int, new_password: str) -> bool:
|
||||
"""Обновляет пароль пользователя"""
|
||||
db_user = get_user(db, user_id)
|
||||
if not db_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Пользователь не найден"
|
||||
)
|
||||
|
||||
hashed_password = get_password_hash(new_password)
|
||||
db_user.password = hashed_password
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
return True
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Ошибка при обновлении пароля"
|
||||
)
|
||||
|
||||
|
||||
def create_password_reset_token(db: Session, user_id: int) -> str:
|
||||
"""Создает токен для сброса пароля"""
|
||||
import secrets
|
||||
import datetime
|
||||
|
||||
# В реальном приложении здесь должна быть модель для токенов сброса пароля
|
||||
# Для примера просто генерируем случайный токен
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# В реальном приложении сохраняем токен в базе данных с привязкой к пользователю
|
||||
# и временем истечения срока действия
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def verify_password_reset_token(db: Session, token: str) -> Optional[int]:
|
||||
"""Проверяет токен сброса пароля и возвращает ID пользователя"""
|
||||
# В реальном приложении проверяем токен в базе данных
|
||||
# и его срок действия
|
||||
|
||||
# Для примера просто возвращаем фиктивный ID пользователя
|
||||
# В реальном приложении это должна быть проверка в базе данных
|
||||
return 1 # Фиктивный ID пользователя
|
||||
Binary file not shown.
Binary file not shown.
@ -3,9 +3,9 @@ from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Dict, Any
|
||||
|
||||
from app.core import get_db
|
||||
from app.core import get_db, get_current_user
|
||||
from app import services
|
||||
from app.schemas.user_schemas import UserCreate, Token
|
||||
from app.schemas.user_schemas import UserCreate, Token, PasswordReset, PasswordChange, User
|
||||
|
||||
# Роутер для аутентификации
|
||||
auth_router = APIRouter(prefix="/auth", tags=["Аутентификация"])
|
||||
@ -15,7 +15,36 @@ async def register(user: UserCreate, db: Session = Depends(get_db)):
|
||||
return services.register_user(db, user)
|
||||
|
||||
|
||||
@auth_router.post("/token", response_model=Token)
|
||||
@auth_router.post("/login", response_model=Token)
|
||||
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
|
||||
# Примечание: form_data.username на самом деле содержит email пользователя
|
||||
# OAuth2PasswordRequestForm использует поле username, но мы используем его для email
|
||||
result = services.login_user(db, form_data.username, form_data.password)
|
||||
return result
|
||||
return result
|
||||
|
||||
|
||||
@auth_router.post("/reset-password", response_model=Dict[str, Any])
|
||||
async def reset_password(reset_data: PasswordReset, db: Session = Depends(get_db)):
|
||||
"""Запрос на сброс пароля"""
|
||||
return services.request_password_reset(db, reset_data.email)
|
||||
|
||||
|
||||
@auth_router.post("/set-new-password", response_model=Dict[str, Any])
|
||||
async def set_new_password(token: str, password: str, db: Session = Depends(get_db)):
|
||||
"""Установка нового пароля по токену сброса"""
|
||||
return services.reset_password(db, token, password)
|
||||
|
||||
|
||||
@auth_router.post("/change-password", response_model=Dict[str, Any])
|
||||
async def change_password(
|
||||
password_data: PasswordChange,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Изменение пароля авторизованным пользователем"""
|
||||
return services.change_password(
|
||||
db,
|
||||
current_user.id,
|
||||
password_data.current_password,
|
||||
password_data.new_password
|
||||
)
|
||||
@ -104,4 +104,6 @@ async def get_products_endpoint(
|
||||
is_active: Optional[bool] = True,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
return get_products(db, skip, limit, category_id, search, min_price, max_price, is_active)
|
||||
products = get_products(db, skip, limit, category_id, search, min_price, max_price, is_active)
|
||||
# Преобразуем объекты SQLAlchemy в схемы Pydantic
|
||||
return [Product.model_validate(product) for product in products]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -131,12 +131,18 @@ class ProductImage(ProductImageBase):
|
||||
class CategoryWithSubcategories(Category):
|
||||
subcategories: List['CategoryWithSubcategories'] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProductWithDetails(Product):
|
||||
category: Category
|
||||
variants: List[ProductVariant] = []
|
||||
images: List[ProductImage] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Рекурсивное обновление для CategoryWithChildren
|
||||
CategoryWithSubcategories.update_forward_refs()
|
||||
@ -34,4 +34,4 @@ class Review(ReviewBase):
|
||||
|
||||
|
||||
class ReviewWithUser(Review):
|
||||
user_username: str
|
||||
user_email: str
|
||||
@ -39,7 +39,9 @@ class Address(AddressBase):
|
||||
# Базовые схемы для пользователя
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
username: str
|
||||
phone: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_admin: bool = False
|
||||
|
||||
@ -57,7 +59,9 @@ class UserCreate(UserBase):
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
username: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_admin: Optional[bool] = None
|
||||
password: Optional[str] = Field(None, min_length=8)
|
||||
@ -81,7 +85,7 @@ class User(UserBase):
|
||||
|
||||
|
||||
class UserInDB(User):
|
||||
hashed_password: str
|
||||
password: str
|
||||
|
||||
|
||||
# Схемы для аутентификации
|
||||
@ -91,5 +95,14 @@ class Token(BaseModel):
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
user_id: Optional[int] = None
|
||||
email: Optional[str] = None
|
||||
user_id: Optional[int] = None
|
||||
|
||||
|
||||
class PasswordReset(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class PasswordChange(BaseModel):
|
||||
current_password: str
|
||||
new_password: str = Field(..., min_length=8)
|
||||
@ -1,6 +1,7 @@
|
||||
from app.services.user_service import (
|
||||
register_user, login_user, get_user_profile, update_user_profile,
|
||||
add_user_address, update_user_address, delete_user_address
|
||||
add_user_address, update_user_address, delete_user_address,
|
||||
request_password_reset, reset_password, change_password
|
||||
)
|
||||
|
||||
from app.services.catalog_service import (
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -18,13 +18,21 @@ from app.schemas.catalog_schemas import (
|
||||
|
||||
# Сервисы каталога
|
||||
def create_category(db: Session, category: CategoryCreate) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import Category as CategorySchema
|
||||
|
||||
new_category = catalog_repo.create_category(db, category)
|
||||
return {"category": new_category}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
category_schema = CategorySchema.model_validate(new_category)
|
||||
return {"category": category_schema}
|
||||
|
||||
|
||||
def update_category(db: Session, category_id: int, category: CategoryUpdate) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import Category as CategorySchema
|
||||
|
||||
updated_category = catalog_repo.update_category(db, category_id, category)
|
||||
return {"category": updated_category}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
category_schema = CategorySchema.model_validate(updated_category)
|
||||
return {"category": category_schema}
|
||||
|
||||
|
||||
def delete_category(db: Session, category_id: int) -> Dict[str, Any]:
|
||||
@ -33,51 +41,55 @@ def delete_category(db: Session, category_id: int) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def get_category_tree(db: Session) -> List[Dict[str, Any]]:
|
||||
from app.schemas.catalog_schemas import Category as CategorySchema
|
||||
|
||||
# Получаем все категории верхнего уровня
|
||||
root_categories = catalog_repo.get_categories(db, parent_id=None)
|
||||
|
||||
result = []
|
||||
for category in root_categories:
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
category_schema = CategorySchema.model_validate(category)
|
||||
# Рекурсивно получаем подкатегории
|
||||
category_dict = {
|
||||
"id": category.id,
|
||||
"name": category.name,
|
||||
"slug": category.slug,
|
||||
"description": category.description,
|
||||
"is_active": category.is_active,
|
||||
"subcategories": _get_subcategories(db, category.id)
|
||||
}
|
||||
category_dict = category_schema.model_dump()
|
||||
category_dict["subcategories"] = _get_subcategories(db, category.id)
|
||||
result.append(category_dict)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _get_subcategories(db: Session, parent_id: int) -> List[Dict[str, Any]]:
|
||||
from app.schemas.catalog_schemas import Category as CategorySchema
|
||||
|
||||
subcategories = catalog_repo.get_categories(db, parent_id=parent_id)
|
||||
|
||||
result = []
|
||||
for category in subcategories:
|
||||
category_dict = {
|
||||
"id": category.id,
|
||||
"name": category.name,
|
||||
"slug": category.slug,
|
||||
"description": category.description,
|
||||
"is_active": category.is_active,
|
||||
"subcategories": _get_subcategories(db, category.id)
|
||||
}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
category_schema = CategorySchema.model_validate(category)
|
||||
category_dict = category_schema.model_dump()
|
||||
category_dict["subcategories"] = _get_subcategories(db, category.id)
|
||||
result.append(category_dict)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
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)
|
||||
return {"product": new_product}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
product_schema = ProductSchema.model_validate(new_product)
|
||||
return {"product": product_schema}
|
||||
|
||||
|
||||
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)
|
||||
return {"product": updated_product}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
product_schema = ProductSchema.model_validate(updated_product)
|
||||
return {"product": product_schema}
|
||||
|
||||
|
||||
def delete_product(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
@ -86,6 +98,10 @@ 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
|
||||
|
||||
product = catalog_repo.get_product(db, product_id)
|
||||
if not product:
|
||||
raise HTTPException(
|
||||
@ -105,23 +121,36 @@ def get_product_details(db: Session, product_id: int) -> Dict[str, Any]:
|
||||
# Получаем отзывы продукта
|
||||
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]
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"variants": variants,
|
||||
"images": images,
|
||||
"product": product_schema,
|
||||
"variants": variants_schema,
|
||||
"images": images_schema,
|
||||
"rating": rating,
|
||||
"reviews": reviews
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
return {"variant": new_variant}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
variant_schema = ProductVariantSchema.model_validate(new_variant)
|
||||
return {"variant": variant_schema}
|
||||
|
||||
|
||||
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)
|
||||
return {"variant": updated_variant}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
variant_schema = ProductVariantSchema.model_validate(updated_variant)
|
||||
return {"variant": variant_schema}
|
||||
|
||||
|
||||
def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]:
|
||||
@ -130,6 +159,8 @@ def delete_product_variant(db: Session, variant_id: int) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def upload_product_image(db: Session, product_id: int, file: UploadFile, is_primary: bool = False) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import ProductImage as ProductImageSchema
|
||||
|
||||
# Проверяем, что продукт существует
|
||||
product = catalog_repo.get_product(db, product_id)
|
||||
if not product:
|
||||
@ -168,12 +199,18 @@ def upload_product_image(db: Session, product_id: int, file: UploadFile, is_prim
|
||||
|
||||
new_image = catalog_repo.create_product_image(db, image_data)
|
||||
|
||||
return {"image": new_image}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
image_schema = ProductImageSchema.model_validate(new_image)
|
||||
return {"image": image_schema}
|
||||
|
||||
|
||||
def update_product_image(db: Session, image_id: int, is_primary: bool) -> Dict[str, Any]:
|
||||
from app.schemas.catalog_schemas import ProductImage as ProductImageSchema
|
||||
|
||||
updated_image = catalog_repo.update_product_image(db, image_id, is_primary)
|
||||
return {"image": updated_image}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
image_schema = ProductImageSchema.model_validate(updated_image)
|
||||
return {"image": image_schema}
|
||||
|
||||
|
||||
def delete_product_image(db: Session, image_id: int) -> Dict[str, Any]:
|
||||
|
||||
@ -9,6 +9,8 @@ from app.schemas.content_schemas import AnalyticsLogCreate
|
||||
|
||||
# Сервисы корзины и заказов
|
||||
def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import CartItem as CartItemSchema
|
||||
|
||||
new_cart_item = order_repo.create_cart_item(db, cart_item, user_id)
|
||||
|
||||
# Логируем событие добавления в корзину
|
||||
@ -20,12 +22,18 @@ def add_to_cart(db: Session, user_id: int, cart_item: CartItemCreate) -> Dict[st
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
return {"cart_item": new_cart_item}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
cart_item_schema = CartItemSchema.model_validate(new_cart_item)
|
||||
return {"cart_item": cart_item_schema}
|
||||
|
||||
|
||||
def update_cart_item(db: Session, user_id: int, cart_item_id: int, cart_item: CartItemUpdate) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import CartItem as CartItemSchema
|
||||
|
||||
updated_cart_item = order_repo.update_cart_item(db, cart_item_id, cart_item, user_id)
|
||||
return {"cart_item": updated_cart_item}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
cart_item_schema = CartItemSchema.model_validate(updated_cart_item)
|
||||
return {"cart_item": cart_item_schema}
|
||||
|
||||
|
||||
def remove_from_cart(db: Session, user_id: int, cart_item_id: int) -> Dict[str, Any]:
|
||||
@ -39,11 +47,15 @@ def clear_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import CartItem as CartItemSchema
|
||||
|
||||
# Получаем элементы корзины с деталями продуктов
|
||||
cart_items = order_repo.get_cart_with_product_details(db, user_id)
|
||||
|
||||
# Рассчитываем общую сумму корзины
|
||||
total_amount = sum(item["total_price"] for item in cart_items)
|
||||
|
||||
# Примечание: cart_items уже содержит сериализованные данные из репозитория
|
||||
return {
|
||||
"items": cart_items,
|
||||
"total_amount": total_amount,
|
||||
@ -52,6 +64,8 @@ def get_cart(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import Order as OrderSchema
|
||||
|
||||
new_order = order_repo.create_order(db, order, user_id)
|
||||
|
||||
# Логируем событие создания заказа
|
||||
@ -62,7 +76,9 @@ def create_order(db: Session, user_id: int, order: OrderCreate) -> Dict[str, Any
|
||||
)
|
||||
content_repo.log_analytics_event(db, log_data)
|
||||
|
||||
return {"order": new_order}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
order_schema = OrderSchema.model_validate(new_order)
|
||||
return {"order": order_schema}
|
||||
|
||||
|
||||
def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False) -> Dict[str, Any]:
|
||||
@ -80,6 +96,8 @@ def get_order(db: Session, user_id: int, order_id: int, is_admin: bool = False)
|
||||
|
||||
|
||||
def update_order(db: Session, user_id: int, order_id: int, order: OrderUpdate, is_admin: bool = False) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import Order as OrderSchema
|
||||
|
||||
updated_order = order_repo.update_order(db, order_id, order, is_admin)
|
||||
|
||||
# Проверяем права доступа
|
||||
@ -89,10 +107,14 @@ def update_order(db: Session, user_id: int, order_id: int, order: OrderUpdate, i
|
||||
detail="Недостаточно прав для обновления этого заказа"
|
||||
)
|
||||
|
||||
return {"order": updated_order}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
order_schema = OrderSchema.model_validate(updated_order)
|
||||
return {"order": order_schema}
|
||||
|
||||
|
||||
def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]:
|
||||
from app.schemas.order_schemas import Order as OrderSchema
|
||||
|
||||
# Отменяем заказ (обычный пользователь может только отменить заказ)
|
||||
order_update = OrderUpdate(status="cancelled")
|
||||
updated_order = order_repo.update_order(db, order_id, order_update, is_admin=False)
|
||||
@ -104,4 +126,6 @@ def cancel_order(db: Session, user_id: int, order_id: int) -> Dict[str, Any]:
|
||||
detail="Недостаточно прав для отмены этого заказа"
|
||||
)
|
||||
|
||||
return {"order": updated_order}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
order_schema = OrderSchema.model_validate(updated_order)
|
||||
return {"order": order_schema}
|
||||
@ -7,13 +7,21 @@ from app.schemas.review_schemas import ReviewCreate, ReviewUpdate
|
||||
|
||||
# Сервисы отзывов
|
||||
def create_review(db: Session, user_id: int, review: ReviewCreate) -> Dict[str, Any]:
|
||||
from app.schemas.review_schemas import Review as ReviewSchema
|
||||
|
||||
new_review = review_repo.create_review(db, review, user_id)
|
||||
return {"review": new_review}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
review_schema = ReviewSchema.model_validate(new_review)
|
||||
return {"review": review_schema}
|
||||
|
||||
|
||||
def update_review(db: Session, user_id: int, review_id: int, review: ReviewUpdate, is_admin: bool = False) -> Dict[str, Any]:
|
||||
from app.schemas.review_schemas import Review as ReviewSchema
|
||||
|
||||
updated_review = review_repo.update_review(db, review_id, review, user_id, is_admin)
|
||||
return {"review": updated_review}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
review_schema = ReviewSchema.model_validate(updated_review)
|
||||
return {"review": review_schema}
|
||||
|
||||
|
||||
def delete_review(db: Session, user_id: int, review_id: int, is_admin: bool = False) -> Dict[str, Any]:
|
||||
@ -22,18 +30,27 @@ def delete_review(db: Session, user_id: int, review_id: int, is_admin: bool = Fa
|
||||
|
||||
|
||||
def approve_review(db: Session, review_id: int) -> Dict[str, Any]:
|
||||
from app.schemas.review_schemas import Review as ReviewSchema
|
||||
|
||||
approved_review = review_repo.approve_review(db, review_id)
|
||||
return {"review": approved_review}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
review_schema = ReviewSchema.model_validate(approved_review)
|
||||
return {"review": review_schema}
|
||||
|
||||
|
||||
def get_product_reviews(db: Session, product_id: int, skip: int = 0, limit: int = 10) -> Dict[str, Any]:
|
||||
from app.schemas.review_schemas import Review as ReviewSchema
|
||||
|
||||
reviews = review_repo.get_product_reviews(db, product_id, skip, limit)
|
||||
|
||||
# Получаем рейтинг продукта
|
||||
rating = review_repo.get_product_rating(db, product_id)
|
||||
|
||||
# Преобразуем объекты SQLAlchemy в схемы Pydantic
|
||||
reviews_schema = [ReviewSchema.model_validate(review) for review in reviews]
|
||||
|
||||
return {
|
||||
"reviews": reviews,
|
||||
"reviews": reviews_schema,
|
||||
"rating": rating,
|
||||
"total": rating["total_reviews"],
|
||||
"skip": skip,
|
||||
|
||||
@ -11,9 +11,14 @@ from app.schemas.user_schemas import UserCreate, UserUpdate, AddressCreate, Addr
|
||||
|
||||
# Сервисы аутентификации и пользователей
|
||||
def register_user(db: Session, user: UserCreate) -> Dict[str, Any]:
|
||||
from app.schemas.user_schemas import User as UserSchema
|
||||
|
||||
# Создаем пользователя
|
||||
db_user = user_repo.create_user(db, user)
|
||||
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
user_schema = UserSchema.model_validate(db_user)
|
||||
|
||||
# Создаем токен доступа
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
@ -21,7 +26,7 @@ def register_user(db: Session, user: UserCreate) -> Dict[str, Any]:
|
||||
)
|
||||
|
||||
return {
|
||||
"user": db_user,
|
||||
"user": user_schema,
|
||||
"access_token": access_token,
|
||||
"token_type": "bearer"
|
||||
}
|
||||
@ -57,6 +62,9 @@ def login_user(db: Session, email: str, password: str) -> Dict[str, Any]:
|
||||
|
||||
|
||||
def get_user_profile(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
from app.schemas.user_schemas import User as UserSchema, Address as AddressSchema
|
||||
from app.schemas.review_schemas import Review as ReviewSchema
|
||||
|
||||
user = user_repo.get_user(db, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
@ -73,29 +81,121 @@ def get_user_profile(db: Session, user_id: int) -> Dict[str, Any]:
|
||||
# Получаем отзывы пользователя
|
||||
reviews = review_repo.get_user_reviews(db, user_id)
|
||||
|
||||
# Преобразуем объекты SQLAlchemy в схемы Pydantic
|
||||
user_dict = {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"phone": user.phone,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"is_active": user.is_active,
|
||||
"is_admin": user.is_admin,
|
||||
"created_at": user.created_at,
|
||||
"updated_at": user.updated_at,
|
||||
"addresses": []
|
||||
}
|
||||
user_schema = UserSchema.model_validate(user_dict)
|
||||
addresses_schema = [AddressSchema.model_validate({
|
||||
"id": address.id,
|
||||
"user_id": address.user_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,
|
||||
"is_default": address.is_default,
|
||||
"created_at": address.created_at,
|
||||
"updated_at": address.updated_at
|
||||
}) for address in addresses]
|
||||
reviews_schema = [ReviewSchema.model_validate(review.__dict__) for review in reviews]
|
||||
|
||||
return {
|
||||
"user": user,
|
||||
"addresses": addresses,
|
||||
"orders": orders,
|
||||
"reviews": reviews
|
||||
"user": user_schema,
|
||||
"addresses": addresses_schema,
|
||||
"orders": orders, # Заказы обрабатываются отдельно в сервисе заказов
|
||||
"reviews": reviews_schema
|
||||
}
|
||||
|
||||
|
||||
def update_user_profile(db: Session, user_id: int, user_data: UserUpdate) -> Dict[str, Any]:
|
||||
from app.schemas.user_schemas import User as UserSchema
|
||||
|
||||
updated_user = user_repo.update_user(db, user_id, user_data)
|
||||
return {"user": updated_user}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
user_schema = UserSchema.model_validate(updated_user)
|
||||
return {"user": user_schema}
|
||||
|
||||
|
||||
def add_user_address(db: Session, user_id: int, address: AddressCreate) -> Dict[str, Any]:
|
||||
from app.schemas.user_schemas import Address as AddressSchema
|
||||
|
||||
new_address = user_repo.create_address(db, address, user_id)
|
||||
return {"address": new_address}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
address_schema = AddressSchema.model_validate(new_address)
|
||||
return {"address": address_schema}
|
||||
|
||||
|
||||
def update_user_address(db: Session, user_id: int, address_id: int, address: AddressUpdate) -> Dict[str, Any]:
|
||||
from app.schemas.user_schemas import Address as AddressSchema
|
||||
|
||||
updated_address = user_repo.update_address(db, address_id, address, user_id)
|
||||
return {"address": updated_address}
|
||||
# Преобразуем объект SQLAlchemy в схему Pydantic
|
||||
address_schema = AddressSchema.model_validate(updated_address)
|
||||
return {"address": address_schema}
|
||||
|
||||
|
||||
def delete_user_address(db: Session, user_id: int, address_id: int) -> Dict[str, Any]:
|
||||
success = user_repo.delete_address(db, address_id, user_id)
|
||||
return {"success": success}
|
||||
return {"success": success}
|
||||
|
||||
|
||||
def request_password_reset(db: Session, email: str) -> Dict[str, Any]:
|
||||
"""Запрос на сброс пароля"""
|
||||
# Проверяем, существует ли пользователь с таким email
|
||||
user = user_repo.get_user_by_email(db, email)
|
||||
if not user:
|
||||
# Не сообщаем, что пользователь не существует (безопасность)
|
||||
return {"message": "Если указанный email зарегистрирован, на него будет отправлена инструкция по сбросу пароля"}
|
||||
|
||||
# Генерируем токен для сброса пароля
|
||||
reset_token = user_repo.create_password_reset_token(db, user.id)
|
||||
|
||||
# В реальном приложении здесь должна быть отправка email
|
||||
# Для примера просто возвращаем токен
|
||||
return {
|
||||
"message": "Инструкция по сбросу пароля отправлена на указанный email",
|
||||
"token": reset_token # В реальном приложении не возвращаем токен
|
||||
}
|
||||
|
||||
|
||||
def reset_password(db: Session, token: str, new_password: str) -> Dict[str, Any]:
|
||||
"""Сброс пароля по токену"""
|
||||
# Проверяем токен и получаем пользователя
|
||||
user_id = user_repo.verify_password_reset_token(db, token)
|
||||
if not user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Недействительный или устаревший токен сброса пароля"
|
||||
)
|
||||
|
||||
# Обновляем пароль
|
||||
user_repo.update_password(db, user_id, new_password)
|
||||
|
||||
return {"message": "Пароль успешно изменен"}
|
||||
|
||||
|
||||
def change_password(db: Session, user_id: int, current_password: str, new_password: str) -> Dict[str, Any]:
|
||||
"""Изменение пароля авторизованным пользователем"""
|
||||
# Проверяем текущий пароль
|
||||
user = user_repo.get_user(db, user_id)
|
||||
if not user or not user_repo.verify_password(current_password, user.password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Неверный текущий пароль"
|
||||
)
|
||||
|
||||
# Обновляем пароль
|
||||
user_repo.update_password(db, user_id, new_password)
|
||||
|
||||
return {"message": "Пароль успешно изменен"}
|
||||
@ -1,14 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import { Search, Heart, User, ShoppingCart, ChevronLeft } from "lucide-react";
|
||||
import { Search, Heart, User, ShoppingCart, ChevronLeft, LogOut } from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
import authService from "../services/auth";
|
||||
|
||||
export default function Header() {
|
||||
const router = useRouter();
|
||||
// Состояние для отслеживания прокрутки страницы
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
// Состояние для отслеживания аутентификации пользователя
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
// Состояние для отображения выпадающего меню пользователя
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
// Эффект для проверки аутентификации при загрузке компонента
|
||||
useEffect(() => {
|
||||
setIsAuthenticated(authService.isAuthenticated());
|
||||
}, []);
|
||||
|
||||
// Эффект для отслеживания прокрутки
|
||||
useEffect(() => {
|
||||
@ -25,6 +35,14 @@ export default function Header() {
|
||||
};
|
||||
}, [scrolled]);
|
||||
|
||||
// Функция для выхода из системы
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
setIsAuthenticated(false);
|
||||
setShowUserMenu(false);
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
// Функция для возврата на предыдущую страницу
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
@ -35,6 +53,11 @@ export default function Header() {
|
||||
// Проверяем, находимся ли мы на странице категорий или коллекций
|
||||
const isDetailPage = router.pathname.includes("[slug]");
|
||||
|
||||
// Функция для переключения отображения меню пользователя
|
||||
const toggleUserMenu = () => {
|
||||
setShowUserMenu(!showUserMenu);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="fixed w-full z-50 transition-all duration-300 bg-white shadow-sm">
|
||||
<nav className="py-4 transition-all duration-300 text-black">
|
||||
@ -79,9 +102,53 @@ export default function Header() {
|
||||
0
|
||||
</span>
|
||||
</Link>
|
||||
<Link href="/account" className="hover:opacity-70 transition-opacity">
|
||||
<User className="w-5 h-5" />
|
||||
</Link>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={toggleUserMenu}
|
||||
className="hover:opacity-70 transition-opacity focus:outline-none"
|
||||
>
|
||||
<User className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{showUserMenu && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link href="/account" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Мой профиль
|
||||
</Link>
|
||||
<Link href="/account/orders" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Мои заказы
|
||||
</Link>
|
||||
{/* Ссылка на админ-панель, если пользователь админ */}
|
||||
<Link href="/admin" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Админ-панель
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Выйти
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Войти
|
||||
</Link>
|
||||
<Link href="/register" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
Регистрация
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Link href="/cart" className="relative hover:opacity-70 transition-opacity">
|
||||
<ShoppingCart className="w-5 h-5" />
|
||||
<span className="absolute -top-2 -right-2 bg-black text-white text-xs rounded-full w-4 h-4 flex items-center justify-center">
|
||||
|
||||
277
frontend/components/admin/AdminLayout.tsx
Normal file
277
frontend/components/admin/AdminLayout.tsx
Normal file
@ -0,0 +1,277 @@
|
||||
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
|
||||
} 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')
|
||||
}
|
||||
];
|
||||
|
||||
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">
|
||||
<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">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
258
frontend/package-lock.json
generated
258
frontend/package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"axios": "^1.8.1",
|
||||
"framer-motion": "^10.16.4",
|
||||
"lucide-react": "^0.476.0",
|
||||
"next": "^13.4.19",
|
||||
@ -333,6 +334,12 @@
|
||||
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/autoprefixer": {
|
||||
"version": "10.4.13",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.13.tgz",
|
||||
@ -367,6 +374,17 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.1.tgz",
|
||||
"integrity": "sha512-NN+fvwH/kV01dYUQ3PTOZns4LWtWhOFCAhQ/pHb88WQ1hNe5V/dvFwc4VJcDL11LT9xSX0QtsR8sWUuyOuOq7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.6",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@ -435,6 +453,19 @@
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@ -512,6 +543,18 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@ -540,6 +583,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detective": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/detective/-/detective-5.2.1.tgz",
|
||||
@ -569,6 +621,20 @@
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.103",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.103.tgz",
|
||||
@ -576,6 +642,51 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@ -635,6 +746,41 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"debug": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
|
||||
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@ -696,6 +842,43 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
|
||||
@ -714,12 +897,51 @@
|
||||
"integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
@ -825,6 +1047,15 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@ -847,6 +1078,27 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
@ -1124,6 +1376,12 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"axios": "^1.8.1",
|
||||
"framer-motion": "^10.16.4",
|
||||
"lucide-react": "^0.476.0",
|
||||
"next": "^13.4.19",
|
||||
|
||||
443
frontend/pages/account/addresses.tsx
Normal file
443
frontend/pages/account/addresses.tsx
Normal file
@ -0,0 +1,443 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { User, Package, Heart, LogOut, MapPin, Plus, Edit, Trash, Check } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
import { userService, Address } from '../../services/users';
|
||||
|
||||
export default function AddressesPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [addresses, setAddresses] = useState<Address[]>([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingAddressId, setEditingAddressId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
type: 'shipping',
|
||||
address: '',
|
||||
city: '',
|
||||
postal_code: '',
|
||||
country: 'Россия',
|
||||
is_default: false
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account/addresses');
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем данные пользователя и адреса
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const userData = await userService.getCurrentUser();
|
||||
setUser(userData);
|
||||
|
||||
// Если у пользователя есть адреса, загружаем их
|
||||
if (userData.addresses) {
|
||||
setAddresses(userData.addresses);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [router]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
type: 'shipping',
|
||||
address: '',
|
||||
city: '',
|
||||
postal_code: '',
|
||||
country: 'Россия',
|
||||
is_default: false
|
||||
});
|
||||
setShowAddForm(false);
|
||||
setEditingAddressId(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
if (editingAddressId) {
|
||||
// Обновляем существующий адрес
|
||||
await userService.updateAddress(editingAddressId, formData);
|
||||
setSuccess('Адрес успешно обновлен');
|
||||
} else {
|
||||
// Добавляем новый адрес
|
||||
await userService.addAddress(formData);
|
||||
setSuccess('Адрес успешно добавлен');
|
||||
}
|
||||
|
||||
// Обновляем список адресов
|
||||
const userData = await userService.getCurrentUser();
|
||||
if (userData.addresses) {
|
||||
setAddresses(userData.addresses);
|
||||
}
|
||||
|
||||
// Сбрасываем форму
|
||||
resetForm();
|
||||
} catch (err) {
|
||||
console.error('Ошибка при сохранении адреса:', err);
|
||||
setError('Не удалось сохранить адрес');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (address: Address) => {
|
||||
setFormData({
|
||||
type: address.type,
|
||||
address: address.address,
|
||||
city: address.city,
|
||||
postal_code: address.postal_code,
|
||||
country: address.country,
|
||||
is_default: address.is_default
|
||||
});
|
||||
setEditingAddressId(address.id);
|
||||
setShowAddForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (addressId: number) => {
|
||||
if (!confirm('Вы уверены, что хотите удалить этот адрес?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
await userService.deleteAddress(addressId);
|
||||
setSuccess('Адрес успешно удален');
|
||||
|
||||
// Обновляем список адресов
|
||||
const userData = await userService.getCurrentUser();
|
||||
if (userData.addresses) {
|
||||
setAddresses(userData.addresses);
|
||||
} else {
|
||||
setAddresses([]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении адреса:', err);
|
||||
setError('Не удалось удалить адрес');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (addressId: number) => {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
await userService.updateAddress(addressId, { is_default: true });
|
||||
setSuccess('Адрес по умолчанию изменен');
|
||||
|
||||
// Обновляем список адресов
|
||||
const userData = await userService.getCurrentUser();
|
||||
if (userData.addresses) {
|
||||
setAddresses(userData.addresses);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при установке адреса по умолчанию:', err);
|
||||
setError('Не удалось установить адрес по умолчанию');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 bg-green-50 border-l-4 border-green-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-green-700">{success}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/account/addresses" className="flex items-center space-x-2 text-indigo-600 font-medium">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>Мои адреса</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-3 space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Мои адреса</h2>
|
||||
<p className="text-gray-600 mt-1">Управление адресами доставки</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="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"
|
||||
>
|
||||
{showAddForm ? 'Отменить' : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Добавить адрес
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddForm && (
|
||||
<div className="mb-8 border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-lg font-medium mb-4">
|
||||
{editingAddressId ? 'Редактирование адреса' : 'Добавление нового адреса'}
|
||||
</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-700">
|
||||
Тип адреса
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
value={formData.type}
|
||||
onChange={handleChange}
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
>
|
||||
<option value="shipping">Адрес доставки</option>
|
||||
<option value="billing">Адрес для выставления счета</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
|
||||
Адрес
|
||||
</label>
|
||||
<textarea
|
||||
id="address"
|
||||
name="address"
|
||||
rows={3}
|
||||
value={formData.address}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Улица, дом, квартира"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
|
||||
Город
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
value={formData.city}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="postal_code" className="block text-sm font-medium text-gray-700">
|
||||
Почтовый индекс
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postal_code"
|
||||
name="postal_code"
|
||||
value={formData.postal_code}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="country" className="block text-sm font-medium text-gray-700">
|
||||
Страна
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
value={formData.country}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="is_default"
|
||||
name="is_default"
|
||||
type="checkbox"
|
||||
checked={formData.is_default}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="is_default" className="ml-2 block text-sm text-gray-900">
|
||||
Использовать как адрес по умолчанию
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md 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"
|
||||
className="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"
|
||||
>
|
||||
{editingAddressId ? 'Сохранить изменения' : 'Добавить адрес'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{addresses.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<MapPin className="h-12 w-12 mx-auto text-gray-400" />
|
||||
<p className="mt-2 text-gray-500">У вас пока нет сохраненных адресов</p>
|
||||
{!showAddForm && (
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
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"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Добавить адрес
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{addresses.map((address) => (
|
||||
<div key={address.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<h3 className="text-lg font-medium">
|
||||
{address.type === 'shipping' ? 'Адрес доставки' : 'Адрес для выставления счета'}
|
||||
</h3>
|
||||
{address.is_default && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
По умолчанию
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 mt-1">{address.address}</p>
|
||||
<p className="text-gray-600">{address.city}, {address.postal_code}</p>
|
||||
<p className="text-gray-600">{address.country}</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
{!address.is_default && (
|
||||
<button
|
||||
onClick={() => handleSetDefault(address.id)}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
title="Сделать адресом по умолчанию"
|
||||
>
|
||||
<Check className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleEdit(address)}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(address.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
frontend/pages/account/change-password.tsx
Normal file
218
frontend/pages/account/change-password.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { User, Package, Heart, LogOut, Lock, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
|
||||
export default function ChangePasswordPage() {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
current_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account/change-password');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Проверяем совпадение паролей
|
||||
if (formData.new_password !== formData.confirm_password) {
|
||||
setError('Новый пароль и подтверждение не совпадают');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Вызываем метод изменения пароля
|
||||
await authService.changePassword(
|
||||
formData.current_password,
|
||||
formData.new_password
|
||||
);
|
||||
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при изменении пароля:', err);
|
||||
setError('Не удалось изменить пароль. Проверьте правильность текущего пароля.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold">Изменение пароля</h2>
|
||||
<p className="text-gray-600 mt-1">Обновите свой пароль для повышения безопасности аккаунта</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success ? (
|
||||
<div className="bg-green-50 border-l-4 border-green-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-green-700">Пароль успешно изменен!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="current_password" className="block text-sm font-medium text-gray-700">
|
||||
Текущий пароль
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="current_password"
|
||||
name="current_password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={formData.current_password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="new_password" className="block text-sm font-medium text-gray-700">
|
||||
Новый пароль
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="new_password"
|
||||
name="new_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.new_password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">Минимум 8 символов</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirm_password" className="block text-sm font-medium text-gray-700">
|
||||
Подтверждение нового пароля
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirm_password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/account" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Вернуться в профиль
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="inline-flex justify-center py-2 px-4 border border-transparent shadow-sm 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Сохранение...' : 'Изменить пароль'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
249
frontend/pages/account/edit.tsx
Normal file
249
frontend/pages/account/edit.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { User, Package, Heart, LogOut, Save, X, MapPin } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
import { userService, UserUpdate } from '../../services/users';
|
||||
|
||||
export default function EditProfilePage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [user, setUser] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account/edit');
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем данные пользователя
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
const userData = await userService.getCurrentUser();
|
||||
setUser(userData);
|
||||
setFormData({
|
||||
first_name: userData.first_name || '',
|
||||
last_name: userData.last_name || '',
|
||||
email: userData.email || '',
|
||||
phone: userData.phone || ''
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных пользователя:', err);
|
||||
setError('Не удалось загрузить данные пользователя');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [router]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
const updateData: UserUpdate = {
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
phone: formData.phone
|
||||
};
|
||||
|
||||
// Email обновляем только если он изменился
|
||||
if (formData.email !== user.email) {
|
||||
updateData.email = formData.email;
|
||||
}
|
||||
|
||||
await userService.updateCurrentUser(updateData);
|
||||
setSuccess(true);
|
||||
|
||||
// Обновляем данные пользователя
|
||||
const updatedUser = await userService.getCurrentUser();
|
||||
setUser(updatedUser);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении профиля:', err);
|
||||
setError('Не удалось обновить профиль. Пожалуйста, проверьте введенные данные.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-indigo-600 font-medium">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>Мои адреса</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold">Редактирование профиля</h2>
|
||||
<p className="text-gray-600 mt-1">Обновите свои персональные данные</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-6 bg-green-50 border-l-4 border-green-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-green-700">Профиль успешно обновлен!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={formData.first_name}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700">
|
||||
Фамилия
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={formData.last_name}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
|
||||
Телефон
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
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"
|
||||
placeholder="+7 (___) ___-__-__"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Link href="/account" className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
<X className="h-5 w-5 mr-2" />
|
||||
Отмена
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить изменения'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
frontend/pages/account/index.tsx
Normal file
145
frontend/pages/account/index.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { User, Package, Heart, LogOut, Edit, MapPin } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
import { userService } from '../../services/users';
|
||||
|
||||
export default function AccountPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account');
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем данные пользователя
|
||||
const fetchUserData = async () => {
|
||||
try {
|
||||
const userData = await userService.getCurrentUser();
|
||||
setUser(userData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных пользователя:', err);
|
||||
setError('Не удалось загрузить данные пользователя');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [router]);
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-indigo-600 font-medium">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/account/addresses" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span>Мои адреса</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold">Личные данные</h2>
|
||||
<Link href="/account/edit" className="flex items-center text-sm text-indigo-600 hover:text-indigo-800">
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Редактировать
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{user && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Имя</p>
|
||||
<p className="font-medium">{user.first_name}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Фамилия</p>
|
||||
<p className="font-medium">{user.last_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Email</p>
|
||||
<p className="font-medium">{user.email}</p>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Телефон</p>
|
||||
<p className="font-medium">{user.phone || 'Не указан'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 border-t pt-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Безопасность</h3>
|
||||
<Link href="/account/change-password" className="inline-flex items-center px-4 py-2 border border-indigo-600 text-sm font-medium rounded-md text-indigo-600 bg-white hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Изменить пароль
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
frontend/pages/account/orders.tsx
Normal file
215
frontend/pages/account/orders.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { User, Package, Heart, LogOut, ExternalLink, Clock, CheckCircle, XCircle } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
import { orderService } from '../../services/orders';
|
||||
|
||||
// Вспомогательная функция для форматирования даты
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Функция для получения статуса заказа
|
||||
const getOrderStatusInfo = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { label: 'Ожидает оплаты', color: 'text-yellow-600', icon: <Clock className="h-5 w-5" /> };
|
||||
case 'processing':
|
||||
return { label: 'В обработке', color: 'text-blue-600', icon: <Clock className="h-5 w-5" /> };
|
||||
case 'shipped':
|
||||
return { label: 'Отправлен', color: 'text-indigo-600', icon: <Package className="h-5 w-5" /> };
|
||||
case 'delivered':
|
||||
return { label: 'Доставлен', color: 'text-green-600', icon: <CheckCircle className="h-5 w-5" /> };
|
||||
case 'cancelled':
|
||||
return { label: 'Отменен', color: 'text-red-600', icon: <XCircle className="h-5 w-5" /> };
|
||||
default:
|
||||
return { label: 'Неизвестно', color: 'text-gray-600', icon: <Clock className="h-5 w-5" /> };
|
||||
}
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orders, setOrders] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=/account/orders');
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем заказы пользователя
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
const ordersData = await orderService.getUserOrders();
|
||||
setOrders(ordersData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError('Не удалось загрузить заказы');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrders();
|
||||
}, [router]);
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-8">Личный кабинет</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-indigo-600 font-medium">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-3 bg-white rounded-lg shadow p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold">Мои заказы</h2>
|
||||
<p className="text-gray-600 mt-1">История ваших заказов</p>
|
||||
</div>
|
||||
|
||||
{orders.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 mx-auto text-gray-400" />
|
||||
<p className="mt-2 text-gray-500">У вас пока нет заказов</p>
|
||||
<Link href="/" 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">
|
||||
Перейти к покупкам
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{orders.map((order) => {
|
||||
const statusInfo = getOrderStatusInfo(order.status);
|
||||
|
||||
return (
|
||||
<div key={order.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500">Заказ №</span>
|
||||
<span className="font-medium ml-1">{order.id}</span>
|
||||
<span className="text-sm text-gray-500 ml-4">от</span>
|
||||
<span className="ml-1">{formatDate(order.created_at)}</span>
|
||||
</div>
|
||||
<div className={`flex items-center ${statusInfo.color}`}>
|
||||
{statusInfo.icon}
|
||||
<span className="ml-1 text-sm font-medium">{statusInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="space-y-3">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="w-16 h-16 flex-shrink-0 bg-gray-100 rounded-md overflow-hidden">
|
||||
{item.product.image && (
|
||||
<img
|
||||
src={item.product.image}
|
||||
alt={item.product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h3 className="text-sm font-medium">{item.product.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{item.variant_name && `Вариант: ${item.variant_name}`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Количество: {item.quantity}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium">{item.price.toLocaleString('ru-RU')} ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-gray-200 pt-4 flex justify-between items-center">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Итого:</p>
|
||||
<p className="text-lg font-bold">{order.total_amount.toLocaleString('ru-RU')} ₽</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/account/orders/${order.id}`}
|
||||
className="inline-flex items-center px-3 py-1.5 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Подробнее
|
||||
<ExternalLink className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
frontend/pages/account/orders/[id].tsx
Normal file
257
frontend/pages/account/orders/[id].tsx
Normal file
@ -0,0 +1,257 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { User, Package, Heart, LogOut, ArrowLeft, Clock, CheckCircle, XCircle, Truck, CreditCard, MapPin } from 'lucide-react';
|
||||
import authService from '../../../services/auth';
|
||||
import { orderService, Order } from '../../../services/orders';
|
||||
|
||||
// Вспомогательная функция для форматирования даты
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Функция для получения статуса заказа
|
||||
const getOrderStatusInfo = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { label: 'Ожидает оплаты', color: 'text-yellow-600 bg-yellow-50', icon: <Clock className="h-5 w-5" /> };
|
||||
case 'processing':
|
||||
return { label: 'В обработке', color: 'text-blue-600 bg-blue-50', icon: <Clock className="h-5 w-5" /> };
|
||||
case 'shipped':
|
||||
return { label: 'Отправлен', color: 'text-indigo-600 bg-indigo-50', icon: <Truck className="h-5 w-5" /> };
|
||||
case 'delivered':
|
||||
return { label: 'Доставлен', color: 'text-green-600 bg-green-50', icon: <CheckCircle className="h-5 w-5" /> };
|
||||
case 'cancelled':
|
||||
return { label: 'Отменен', color: 'text-red-600 bg-red-50', icon: <XCircle className="h-5 w-5" /> };
|
||||
default:
|
||||
return { label: 'Неизвестно', color: 'text-gray-600 bg-gray-50', icon: <Clock className="h-5 w-5" /> };
|
||||
}
|
||||
};
|
||||
|
||||
export default function OrderDetailsPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
// Проверяем, авторизован ли пользователь
|
||||
if (!authService.isAuthenticated()) {
|
||||
router.push('/login?redirect=' + router.asPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Загружаем данные заказа
|
||||
const fetchOrderDetails = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const orderData = await orderService.getOrderById(Number(id));
|
||||
setOrder(orderData);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных заказа:', err);
|
||||
setError('Не удалось загрузить данные заказа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (id) {
|
||||
fetchOrderDetails();
|
||||
}
|
||||
}, [router, id]);
|
||||
|
||||
const handleLogout = () => {
|
||||
authService.logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
const handleCancelOrder = async () => {
|
||||
if (!order) return;
|
||||
|
||||
try {
|
||||
await orderService.cancelOrder(order.id);
|
||||
// Обновляем данные заказа после отмены
|
||||
const updatedOrder = await orderService.getOrderById(order.id);
|
||||
setOrder(updatedOrder);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при отмене заказа:', err);
|
||||
setError('Не удалось отменить заказ');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-indigo-500"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Заказ не найден</h1>
|
||||
<p className="text-gray-600 mb-4">Запрашиваемый заказ не существует или у вас нет к нему доступа.</p>
|
||||
<Link href="/account/orders" className="inline-flex items-center text-indigo-600 hover:text-indigo-500">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Вернуться к списку заказов
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusInfo = getOrderStatusInfo(order.status);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 pt-20 pb-12">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<Link href="/account/orders" className="inline-flex items-center text-indigo-600 hover:text-indigo-500">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Вернуться к списку заказов
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6">Заказ №{order.id}</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Боковая панель навигации */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Link href="/account" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<User className="h-5 w-5" />
|
||||
<span>Профиль</span>
|
||||
</Link>
|
||||
<Link href="/account/orders" className="flex items-center space-x-2 text-indigo-600 font-medium">
|
||||
<Package className="h-5 w-5" />
|
||||
<span>Мои заказы</span>
|
||||
</Link>
|
||||
<Link href="/favorites" className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors">
|
||||
<Heart className="h-5 w-5" />
|
||||
<span>Избранное</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-indigo-600 transition-colors"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
<span>Выйти</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Основное содержимое */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{/* Информация о заказе */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">Информация о заказе</h2>
|
||||
<p className="text-gray-500 mt-1">Создан: {formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full ${statusInfo.color} flex items-center`}>
|
||||
{statusInfo.icon}
|
||||
<span className="ml-1 text-sm font-medium">{statusInfo.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div className="border border-gray-200 rounded-md p-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<MapPin className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<h3 className="font-medium">Адрес доставки</h3>
|
||||
</div>
|
||||
<p className="text-gray-600 whitespace-pre-line">{order.shipping_address}</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded-md p-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<CreditCard className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<h3 className="font-medium">Способ оплаты</h3>
|
||||
</div>
|
||||
<p className="text-gray-600">{order.payment_method}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{order.status === 'pending' && (
|
||||
<button
|
||||
onClick={handleCancelOrder}
|
||||
className="w-full md:w-auto px-4 py-2 border border-red-300 text-red-700 rounded-md hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Отменить заказ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Товары в заказе */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-xl font-semibold">Товары в заказе</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
{order.items.map((item) => (
|
||||
<div key={item.id} className="p-6 flex items-start">
|
||||
<div className="w-20 h-20 flex-shrink-0 bg-gray-100 rounded-md overflow-hidden">
|
||||
{item.product.image && (
|
||||
<img
|
||||
src={item.product.image}
|
||||
alt={item.product.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<h3 className="text-lg font-medium">{item.product.name}</h3>
|
||||
{item.variant_name && (
|
||||
<p className="text-gray-500">Вариант: {item.variant_name}</p>
|
||||
)}
|
||||
<div className="mt-1 flex justify-between">
|
||||
<p className="text-gray-500">Количество: {item.quantity}</p>
|
||||
<p className="font-medium">{item.price.toLocaleString('ru-RU')} ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">Итого:</span>
|
||||
<span className="text-xl font-bold">{order.total_amount.toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
309
frontend/pages/admin/categories/[id].tsx
Normal file
309
frontend/pages/admin/categories/[id].tsx
Normal file
@ -0,0 +1,309 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Save, X, ArrowLeft } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { categoryService, Category } from '../../../services/catalog';
|
||||
|
||||
export default function EditCategoryPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [category, setCategory] = useState<Category | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
parent_id: null as number | null,
|
||||
is_active: true
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Загрузка категории и списка категорий при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Загружаем список всех категорий для выбора родительской
|
||||
const categoriesData = await categoryService.getCategories();
|
||||
setCategories(categoriesData);
|
||||
|
||||
// Находим текущую категорию по ID
|
||||
const currentCategory = categoriesData.find(cat => cat.id === Number(id));
|
||||
|
||||
if (currentCategory) {
|
||||
setCategory(currentCategory);
|
||||
setFormData({
|
||||
name: currentCategory.name,
|
||||
slug: currentCategory.slug,
|
||||
parent_id: currentCategory.parent_id,
|
||||
is_active: currentCategory.is_active
|
||||
});
|
||||
} else {
|
||||
setError('Категория не найдена');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные категории. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
// Автоматическое создание slug из названия
|
||||
const generateSlug = (name: string) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\sа-яё-]/g, '') // Удаляем специальные символы, но оставляем кириллицу
|
||||
.replace(/\s+/g, '-') // Заменяем пробелы на дефисы
|
||||
.replace(/[а-яё]/g, char => { // Транслитерация кириллицы
|
||||
const translitMap = {
|
||||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
|
||||
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
|
||||
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||||
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '',
|
||||
'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
|
||||
};
|
||||
return translitMap[char] || char;
|
||||
})
|
||||
.replace(/--+/g, '-') // Заменяем множественные дефисы на один
|
||||
.replace(/^-|-$/g, ''); // Удаляем дефисы в начале и конце
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
|
||||
// Обновляем форму
|
||||
setFormData(prev => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
};
|
||||
|
||||
// Если изменилось название, автоматически обновляем slug
|
||||
if (name === 'name') {
|
||||
newData.slug = generateSlug(value);
|
||||
}
|
||||
|
||||
// Если выбрана родительская категория, преобразуем строку в число или null
|
||||
if (name === 'parent_id') {
|
||||
newData.parent_id = value === '' ? null : Number(value);
|
||||
}
|
||||
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (!category) return;
|
||||
|
||||
// Проверяем, не выбрана ли текущая категория в качестве родительской
|
||||
if (formData.parent_id === category.id) {
|
||||
setError('Категория не может быть родительской для самой себя');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем, не выбран ли потомок в качестве родительской категории
|
||||
const isDescendant = (parentId: number | null, childId: number): boolean => {
|
||||
if (parentId === childId) return true;
|
||||
|
||||
const children = categories.filter(cat => cat.parent_id === childId);
|
||||
return children.some(child => isDescendant(parentId, child.id));
|
||||
};
|
||||
|
||||
if (formData.parent_id !== null && isDescendant(formData.parent_id, category.id)) {
|
||||
setError('Нельзя выбрать потомка в качестве родительской категории');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Обновляем категорию через API
|
||||
await categoryService.updateCategory(category.id, formData);
|
||||
|
||||
// Перенаправляем на страницу категорий
|
||||
router.push('/admin/categories');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении категории:', err);
|
||||
setError('Не удалось обновить категорию. Пожалуйста, проверьте введенные данные и попробуйте снова.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout title="Редактирование категории">
|
||||
<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>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!category && !loading) {
|
||||
return (
|
||||
<AdminLayout title="Категория не найдена">
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">Категория не найдена</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/admin/categories')}
|
||||
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"
|
||||
>
|
||||
Вернуться к списку категорий
|
||||
</button>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title={`Редактирование: ${category?.name}`}>
|
||||
<div className="mb-6 flex items-center">
|
||||
<button
|
||||
onClick={() => router.push('/admin/categories')}
|
||||
className="mr-4 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold text-gray-800">Редактирование категории</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Название категории *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
<label htmlFor="slug" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Slug *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
required
|
||||
value={formData.slug}
|
||||
onChange={handleChange}
|
||||
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"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
URL-совместимый идентификатор категории. Генерируется автоматически из названия.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="parent_id" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Родительская категория
|
||||
</label>
|
||||
<select
|
||||
id="parent_id"
|
||||
name="parent_id"
|
||||
value={formData.parent_id === null ? '' : formData.parent_id}
|
||||
onChange={handleChange}
|
||||
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"
|
||||
>
|
||||
<option value="">Нет (корневая категория)</option>
|
||||
{categories
|
||||
.filter(cat => cat.id !== category?.id) // Исключаем текущую категорию
|
||||
.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div 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"
|
||||
/>
|
||||
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-700">
|
||||
Активна
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Неактивные категории не будут отображаться на сайте.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/categories')}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-3"
|
||||
>
|
||||
<X className="h-5 w-5 mr-2" />
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
236
frontend/pages/admin/categories/create.tsx
Normal file
236
frontend/pages/admin/categories/create.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Save, X, ArrowLeft } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { categoryService, Category } from '../../../services/catalog';
|
||||
|
||||
export default function CreateCategoryPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
parent_id: null as number | null,
|
||||
order: 0,
|
||||
is_active: true
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Загрузка списка категорий для выбора родительской категории
|
||||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const data = await categoryService.getCategories();
|
||||
setCategories(data);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке категорий:', err);
|
||||
setError('Не удалось загрузить список категорий');
|
||||
}
|
||||
};
|
||||
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
// Автоматическое создание slug из названия
|
||||
const generateSlug = (name: string) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\sа-яё-]/g, '') // Удаляем специальные символы, но оставляем кириллицу
|
||||
.replace(/\s+/g, '-') // Заменяем пробелы на дефисы
|
||||
.replace(/[а-яё]/g, char => { // Транслитерация кириллицы
|
||||
const translitMap = {
|
||||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
|
||||
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
|
||||
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||||
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '',
|
||||
'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
|
||||
};
|
||||
return translitMap[char] || char;
|
||||
})
|
||||
.replace(/--+/g, '-') // Заменяем множественные дефисы на один
|
||||
.replace(/^-|-$/g, ''); // Удаляем дефисы в начале и конце
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
|
||||
// Обновляем форму
|
||||
setFormData(prev => {
|
||||
const newData = {
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
};
|
||||
|
||||
// Если изменилось название, автоматически обновляем slug
|
||||
if (name === 'name') {
|
||||
newData.slug = generateSlug(value);
|
||||
}
|
||||
|
||||
// Если выбрана родительская категория, преобразуем строку в число или null
|
||||
if (name === 'parent_id') {
|
||||
newData.parent_id = value === '' ? null : Number(value);
|
||||
}
|
||||
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Определяем порядок для новой категории
|
||||
const categoriesWithSameParent = categories.filter(
|
||||
cat => cat.parent_id === formData.parent_id
|
||||
);
|
||||
const newOrder = categoriesWithSameParent.length > 0
|
||||
? Math.max(...categoriesWithSameParent.map(cat => cat.order)) + 1
|
||||
: 1;
|
||||
|
||||
// Создаем категорию через API
|
||||
await categoryService.createCategory({
|
||||
...formData,
|
||||
order: newOrder
|
||||
});
|
||||
|
||||
// Перенаправляем на страницу категорий
|
||||
router.push('/admin/categories');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при создании категории:', err);
|
||||
setError('Не удалось создать категорию. Пожалуйста, проверьте введенные данные и попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Создание категории">
|
||||
<div className="mb-6 flex items-center">
|
||||
<button
|
||||
onClick={() => router.push('/admin/categories')}
|
||||
className="mr-4 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold text-gray-800">Создание новой категории</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Название категории *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
<label htmlFor="slug" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Slug *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
required
|
||||
value={formData.slug}
|
||||
onChange={handleChange}
|
||||
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"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
URL-совместимый идентификатор категории. Генерируется автоматически из названия.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="parent_id" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Родительская категория
|
||||
</label>
|
||||
<select
|
||||
id="parent_id"
|
||||
name="parent_id"
|
||||
value={formData.parent_id === null ? '' : formData.parent_id}
|
||||
onChange={handleChange}
|
||||
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"
|
||||
>
|
||||
<option value="">Нет (корневая категория)</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div 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"
|
||||
/>
|
||||
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-700">
|
||||
Активна
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Неактивные категории не будут отображаться на сайте.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-gray-50 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/categories')}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-3"
|
||||
>
|
||||
<X className="h-5 w-5 mr-2" />
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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"
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
284
frontend/pages/admin/categories/index.tsx
Normal file
284
frontend/pages/admin/categories/index.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Edit, Trash, ChevronLeft, ChevronRight, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { categoryService, Category } from '../../../services/catalog';
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Загрузка категорий при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await categoryService.getCategories();
|
||||
setCategories(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке категорий:', err);
|
||||
setError('Не удалось загрузить категории. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
// Функция для изменения порядка категорий
|
||||
const handleReorder = async (id: number, direction: 'up' | 'down') => {
|
||||
const index = categories.findIndex(cat => cat.id === id);
|
||||
if (index === -1) return;
|
||||
|
||||
// Если двигаем вверх и это не первый элемент
|
||||
if (direction === 'up' && index > 0) {
|
||||
try {
|
||||
const currentCategory = categories[index];
|
||||
const prevCategory = categories[index - 1];
|
||||
|
||||
// Обновляем порядок в API
|
||||
await categoryService.updateCategory(currentCategory.id, { order: prevCategory.order });
|
||||
await categoryService.updateCategory(prevCategory.id, { order: currentCategory.order });
|
||||
|
||||
// Обновляем локальное состояние
|
||||
const newCategories = [...categories];
|
||||
newCategories[index] = { ...newCategories[index], order: prevCategory.order };
|
||||
newCategories[index - 1] = { ...newCategories[index - 1], order: currentCategory.order };
|
||||
|
||||
// Сортируем по порядку
|
||||
newCategories.sort((a, b) => a.order - b.order);
|
||||
|
||||
setCategories(newCategories);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при изменении порядка:', err);
|
||||
setError('Не удалось изменить порядок категории.');
|
||||
}
|
||||
}
|
||||
// Если двигаем вниз и это не последний элемент
|
||||
else if (direction === 'down' && index < categories.length - 1) {
|
||||
try {
|
||||
const currentCategory = categories[index];
|
||||
const nextCategory = categories[index + 1];
|
||||
|
||||
// Обновляем порядок в API
|
||||
await categoryService.updateCategory(currentCategory.id, { order: nextCategory.order });
|
||||
await categoryService.updateCategory(nextCategory.id, { order: currentCategory.order });
|
||||
|
||||
// Обновляем локальное состояние
|
||||
const newCategories = [...categories];
|
||||
newCategories[index] = { ...newCategories[index], order: nextCategory.order };
|
||||
newCategories[index + 1] = { ...newCategories[index + 1], order: currentCategory.order };
|
||||
|
||||
// Сортируем по порядку
|
||||
newCategories.sort((a, b) => a.order - b.order);
|
||||
|
||||
setCategories(newCategories);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при изменении порядка:', err);
|
||||
setError('Не удалось изменить порядок категории.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для удаления категории
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить эту категорию?')) {
|
||||
try {
|
||||
await categoryService.deleteCategory(id);
|
||||
setCategories(categories.filter(cat => cat.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении категории:', err);
|
||||
setError('Не удалось удалить категорию.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для изменения статуса активности
|
||||
const handleToggleActive = async (id: number, currentStatus: boolean) => {
|
||||
try {
|
||||
await categoryService.updateCategory(id, { is_active: !currentStatus });
|
||||
setCategories(categories.map(cat =>
|
||||
cat.id === id ? { ...cat, is_active: !cat.is_active } : cat
|
||||
));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при изменении статуса категории:', err);
|
||||
setError('Не удалось изменить статус категории.');
|
||||
}
|
||||
};
|
||||
|
||||
// Получаем корневые категории и подкатегории
|
||||
const rootCategories = categories.filter(cat => cat.parent_id === null);
|
||||
const getSubcategories = (parentId: number) => categories.filter(cat => cat.parent_id === parentId);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout title="Управление категориями">
|
||||
<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>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AdminLayout title="Управление категориями">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
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"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title="Управление категориями">
|
||||
<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 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">Название</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-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">
|
||||
{rootCategories.map((category) => (
|
||||
<>
|
||||
<tr key={category.id} className="bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{category.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.slug}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">-</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.products_count || 0}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleToggleActive(category.id, category.is_active)}
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${category.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
|
||||
>
|
||||
{category.is_active ? 'Активна' : 'Неактивна'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleReorder(category.id, 'up')}
|
||||
disabled={category.order === 1}
|
||||
className={`p-1 rounded-full ${category.order === 1 ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReorder(category.id, 'down')}
|
||||
disabled={category.order === rootCategories.length}
|
||||
className={`p-1 rounded-full ${category.order === rootCategories.length ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
<span>{category.order}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link href={`/admin/categories/${category.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
<Edit className="h-5 w-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(category.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Подкатегории */}
|
||||
{getSubcategories(category.id).map(subcat => (
|
||||
<tr key={subcat.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 pl-12">
|
||||
— {subcat.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{subcat.slug}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{category.name}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{subcat.products_count || 0}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleToggleActive(subcat.id, subcat.is_active)}
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${subcat.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
|
||||
>
|
||||
{subcat.is_active ? 'Активна' : 'Неактивна'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleReorder(subcat.id, 'up')}
|
||||
disabled={subcat.order === 1}
|
||||
className={`p-1 rounded-full ${subcat.order === 1 ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleReorder(subcat.id, 'down')}
|
||||
disabled={subcat.order === getSubcategories(category.id).length}
|
||||
className={`p-1 rounded-full ${subcat.order === getSubcategories(category.id).length ? 'text-gray-300' : 'text-gray-600 hover:bg-gray-100'}`}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</button>
|
||||
<span>{subcat.order}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link href={`/admin/categories/${subcat.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
<Edit className="h-5 w-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(subcat.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Показано {categories.length} категорий
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
330
frontend/pages/admin/customers/[id].tsx
Normal file
330
frontend/pages/admin/customers/[id].tsx
Normal file
@ -0,0 +1,330 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Mail, Phone, MapPin, Calendar, ShoppingBag, CreditCard, Clock, Check, X } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { userService, User, Address } from '../../../services/users';
|
||||
import { orderService, Order } from '../../../services/orders';
|
||||
|
||||
// Компонент для отображения статуса заказа
|
||||
const OrderStatus = ({ status }) => {
|
||||
let bgColor = 'bg-gray-100';
|
||||
let textColor = 'text-gray-800';
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
bgColor = 'bg-yellow-100';
|
||||
textColor = 'text-yellow-800';
|
||||
break;
|
||||
case 'paid':
|
||||
bgColor = 'bg-blue-100';
|
||||
textColor = 'text-blue-800';
|
||||
break;
|
||||
case 'processing':
|
||||
bgColor = 'bg-purple-100';
|
||||
textColor = 'text-purple-800';
|
||||
break;
|
||||
case 'shipped':
|
||||
bgColor = 'bg-indigo-100';
|
||||
textColor = 'text-indigo-800';
|
||||
break;
|
||||
case 'delivered':
|
||||
bgColor = 'bg-green-100';
|
||||
textColor = 'text-green-800';
|
||||
break;
|
||||
case 'cancelled':
|
||||
bgColor = 'bg-red-100';
|
||||
textColor = 'text-red-800';
|
||||
break;
|
||||
}
|
||||
|
||||
const statusText = {
|
||||
pending: 'Ожидает оплаты',
|
||||
paid: 'Оплачен',
|
||||
processing: 'В обработке',
|
||||
shipped: 'Отправлен',
|
||||
delivered: 'Доставлен',
|
||||
cancelled: 'Отменен'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${bgColor} ${textColor}`}>
|
||||
{statusText[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CustomerDetailPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [stats, setStats] = useState({
|
||||
ordersCount: 0,
|
||||
totalSpent: 0,
|
||||
lastOrderDate: ''
|
||||
});
|
||||
|
||||
// Загрузка данных пользователя
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const userData = await userService.getUserById(Number(id));
|
||||
setUser(userData);
|
||||
|
||||
// Загрузка заказов пользователя
|
||||
try {
|
||||
// Предполагаем, что в API есть эндпоинт для получения заказов пользователя
|
||||
const response = await fetch(`/api/users/${id}/orders`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка при загрузке заказов');
|
||||
}
|
||||
const ordersData = await response.json();
|
||||
setOrders(ordersData);
|
||||
|
||||
// Расчет статистики
|
||||
if (ordersData.length > 0) {
|
||||
const totalSpent = ordersData.reduce((sum, order) => sum + order.total, 0);
|
||||
const lastOrder = ordersData.sort((a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||
)[0];
|
||||
|
||||
setStats({
|
||||
ordersCount: ordersData.length,
|
||||
totalSpent: totalSpent,
|
||||
lastOrderDate: lastOrder.created_at
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
// Продолжаем работу даже если не удалось загрузить заказы
|
||||
}
|
||||
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных пользователя:', err);
|
||||
setError('Не удалось загрузить данные клиента. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [id]);
|
||||
|
||||
// Форматирование даты для отображения
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Форматирование суммы для отображения
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Получение полного имени пользователя
|
||||
const getFullName = (user: User) => {
|
||||
return `${user.first_name} ${user.last_name}`.trim() || 'Нет имени';
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title={loading ? 'Загрузка...' : `Клиент: ${user ? getFullName(user) : ''}`}>
|
||||
<div className="mb-6 flex items-center">
|
||||
<Link href="/admin/customers" className="inline-flex items-center mr-4 text-indigo-600 hover:text-indigo-800">
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Назад к списку
|
||||
</Link>
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{loading ? 'Загрузка...' : `Информация о клиенте: ${user ? getFullName(user) : ''}`}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<X className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<svg className="animate-spin h-8 w-8 text-indigo-600" 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>
|
||||
</div>
|
||||
) : user ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
{/* Основная информация */}
|
||||
<div className="bg-white rounded-lg shadow p-6 col-span-2">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Основная информация</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Имя</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{getFullName(user)}</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Mail className="h-4 w-4 text-gray-400 mr-2" />
|
||||
<h4 className="text-sm font-medium text-gray-500 mr-2">Email:</h4>
|
||||
<a href={`mailto:${user.email}`} className="text-sm text-indigo-600 hover:text-indigo-800">
|
||||
{user.email}
|
||||
</a>
|
||||
</div>
|
||||
{user.phone && (
|
||||
<div className="flex items-center">
|
||||
<Phone className="h-4 w-4 text-gray-400 mr-2" />
|
||||
<h4 className="text-sm font-medium text-gray-500 mr-2">Телефон:</h4>
|
||||
<a href={`tel:${user.phone}`} className="text-sm text-indigo-600 hover:text-indigo-800">
|
||||
{user.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-4 w-4 text-gray-400 mr-2" />
|
||||
<h4 className="text-sm font-medium text-gray-500 mr-2">Дата регистрации:</h4>
|
||||
<p className="text-sm text-gray-900">{formatDate(user.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Статистика</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Всего заказов</h4>
|
||||
<p className="mt-1 text-lg font-semibold text-gray-900">{stats.ordersCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Общая сумма покупок</h4>
|
||||
<p className="mt-1 text-lg font-semibold text-indigo-600">{formatCurrency(stats.totalSpent)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-500">Последний заказ</h4>
|
||||
<p className="mt-1 text-sm text-gray-900">{formatDate(stats.lastOrderDate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Адреса */}
|
||||
{user.addresses && user.addresses.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-8">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Адреса</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{user.addresses.map((address) => (
|
||||
<div key={address.id} className="border rounded-lg p-4 relative">
|
||||
{address.is_default && (
|
||||
<span className="absolute top-2 right-2 bg-green-100 text-green-800 text-xs px-2 py-1 rounded">
|
||||
По умолчанию
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-start mb-2">
|
||||
<MapPin className="h-5 w-5 text-gray-400 mr-2 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">{address.type}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
{address.address}, {address.city}, {address.postal_code}, {address.country}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* История заказов */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">История заказов</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
{orders.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-500">
|
||||
У клиента пока нет заказов
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
{orders.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="flex items-center">
|
||||
<Clock className="h-4 w-4 text-gray-400 mr-1" />
|
||||
{formatDate(order.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<OrderStatus status={order.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{formatCurrency(order.total)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<CreditCard className="h-4 w-4 text-gray-400 mr-1" />
|
||||
{order.payment_method}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<ShoppingBag className="h-4 w-4 text-gray-400 mr-1" />
|
||||
{order.items.length}
|
||||
</div>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-6 text-center">
|
||||
<p className="text-gray-500">Клиент не найден</p>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
236
frontend/pages/admin/customers/index.tsx
Normal file
236
frontend/pages/admin/customers/index.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, ChevronLeft, ChevronRight, Mail, Phone } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { userService, User } from '../../../services/users';
|
||||
|
||||
// Компонент для фильтрации и поиска
|
||||
const FilterBar = ({ onSearch }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
onSearch(searchTerm);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<form onSubmit={handleSearch} 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="Поиск по имени, 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>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
Поиск
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CustomersPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [pagination, setPagination] = useState({
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// Загрузка пользователей при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
// Используем метод из userService для получения списка пользователей
|
||||
const data = await userService.getUsers({
|
||||
skip: pagination.skip,
|
||||
limit: pagination.limit,
|
||||
search: searchTerm
|
||||
});
|
||||
setUsers(data.users || []);
|
||||
setPagination(prev => ({ ...prev, total: data.total || 0 }));
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке пользователей:', err);
|
||||
setError('Не удалось загрузить список клиентов. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUsers();
|
||||
}, [pagination.skip, pagination.limit, searchTerm]);
|
||||
|
||||
const handleSearch = (term) => {
|
||||
setSearchTerm(term);
|
||||
// Сбрасываем пагинацию при новом поиске
|
||||
setPagination(prev => ({ ...prev, skip: 0 }));
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (pagination.skip + pagination.limit < pagination.total) {
|
||||
setPagination(prev => ({ ...prev, skip: prev.skip + prev.limit }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (pagination.skip > 0) {
|
||||
setPagination(prev => ({ ...prev, skip: Math.max(0, prev.skip - prev.limit) }));
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование даты для отображения
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
// Получение полного имени пользователя
|
||||
const getFullName = (user: User) => {
|
||||
return `${user.first_name} ${user.last_name}`.trim() || 'Нет имени';
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Управление клиентами">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Список клиентов</h2>
|
||||
</div>
|
||||
|
||||
<FilterBar onSearch={handleSearch} />
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</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">Клиент</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">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 text-center">
|
||||
<div className="flex justify-center">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-600" 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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Клиенты не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{getFullName(user)}</div>
|
||||
<div className="text-sm text-gray-500">ID: {user.id}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center text-sm text-gray-500 mb-1">
|
||||
<Mail className="h-4 w-4 mr-1" />
|
||||
<a href={`mailto:${user.email}`} className="hover:text-indigo-600">{user.email}</a>
|
||||
</div>
|
||||
{user.phone && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<Phone className="h-4 w-4 mr-1" />
|
||||
<a href={`tel:${user.phone}`} className="hover:text-indigo-600">{user.phone}</a>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{/* Эти данные должны приходить с бекенда */}
|
||||
-
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{/* Эти данные должны приходить с бекенда */}
|
||||
-
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatDate(user.created_at)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{/* Эти данные должны приходить с бекенда */}
|
||||
-
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/customers/${user.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
Подробнее
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Показано {pagination.skip + 1}-{Math.min(pagination.skip + pagination.limit, pagination.total)} из {pagination.total} клиентов
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={pagination.skip === 0}
|
||||
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
|
||||
pagination.skip === 0 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={pagination.skip + pagination.limit >= pagination.total}
|
||||
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
|
||||
pagination.skip + pagination.limit >= pagination.total ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
320
frontend/pages/admin/index.tsx
Normal file
320
frontend/pages/admin/index.tsx
Normal file
@ -0,0 +1,320 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { BarChart3, Package, Tag, Users, ShoppingBag, FileText, Settings } from 'lucide-react';
|
||||
import AdminLayout from '../../components/admin/AdminLayout';
|
||||
import { analyticsService } from '../../services/analytics';
|
||||
import { orderService, Order } from '../../services/orders';
|
||||
import { productService, Product } from '../../services/catalog';
|
||||
|
||||
// Компонент статистической карточки
|
||||
const StatCard = ({ title, value, icon, color }) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент последних заказов
|
||||
const RecentOrders = ({ orders, loading, error }) => {
|
||||
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.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>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент популярных товаров
|
||||
const PopularProducts = ({ products, loading, error }) => {
|
||||
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">{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
|
||||
${product.stock > 20 ? 'bg-green-100 text-green-800' :
|
||||
product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'}`}>
|
||||
{product.stock}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<Link href={`/admin/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/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: null,
|
||||
orders: null,
|
||||
products: null
|
||||
});
|
||||
|
||||
// Загрузка данных при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, stats: true }));
|
||||
// В реальном приложении здесь будет запрос к API для получения статистики
|
||||
// Например: const statsData = await analyticsService.getReport('dashboard');
|
||||
|
||||
// Для демонстрации используем моковые данные
|
||||
setTimeout(() => {
|
||||
setStats({
|
||||
ordersCount: 1248,
|
||||
totalSales: 2456789,
|
||||
customersCount: 3456,
|
||||
productsCount: 867
|
||||
});
|
||||
setLoading(prev => ({ ...prev, stats: false }));
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке статистики:', err);
|
||||
setError(prev => ({ ...prev, stats: 'Не удалось загрузить статистику' }));
|
||||
setLoading(prev => ({ ...prev, stats: false }));
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, orders: true }));
|
||||
// Загружаем последние заказы
|
||||
const orders = await orderService.getOrders({ limit: 4 });
|
||||
setRecentOrders(orders);
|
||||
setError(prev => ({ ...prev, orders: null }));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError(prev => ({ ...prev, orders: 'Не удалось загрузить заказы' }));
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, orders: false }));
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(prev => ({ ...prev, products: true }));
|
||||
// Загружаем популярные товары
|
||||
// В реальном API должен быть эндпоинт для получения популярных товаров
|
||||
// Здесь просто получаем первые 4 товара
|
||||
const products = await productService.getProducts({ limit: 4 });
|
||||
setPopularProducts(products);
|
||||
setError(prev => ({ ...prev, products: null }));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке товаров:', err);
|
||||
setError(prev => ({ ...prev, products: 'Не удалось загрузить товары' }));
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, products: false }));
|
||||
}
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AdminLayout title="Панель управления">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatCard
|
||||
title="Всего заказов"
|
||||
value={loading.stats ? "..." : stats.ordersCount.toLocaleString('ru-RU')}
|
||||
icon={<ShoppingBag className="h-6 w-6 text-white" />}
|
||||
color="bg-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Всего продаж"
|
||||
value={loading.stats ? "..." : `₽${stats.totalSales.toLocaleString('ru-RU')}`}
|
||||
icon={<BarChart3 className="h-6 w-6 text-white" />}
|
||||
color="bg-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Клиентов"
|
||||
value={loading.stats ? "..." : stats.customersCount.toLocaleString('ru-RU')}
|
||||
icon={<Users className="h-6 w-6 text-white" />}
|
||||
color="bg-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
title="Товаров"
|
||||
value={loading.stats ? "..." : stats.productsCount.toLocaleString('ru-RU')}
|
||||
icon={<Package className="h-6 w-6 text-white" />}
|
||||
color="bg-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<RecentOrders
|
||||
orders={recentOrders}
|
||||
loading={loading.orders}
|
||||
error={error.orders}
|
||||
/>
|
||||
<PopularProducts
|
||||
products={popularProducts}
|
||||
loading={loading.products}
|
||||
error={error.products}
|
||||
/>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
389
frontend/pages/admin/orders/[id].tsx
Normal file
389
frontend/pages/admin/orders/[id].tsx
Normal file
@ -0,0 +1,389 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Package, Truck, CreditCard, Calendar, User, MapPin, Check, X } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { orderService, Order, OrderUpdate } from '../../../services/orders';
|
||||
|
||||
// Компонент для отображения статуса заказа
|
||||
const OrderStatus = ({ status }) => {
|
||||
let bgColor = 'bg-gray-100';
|
||||
let textColor = 'text-gray-800';
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
bgColor = 'bg-yellow-100';
|
||||
textColor = 'text-yellow-800';
|
||||
break;
|
||||
case 'paid':
|
||||
bgColor = 'bg-blue-100';
|
||||
textColor = 'text-blue-800';
|
||||
break;
|
||||
case 'processing':
|
||||
bgColor = 'bg-purple-100';
|
||||
textColor = 'text-purple-800';
|
||||
break;
|
||||
case 'shipped':
|
||||
bgColor = 'bg-indigo-100';
|
||||
textColor = 'text-indigo-800';
|
||||
break;
|
||||
case 'delivered':
|
||||
bgColor = 'bg-green-100';
|
||||
textColor = 'text-green-800';
|
||||
break;
|
||||
case 'cancelled':
|
||||
bgColor = 'bg-red-100';
|
||||
textColor = 'text-red-800';
|
||||
break;
|
||||
}
|
||||
|
||||
const statusText = {
|
||||
pending: 'Ожидает оплаты',
|
||||
paid: 'Оплачен',
|
||||
processing: 'В обработке',
|
||||
shipped: 'Отправлен',
|
||||
delivered: 'Доставлен',
|
||||
cancelled: 'Отменен'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-3 py-1 inline-flex text-sm leading-5 font-semibold rounded-full ${bgColor} ${textColor}`}>
|
||||
{statusText[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OrderDetailsPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [updateStatus, setUpdateStatus] = useState('');
|
||||
|
||||
// Загрузка данных заказа
|
||||
useEffect(() => {
|
||||
const fetchOrder = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await orderService.getOrderById(Number(id));
|
||||
setOrder(data);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказа:', err);
|
||||
setError('Не удалось загрузить данные заказа. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrder();
|
||||
}, [id]);
|
||||
|
||||
// Обработчик изменения статуса заказа
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
if (!order) return;
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
setUpdateStatus('');
|
||||
|
||||
const updatedOrder = await orderService.updateOrder(order.id, { status: newStatus });
|
||||
setOrder(updatedOrder);
|
||||
setUpdateStatus('Статус заказа успешно обновлен');
|
||||
|
||||
// Скрываем сообщение об успешном обновлении через 3 секунды
|
||||
setTimeout(() => {
|
||||
setUpdateStatus('');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при обновлении статуса заказа:', err);
|
||||
setUpdateStatus('Не удалось обновить статус заказа. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик отмены заказа
|
||||
const handleCancelOrder = async () => {
|
||||
if (!order) return;
|
||||
|
||||
if (!confirm('Вы уверены, что хотите отменить заказ?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUpdating(true);
|
||||
setUpdateStatus('');
|
||||
|
||||
const updatedOrder = await orderService.cancelOrder(order.id);
|
||||
setOrder(updatedOrder);
|
||||
setUpdateStatus('Заказ успешно отменен');
|
||||
|
||||
// Скрываем сообщение об успешном обновлении через 3 секунды
|
||||
setTimeout(() => {
|
||||
setUpdateStatus('');
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при отмене заказа:', err);
|
||||
setUpdateStatus('Не удалось отменить заказ. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование даты для отображения
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Форматирование суммы для отображения
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title={`Заказ #${id || ''}`}>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Link href="/admin/orders" className="mr-4 text-gray-500 hover:text-gray-700">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h2 className="text-xl font-semibold text-gray-800">
|
||||
{loading ? 'Загрузка...' : `Заказ #${order?.id || ''}`}
|
||||
</h2>
|
||||
</div>
|
||||
{order && order.status !== 'cancelled' && (
|
||||
<button
|
||||
onClick={handleCancelOrder}
|
||||
disabled={updating}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Отменить заказ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateStatus && (
|
||||
<div className={`p-4 mb-6 rounded-md ${updateStatus.includes('успешно') ? 'bg-green-50 border-l-4 border-green-500' : 'bg-red-50 border-l-4 border-red-500'}`}>
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
{updateStatus.includes('успешно') ? (
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<X className="h-5 w-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className={`text-sm ${updateStatus.includes('успешно') ? 'text-green-700' : 'text-red-700'}`}>{updateStatus}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<svg className="animate-spin h-8 w-8 text-indigo-600" 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>
|
||||
</div>
|
||||
) : order ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Основная информация о заказе */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Информация о заказе</h3>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Calendar className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Дата заказа</p>
|
||||
<p className="text-sm font-medium">{formatDate(order.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mb-4">
|
||||
<User className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Клиент</p>
|
||||
<p className="text-sm font-medium">ID: {order.user_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<MapPin className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Адрес доставки</p>
|
||||
<p className="text-sm font-medium">{order.shipping_address}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Package className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Статус</p>
|
||||
<div className="mt-1">
|
||||
<OrderStatus status={order.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Truck className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Способ доставки</p>
|
||||
<p className="text-sm font-medium">{order.shipping_method}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CreditCard className="h-5 w-5 text-gray-500 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">Способ оплаты</p>
|
||||
<p className="text-sm font-medium">{order.payment_method}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Товары в заказе */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden mt-6">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Товары в заказе</h3>
|
||||
</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">
|
||||
{order.items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{item.product_name}</div>
|
||||
{(item.variant_size || item.variant_color) && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{item.variant_size && `Размер: ${item.variant_size}`}
|
||||
{item.variant_size && item.variant_color && ', '}
|
||||
{item.variant_color && `Цвет: ${item.variant_color}`}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{item.product_sku}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatCurrency(item.price)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{item.quantity}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-right font-medium">{formatCurrency(item.price * item.quantity)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Боковая панель с действиями и итогами */}
|
||||
<div>
|
||||
{/* Изменение статуса заказа */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Изменить статус</h3>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<div className="space-y-3">
|
||||
{['pending', 'paid', 'processing', 'shipped', 'delivered'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleStatusChange(status)}
|
||||
disabled={updating || order.status === status || order.status === 'cancelled'}
|
||||
className={`w-full py-2 px-4 rounded-md text-sm font-medium ${
|
||||
order.status === status
|
||||
? 'bg-indigo-100 text-indigo-800 cursor-default'
|
||||
: order.status === 'cancelled'
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{status === 'pending' && 'Ожидает оплаты'}
|
||||
{status === 'paid' && 'Оплачен'}
|
||||
{status === 'processing' && 'В обработке'}
|
||||
{status === 'shipped' && 'Отправлен'}
|
||||
{status === 'delivered' && 'Доставлен'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Итоги заказа */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden mt-6">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Итого</h3>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Сумма товаров</span>
|
||||
<span className="text-sm font-medium">{formatCurrency(order.total)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Доставка</span>
|
||||
<span className="text-sm font-medium">0 ₽</span>
|
||||
</div>
|
||||
<div className="pt-3 border-t border-gray-200 flex justify-between">
|
||||
<span className="text-base font-medium">Итого</span>
|
||||
<span className="text-base font-bold">{formatCurrency(order.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-6 text-center">
|
||||
<p className="text-gray-500">Заказ не найден</p>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
361
frontend/pages/admin/orders/index.tsx
Normal file
361
frontend/pages/admin/orders/index.tsx
Normal file
@ -0,0 +1,361 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Search, Filter, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { orderService, Order } from '../../../services/orders';
|
||||
|
||||
// Компонент для фильтрации и поиска
|
||||
const FilterBar = ({ onSearch, onFilter }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters, setFilters] = useState({
|
||||
status: '',
|
||||
dateFrom: '',
|
||||
dateTo: ''
|
||||
});
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
onSearch(searchTerm);
|
||||
};
|
||||
|
||||
const handleFilterChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters({
|
||||
...filters,
|
||||
[name]: value
|
||||
});
|
||||
};
|
||||
|
||||
const handleFilter = (e) => {
|
||||
e.preventDefault();
|
||||
onFilter(filters);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<form onSubmit={handleSearch} className="flex flex-col md:flex-row gap-4 mb-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>
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
Поиск
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4">
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-3 flex items-center">
|
||||
<Filter className="h-4 w-4 mr-1" />
|
||||
Фильтры
|
||||
</h3>
|
||||
<form onSubmit={handleFilter} className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">Статус</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={filters.status}
|
||||
onChange={handleFilterChange}
|
||||
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"
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="pending">Ожидает оплаты</option>
|
||||
<option value="paid">Оплачен</option>
|
||||
<option value="processing">В обработке</option>
|
||||
<option value="shipped">Отправлен</option>
|
||||
<option value="delivered">Доставлен</option>
|
||||
<option value="cancelled">Отменен</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dateFrom" className="block text-sm font-medium text-gray-700 mb-1">Дата с</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateFrom"
|
||||
name="dateFrom"
|
||||
value={filters.dateFrom}
|
||||
onChange={handleFilterChange}
|
||||
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>
|
||||
<label htmlFor="dateTo" className="block text-sm font-medium text-gray-700 mb-1">Дата по</label>
|
||||
<input
|
||||
type="date"
|
||||
id="dateTo"
|
||||
name="dateTo"
|
||||
value={filters.dateTo}
|
||||
onChange={handleFilterChange}
|
||||
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="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
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 w-full"
|
||||
>
|
||||
Применить фильтры
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент для отображения статуса заказа
|
||||
const OrderStatus = ({ status }) => {
|
||||
let bgColor = 'bg-gray-100';
|
||||
let textColor = 'text-gray-800';
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
bgColor = 'bg-yellow-100';
|
||||
textColor = 'text-yellow-800';
|
||||
break;
|
||||
case 'paid':
|
||||
bgColor = 'bg-blue-100';
|
||||
textColor = 'text-blue-800';
|
||||
break;
|
||||
case 'processing':
|
||||
bgColor = 'bg-purple-100';
|
||||
textColor = 'text-purple-800';
|
||||
break;
|
||||
case 'shipped':
|
||||
bgColor = 'bg-indigo-100';
|
||||
textColor = 'text-indigo-800';
|
||||
break;
|
||||
case 'delivered':
|
||||
bgColor = 'bg-green-100';
|
||||
textColor = 'text-green-800';
|
||||
break;
|
||||
case 'cancelled':
|
||||
bgColor = 'bg-red-100';
|
||||
textColor = 'text-red-800';
|
||||
break;
|
||||
}
|
||||
|
||||
const statusText = {
|
||||
pending: 'Ожидает оплаты',
|
||||
paid: 'Оплачен',
|
||||
processing: 'В обработке',
|
||||
shipped: 'Отправлен',
|
||||
delivered: 'Доставлен',
|
||||
cancelled: 'Отменен'
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${bgColor} ${textColor}`}>
|
||||
{statusText[status] || status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [activeFilters, setActiveFilters] = useState<{
|
||||
status?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}>({});
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [pagination, setPagination] = useState({
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// Загрузка заказов при монтировании компонента и при изменении фильтров
|
||||
useEffect(() => {
|
||||
const fetchOrders = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = {
|
||||
skip: pagination.skip,
|
||||
limit: pagination.limit,
|
||||
...activeFilters
|
||||
};
|
||||
const data = await orderService.getOrders(params);
|
||||
setOrders(data);
|
||||
// Предполагаем, что общее количество заказов будет возвращаться с бекенда
|
||||
// В реальном API это может быть в заголовках или в отдельном поле ответа
|
||||
setPagination(prev => ({ ...prev, total: data.length > 0 ? 50 : 0 })); // Временное решение
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке заказов:', err);
|
||||
setError('Не удалось загрузить заказы. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchOrders();
|
||||
}, [activeFilters, pagination.skip, pagination.limit]);
|
||||
|
||||
const handleSearch = (term) => {
|
||||
setSearchTerm(term);
|
||||
// Сбрасываем пагинацию при новом поиске
|
||||
setPagination(prev => ({ ...prev, skip: 0 }));
|
||||
// Здесь можно добавить логику для поиска по заказам
|
||||
// Например, отправить запрос к API с параметром поиска
|
||||
};
|
||||
|
||||
const handleFilter = (filters) => {
|
||||
setActiveFilters(filters);
|
||||
// Сбрасываем пагинацию при новых фильтрах
|
||||
setPagination(prev => ({ ...prev, skip: 0 }));
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (pagination.skip + pagination.limit < pagination.total) {
|
||||
setPagination(prev => ({ ...prev, skip: prev.skip + prev.limit }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (pagination.skip > 0) {
|
||||
setPagination(prev => ({ ...prev, skip: Math.max(0, prev.skip - prev.limit) }));
|
||||
}
|
||||
};
|
||||
|
||||
// Форматирование даты для отображения
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
// Форматирование суммы для отображения
|
||||
const formatCurrency = (amount) => {
|
||||
return new Intl.NumberFormat('ru-RU', {
|
||||
style: 'currency',
|
||||
currency: 'RUB',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Управление заказами">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Список заказов</h2>
|
||||
</div>
|
||||
|
||||
<FilterBar onSearch={handleSearch} onFilter={handleFilter} />
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</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">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-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">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-4 text-center">
|
||||
<div className="flex justify-center">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-600" 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>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : orders.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-4 text-center text-sm text-gray-500">
|
||||
Заказы не найдены
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
orders.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">
|
||||
{/* Здесь будет имя клиента, когда API вернет эти данные */}
|
||||
Клиент #{order.user_id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{formatDate(order.created_at)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<OrderStatus status={order.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{formatCurrency(order.total)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{order.payment_method}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{order.items.length}</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>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="px-6 py-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Показано {pagination.skip + 1}-{Math.min(pagination.skip + pagination.limit, pagination.total)} из {pagination.total} заказов
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={pagination.skip === 0}
|
||||
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
|
||||
pagination.skip === 0 ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={pagination.skip + pagination.limit >= pagination.total}
|
||||
className={`inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md ${
|
||||
pagination.skip + pagination.limit >= pagination.total ? 'bg-gray-100 text-gray-400 cursor-not-allowed' : 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
679
frontend/pages/admin/products/[id].tsx
Normal file
679
frontend/pages/admin/products/[id].tsx
Normal file
@ -0,0 +1,679 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Save, X, Plus, Trash, ArrowLeft } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { productService, categoryService, Category, Product, ProductVariant, ProductImage } from '../../../services/catalog';
|
||||
|
||||
// Компонент для загрузки изображений
|
||||
const ImageUploader = ({ images, setImages, productId }) => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleImageUpload = async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
if (!(file instanceof File)) {
|
||||
console.error('Объект не является файлом:', file);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Создаем временный объект для предпросмотра
|
||||
const tempImage = {
|
||||
id: Date.now() + Math.random().toString(36).substring(2, 9),
|
||||
url: URL.createObjectURL(file),
|
||||
is_primary: images.length === 0, // Первое изображение будет основным
|
||||
product_id: productId,
|
||||
isUploading: true
|
||||
};
|
||||
|
||||
setImages(prev => [...prev, tempImage]);
|
||||
|
||||
// Загружаем изображение на сервер
|
||||
const uploadedImage = await productService.uploadProductImage(
|
||||
productId,
|
||||
file,
|
||||
tempImage.is_primary
|
||||
);
|
||||
|
||||
// Обновляем список изображений, заменяя временное на загруженное
|
||||
setImages(prev => prev.map(img =>
|
||||
img.id === tempImage.id ? { ...uploadedImage, url: uploadedImage.url } : img
|
||||
));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке изображений:', error);
|
||||
alert('Не удалось загрузить одно или несколько изображений');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = async (id) => {
|
||||
try {
|
||||
// Проверяем, является ли изображение временным или уже загруженным
|
||||
const image = images.find(img => img.id === id);
|
||||
|
||||
if (!image.isUploading) {
|
||||
// Если изображение уже загружено, удаляем его с сервера
|
||||
await productService.deleteProductImage(id);
|
||||
}
|
||||
|
||||
const updatedImages = images.filter(image => image.id !== id);
|
||||
|
||||
// Если удалили основное изображение, делаем первое в списке основным
|
||||
if (updatedImages.length > 0 && !updatedImages.some(img => img.is_primary)) {
|
||||
const firstImage = updatedImages[0];
|
||||
await productService.updateProductImage(firstImage.id, { is_primary: true });
|
||||
updatedImages[0] = { ...firstImage, is_primary: true };
|
||||
}
|
||||
|
||||
setImages(updatedImages);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении изображения:', error);
|
||||
alert('Не удалось удалить изображение');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetPrimary = async (id) => {
|
||||
try {
|
||||
// Обновляем на сервере
|
||||
await productService.updateProductImage(id, { is_primary: true });
|
||||
|
||||
// Обновляем локальное состояние
|
||||
const updatedImages = images.map(image => ({
|
||||
...image,
|
||||
is_primary: image.id === id
|
||||
}));
|
||||
|
||||
setImages(updatedImages);
|
||||
} catch (error) {
|
||||
console.error('Ошибка при установке основного изображения:', error);
|
||||
alert('Не удалось установить основное изображение');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Изображения товара</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{images.map(image => (
|
||||
<div key={image.id} className="relative border rounded-lg overflow-hidden group">
|
||||
<img src={image.url} alt="Product" className="w-full h-32 object-cover" />
|
||||
{image.isUploading && (
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => handleSetPrimary(image.id)}
|
||||
disabled={image.is_primary || image.isUploading}
|
||||
className={`p-1 rounded-full ${image.is_primary ? 'bg-green-500' : 'bg-white'} text-black mr-2 ${image.isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={image.is_primary ? "Основное изображение" : "Сделать основным"}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveImage(image.id)}
|
||||
disabled={image.isUploading}
|
||||
className={`p-1 rounded-full bg-red-500 text-white ${image.isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{image.is_primary && (
|
||||
<div className="absolute top-0 left-0 bg-green-500 text-white text-xs px-2 py-1">
|
||||
Основное
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<label className="border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center h-32 cursor-pointer hover:border-indigo-500 transition-colors">
|
||||
{uploading ? (
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-8 w-8 text-gray-400" />
|
||||
<span className="mt-2 text-sm text-gray-500">Добавить изображение</span>
|
||||
<input type="file" className="hidden" accept="image/*" multiple onChange={handleImageUpload} />
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">Загрузите до 8 изображений товара. Первое изображение будет использоваться как основное.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент для вариантов товара
|
||||
const VariantManager = ({ variants, setVariants, productId }) => {
|
||||
const [newVariant, setNewVariant] = useState({
|
||||
color: '',
|
||||
size: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
stock: ''
|
||||
});
|
||||
|
||||
const handleAddVariant = async () => {
|
||||
if (!newVariant.color || !newVariant.size || !newVariant.price || !newVariant.stock) {
|
||||
alert('Пожалуйста, заполните все поля варианта');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Создаем вариант на сервере
|
||||
const variant = await productService.addProductVariant(productId, {
|
||||
sku: newVariant.sku,
|
||||
size: newVariant.size,
|
||||
color: newVariant.color,
|
||||
price: parseFloat(newVariant.price),
|
||||
stock: parseInt(newVariant.stock, 10)
|
||||
});
|
||||
|
||||
// Добавляем вариант в локальное состояние
|
||||
setVariants([...variants, variant]);
|
||||
|
||||
// Сбрасываем форму
|
||||
setNewVariant({
|
||||
color: '',
|
||||
size: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
stock: ''
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка при добавлении варианта:', error);
|
||||
alert('Не удалось добавить вариант товара');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveVariant = async (id) => {
|
||||
try {
|
||||
// Удаляем вариант на сервере
|
||||
await productService.deleteProductVariant(id);
|
||||
|
||||
// Обновляем локальное состояние
|
||||
setVariants(variants.filter(variant => variant.id !== id));
|
||||
} catch (error) {
|
||||
console.error('Ошибка при удалении варианта:', error);
|
||||
alert('Не удалось удалить вариант товара');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewVariantChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setNewVariant({
|
||||
...newVariant,
|
||||
[name]: value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Варианты товара</label>
|
||||
<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-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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{variants.map(variant => (
|
||||
<tr key={variant.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.color}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.size}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.sku}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.price}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.stock}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveVariant(variant.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="color"
|
||||
value={newVariant.color}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Цвет"
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="size"
|
||||
value={newVariant.size}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Размер"
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="sku"
|
||||
value={newVariant.sku}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Артикул"
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="number"
|
||||
name="price"
|
||||
value={newVariant.price}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Цена"
|
||||
min="0"
|
||||
step="0.01"
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="number"
|
||||
name="stock"
|
||||
value={newVariant.stock}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Наличие"
|
||||
min="0"
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddVariant}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 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-4 w-4 mr-1" />
|
||||
Добавить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function EditProductPage() {
|
||||
const router = useRouter();
|
||||
const { id } = router.query;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [images, setImages] = useState<ProductImage[]>([]);
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
category_id: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
stock: '',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
// Загрузка данных продукта и категорий при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Загружаем категории
|
||||
const categoriesData = await categoryService.getCategories();
|
||||
setCategories(categoriesData);
|
||||
|
||||
// Загружаем данные продукта
|
||||
const productData = await productService.getProductById(Number(id));
|
||||
console.log('Полученные данные продукта:', productData);
|
||||
|
||||
if (!productData || !productData.id) {
|
||||
console.error('Продукт не найден или данные некорректны:', productData);
|
||||
setError('Продукт не найден или данные некорректны');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setProduct(productData);
|
||||
|
||||
// Заполняем форму данными продукта
|
||||
setFormData({
|
||||
name: productData.name || '',
|
||||
description: productData.description || '',
|
||||
category_id: productData.category_id ? String(productData.category_id) : '',
|
||||
sku: productData.sku || '',
|
||||
price: productData.price !== undefined ? String(productData.price) : '',
|
||||
stock: productData.stock !== undefined ? String(productData.stock) : '',
|
||||
is_active: typeof productData.is_active === 'boolean' ? productData.is_active : true
|
||||
});
|
||||
|
||||
// Загружаем изображения и варианты
|
||||
if (productData.images && Array.isArray(productData.images)) {
|
||||
setImages(productData.images);
|
||||
} else {
|
||||
console.log('Изображения отсутствуют или не являются массивом');
|
||||
setImages([]);
|
||||
}
|
||||
|
||||
if (productData.variants && Array.isArray(productData.variants)) {
|
||||
setVariants(productData.variants);
|
||||
} else {
|
||||
console.log('Варианты отсутствуют или не являются массивом');
|
||||
setVariants([]);
|
||||
}
|
||||
|
||||
setError('');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные продукта. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [id]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
if (!product) {
|
||||
console.error('Продукт не найден');
|
||||
setError('Продукт не найден');
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверяем и логируем данные перед отправкой
|
||||
console.log('Данные формы перед отправкой:', formData);
|
||||
|
||||
// Генерируем slug, если он отсутствует
|
||||
const slug = product.slug && product.slug.trim() !== ''
|
||||
? product.slug
|
||||
: generateSlug(formData.name);
|
||||
|
||||
// Обновляем данные продукта
|
||||
const productData = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
category_id: parseInt(formData.category_id, 10),
|
||||
sku: formData.sku,
|
||||
price: parseFloat(formData.price),
|
||||
stock: parseInt(formData.stock, 10),
|
||||
is_active: formData.is_active,
|
||||
slug: slug
|
||||
};
|
||||
|
||||
console.log('Данные для отправки на сервер:', productData);
|
||||
|
||||
// Отправляем данные на сервер
|
||||
const updatedProduct = await productService.updateProduct(product.id, productData);
|
||||
console.log('Ответ сервера:', updatedProduct);
|
||||
|
||||
// После успешного обновления перенаправляем на страницу товаров
|
||||
router.push('/admin/products');
|
||||
} catch (error) {
|
||||
console.error('Ошибка при обновлении товара:', error);
|
||||
setError('Произошла ошибка при обновлении товара. Пожалуйста, проверьте введенные данные и попробуйте снова.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Функция для генерации slug из названия
|
||||
const generateSlug = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\sа-яё-]/g, '') // Удаляем специальные символы, но оставляем кириллицу и дефисы
|
||||
.replace(/\s+/g, '-') // Заменяем пробелы на дефисы
|
||||
.replace(/[а-яё]/g, char => { // Транслитерация кириллицы
|
||||
const translitMap = {
|
||||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
|
||||
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
|
||||
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||||
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '',
|
||||
'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
|
||||
};
|
||||
return translitMap[char] || char;
|
||||
})
|
||||
.replace(/--+/g, '-') // Заменяем множественные дефисы на один
|
||||
.replace(/^-|-$/g, ''); // Удаляем дефисы в начале и конце
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<AdminLayout title="Редактирование товара">
|
||||
<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>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!product && !loading) {
|
||||
return (
|
||||
<AdminLayout title="Товар не найден">
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">Товар не найден</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/admin/products')}
|
||||
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"
|
||||
>
|
||||
Вернуться к списку товаров
|
||||
</button>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title={`Редактирование: ${product?.name}`}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/products')}
|
||||
className="mr-4 text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold text-gray-800">Редактирование товара</h2>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/products')}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<X className="h-5 w-5 mr-2" />
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
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"
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">Название товара</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700 mb-1">Категория</label>
|
||||
<select
|
||||
id="category_id"
|
||||
name="category_id"
|
||||
required
|
||||
value={formData.category_id}
|
||||
onChange={handleChange}
|
||||
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"
|
||||
>
|
||||
<option value="">Выберите категорию</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="sku" className="block text-sm font-medium text-gray-700 mb-1">Артикул</label>
|
||||
<input
|
||||
type="text"
|
||||
id="sku"
|
||||
name="sku"
|
||||
required
|
||||
value={formData.sku}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">Цена</label>
|
||||
<input
|
||||
type="number"
|
||||
id="price"
|
||||
name="price"
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.price}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
<label htmlFor="stock" className="block text-sm font-medium text-gray-700 mb-1">Количество на складе</label>
|
||||
<input
|
||||
type="number"
|
||||
id="stock"
|
||||
name="stock"
|
||||
required
|
||||
min="0"
|
||||
value={formData.stock}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
<div className="flex items-center h-full">
|
||||
<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"
|
||||
/>
|
||||
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-700">
|
||||
Активен (отображается на сайте)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">Описание</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={4}
|
||||
required
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
|
||||
{product && (
|
||||
<>
|
||||
<ImageUploader images={images} setImages={setImages} productId={product.id} />
|
||||
<VariantManager variants={variants} setVariants={setVariants} productId={product.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
503
frontend/pages/admin/products/create.tsx
Normal file
503
frontend/pages/admin/products/create.tsx
Normal file
@ -0,0 +1,503 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { Save, X, Plus, Trash } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import { productService, categoryService, Category, ProductVariant } from '../../../services/catalog';
|
||||
|
||||
// Компонент для загрузки изображений
|
||||
const ImageUploader = ({ images, setImages }) => {
|
||||
const handleImageUpload = (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Создаем временные объекты изображений для предпросмотра
|
||||
const newImages = files.map(file => {
|
||||
if (!(file instanceof File)) {
|
||||
console.error('Объект не является файлом:', file);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Date.now() + Math.random().toString(36).substring(2, 9),
|
||||
url: URL.createObjectURL(file),
|
||||
file,
|
||||
isPrimary: images.length === 0 // Первое изображение будет основным
|
||||
};
|
||||
}).filter(Boolean); // Удаляем null значения
|
||||
|
||||
setImages([...images, ...newImages]);
|
||||
};
|
||||
|
||||
const handleRemoveImage = (id) => {
|
||||
const updatedImages = images.filter(image => image.id !== id);
|
||||
|
||||
// Если удалили основное изображение, делаем первое в списке основным
|
||||
if (updatedImages.length > 0 && !updatedImages.some(img => img.isPrimary)) {
|
||||
updatedImages[0].isPrimary = true;
|
||||
}
|
||||
|
||||
setImages(updatedImages);
|
||||
};
|
||||
|
||||
const handleSetPrimary = (id) => {
|
||||
const updatedImages = images.map(image => ({
|
||||
...image,
|
||||
isPrimary: image.id === id
|
||||
}));
|
||||
setImages(updatedImages);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Изображения товара</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{images.map(image => (
|
||||
<div key={image.id} className="relative border rounded-lg overflow-hidden group">
|
||||
<img src={image.url} alt="Product" className="w-full h-32 object-cover" />
|
||||
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<button
|
||||
onClick={() => handleSetPrimary(image.id)}
|
||||
className={`p-1 rounded-full ${image.isPrimary ? 'bg-green-500' : 'bg-white'} text-black mr-2`}
|
||||
title={image.isPrimary ? "Основное изображение" : "Сделать основным"}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveImage(image.id)}
|
||||
className="p-1 rounded-full bg-red-500 text-white"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{image.isPrimary && (
|
||||
<div className="absolute top-0 left-0 bg-green-500 text-white text-xs px-2 py-1">
|
||||
Основное
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<label className="border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center h-32 cursor-pointer hover:border-indigo-500 transition-colors">
|
||||
<Plus className="h-8 w-8 text-gray-400" />
|
||||
<span className="mt-2 text-sm text-gray-500">Добавить изображение</span>
|
||||
<input type="file" className="hidden" accept="image/*" multiple onChange={handleImageUpload} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-2">Загрузите до 8 изображений товара. Первое изображение будет использоваться как основное.</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Компонент для вариантов товара
|
||||
const VariantManager = ({ variants, setVariants }) => {
|
||||
const [newVariant, setNewVariant] = useState({
|
||||
color: '',
|
||||
size: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
stock: ''
|
||||
});
|
||||
|
||||
const handleAddVariant = () => {
|
||||
if (!newVariant.color || !newVariant.size || !newVariant.price || !newVariant.stock) {
|
||||
alert('Пожалуйста, заполните все поля варианта');
|
||||
return;
|
||||
}
|
||||
|
||||
const variant = {
|
||||
id: Date.now(),
|
||||
color: newVariant.color,
|
||||
size: newVariant.size,
|
||||
sku: newVariant.sku,
|
||||
price: parseFloat(newVariant.price),
|
||||
stock: parseInt(newVariant.stock, 10)
|
||||
};
|
||||
|
||||
setVariants([...variants, variant]);
|
||||
setNewVariant({
|
||||
color: '',
|
||||
size: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
stock: ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveVariant = (id) => {
|
||||
setVariants(variants.filter(variant => variant.id !== id));
|
||||
};
|
||||
|
||||
const handleNewVariantChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setNewVariant({
|
||||
...newVariant,
|
||||
[name]: value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Варианты товара</label>
|
||||
<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-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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{variants.map(variant => (
|
||||
<tr key={variant.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.color}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.size}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.sku}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.price}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{variant.stock}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveVariant(variant.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="color"
|
||||
value={newVariant.color}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Цвет"
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="size"
|
||||
value={newVariant.size}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Размер"
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="text"
|
||||
name="sku"
|
||||
value={newVariant.sku}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Артикул"
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="number"
|
||||
name="price"
|
||||
value={newVariant.price}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Цена"
|
||||
min="0"
|
||||
step="0.01"
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<input
|
||||
type="number"
|
||||
name="stock"
|
||||
value={newVariant.stock}
|
||||
onChange={handleNewVariantChange}
|
||||
placeholder="Наличие"
|
||||
min="0"
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddVariant}
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 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-4 w-4 mr-1" />
|
||||
Добавить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function CreateProductPage() {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [images, setImages] = useState([]);
|
||||
const [variants, setVariants] = useState<ProductVariant[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
category_id: '',
|
||||
sku: '',
|
||||
price: '',
|
||||
stock: '',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
// Загрузка категорий при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const data = await categoryService.getCategories();
|
||||
setCategories(data);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке категорий:', err);
|
||||
setError('Не удалось загрузить категории. Пожалуйста, попробуйте позже.');
|
||||
}
|
||||
};
|
||||
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
// Создаем базовый продукт
|
||||
const productData = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
category_id: parseInt(formData.category_id, 10),
|
||||
sku: formData.sku,
|
||||
price: parseFloat(formData.price),
|
||||
stock: parseInt(formData.stock, 10),
|
||||
is_active: formData.is_active,
|
||||
slug: generateSlug(formData.name)
|
||||
};
|
||||
|
||||
// Отправляем данные на сервер
|
||||
const product = await productService.createProduct(productData);
|
||||
|
||||
// Загружаем изображения
|
||||
for (const image of images) {
|
||||
await productService.uploadProductImage(
|
||||
product.id,
|
||||
image.file,
|
||||
image.isPrimary
|
||||
);
|
||||
}
|
||||
|
||||
// Добавляем варианты
|
||||
for (const variant of variants) {
|
||||
await productService.addProductVariant(product.id, {
|
||||
sku: variant.sku,
|
||||
size: variant.size,
|
||||
color: variant.color,
|
||||
price: variant.price,
|
||||
stock: variant.stock
|
||||
});
|
||||
}
|
||||
|
||||
// После успешного создания перенаправляем на страницу товаров
|
||||
router.push('/admin/products');
|
||||
} catch (error) {
|
||||
console.error('Ошибка при создании товара:', error);
|
||||
setError('Произошла ошибка при создании товара. Пожалуйста, проверьте введенные данные и попробуйте снова.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Генерация slug из названия
|
||||
const generateSlug = (name) => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\sа-яё-]/g, '') // Удаляем специальные символы, но оставляем кириллицу
|
||||
.replace(/\s+/g, '-') // Заменяем пробелы на дефисы
|
||||
.replace(/[а-яё]/g, char => { // Транслитерация кириллицы
|
||||
const translitMap = {
|
||||
'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo',
|
||||
'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm',
|
||||
'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
|
||||
'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '',
|
||||
'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya'
|
||||
};
|
||||
return translitMap[char] || char;
|
||||
})
|
||||
.replace(/--+/g, '-') // Заменяем множественные дефисы на один
|
||||
.replace(/^-|-$/g, ''); // Удаляем дефисы в начале и конце
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Добавление товара">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Новый товар</h2>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/admin/products')}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<X className="h-5 w-5 mr-2" />
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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"
|
||||
>
|
||||
<Save className="h-5 w-5 mr-2" />
|
||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-6">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">Название товара</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700 mb-1">Категория</label>
|
||||
<select
|
||||
id="category_id"
|
||||
name="category_id"
|
||||
required
|
||||
value={formData.category_id}
|
||||
onChange={handleChange}
|
||||
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"
|
||||
>
|
||||
<option value="">Выберите категорию</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="sku" className="block text-sm font-medium text-gray-700 mb-1">Артикул</label>
|
||||
<input
|
||||
type="text"
|
||||
id="sku"
|
||||
name="sku"
|
||||
required
|
||||
value={formData.sku}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
<label htmlFor="price" className="block text-sm font-medium text-gray-700 mb-1">Цена</label>
|
||||
<input
|
||||
type="number"
|
||||
id="price"
|
||||
name="price"
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={formData.price}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
<label htmlFor="stock" className="block text-sm font-medium text-gray-700 mb-1">Количество на складе</label>
|
||||
<input
|
||||
type="number"
|
||||
id="stock"
|
||||
name="stock"
|
||||
required
|
||||
min="0"
|
||||
value={formData.stock}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
<div className="flex items-center h-full">
|
||||
<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"
|
||||
/>
|
||||
<label htmlFor="is_active" className="ml-2 block text-sm text-gray-700">
|
||||
Активен (отображается на сайте)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">Описание</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={4}
|
||||
required
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
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>
|
||||
|
||||
<ImageUploader images={images} setImages={setImages} />
|
||||
<VariantManager variants={variants} setVariants={setVariants} />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
328
frontend/pages/admin/products/index.tsx
Normal file
328
frontend/pages/admin/products/index.tsx
Normal file
@ -0,0 +1,328 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Search, Edit, Trash, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import AdminLayout from '../../../components/admin/AdminLayout';
|
||||
import Image from 'next/image';
|
||||
import { productService, categoryService, Product, Category } from '../../../services/catalog';
|
||||
|
||||
// Компонент для фильтрации и поиска
|
||||
const FilterBar = ({ onSearch, categories, onFilter }) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [categoryId, setCategoryId] = useState('');
|
||||
const [status, setStatus] = useState('');
|
||||
|
||||
const handleSearch = (e) => {
|
||||
e.preventDefault();
|
||||
onSearch(searchTerm);
|
||||
};
|
||||
|
||||
const handleFilter = () => {
|
||||
onFilter({
|
||||
category_id: categoryId ? parseInt(categoryId) : undefined,
|
||||
is_active: status === 'active' ? true : status === 'inactive' ? false : undefined
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-6">
|
||||
<form onSubmit={handleSearch} 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">
|
||||
<select
|
||||
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
>
|
||||
<option value="">Все категории</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>{category.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="">Статус</option>
|
||||
<option value="active">Активные</option>
|
||||
<option value="inactive">Неактивные</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFilter}
|
||||
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"
|
||||
>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [filters, setFilters] = useState<{
|
||||
category_id?: number;
|
||||
is_active?: boolean;
|
||||
}>({});
|
||||
const [pagination, setPagination] = useState({
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
// Загрузка товаров и категорий при монтировании компонента
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Загружаем категории
|
||||
const categoriesData = await categoryService.getCategories();
|
||||
setCategories(categoriesData);
|
||||
|
||||
// Загружаем товары с учетом фильтров и пагинации
|
||||
const productsData = await productService.getProducts({
|
||||
skip: pagination.skip,
|
||||
limit: pagination.limit,
|
||||
search: searchTerm,
|
||||
...filters
|
||||
});
|
||||
|
||||
setProducts(productsData);
|
||||
// В реальном API должно возвращаться общее количество товаров
|
||||
// Здесь просто устанавливаем примерное значение
|
||||
setPagination(prev => ({ ...prev, total: 50 }));
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при загрузке данных:', err);
|
||||
setError('Не удалось загрузить данные. Пожалуйста, попробуйте позже.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [searchTerm, filters, pagination.skip, pagination.limit]);
|
||||
|
||||
// Обработчик поиска
|
||||
const handleSearch = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
setPagination(prev => ({ ...prev, skip: 0 })); // Сбрасываем пагинацию при поиске
|
||||
};
|
||||
|
||||
// Обработчик фильтрации
|
||||
const handleFilter = (newFilters: { category_id?: number; is_active?: boolean }) => {
|
||||
setFilters(newFilters);
|
||||
setPagination(prev => ({ ...prev, skip: 0 })); // Сбрасываем пагинацию при фильтрации
|
||||
};
|
||||
|
||||
// Обработчик удаления товара
|
||||
const handleDeleteProduct = async (id: number) => {
|
||||
if (window.confirm('Вы уверены, что хотите удалить этот товар?')) {
|
||||
try {
|
||||
await productService.deleteProduct(id);
|
||||
setProducts(products.filter(product => product.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при удалении товара:', err);
|
||||
setError('Не удалось удалить товар.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик изменения статуса товара
|
||||
const handleToggleStatus = async (id: number, currentStatus: boolean) => {
|
||||
try {
|
||||
await productService.updateProduct(id, { is_active: !currentStatus });
|
||||
setProducts(products.map(product =>
|
||||
product.id === id ? { ...product, is_active: !product.is_active } : product
|
||||
));
|
||||
} catch (err) {
|
||||
console.error('Ошибка при изменении статуса товара:', err);
|
||||
setError('Не удалось изменить статус товара.');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчики пагинации
|
||||
const handlePrevPage = () => {
|
||||
if (pagination.skip > 0) {
|
||||
setPagination(prev => ({ ...prev, skip: Math.max(0, prev.skip - prev.limit) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (pagination.skip + pagination.limit < pagination.total) {
|
||||
setPagination(prev => ({ ...prev, skip: prev.skip + prev.limit }));
|
||||
}
|
||||
};
|
||||
|
||||
// Вычисляем текущую страницу и общее количество страниц
|
||||
const currentPage = Math.floor(pagination.skip / pagination.limit) + 1;
|
||||
const totalPages = Math.ceil(pagination.total / pagination.limit);
|
||||
|
||||
if (loading && products.length === 0) {
|
||||
return (
|
||||
<AdminLayout title="Управление товарами">
|
||||
<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>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<AdminLayout title="Управление товарами">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
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"
|
||||
>
|
||||
Попробовать снова
|
||||
</button>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminLayout title="Управление товарами">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-800">Список товаров</h2>
|
||||
<Link href="/admin/products/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>
|
||||
|
||||
<FilterBar
|
||||
onSearch={handleSearch}
|
||||
categories={categories}
|
||||
onFilter={handleFilter}
|
||||
/>
|
||||
|
||||
<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">Товар</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">
|
||||
{products.map((product) => (
|
||||
<tr key={product.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10 relative">
|
||||
<Image
|
||||
src={product.images && product.images.length > 0
|
||||
? product.images.find(img => img.is_primary)?.url || product.images[0].url
|
||||
: '/placeholder.jpg'}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">{product.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.sku}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{product.price.toLocaleString('ru-RU')} ₽</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">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${product.stock > 20 ? 'bg-green-100 text-green-800' :
|
||||
product.stock > 10 ? 'bg-yellow-100 text-yellow-800' :
|
||||
'bg-red-100 text-red-800'}`}>
|
||||
{product.stock}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleToggleStatus(product.id, product.is_active)}
|
||||
className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
${product.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`}
|
||||
>
|
||||
{product.is_active ? 'Активен' : 'Неактивен'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link href={`/admin/products/${product.id}`} className="text-indigo-600 hover:text-indigo-900">
|
||||
<Edit className="h-5 w-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDeleteProduct(product.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<Trash className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{products.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={7} 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 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500">
|
||||
Показано {pagination.skip + 1}-{Math.min(pagination.skip + products.length, pagination.total)} из {pagination.total} товаров
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
className="inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={handlePrevPage}
|
||||
disabled={pagination.skip === 0}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center px-3 py-1 border border-gray-300 text-sm font-medium rounded-md bg-white text-gray-700 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={handleNextPage}
|
||||
disabled={pagination.skip + pagination.limit >= pagination.total}
|
||||
>
|
||||
Вперед
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
130
frontend/pages/forgot-password.tsx
Normal file
130
frontend/pages/forgot-password.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Mail, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import authService from '../services/auth';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
setEmail(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuccess(false);
|
||||
|
||||
try {
|
||||
// Предполагаем, что в authService есть метод resetPassword
|
||||
// Если его нет, нужно будет добавить
|
||||
await authService.resetPassword(email);
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при запросе сброса пароля:', err);
|
||||
setError('Не удалось отправить запрос на сброс пароля. Пожалуйста, проверьте введенный email.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<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">
|
||||
Введите email, указанный при регистрации, и мы отправим вам инструкции по сбросу пароля.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success ? (
|
||||
<div className="text-center">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Проверьте вашу почту</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Мы отправили инструкции по сбросу пароля на адрес {email}.
|
||||
Если вы не получили письмо в течение нескольких минут, проверьте папку "Спам".
|
||||
</p>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<button
|
||||
onClick={() => setSuccess(false)}
|
||||
className="w-full flex justify-center py-2 px-4 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"
|
||||
>
|
||||
Отправить еще раз
|
||||
</button>
|
||||
<Link href="/login" className="w-full flex justify-center py-2 px-4 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">
|
||||
Вернуться на страницу входа
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="example@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Отправка...' : 'Отправить инструкции'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Вернуться на страницу входа
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
frontend/pages/login.tsx
Normal file
200
frontend/pages/login.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Mail, Lock, AlertCircle } from 'lucide-react';
|
||||
import authService from '../services/auth';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [redirectTo, setRedirectTo] = useState<string | null>(null);
|
||||
|
||||
// Проверяем, есть ли параметр redirect в URL
|
||||
useEffect(() => {
|
||||
if (router.query.redirect) {
|
||||
setRedirectTo(router.query.redirect as string);
|
||||
}
|
||||
|
||||
// Если пользователь уже авторизован, перенаправляем его
|
||||
if (authService.isAuthenticated()) {
|
||||
router.push(redirectTo || '/');
|
||||
}
|
||||
}, [router.query.redirect, redirectTo, router]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await authService.login(formData);
|
||||
|
||||
// Перенаправляем пользователя после успешного входа
|
||||
router.push(redirectTo || '/');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при входе:', err);
|
||||
setError('Неверный email или пароль. Пожалуйста, проверьте введенные данные.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<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">
|
||||
Или{' '}
|
||||
<Link href="/register" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
зарегистрируйтесь, если у вас еще нет аккаунта
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
|
||||
Имя пользователя или Email
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="username или example@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</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">
|
||||
<Link href="/forgot-password" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Забыли пароль?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Или войдите через</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<a
|
||||
href="#"
|
||||
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span className="sr-only">Войти через Google</span>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<a
|
||||
href="#"
|
||||
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
<span className="sr-only">Войти через Facebook</span>
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
260
frontend/pages/register.tsx
Normal file
260
frontend/pages/register.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Mail, Lock, User, Phone, AlertCircle } from 'lucide-react';
|
||||
import authService from '../services/auth';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirm: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [redirectTo, setRedirectTo] = useState<string | null>(null);
|
||||
|
||||
// Проверяем, есть ли параметр redirect в URL
|
||||
useEffect(() => {
|
||||
if (router.query.redirect) {
|
||||
setRedirectTo(router.query.redirect as string);
|
||||
}
|
||||
|
||||
// Если пользователь уже авторизован, перенаправляем его
|
||||
if (authService.isAuthenticated()) {
|
||||
router.push(redirectTo || '/');
|
||||
}
|
||||
}, [router.query.redirect, redirectTo, router]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Проверяем совпадение паролей
|
||||
if (formData.password !== formData.password_confirm) {
|
||||
setError('Пароли не совпадают');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Отправляем данные для регистрации (включая поле password_confirm)
|
||||
await authService.register(formData);
|
||||
|
||||
// Перенаправляем пользователя после успешной регистрации
|
||||
router.push(redirectTo || '/');
|
||||
} catch (err) {
|
||||
console.error('Ошибка при регистрации:', err);
|
||||
setError('Не удалось зарегистрироваться. Возможно, пользователь с таким email уже существует.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<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">
|
||||
Уже есть аккаунт?{' '}
|
||||
<Link href="/login" className="font-medium text-indigo-600 hover:text-indigo-500">
|
||||
Войдите
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="first_name" className="block text-sm font-medium text-gray-700">
|
||||
Имя
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
required
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Иван"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="last_name" className="block text-sm font-medium text-gray-700">
|
||||
Фамилия
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
required
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Иванов"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
||||
Email
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="example@example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
|
||||
Телефон (необязательно)
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Phone className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="+7 (999) 123-45-67"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Пароль
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">Минимум 8 символов</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password_confirm" className="block text-sm font-medium text-gray-700">
|
||||
Подтверждение пароля
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password_confirm"
|
||||
name="password_confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password_confirm}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="terms"
|
||||
name="terms"
|
||||
type="checkbox"
|
||||
required
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="terms" className="ml-2 block text-sm text-gray-900">
|
||||
Я согласен с <a href="#" className="text-indigo-600 hover:text-indigo-500">условиями использования</a> и <a href="#" className="text-indigo-600 hover:text-indigo-500">политикой конфиденциальности</a>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Регистрация...' : 'Зарегистрироваться'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
frontend/pages/reset-password/[token].tsx
Normal file
210
frontend/pages/reset-password/[token].tsx
Normal file
@ -0,0 +1,210 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Link from 'next/link';
|
||||
import { Lock, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import authService from '../../services/auth';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter();
|
||||
const { token } = router.query;
|
||||
const [formData, setFormData] = useState({
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [tokenValid, setTokenValid] = useState(true);
|
||||
|
||||
// Проверка валидности токена
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
// В реальном приложении здесь можно сделать запрос к API для проверки токена
|
||||
// Для примера просто проверяем, что токен не пустой
|
||||
setTokenValid(typeof token === 'string' && token.length > 0);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Проверяем совпадение паролей
|
||||
if (formData.password !== formData.password_confirm) {
|
||||
setError('Пароли не совпадают');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof token !== 'string') {
|
||||
throw new Error('Недействительный токен');
|
||||
}
|
||||
|
||||
await authService.setNewPassword(token, formData.password);
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
console.error('Ошибка при сбросе пароля:', err);
|
||||
setError('Не удалось установить новый пароль. Возможно, ссылка устарела или недействительна.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10 text-center">
|
||||
<p className="text-gray-600 mb-4">Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tokenValid) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Недействительная ссылка
|
||||
</h2>
|
||||
<div className="mt-8 bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10 text-center">
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4 text-left">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">
|
||||
Ссылка для сброса пароля недействительна или устарела.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Пожалуйста, запросите новую ссылку для сброса пароля.
|
||||
</p>
|
||||
<Link href="/forgot-password" className="w-full flex justify-center py-2 px-4 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<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>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border-l-4 border-red-500 p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertCircle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success ? (
|
||||
<div className="text-center">
|
||||
<div className="mb-4 flex justify-center">
|
||||
<CheckCircle className="h-12 w-12 text-green-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">Пароль успешно изменен</h3>
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
Ваш пароль был успешно изменен. Теперь вы можете войти в систему, используя новый пароль.
|
||||
</p>
|
||||
<Link href="/login" className="w-full flex justify-center py-2 px-4 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>
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
||||
Новый пароль
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">Минимум 8 символов</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password_confirm" className="block text-sm font-medium text-gray-700">
|
||||
Подтверждение пароля
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password_confirm"
|
||||
name="password_confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password_confirm}
|
||||
onChange={handleChange}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="••••••••"
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full flex justify-center py-2 px-4 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Сохранение...' : 'Установить новый пароль'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
frontend/services/analytics.ts
Normal file
100
frontend/services/analytics.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import api from './api';
|
||||
|
||||
// Типы данных
|
||||
export interface AnalyticsLog {
|
||||
id: number;
|
||||
event_type: string;
|
||||
user_id?: number;
|
||||
session_id: string;
|
||||
data: Record<string, any>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsLogCreate {
|
||||
event_type: string;
|
||||
session_id: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface AnalyticsReport {
|
||||
report_type: string;
|
||||
data: Record<string, any>;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
// Сервис для работы с аналитикой
|
||||
export const analyticsService = {
|
||||
// Логирование события аналитики
|
||||
logEvent: async (event: AnalyticsLogCreate): Promise<AnalyticsLog> => {
|
||||
const response = await api.post('/analytics/log', event);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Получить логи аналитики (только для админов)
|
||||
getLogs: async (params?: {
|
||||
event_type?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
}): Promise<{ logs: AnalyticsLog[], total: number }> => {
|
||||
const response = await api.get('/analytics/logs', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Получить отчет по аналитике (только для админов)
|
||||
getReport: async (reportType: string, params?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}): Promise<AnalyticsReport> => {
|
||||
const response = await api.get(`/analytics/report`, {
|
||||
params: {
|
||||
report_type: reportType,
|
||||
...params
|
||||
}
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Инициализация отслеживания сессии
|
||||
initTracking: (): string => {
|
||||
// Генерируем ID сессии, если его нет
|
||||
let sessionId = localStorage.getItem('session_id');
|
||||
if (!sessionId) {
|
||||
sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
localStorage.setItem('session_id', sessionId);
|
||||
}
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
// Отслеживание просмотра страницы
|
||||
trackPageView: async (path: string): Promise<void> => {
|
||||
const sessionId = analyticsService.initTracking();
|
||||
await analyticsService.logEvent({
|
||||
event_type: 'page_view',
|
||||
session_id: sessionId,
|
||||
data: { path }
|
||||
});
|
||||
},
|
||||
|
||||
// Отслеживание просмотра товара
|
||||
trackProductView: async (productId: number, productName: string): Promise<void> => {
|
||||
const sessionId = analyticsService.initTracking();
|
||||
await analyticsService.logEvent({
|
||||
event_type: 'product_view',
|
||||
session_id: sessionId,
|
||||
data: { product_id: productId, product_name: productName }
|
||||
});
|
||||
},
|
||||
|
||||
// Отслеживание добавления товара в корзину
|
||||
trackAddToCart: async (productId: number, productName: string, quantity: number): Promise<void> => {
|
||||
const sessionId = analyticsService.initTracking();
|
||||
await analyticsService.logEvent({
|
||||
event_type: 'add_to_cart',
|
||||
session_id: sessionId,
|
||||
data: { product_id: productId, product_name: productName, quantity }
|
||||
});
|
||||
}
|
||||
};
|
||||
45
frontend/services/api.ts
Normal file
45
frontend/services/api.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Создаем экземпляр axios с базовыми настройками
|
||||
const api = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Перехватчик запросов для добавления токена авторизации
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Перехватчик ответов для обработки ошибок
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Если ошибка 401 (неавторизован) и это не повторный запрос
|
||||
if (error.response.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
// Здесь можно добавить логику обновления токена
|
||||
// Например, перенаправление на страницу входа
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
94
frontend/services/auth.ts
Normal file
94
frontend/services/auth.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import api from './api';
|
||||
import { User } from './users';
|
||||
|
||||
// Типы данных
|
||||
export interface LoginCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirm: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
// Сервис для аутентификации
|
||||
const authService = {
|
||||
// Вход в систему
|
||||
login: async (credentials: LoginCredentials): Promise<AuthResponse> => {
|
||||
// Создаем FormData для отправки данных в формате x-www-form-urlencoded
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('username', credentials.username);
|
||||
formData.append('password', credentials.password);
|
||||
|
||||
const response = await api.post<AuthResponse>('/auth/login', formData.toString(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
|
||||
// Сохраняем токен в localStorage
|
||||
if (response.data.access_token) {
|
||||
localStorage.setItem('token', response.data.access_token);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Регистрация нового пользователя
|
||||
register: async (data: RegisterData): Promise<AuthResponse> => {
|
||||
const response = await api.post<AuthResponse>('/auth/register', data);
|
||||
|
||||
// Сохраняем токен в localStorage
|
||||
if (response.data.access_token) {
|
||||
localStorage.setItem('token', response.data.access_token);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Выход из системы
|
||||
logout: (): void => {
|
||||
localStorage.removeItem('token');
|
||||
},
|
||||
|
||||
// Проверка, авторизован ли пользователь
|
||||
isAuthenticated: (): boolean => {
|
||||
return !!localStorage.getItem('token');
|
||||
},
|
||||
|
||||
// Получить текущий токен
|
||||
getToken: (): string | null => {
|
||||
return localStorage.getItem('token');
|
||||
},
|
||||
|
||||
// Запрос на сброс пароля
|
||||
resetPassword: async (email: string): Promise<void> => {
|
||||
await api.post('/auth/reset-password', { email });
|
||||
},
|
||||
|
||||
// Установка нового пароля после сброса
|
||||
setNewPassword: async (token: string, password: string): Promise<void> => {
|
||||
await api.post('/auth/set-new-password', { token, password });
|
||||
},
|
||||
|
||||
// Изменение пароля авторизованным пользователем
|
||||
changePassword: async (currentPassword: string, newPassword: string): Promise<void> => {
|
||||
await api.post('/auth/change-password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default authService;
|
||||
178
frontend/services/catalog.ts
Normal file
178
frontend/services/catalog.ts
Normal file
@ -0,0 +1,178 @@
|
||||
import api from './api';
|
||||
|
||||
// Типы данных
|
||||
export interface Category {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
parent_id: number | null;
|
||||
order: number;
|
||||
is_active: boolean;
|
||||
products_count?: number;
|
||||
children?: Category[];
|
||||
}
|
||||
|
||||
export interface ProductImage {
|
||||
id: number;
|
||||
url: string;
|
||||
is_primary: boolean;
|
||||
product_id: number;
|
||||
}
|
||||
|
||||
export interface ProductVariant {
|
||||
id: number;
|
||||
sku: string;
|
||||
size: string;
|
||||
color: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
product_id: number;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
price: number;
|
||||
sku: string;
|
||||
stock: number;
|
||||
is_active: boolean;
|
||||
category_id: number;
|
||||
category?: Category;
|
||||
images?: ProductImage[];
|
||||
variants?: ProductVariant[];
|
||||
}
|
||||
|
||||
// Сервисы для работы с категориями
|
||||
export const categoryService = {
|
||||
// Получить все категории в виде дерева
|
||||
getCategories: async (): Promise<Category[]> => {
|
||||
const response = await api.get('/catalog/categories');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Создать новую категорию
|
||||
createCategory: async (category: Omit<Category, 'id'>): Promise<Category> => {
|
||||
const response = await api.post('/catalog/categories', category);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Обновить категорию
|
||||
updateCategory: async (id: number, category: Partial<Category>): Promise<Category> => {
|
||||
const response = await api.put(`/catalog/categories/${id}`, category);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Удалить категорию
|
||||
deleteCategory: async (id: number): Promise<void> => {
|
||||
await api.delete(`/catalog/categories/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Сервисы для работы с товарами
|
||||
export const productService = {
|
||||
// Получить список товаров с фильтрацией
|
||||
getProducts: async (params?: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
category_id?: number;
|
||||
search?: string;
|
||||
min_price?: number;
|
||||
max_price?: number;
|
||||
is_active?: boolean;
|
||||
}): Promise<Product[]> => {
|
||||
const response = await api.get('/catalog/products', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Получить детали товара по ID
|
||||
getProductById: async (id: number): Promise<Product> => {
|
||||
try {
|
||||
const response = await api.get(`/catalog/products/${id}`);
|
||||
|
||||
// Проверяем структуру ответа
|
||||
if (response.data && response.data.product) {
|
||||
// Если ответ содержит вложенный объект product, используем его
|
||||
const product = response.data.product;
|
||||
|
||||
// Добавляем изображения и варианты из ответа
|
||||
if (response.data.images && Array.isArray(response.data.images)) {
|
||||
product.images = response.data.images;
|
||||
}
|
||||
|
||||
if (response.data.variants && Array.isArray(response.data.variants)) {
|
||||
product.variants = response.data.variants;
|
||||
}
|
||||
|
||||
return product;
|
||||
} else {
|
||||
// Если структура ответа другая, возвращаем как есть
|
||||
return response.data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении продукта:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Создать новый товар
|
||||
createProduct: async (product: Omit<Product, 'id'>): Promise<Product> => {
|
||||
const response = await api.post('/catalog/products', product);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Обновить товар
|
||||
updateProduct: async (id: number, product: Partial<Product>): Promise<Product> => {
|
||||
const response = await api.put(`/catalog/products/${id}`, product);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Удалить товар
|
||||
deleteProduct: async (id: number): Promise<void> => {
|
||||
await api.delete(`/catalog/products/${id}`);
|
||||
},
|
||||
|
||||
// Загрузить изображение товара
|
||||
uploadProductImage: async (productId: number, file: File, isPrimary: boolean): Promise<ProductImage> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('is_primary', isPrimary.toString());
|
||||
|
||||
const response = await api.post(`/catalog/products/${productId}/images`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Обновить изображение товара
|
||||
updateProductImage: async (imageId: number, data: { is_primary: boolean }): Promise<ProductImage> => {
|
||||
const response = await api.put(`/catalog/images/${imageId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Удалить изображение товара
|
||||
deleteProductImage: async (imageId: number): Promise<void> => {
|
||||
await api.delete(`/catalog/images/${imageId}`);
|
||||
},
|
||||
|
||||
// Добавить вариант товара
|
||||
addProductVariant: async (productId: number, variant: Omit<ProductVariant, 'id' | 'product_id'>): Promise<ProductVariant> => {
|
||||
const response = await api.post(`/catalog/products/${productId}/variants`, variant);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Обновить вариант товара
|
||||
updateProductVariant: async (variantId: number, variant: Partial<ProductVariant>): Promise<ProductVariant> => {
|
||||
const response = await api.put(`/catalog/variants/${variantId}`, variant);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Удалить вариант товара
|
||||
deleteProductVariant: async (variantId: number): Promise<void> => {
|
||||
await api.delete(`/catalog/variants/${variantId}`);
|
||||
}
|
||||
};
|
||||
78
frontend/services/orders.ts
Normal file
78
frontend/services/orders.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import api from './api';
|
||||
|
||||
// Типы данных
|
||||
export interface OrderItem {
|
||||
id: number;
|
||||
order_id: number;
|
||||
product_id: number;
|
||||
variant_id?: number;
|
||||
quantity: number;
|
||||
price: number;
|
||||
product: {
|
||||
id: number;
|
||||
name: string;
|
||||
image?: string;
|
||||
};
|
||||
variant_name?: string;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: number;
|
||||
user_id: number;
|
||||
status: string;
|
||||
total_amount: number;
|
||||
shipping_address: string;
|
||||
payment_method: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
items: OrderItem[];
|
||||
}
|
||||
|
||||
export interface OrderCreate {
|
||||
shipping_address_id: number;
|
||||
payment_method: string;
|
||||
items?: {
|
||||
product_id: number;
|
||||
variant_id?: number;
|
||||
quantity: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface OrderUpdate {
|
||||
status?: string;
|
||||
shipping_address_id?: number;
|
||||
payment_method?: string;
|
||||
}
|
||||
|
||||
// Сервис для работы с заказами
|
||||
export const orderService = {
|
||||
// Получить список заказов пользователя
|
||||
getUserOrders: async (): Promise<Order[]> => {
|
||||
const response = await api.get('/orders');
|
||||
return response.data.orders;
|
||||
},
|
||||
|
||||
// Получить заказ по ID
|
||||
getOrderById: async (id: number): Promise<Order> => {
|
||||
const response = await api.get(`/orders/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Создать новый заказ
|
||||
createOrder: async (order: OrderCreate): Promise<Order> => {
|
||||
const response = await api.post('/orders', order);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Обновить заказ
|
||||
updateOrder: async (id: number, order: OrderUpdate): Promise<Order> => {
|
||||
const response = await api.put(`/orders/${id}`, order);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Отменить заказ
|
||||
cancelOrder: async (id: number): Promise<Order> => {
|
||||
const response = await api.post(`/orders/${id}/cancel`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
99
frontend/services/users.ts
Normal file
99
frontend/services/users.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import api from './api';
|
||||
|
||||
// Типы данных
|
||||
export interface Address {
|
||||
id: number;
|
||||
user_id: number;
|
||||
type: string;
|
||||
address: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
phone?: string;
|
||||
is_active: boolean;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
addresses?: Address[];
|
||||
}
|
||||
|
||||
export interface UserUpdate {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
phone?: string;
|
||||
password?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface AddressCreate {
|
||||
type: string;
|
||||
address: string;
|
||||
city: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface AddressUpdate {
|
||||
type?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
// Сервис для работы с пользователями
|
||||
export const userService = {
|
||||
// Получить профиль текущего пользователя
|
||||
getCurrentUser: async (): Promise<User> => {
|
||||
const response = await api.get('/users/me');
|
||||
return response.data.user;
|
||||
},
|
||||
|
||||
// Обновить профиль текущего пользователя
|
||||
updateCurrentUser: async (userData: UserUpdate): Promise<User> => {
|
||||
const response = await api.put('/users/me', userData);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Добавить адрес для текущего пользователя
|
||||
addAddress: async (address: AddressCreate): Promise<Address> => {
|
||||
const response = await api.post('/users/me/addresses', address);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Обновить адрес
|
||||
updateAddress: async (addressId: number, address: AddressUpdate): Promise<Address> => {
|
||||
const response = await api.put(`/users/me/addresses/${addressId}`, address);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Удалить адрес
|
||||
deleteAddress: async (addressId: number): Promise<void> => {
|
||||
await api.delete(`/users/me/addresses/${addressId}`);
|
||||
},
|
||||
|
||||
// Получить информацию о пользователе по ID (только для админов)
|
||||
getUserById: async (userId: number): Promise<User> => {
|
||||
const response = await api.get(`/users/${userId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Получить список всех пользователей (только для админов)
|
||||
getUsers: async (params?: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
}): Promise<{ users: User[], total: number }> => {
|
||||
const response = await api.get('/users', { params });
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user