init
This commit is contained in:
@@ -21,7 +21,7 @@ async def get_current_admin(
|
||||
"""Dependency that validates JWT and returns the current admin user."""
|
||||
token = credentials.credentials
|
||||
payload = decode_access_token(token)
|
||||
if not payload:
|
||||
if not payload or payload.get("type", "admin") != "admin":
|
||||
logger.warning("Invalid or expired token")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Невалидный или просроченный токен")
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from app.database import async_session, engine, Base
|
||||
from app.models import Material, AdminUser
|
||||
from app.seed.materials import MATERIALS
|
||||
from app.services.auth import hash_password
|
||||
from app.routers import calculate, materials, orders, ai_advisor, admin
|
||||
from app.routers import calculate, materials, orders, ai_advisor, admin, clients, track
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
@@ -100,6 +100,8 @@ app.include_router(calculate.router, prefix="/api")
|
||||
app.include_router(materials.router, prefix="/api")
|
||||
app.include_router(orders.router, prefix="/api")
|
||||
app.include_router(ai_advisor.router, prefix="/api")
|
||||
app.include_router(track.router, prefix="/api")
|
||||
app.include_router(clients.router, prefix="/api/client")
|
||||
app.include_router(admin.router, prefix="/api/admin")
|
||||
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@ from app.models.calculation import Calculation
|
||||
from app.models.order import Order
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.app_settings import AppSettings
|
||||
from app.models.client import Client
|
||||
|
||||
__all__ = ["Material", "Calculation", "Order", "AdminUser", "AppSettings"]
|
||||
__all__ = ["Material", "Calculation", "Order", "AdminUser", "AppSettings", "Client"]
|
||||
|
||||
18
backend/app/models/client.py
Normal file
18
backend/app/models/client.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import Boolean, Integer, String, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Client(Base):
|
||||
__tablename__ = "clients"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(200), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
phone: Mapped[str | None] = mapped_column(String(20))
|
||||
company: Mapped[str | None] = mapped_column(String(200))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(default=func.now())
|
||||
@@ -13,6 +13,7 @@ class Order(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
order_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
|
||||
calculation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("calculations.id"), nullable=False)
|
||||
client_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("clients.id"))
|
||||
client_name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
client_phone: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
client_email: Mapped[str | None] = mapped_column(String(200))
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_admin
|
||||
from app.models.admin_user import AdminUser
|
||||
from app.models.client import Client
|
||||
from app.models.material import Material
|
||||
from app.models.calculation import Calculation
|
||||
from app.models.order import Order
|
||||
@@ -230,6 +231,7 @@ class DashboardStats(BaseModel):
|
||||
total_revenue: float
|
||||
total_calculations: int
|
||||
materials_count: int
|
||||
clients_count: int
|
||||
orders_today: int
|
||||
|
||||
|
||||
@@ -244,6 +246,7 @@ async def dashboard(admin: AdminUser = Depends(get_current_admin), db: AsyncSess
|
||||
total_revenue = (await db.execute(select(func.sum(Order.total_rub)))).scalar() or 0.0
|
||||
total_calcs = (await db.execute(select(func.count(Calculation.id)))).scalar() or 0
|
||||
materials_count = (await db.execute(select(func.count(Material.id)))).scalar() or 0
|
||||
clients_count = (await db.execute(select(func.count(Client.id)))).scalar() or 0
|
||||
|
||||
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
orders_today = (await db.execute(
|
||||
@@ -256,6 +259,7 @@ async def dashboard(admin: AdminUser = Depends(get_current_admin), db: AsyncSess
|
||||
total_revenue=round(total_revenue, 2),
|
||||
total_calculations=total_calcs,
|
||||
materials_count=materials_count,
|
||||
clients_count=clients_count,
|
||||
orders_today=orders_today,
|
||||
)
|
||||
|
||||
@@ -276,7 +280,7 @@ class MaterialCreate(BaseModel):
|
||||
uv_resistance: str | None = None
|
||||
food_safe: bool = False
|
||||
description: str | None = None
|
||||
color_options: list[str] = []
|
||||
color_options: list[dict] = []
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
@@ -575,3 +579,96 @@ async def update_setting(
|
||||
await db.commit()
|
||||
logger.info("Setting updated: %s = %s", key, data.value)
|
||||
return {"key": key, "value": data.value}
|
||||
|
||||
|
||||
# ─── Clients CRM ────────────────────────────────────────
|
||||
|
||||
class ClientOut(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
phone: str | None
|
||||
company: str | None
|
||||
is_active: bool
|
||||
created_at: str
|
||||
orders_count: int = 0
|
||||
total_spent: float = 0
|
||||
|
||||
|
||||
@router.get("/clients", response_model=list[ClientOut])
|
||||
async def list_clients(
|
||||
admin: AdminUser = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
logger.info("Admin clients list requested")
|
||||
result = await db.execute(select(Client).order_by(desc(Client.created_at)))
|
||||
clients = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for c in clients:
|
||||
orders_result = await db.execute(
|
||||
select(func.count(Order.id), func.coalesce(func.sum(Order.total_rub), 0))
|
||||
.where(Order.client_id == c.id)
|
||||
)
|
||||
row = orders_result.one()
|
||||
out.append(ClientOut(
|
||||
id=c.id, email=c.email, name=c.name, phone=c.phone,
|
||||
company=c.company, is_active=c.is_active,
|
||||
created_at=c.created_at.isoformat() if c.created_at else "",
|
||||
orders_count=row[0], total_spent=round(row[1], 2),
|
||||
))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/clients/{client_id}")
|
||||
async def get_client_detail(
|
||||
client_id: int,
|
||||
admin: AdminUser = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Client).where(Client.id == client_id))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(404, "Клиент не найден")
|
||||
|
||||
orders_result = await db.execute(
|
||||
select(Order).where(Order.client_id == client_id).order_by(desc(Order.created_at))
|
||||
)
|
||||
orders = orders_result.scalars().all()
|
||||
|
||||
return {
|
||||
"client": {
|
||||
"id": client.id, "email": client.email, "name": client.name,
|
||||
"phone": client.phone, "company": client.company,
|
||||
"is_active": client.is_active,
|
||||
"created_at": client.created_at.isoformat() if client.created_at else "",
|
||||
},
|
||||
"orders": [
|
||||
{
|
||||
"order_id": o.order_id, "status": o.status, "total_rub": o.total_rub,
|
||||
"created_at": o.created_at.isoformat() if o.created_at else "",
|
||||
}
|
||||
for o in orders
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class ClientToggleActive(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
@router.patch("/clients/{client_id}/active")
|
||||
async def toggle_client_active(
|
||||
client_id: int,
|
||||
data: ClientToggleActive,
|
||||
admin: AdminUser = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(Client).where(Client.id == client_id))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client:
|
||||
raise HTTPException(404, "Клиент не найден")
|
||||
client.is_active = data.is_active
|
||||
await db.commit()
|
||||
logger.info("Client id=%d is_active=%s (by %s)", client_id, data.is_active, admin.email)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -37,6 +37,8 @@ async def calculate(
|
||||
layer_height_mm: float = Form(0.2),
|
||||
quantity: int = Form(1),
|
||||
post_processing: str = Form(""),
|
||||
color: str = Form(""),
|
||||
multicolor: bool = Form(False),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
logger.info("===== /api/calculate request =====")
|
||||
@@ -51,8 +53,8 @@ async def calculate(
|
||||
raise HTTPException(400, f"Неподдерживаемый формат файла. Допустимые: {', '.join(SUPPORTED_EXTENSIONS)}")
|
||||
|
||||
# Validate params
|
||||
logger.info("Params: material_id=%d, infill=%d%%, layer=%.2fmm, qty=%d, post_processing='%s'",
|
||||
material_id, infill_percent, layer_height_mm, quantity, post_processing)
|
||||
logger.info("Params: material_id=%d, infill=%d%%, layer=%.2fmm, qty=%d, color='%s', multicolor=%s, post_processing='%s'",
|
||||
material_id, infill_percent, layer_height_mm, quantity, color, multicolor, post_processing)
|
||||
|
||||
if not 10 <= infill_percent <= 100:
|
||||
logger.warning("Invalid infill_percent: %d", infill_percent)
|
||||
@@ -134,6 +136,7 @@ async def calculate(
|
||||
layer_height_mm=layer_height_mm,
|
||||
quantity=quantity,
|
||||
post_processing=pp_list,
|
||||
multicolor=multicolor,
|
||||
)
|
||||
|
||||
# Save calculation to DB
|
||||
|
||||
181
backend/app/routers/clients.py
Normal file
181
backend/app/routers/clients.py
Normal file
@@ -0,0 +1,181 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.client import Client
|
||||
from app.models.order import Order
|
||||
from app.models.calculation import Calculation
|
||||
from app.models.material import Material
|
||||
from app.services.auth import hash_password, verify_password, create_client_token, decode_access_token
|
||||
|
||||
logger = logging.getLogger("app.routers.clients")
|
||||
|
||||
router = APIRouter()
|
||||
bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────
|
||||
|
||||
async def get_current_client(
|
||||
creds: HTTPAuthorizationCredentials = Depends(bearer),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Client:
|
||||
if not creds:
|
||||
raise HTTPException(401, "Требуется авторизация")
|
||||
payload = decode_access_token(creds.credentials)
|
||||
if not payload or payload.get("type") != "client":
|
||||
raise HTTPException(401, "Невалидный токен")
|
||||
result = await db.execute(select(Client).where(Client.id == int(payload["sub"])))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client or not client.is_active:
|
||||
raise HTTPException(401, "Клиент не найден или деактивирован")
|
||||
return client
|
||||
|
||||
|
||||
# ─── Auth ────────────────────────────────────────────────
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
name: str
|
||||
phone: str | None = None
|
||||
company: str | None = None
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
token: str
|
||||
client: dict
|
||||
|
||||
|
||||
@router.post("/register", response_model=AuthResponse)
|
||||
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
logger.info("Client register: %s", data.email)
|
||||
existing = await db.execute(select(Client).where(Client.email == data.email))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(400, "Пользователь с таким email уже зарегистрирован")
|
||||
|
||||
client = Client(
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
name=data.name,
|
||||
phone=data.phone,
|
||||
company=data.company,
|
||||
)
|
||||
db.add(client)
|
||||
await db.commit()
|
||||
await db.refresh(client)
|
||||
logger.info("Client registered: id=%d, email=%s", client.id, client.email)
|
||||
|
||||
token = create_client_token(client.id, client.email)
|
||||
return AuthResponse(
|
||||
token=token,
|
||||
client={"id": client.id, "email": client.email, "name": client.name, "phone": client.phone, "company": client.company},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthResponse)
|
||||
async def login(data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
logger.info("Client login: %s", data.email)
|
||||
result = await db.execute(select(Client).where(Client.email == data.email, Client.is_active == True))
|
||||
client = result.scalar_one_or_none()
|
||||
if not client or not verify_password(data.password, client.password_hash):
|
||||
raise HTTPException(401, "Неверный email или пароль")
|
||||
|
||||
token = create_client_token(client.id, client.email)
|
||||
logger.info("Client logged in: id=%d", client.id)
|
||||
return AuthResponse(
|
||||
token=token,
|
||||
client={"id": client.id, "email": client.email, "name": client.name, "phone": client.phone, "company": client.company},
|
||||
)
|
||||
|
||||
|
||||
# ─── Profile ────────────────────────────────────────────
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
id: int
|
||||
email: str
|
||||
name: str
|
||||
phone: str | None
|
||||
company: str | None
|
||||
|
||||
|
||||
@router.get("/me", response_model=ProfileResponse)
|
||||
async def get_profile(client: Client = Depends(get_current_client)):
|
||||
return ProfileResponse(id=client.id, email=client.email, name=client.name, phone=client.phone, company=client.company)
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
phone: str | None = None
|
||||
company: str | None = None
|
||||
|
||||
|
||||
@router.put("/me", response_model=ProfileResponse)
|
||||
async def update_profile(data: ProfileUpdate, client: Client = Depends(get_current_client), db: AsyncSession = Depends(get_db)):
|
||||
if data.name is not None:
|
||||
client.name = data.name
|
||||
if data.phone is not None:
|
||||
client.phone = data.phone
|
||||
if data.company is not None:
|
||||
client.company = data.company
|
||||
await db.commit()
|
||||
await db.refresh(client)
|
||||
return ProfileResponse(id=client.id, email=client.email, name=client.name, phone=client.phone, company=client.company)
|
||||
|
||||
|
||||
# ─── Client Orders ───────────────────────────────────────
|
||||
|
||||
class ClientOrderOut(BaseModel):
|
||||
order_id: str
|
||||
status: str
|
||||
total_rub: float
|
||||
material_name: str | None = None
|
||||
file_name: str | None = None
|
||||
quantity: int | None = None
|
||||
created_at: str
|
||||
delivery_method: str
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
@router.get("/orders", response_model=list[ClientOrderOut])
|
||||
async def get_my_orders(client: Client = Depends(get_current_client), db: AsyncSession = Depends(get_db)):
|
||||
logger.info("Client orders requested: client_id=%d", client.id)
|
||||
result = await db.execute(
|
||||
select(Order).where(Order.client_id == client.id).order_by(desc(Order.created_at))
|
||||
)
|
||||
orders = result.scalars().all()
|
||||
|
||||
out = []
|
||||
for o in orders:
|
||||
calc_result = await db.execute(select(Calculation).where(Calculation.id == o.calculation_id))
|
||||
calc = calc_result.scalar_one_or_none()
|
||||
material_name = None
|
||||
if calc:
|
||||
mat_result = await db.execute(select(Material).where(Material.id == calc.material_id))
|
||||
mat = mat_result.scalar_one_or_none()
|
||||
material_name = mat.name if mat else None
|
||||
|
||||
out.append(ClientOrderOut(
|
||||
order_id=o.order_id,
|
||||
status=o.status,
|
||||
total_rub=o.total_rub,
|
||||
material_name=material_name,
|
||||
file_name=calc.file_name if calc else None,
|
||||
quantity=calc.quantity if calc else None,
|
||||
created_at=o.created_at.isoformat() if o.created_at else "",
|
||||
delivery_method=o.delivery_method,
|
||||
comment=o.comment,
|
||||
))
|
||||
|
||||
logger.info("Returning %d orders for client_id=%d", len(out), client.id)
|
||||
return out
|
||||
@@ -8,9 +8,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.calculation import Calculation
|
||||
from app.models.client import Client
|
||||
from app.models.material import Material
|
||||
from app.models.order import Order
|
||||
from app.schemas.order import OrderCreate, OrderResponse
|
||||
from app.services.auth import decode_access_token
|
||||
from app.services.telegram_notify import notify_new_order
|
||||
|
||||
logger = logging.getLogger("app.routers.orders")
|
||||
@@ -61,6 +63,14 @@ async def create_order(order_data: OrderCreate, db: AsyncSession = Depends(get_d
|
||||
material_name = material.name if material else "Неизвестный"
|
||||
logger.info("Material for notification: %s", material_name)
|
||||
|
||||
# Resolve client_id from token if provided
|
||||
client_id = None
|
||||
if order_data.client_token:
|
||||
payload = decode_access_token(order_data.client_token)
|
||||
if payload and payload.get("type") == "client":
|
||||
client_id = int(payload["sub"])
|
||||
logger.info("Order linked to client_id=%d", client_id)
|
||||
|
||||
order_id = await generate_order_id(db)
|
||||
estimated_ready = datetime.now() + timedelta(days=calc.estimated_days or 3)
|
||||
logger.info("Order ID: %s, estimated ready: %s", order_id, estimated_ready.strftime("%Y-%m-%d"))
|
||||
@@ -68,6 +78,7 @@ async def create_order(order_data: OrderCreate, db: AsyncSession = Depends(get_d
|
||||
order = Order(
|
||||
order_id=order_id,
|
||||
calculation_id=calc.id,
|
||||
client_id=client_id,
|
||||
client_name=order_data.client_name,
|
||||
client_phone=order_data.client_phone,
|
||||
client_email=order_data.client_email,
|
||||
|
||||
35
backend/app/routers/track.py
Normal file
35
backend/app/routers/track.py
Normal file
@@ -0,0 +1,35 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from fastapi import Depends
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.order import Order
|
||||
|
||||
logger = logging.getLogger("app.routers.track")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TrackResponse(BaseModel):
|
||||
order_id: str
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("/track/{order_id}", response_model=TrackResponse)
|
||||
async def track_order(order_id: str, db: AsyncSession = Depends(get_db)):
|
||||
logger.info("Track order: %s", order_id)
|
||||
result = await db.execute(select(Order).where(Order.order_id == order_id))
|
||||
order = result.scalar_one_or_none()
|
||||
if not order:
|
||||
raise HTTPException(404, "Заказ не найден")
|
||||
|
||||
return TrackResponse(
|
||||
order_id=order.order_id,
|
||||
status=order.status,
|
||||
created_at=order.created_at.isoformat() if order.created_at else "",
|
||||
)
|
||||
@@ -20,6 +20,6 @@ class MaterialResponse(BaseModel):
|
||||
flow_rate_mm3_s: float
|
||||
properties: MaterialProperties
|
||||
description: str | None = None
|
||||
color_options: list[str] = []
|
||||
color_options: list[dict] = []
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -9,6 +9,7 @@ class OrderCreate(BaseModel):
|
||||
client_company: str | None = None
|
||||
delivery_method: str = "pickup"
|
||||
comment: str | None = None
|
||||
client_token: str | None = None # JWT token if logged in
|
||||
|
||||
|
||||
class OrderResponse(BaseModel):
|
||||
|
||||
@@ -3,7 +3,7 @@ MATERIALS = [
|
||||
"name": "PLA",
|
||||
"category": "basic",
|
||||
"density_g_cm3": 1.24,
|
||||
"price_per_gram": 25.0,
|
||||
"price_per_gram": 15.0,
|
||||
"flow_rate_mm3_s": 15.0,
|
||||
"max_temp_c": 60,
|
||||
"min_temp_c": -20,
|
||||
@@ -13,13 +13,23 @@ MATERIALS = [
|
||||
"uv_resistance": "low",
|
||||
"food_safe": True,
|
||||
"description": "Базовый пластик. Лёгкий в печати, хорошая детализация. Для прототипов и декора.",
|
||||
"color_options": ["white", "black", "gray", "red", "blue", "green", "natural"],
|
||||
"color_options": [
|
||||
{"name": "Белый", "hex": "#FFFFFF"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
{"name": "Серый", "hex": "#888888"},
|
||||
{"name": "Красный", "hex": "#E53E3E"},
|
||||
{"name": "Синий", "hex": "#3182CE"},
|
||||
{"name": "Зелёный", "hex": "#38A169"},
|
||||
{"name": "Натуральный", "hex": "#F5E6D3"},
|
||||
{"name": "Жёлтый", "hex": "#ECC94B"},
|
||||
{"name": "Оранжевый", "hex": "#ED8936"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "PETG",
|
||||
"category": "basic",
|
||||
"density_g_cm3": 1.27,
|
||||
"price_per_gram": 28.0,
|
||||
"price_per_gram": 17.0,
|
||||
"flow_rate_mm3_s": 12.0,
|
||||
"max_temp_c": 80,
|
||||
"min_temp_c": -40,
|
||||
@@ -29,13 +39,20 @@ MATERIALS = [
|
||||
"uv_resistance": "medium",
|
||||
"food_safe": True,
|
||||
"description": "Универсальный инженерный пластик. Прочный, химстойкий, подходит для улицы.",
|
||||
"color_options": ["white", "black", "gray", "natural", "blue"],
|
||||
"color_options": [
|
||||
{"name": "Белый", "hex": "#FFFFFF"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
{"name": "Серый", "hex": "#888888"},
|
||||
{"name": "Натуральный", "hex": "#F5E6D3"},
|
||||
{"name": "Синий", "hex": "#3182CE"},
|
||||
{"name": "Оранжевый", "hex": "#ED8936"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "ABS",
|
||||
"category": "basic",
|
||||
"density_g_cm3": 1.04,
|
||||
"price_per_gram": 25.0,
|
||||
"price_per_gram": 16.0,
|
||||
"flow_rate_mm3_s": 12.0,
|
||||
"max_temp_c": 100,
|
||||
"min_temp_c": -30,
|
||||
@@ -45,7 +62,13 @@ MATERIALS = [
|
||||
"uv_resistance": "low",
|
||||
"food_safe": False,
|
||||
"description": "Термостойкий, ударопрочный. Требует закрытой камеры. Обрабатывается ацетоном.",
|
||||
"color_options": ["white", "black", "gray", "red", "blue"],
|
||||
"color_options": [
|
||||
{"name": "Белый", "hex": "#FFFFFF"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
{"name": "Серый", "hex": "#888888"},
|
||||
{"name": "Красный", "hex": "#E53E3E"},
|
||||
{"name": "Синий", "hex": "#3182CE"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "PA (Nylon)",
|
||||
@@ -61,7 +84,10 @@ MATERIALS = [
|
||||
"uv_resistance": "medium",
|
||||
"food_safe": False,
|
||||
"description": "Инженерный пластик. Высокая прочность, износостойкость. Для шестерён, креплений.",
|
||||
"color_options": ["natural", "black"],
|
||||
"color_options": [
|
||||
{"name": "Натуральный", "hex": "#F5E6D3"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "PC (Поликарбонат)",
|
||||
@@ -77,7 +103,10 @@ MATERIALS = [
|
||||
"uv_resistance": "high",
|
||||
"food_safe": False,
|
||||
"description": "Максимальная термостойкость и прочность. Для корпусов, работающих при высоких температурах.",
|
||||
"color_options": ["natural", "black"],
|
||||
"color_options": [
|
||||
{"name": "Натуральный", "hex": "#F5E6D3"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "TPU",
|
||||
@@ -93,7 +122,11 @@ MATERIALS = [
|
||||
"uv_resistance": "medium",
|
||||
"food_safe": False,
|
||||
"description": "Эластичный пластик, аналог резины. Для прокладок, амортизаторов, гибких деталей.",
|
||||
"color_options": ["white", "black", "natural"],
|
||||
"color_options": [
|
||||
{"name": "Белый", "hex": "#FFFFFF"},
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
{"name": "Натуральный", "hex": "#F5E6D3"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "PA-CF (Нейлон + углеволокно)",
|
||||
@@ -109,6 +142,8 @@ MATERIALS = [
|
||||
"uv_resistance": "high",
|
||||
"food_safe": False,
|
||||
"description": "Композит с углеволокном. Максимальная жёсткость и прочность. Замена алюминия.",
|
||||
"color_options": ["black"],
|
||||
"color_options": [
|
||||
{"name": "Чёрный", "hex": "#222222"},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -17,18 +17,23 @@ def verify_password(plain: str, hashed: str) -> bool:
|
||||
return bcrypt.checkpw(plain.encode(), hashed.encode())
|
||||
|
||||
|
||||
def create_access_token(user_id: int, email: str) -> str:
|
||||
def create_access_token(user_id: int, email: str, token_type: str = "admin") -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"email": email,
|
||||
"type": token_type,
|
||||
"exp": expire,
|
||||
}
|
||||
token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
|
||||
logger.info("Created JWT for user_id=%d, email=%s, expires=%s", user_id, email, expire)
|
||||
logger.info("Created JWT (%s) for id=%d, email=%s, expires=%s", token_type, user_id, email, expire)
|
||||
return token
|
||||
|
||||
|
||||
def create_client_token(client_id: int, email: str) -> str:
|
||||
return create_access_token(client_id, email, token_type="client")
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])
|
||||
|
||||
@@ -15,6 +15,8 @@ POST_PROCESSING_COSTS = {
|
||||
"acetone_smoothing": 400.0,
|
||||
}
|
||||
|
||||
MULTICOLOR_SURCHARGE_PERCENT = 30 # наценка за многоцветную печать
|
||||
|
||||
QUANTITY_DISCOUNTS = [
|
||||
(1, 0),
|
||||
(2, 5),
|
||||
@@ -71,14 +73,15 @@ def calculate_price(
|
||||
layer_height_mm: float = 0.2,
|
||||
quantity: int = 1,
|
||||
post_processing: list[str] | None = None,
|
||||
multicolor: bool = False,
|
||||
) -> PriceResult:
|
||||
post_processing = post_processing or []
|
||||
|
||||
logger.info("=== Price calculation start ===")
|
||||
logger.info("Input: volume=%.2f cm3, density=%.2f g/cm3, price_per_gram=%.1f RUB",
|
||||
file_info.volume_cm3, density_g_cm3, price_per_gram)
|
||||
logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, post_processing=%s",
|
||||
infill_percent, layer_height_mm, quantity, post_processing)
|
||||
logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, multicolor=%s, post_processing=%s",
|
||||
infill_percent, layer_height_mm, quantity, multicolor, post_processing)
|
||||
|
||||
effective_volume = file_info.volume_cm3 * (infill_percent / 100.0) * 0.7 + file_info.volume_cm3 * 0.3
|
||||
logger.debug("Effective volume: %.2f cm3 (infill-scaled: %.2f + walls: %.2f)",
|
||||
@@ -103,9 +106,15 @@ def calculate_price(
|
||||
logger.debug("Total post-processing cost: %.2f RUB", pp_cost)
|
||||
|
||||
subtotal = round(material_cost + time_cost + pp_cost, 2)
|
||||
logger.debug("Subtotal (1 pc): %.2f RUB = material(%.2f) + time(%.2f) + pp(%.2f)",
|
||||
logger.debug("Subtotal before multicolor (1 pc): %.2f RUB = material(%.2f) + time(%.2f) + pp(%.2f)",
|
||||
subtotal, material_cost, time_cost, pp_cost)
|
||||
|
||||
if multicolor:
|
||||
multicolor_surcharge = round(subtotal * MULTICOLOR_SURCHARGE_PERCENT / 100.0, 2)
|
||||
subtotal = round(subtotal + multicolor_surcharge, 2)
|
||||
logger.debug("Multicolor surcharge: +%.2f RUB (%d%%), new subtotal: %.2f",
|
||||
multicolor_surcharge, MULTICOLOR_SURCHARGE_PERCENT, subtotal)
|
||||
|
||||
discount_pct = get_quantity_discount(quantity)
|
||||
total = round(subtotal * quantity * (1 - discount_pct / 100.0), 2)
|
||||
logger.info("Total: %.2f RUB (qty=%d, discount=%d%%, subtotal_per_unit=%.2f)",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex min-h-screen flex-col bg-gray-50">
|
||||
<header class="sticky top-0 z-40 border-b border-gray-200 bg-white/95 backdrop-blur">
|
||||
<header v-if="!isAdmin" class="sticky top-0 z-40 border-b border-gray-200 bg-white/95 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3 sm:px-6">
|
||||
<router-link to="/" class="flex items-center gap-2.5">
|
||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600">
|
||||
@@ -33,16 +33,46 @@
|
||||
>
|
||||
Блог
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/track"
|
||||
class="rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
active-class="!bg-primary-50 !text-primary-700"
|
||||
>
|
||||
Проверить заказ
|
||||
</router-link>
|
||||
<router-link
|
||||
to="/account"
|
||||
class="ml-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 hover:text-gray-900"
|
||||
active-class="!bg-primary-50 !text-primary-700"
|
||||
>
|
||||
<svg class="inline-block h-4 w-4 mr-1 -mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
{{ clientStore.isAuthenticated ? clientStore.user?.name || 'Кабинет' : 'Войти' }}
|
||||
</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6">
|
||||
<main :class="isAdmin ? 'flex-1' : 'mx-auto w-full max-w-6xl flex-1 px-4 py-8 sm:px-6'">
|
||||
<router-view />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
<SiteFooter v-if="!isAdmin" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import SiteFooter from './components/SiteFooter.vue'
|
||||
import { useClientStore } from './stores/client'
|
||||
|
||||
const route = useRoute()
|
||||
const clientStore = useClientStore()
|
||||
const isAdmin = computed(() => route.path.startsWith('/admin'))
|
||||
|
||||
onMounted(() => {
|
||||
if (clientStore.isAuthenticated) {
|
||||
clientStore.fetchMe()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<button
|
||||
v-for="mat in materialsByCategory(cat)"
|
||||
:key="mat.id"
|
||||
@click="selectMaterial(mat.id)"
|
||||
@click="selectMaterial(mat)"
|
||||
:class="[
|
||||
'flex flex-col rounded-lg border-2 p-3.5 text-left transition-all',
|
||||
store.materialId === mat.id
|
||||
@@ -29,6 +29,18 @@
|
||||
<span class="text-xs font-medium text-gray-500">{{ mat.price_per_gram }} ₽/г</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs leading-relaxed text-gray-500">{{ mat.description }}</p>
|
||||
|
||||
<!-- Color palette -->
|
||||
<div v-if="mat.color_options && mat.color_options.length" class="mt-2 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="c in mat.color_options"
|
||||
:key="c.hex || c"
|
||||
class="h-4 w-4 rounded-full border border-gray-300"
|
||||
:style="{ backgroundColor: c.hex || c }"
|
||||
:title="c.name || c"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
<span v-if="mat.properties.food_safe" class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-[10px] font-medium text-green-700">
|
||||
Food safe
|
||||
@@ -43,11 +55,35 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color selection (shown when material is selected) -->
|
||||
<div v-if="selectedMaterial && selectedMaterial.color_options && selectedMaterial.color_options.length" class="mt-5 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-700">Выберите цвет</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="c in selectedMaterial.color_options"
|
||||
:key="c.hex || c"
|
||||
@click="selectColor(c)"
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg border-2 px-3 py-1.5 text-xs font-medium transition-all',
|
||||
store.color === (c.name || c)
|
||||
? 'border-primary-500 bg-white ring-1 ring-primary-500'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300',
|
||||
]"
|
||||
>
|
||||
<span
|
||||
class="h-5 w-5 rounded-full border border-gray-300 flex-shrink-0"
|
||||
:style="{ backgroundColor: c.hex || c }"
|
||||
></span>
|
||||
<span class="text-gray-700">{{ c.name || c }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useCalculatorStore } from '../stores/calculator'
|
||||
import { useMaterialsStore } from '../stores/materials'
|
||||
|
||||
@@ -63,8 +99,19 @@ function materialsByCategory(cat) {
|
||||
return materialsStore.materials.filter((m) => m.category === cat)
|
||||
}
|
||||
|
||||
function selectMaterial(id) {
|
||||
store.materialId = id
|
||||
const selectedMaterial = computed(() => {
|
||||
if (!store.materialId) return null
|
||||
return materialsStore.materials.find((m) => m.id === store.materialId)
|
||||
})
|
||||
|
||||
function selectMaterial(mat) {
|
||||
store.materialId = mat.id
|
||||
store.color = null
|
||||
store.result = null
|
||||
}
|
||||
|
||||
function selectColor(c) {
|
||||
store.color = c.name || c
|
||||
store.result = null
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,147 @@
|
||||
<template>
|
||||
<form @submit.prevent="submitOrder" class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Имя *</label>
|
||||
<input v-model="form.client_name" required class="input-field" placeholder="Иван Петров" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Телефон *</label>
|
||||
<input v-model="form.client_phone" required class="input-field" placeholder="+79001234567" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
|
||||
<input v-model="form.client_email" type="email" class="input-field" placeholder="ivan@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Компания</label>
|
||||
<input v-model="form.client_company" class="input-field" placeholder="ООО Технопарк" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Способ получения</label>
|
||||
<select v-model="form.delivery_method" class="input-field">
|
||||
<option value="pickup">Самовывоз</option>
|
||||
<option value="delivery">Доставка</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Комментарий</label>
|
||||
<textarea v-model="form.comment" rows="3" class="input-field" placeholder="Дополнительные пожелания"></textarea>
|
||||
<div class="space-y-5">
|
||||
<!-- Auth section -->
|
||||
<div v-if="!clientStore.isAuthenticated" class="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button
|
||||
@click="authMode = 'login'"
|
||||
:class="['rounded-lg px-4 py-1.5 text-sm font-medium transition-colors', authMode === 'login' ? 'bg-primary-600 text-white' : 'bg-white text-gray-600 border border-gray-200']"
|
||||
>Войти</button>
|
||||
<button
|
||||
@click="authMode = 'register'"
|
||||
:class="['rounded-lg px-4 py-1.5 text-sm font-medium transition-colors', authMode === 'register' ? 'bg-primary-600 text-white' : 'bg-white text-gray-600 border border-gray-200']"
|
||||
>Регистрация</button>
|
||||
<button
|
||||
@click="authMode = 'guest'"
|
||||
:class="['rounded-lg px-4 py-1.5 text-sm font-medium transition-colors', authMode === 'guest' ? 'bg-gray-700 text-white' : 'bg-white text-gray-600 border border-gray-200']"
|
||||
>Без регистрации</button>
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
<form v-if="authMode === 'login'" @submit.prevent="handleLogin" class="space-y-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">Email</label>
|
||||
<input v-model="authForm.email" type="email" required class="input-field" placeholder="your@email.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">Пароль</label>
|
||||
<input v-model="authForm.password" type="password" required class="input-field" />
|
||||
</div>
|
||||
<p v-if="authError" class="text-sm text-red-600">{{ authError }}</p>
|
||||
<button type="submit" :disabled="authLoading" class="btn-primary text-sm">
|
||||
{{ authLoading ? 'Вход...' : 'Войти' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Register form -->
|
||||
<form v-if="authMode === 'register'" @submit.prevent="handleRegister" class="space-y-3">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">Имя *</label>
|
||||
<input v-model="authForm.name" required class="input-field" placeholder="Иван Петров" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">Email *</label>
|
||||
<input v-model="authForm.email" type="email" required class="input-field" placeholder="your@email.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">Пароль *</label>
|
||||
<input v-model="authForm.password" type="password" required minlength="6" class="input-field" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">Телефон</label>
|
||||
<input v-model="authForm.phone" class="input-field" placeholder="+79001234567" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600">Компания</label>
|
||||
<input v-model="authForm.company" class="input-field" placeholder="ООО Технопарк" />
|
||||
</div>
|
||||
<p v-if="authError" class="text-sm text-red-600">{{ authError }}</p>
|
||||
<button type="submit" :disabled="authLoading" class="btn-primary text-sm">
|
||||
{{ authLoading ? 'Регистрация...' : 'Зарегистрироваться' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Guest info -->
|
||||
<p v-if="authMode === 'guest'" class="text-sm text-gray-500">
|
||||
Вы можете оформить заказ без регистрации. Отслеживание заказа будет доступно только по номеру заказа.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
<!-- Logged in info -->
|
||||
<div v-else class="flex items-center justify-between rounded-lg border border-green-200 bg-green-50 px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-green-800">{{ clientStore.user?.name }}</span>
|
||||
<span class="text-xs text-green-600">{{ clientStore.user?.email }}</span>
|
||||
</div>
|
||||
<button @click="clientStore.logout()" class="text-xs text-green-700 hover:text-green-900 underline">Выйти</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn-primary w-full">
|
||||
<svg v-if="loading" class="mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{{ loading ? 'Оформляем...' : 'Оформить заказ' }}
|
||||
</button>
|
||||
</form>
|
||||
<!-- Order form -->
|
||||
<form @submit.prevent="submitOrder" class="space-y-4">
|
||||
<template v-if="!clientStore.isAuthenticated">
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Имя *</label>
|
||||
<input v-model="form.client_name" required class="input-field" placeholder="Иван Петров" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Телефон *</label>
|
||||
<input v-model="form.client_phone" required class="input-field" placeholder="+79001234567" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label>
|
||||
<input v-model="form.client_email" type="email" class="input-field" placeholder="ivan@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Компания</label>
|
||||
<input v-model="form.client_company" class="input-field" placeholder="ООО Технопарк" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Способ получения</label>
|
||||
<select v-model="form.delivery_method" class="input-field">
|
||||
<option value="pickup">Самовывоз</option>
|
||||
<option value="delivery">Доставка</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Комментарий</label>
|
||||
<textarea v-model="form.comment" rows="3" class="input-field" placeholder="Дополнительные пожелания"></textarea>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn-primary w-full">
|
||||
<svg v-if="loading" class="mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{{ loading ? 'Оформляем...' : 'Оформить заказ' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import api from '../api/client'
|
||||
import { useClientStore } from '../stores/client'
|
||||
|
||||
const props = defineProps({ calculationId: String })
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const clientStore = useClientStore()
|
||||
|
||||
const authMode = ref('login')
|
||||
const authForm = reactive({ email: '', password: '', name: '', phone: '', company: '' })
|
||||
const authError = ref('')
|
||||
const authLoading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
client_name: '',
|
||||
client_phone: '',
|
||||
@@ -59,14 +154,70 @@ const form = reactive({
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Pre-fill form from client data
|
||||
watch(() => clientStore.user, (u) => {
|
||||
if (u) {
|
||||
form.client_name = u.name || ''
|
||||
form.client_phone = u.phone || ''
|
||||
form.client_email = u.email || ''
|
||||
form.client_company = u.company || ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function handleLogin() {
|
||||
authLoading.value = true
|
||||
authError.value = ''
|
||||
try {
|
||||
await clientStore.login(authForm.email, authForm.password)
|
||||
} catch (e) {
|
||||
authError.value = e.response?.data?.detail || 'Ошибка входа'
|
||||
} finally {
|
||||
authLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
authLoading.value = true
|
||||
authError.value = ''
|
||||
try {
|
||||
await clientStore.register({
|
||||
email: authForm.email,
|
||||
password: authForm.password,
|
||||
name: authForm.name,
|
||||
phone: authForm.phone || null,
|
||||
company: authForm.company || null,
|
||||
})
|
||||
} catch (e) {
|
||||
authError.value = e.response?.data?.detail || 'Ошибка регистрации'
|
||||
} finally {
|
||||
authLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitOrder() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const { data } = await api.post('/orders', {
|
||||
const payload = {
|
||||
calculation_id: props.calculationId,
|
||||
...form,
|
||||
})
|
||||
delivery_method: form.delivery_method,
|
||||
comment: form.comment,
|
||||
}
|
||||
|
||||
if (clientStore.isAuthenticated) {
|
||||
payload.client_name = clientStore.user.name
|
||||
payload.client_phone = clientStore.user.phone || '+70000000000'
|
||||
payload.client_email = clientStore.user.email
|
||||
payload.client_company = clientStore.user.company
|
||||
payload.client_token = clientStore.token
|
||||
} else {
|
||||
payload.client_name = form.client_name
|
||||
payload.client_phone = form.client_phone
|
||||
payload.client_email = form.client_email
|
||||
payload.client_company = form.client_company
|
||||
}
|
||||
|
||||
const { data } = await api.post('/orders', payload)
|
||||
emit('success', data)
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.detail || 'Ошибка оформления заказа'
|
||||
|
||||
@@ -55,6 +55,25 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Multicolor -->
|
||||
<div>
|
||||
<label class="flex items-center gap-3 cursor-pointer rounded-lg border-2 p-3 transition-all"
|
||||
:class="store.multicolor ? 'border-primary-500 bg-primary-50' : 'border-gray-200 hover:border-gray-300'"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="store.multicolor"
|
||||
@change="toggleMulticolor"
|
||||
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-900">Многоцветная печать</span>
|
||||
<span class="ml-2 text-xs text-gray-400">(AMS, +30% к стоимости)</span>
|
||||
<p class="text-xs text-gray-500 mt-0.5">Печать несколькими цветами на Bambu Lab AMS</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Post-processing -->
|
||||
<div>
|
||||
<label class="mb-2.5 block text-sm font-medium text-gray-700">Постобработка</label>
|
||||
@@ -88,6 +107,11 @@ const postProcessingOptions = [
|
||||
{ value: 'acetone_smoothing', label: 'Ацетоновая обработка (ABS)', price: '400 ₽/шт' },
|
||||
]
|
||||
|
||||
function toggleMulticolor() {
|
||||
store.multicolor = !store.multicolor
|
||||
store.result = null
|
||||
}
|
||||
|
||||
function togglePP(value) {
|
||||
const idx = store.settings.post_processing.indexOf(value)
|
||||
if (idx > -1) {
|
||||
|
||||
@@ -11,6 +11,9 @@ import AdminOrders from '../views/admin/AdminOrders.vue'
|
||||
import AdminMaterials from '../views/admin/AdminMaterials.vue'
|
||||
import AdminSettings from '../views/admin/AdminSettings.vue'
|
||||
import AdminUsers from '../views/admin/AdminUsers.vue'
|
||||
import AdminClients from '../views/admin/AdminClients.vue'
|
||||
import AccountView from '../views/AccountView.vue'
|
||||
import TrackView from '../views/TrackView.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -42,6 +45,18 @@ const routes = [
|
||||
name: 'article',
|
||||
component: ArticleView,
|
||||
},
|
||||
{
|
||||
path: '/track',
|
||||
name: 'track',
|
||||
component: TrackView,
|
||||
meta: { title: 'Проверка заказа — Filam3D' },
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
name: 'account',
|
||||
component: AccountView,
|
||||
meta: { title: 'Личный кабинет — Filam3D' },
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'admin-login',
|
||||
@@ -77,6 +92,12 @@ const routes = [
|
||||
component: AdminSettings,
|
||||
meta: { title: 'Настройки — Админ-панель Filam3D' },
|
||||
},
|
||||
{
|
||||
path: 'clients',
|
||||
name: 'admin-clients',
|
||||
component: AdminClients,
|
||||
meta: { title: 'Клиенты — Админ-панель Filam3D' },
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'admin-users',
|
||||
|
||||
@@ -5,6 +5,8 @@ import api from '../api/client'
|
||||
export const useCalculatorStore = defineStore('calculator', () => {
|
||||
const file = ref(null)
|
||||
const materialId = ref(null)
|
||||
const color = ref(null)
|
||||
const multicolor = ref(false)
|
||||
const settings = reactive({
|
||||
infill_percent: 30,
|
||||
layer_height_mm: 0.2,
|
||||
@@ -30,6 +32,8 @@ export const useCalculatorStore = defineStore('calculator', () => {
|
||||
formData.append('layer_height_mm', settings.layer_height_mm)
|
||||
formData.append('quantity', settings.quantity)
|
||||
formData.append('post_processing', settings.post_processing.join(','))
|
||||
if (color.value) formData.append('color', color.value)
|
||||
formData.append('multicolor', multicolor.value)
|
||||
|
||||
try {
|
||||
const { data } = await api.post('/calculate', formData, {
|
||||
@@ -50,6 +54,8 @@ export const useCalculatorStore = defineStore('calculator', () => {
|
||||
function reset() {
|
||||
file.value = null
|
||||
materialId.value = null
|
||||
color.value = null
|
||||
multicolor.value = false
|
||||
settings.infill_percent = 30
|
||||
settings.layer_height_mm = 0.2
|
||||
settings.quantity = 1
|
||||
@@ -59,5 +65,5 @@ export const useCalculatorStore = defineStore('calculator', () => {
|
||||
uploadProgress.value = 0
|
||||
}
|
||||
|
||||
return { file, materialId, settings, result, loading, error, uploadProgress, calculate, reset }
|
||||
return { file, materialId, color, multicolor, settings, result, loading, error, uploadProgress, calculate, reset }
|
||||
})
|
||||
|
||||
54
frontend/src/stores/client.js
Normal file
54
frontend/src/stores/client.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '../api/client'
|
||||
|
||||
export const useClientStore = defineStore('client', () => {
|
||||
const token = ref(localStorage.getItem('client_token') || '')
|
||||
const user = ref(null)
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
function setAuth(tokenValue, userData) {
|
||||
token.value = tokenValue
|
||||
user.value = userData
|
||||
localStorage.setItem('client_token', tokenValue)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
user.value = null
|
||||
localStorage.removeItem('client_token')
|
||||
}
|
||||
|
||||
async function login(email, password) {
|
||||
const { data } = await api.post('/client/login', { email, password })
|
||||
setAuth(data.token, data.client)
|
||||
return data
|
||||
}
|
||||
|
||||
async function register(form) {
|
||||
const { data } = await api.post('/client/register', form)
|
||||
setAuth(data.token, data.client)
|
||||
return data
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
if (!token.value) return
|
||||
try {
|
||||
const { data } = await api.get('/client/me', {
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
})
|
||||
user.value = data
|
||||
} catch {
|
||||
logout()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOrders() {
|
||||
const { data } = await api.get('/client/orders', {
|
||||
headers: { Authorization: `Bearer ${token.value}` },
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
return { token, user, isAuthenticated, login, register, logout, fetchMe, fetchOrders, setAuth }
|
||||
})
|
||||
209
frontend/src/views/AccountView.vue
Normal file
209
frontend/src/views/AccountView.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<!-- Not logged in -->
|
||||
<div v-if="!clientStore.isAuthenticated" class="text-center py-16">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2">Личный кабинет</h1>
|
||||
<p class="text-gray-500 mb-6">Войдите или зарегистрируйтесь, чтобы увидеть свои заказы</p>
|
||||
|
||||
<div class="mx-auto max-w-sm card">
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button @click="mode = 'login'"
|
||||
:class="['flex-1 rounded-lg py-2 text-sm font-medium transition-colors', mode === 'login' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-600']"
|
||||
>Вход</button>
|
||||
<button @click="mode = 'register'"
|
||||
:class="['flex-1 rounded-lg py-2 text-sm font-medium transition-colors', mode === 'register' ? 'bg-primary-600 text-white' : 'bg-gray-100 text-gray-600']"
|
||||
>Регистрация</button>
|
||||
</div>
|
||||
|
||||
<form v-if="mode === 'login'" @submit.prevent="handleLogin" class="space-y-3">
|
||||
<input v-model="authForm.email" type="email" required class="input-field" placeholder="Email" />
|
||||
<input v-model="authForm.password" type="password" required class="input-field" placeholder="Пароль" />
|
||||
<p v-if="authError" class="text-sm text-red-600">{{ authError }}</p>
|
||||
<button type="submit" :disabled="authLoading" class="btn-primary w-full">{{ authLoading ? '...' : 'Войти' }}</button>
|
||||
</form>
|
||||
|
||||
<form v-else @submit.prevent="handleRegister" class="space-y-3">
|
||||
<input v-model="authForm.name" required class="input-field" placeholder="Имя" />
|
||||
<input v-model="authForm.email" type="email" required class="input-field" placeholder="Email" />
|
||||
<input v-model="authForm.password" type="password" required minlength="6" class="input-field" placeholder="Пароль (мин. 6 символов)" />
|
||||
<input v-model="authForm.phone" class="input-field" placeholder="Телефон (необязательно)" />
|
||||
<input v-model="authForm.company" class="input-field" placeholder="Компания (необязательно)" />
|
||||
<p v-if="authError" class="text-sm text-red-600">{{ authError }}</p>
|
||||
<button type="submit" :disabled="authLoading" class="btn-primary w-full">{{ authLoading ? '...' : 'Зарегистрироваться' }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logged in -->
|
||||
<div v-else>
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Личный кабинет</h1>
|
||||
<p class="text-sm text-gray-500">{{ clientStore.user?.email }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<router-link to="/" class="btn-secondary !py-1.5 !px-3 !text-xs">Калькулятор</router-link>
|
||||
<button @click="handleLogout" class="text-sm text-gray-500 hover:text-gray-700">Выход</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile card -->
|
||||
<div class="card mb-6">
|
||||
<h2 class="text-sm font-bold uppercase text-gray-500 mb-3">Профиль</h2>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Имя:</span>
|
||||
<span class="ml-1 font-medium text-gray-900">{{ clientStore.user?.name }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Email:</span>
|
||||
<span class="ml-1 font-medium text-gray-900">{{ clientStore.user?.email }}</span>
|
||||
</div>
|
||||
<div v-if="clientStore.user?.phone">
|
||||
<span class="text-gray-500">Телефон:</span>
|
||||
<span class="ml-1 font-medium text-gray-900">{{ clientStore.user?.phone }}</span>
|
||||
</div>
|
||||
<div v-if="clientStore.user?.company">
|
||||
<span class="text-gray-500">Компания:</span>
|
||||
<span class="ml-1 font-medium text-gray-900">{{ clientStore.user?.company }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders -->
|
||||
<div>
|
||||
<h2 class="text-sm font-bold uppercase text-gray-500 mb-3">Мои заказы</h2>
|
||||
|
||||
<div v-if="ordersLoading" class="text-gray-400 text-sm">Загрузка...</div>
|
||||
|
||||
<div v-else-if="orders.length === 0" class="text-center py-10 text-gray-400">
|
||||
<p class="mb-2">Заказов пока нет</p>
|
||||
<router-link to="/" class="text-primary-600 hover:text-primary-700 text-sm font-medium">Рассчитать стоимость</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="o in orders" :key="o.order_id"
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 hover:shadow-sm transition-shadow"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="text-sm font-bold text-gray-900">{{ o.order_id }}</span>
|
||||
<span :class="statusClass(o.status)" class="rounded-full px-2.5 py-0.5 text-xs font-medium">
|
||||
{{ statusLabel(o.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 space-x-2">
|
||||
<span v-if="o.material_name">{{ o.material_name }}</span>
|
||||
<span v-if="o.quantity">· {{ o.quantity }} шт</span>
|
||||
<span v-if="o.file_name">· {{ o.file_name }}</span>
|
||||
<span>· {{ o.delivery_method === 'pickup' ? 'Самовывоз' : 'Доставка' }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400 mt-1">{{ formatDate(o.created_at) }}</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-bold text-gray-900">{{ fmt(o.total_rub) }} ₽</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useClientStore } from '../stores/client'
|
||||
|
||||
const router = useRouter()
|
||||
const clientStore = useClientStore()
|
||||
|
||||
const mode = ref('login')
|
||||
const authForm = reactive({ email: '', password: '', name: '', phone: '', company: '' })
|
||||
const authError = ref('')
|
||||
const authLoading = ref(false)
|
||||
|
||||
const orders = ref([])
|
||||
const ordersLoading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (clientStore.isAuthenticated) {
|
||||
clientStore.fetchMe()
|
||||
loadOrders()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => clientStore.isAuthenticated, (val) => {
|
||||
if (val) loadOrders()
|
||||
})
|
||||
|
||||
async function loadOrders() {
|
||||
ordersLoading.value = true
|
||||
try {
|
||||
orders.value = await clientStore.fetchOrders()
|
||||
} finally {
|
||||
ordersLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
authLoading.value = true
|
||||
authError.value = ''
|
||||
try {
|
||||
await clientStore.login(authForm.email, authForm.password)
|
||||
} catch (e) {
|
||||
authError.value = e.response?.data?.detail || 'Ошибка входа'
|
||||
} finally {
|
||||
authLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
authLoading.value = true
|
||||
authError.value = ''
|
||||
try {
|
||||
await clientStore.register({
|
||||
email: authForm.email,
|
||||
password: authForm.password,
|
||||
name: authForm.name,
|
||||
phone: authForm.phone || null,
|
||||
company: authForm.company || null,
|
||||
})
|
||||
} catch (e) {
|
||||
authError.value = e.response?.data?.detail || 'Ошибка регистрации'
|
||||
} finally {
|
||||
authLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
clientStore.logout()
|
||||
}
|
||||
|
||||
const statuses = {
|
||||
pending: 'Новый', confirmed: 'Подтверждён', printing: 'Печатается',
|
||||
ready: 'Готов', delivered: 'Выдан', cancelled: 'Отменён',
|
||||
}
|
||||
function statusLabel(s) { return statuses[s] || s }
|
||||
|
||||
function statusClass(s) {
|
||||
const map = {
|
||||
pending: 'bg-amber-100 text-amber-700', confirmed: 'bg-blue-100 text-blue-700',
|
||||
printing: 'bg-purple-100 text-purple-700', ready: 'bg-green-100 text-green-700',
|
||||
delivered: 'bg-gray-100 text-gray-600', cancelled: 'bg-red-100 text-red-700',
|
||||
}
|
||||
return map[s] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(n || 0)
|
||||
}
|
||||
</script>
|
||||
124
frontend/src/views/TrackView.vue
Normal file
124
frontend/src/views/TrackView.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="mx-auto max-w-md py-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-2 text-center">Проверка заказа</h1>
|
||||
<p class="text-sm text-gray-500 text-center mb-8">Введите номер заказа, чтобы узнать его статус</p>
|
||||
|
||||
<form @submit.prevent="checkOrder" class="card">
|
||||
<div class="mb-4">
|
||||
<label class="mb-1.5 block text-sm font-medium text-gray-700">Номер заказа</label>
|
||||
<input
|
||||
v-model="orderId"
|
||||
required
|
||||
class="input-field text-center text-lg tracking-wider"
|
||||
placeholder="ORD-2026-0001"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="btn-primary w-full">
|
||||
{{ loading ? 'Проверяем...' : 'Проверить' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Result -->
|
||||
<div v-if="result" class="mt-6 card text-center">
|
||||
<div class="mb-3">
|
||||
<span :class="statusClass(result.status)" class="inline-block rounded-full px-4 py-1.5 text-sm font-semibold">
|
||||
{{ statusLabel(result.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-700 font-medium mb-1">{{ result.order_id }}</p>
|
||||
<p class="text-xs text-gray-400">Дата оформления: {{ formatDate(result.created_at) }}</p>
|
||||
|
||||
<div class="mt-5 border-t border-gray-100 pt-4">
|
||||
<div class="flex justify-center gap-1">
|
||||
<div v-for="s in steps" :key="s.key" class="flex flex-col items-center flex-1">
|
||||
<div :class="[
|
||||
'h-3 w-3 rounded-full mb-1.5',
|
||||
stepReached(result.status, s.key) ? stepColor(s.key) : 'bg-gray-200'
|
||||
]"></div>
|
||||
<span :class="['text-[10px] font-medium', stepReached(result.status, s.key) ? 'text-gray-700' : 'text-gray-300']">
|
||||
{{ s.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="mt-6 card text-center">
|
||||
<p class="text-sm text-red-600">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '../api/client'
|
||||
|
||||
const orderId = ref('')
|
||||
const result = ref(null)
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const steps = [
|
||||
{ key: 'pending', label: 'Новый' },
|
||||
{ key: 'confirmed', label: 'Подтверждён' },
|
||||
{ key: 'printing', label: 'Печать' },
|
||||
{ key: 'ready', label: 'Готов' },
|
||||
{ key: 'delivered', label: 'Выдан' },
|
||||
]
|
||||
|
||||
const statusOrder = ['pending', 'confirmed', 'printing', 'ready', 'delivered']
|
||||
|
||||
function stepReached(current, step) {
|
||||
if (current === 'cancelled') return step === 'pending'
|
||||
const ci = statusOrder.indexOf(current)
|
||||
const si = statusOrder.indexOf(step)
|
||||
return si <= ci
|
||||
}
|
||||
|
||||
function stepColor(s) {
|
||||
const map = {
|
||||
pending: 'bg-amber-500', confirmed: 'bg-blue-500',
|
||||
printing: 'bg-purple-500', ready: 'bg-green-500', delivered: 'bg-gray-500',
|
||||
}
|
||||
return map[s] || 'bg-gray-500'
|
||||
}
|
||||
|
||||
const statusLabels = {
|
||||
pending: 'Новый', confirmed: 'Подтверждён', printing: 'Печатается',
|
||||
ready: 'Готов к выдаче', delivered: 'Выдан', cancelled: 'Отменён',
|
||||
}
|
||||
function statusLabel(s) { return statusLabels[s] || s }
|
||||
|
||||
function statusClass(s) {
|
||||
const map = {
|
||||
pending: 'bg-amber-100 text-amber-700', confirmed: 'bg-blue-100 text-blue-700',
|
||||
printing: 'bg-purple-100 text-purple-700', ready: 'bg-green-100 text-green-700',
|
||||
delivered: 'bg-gray-100 text-gray-600', cancelled: 'bg-red-100 text-red-700',
|
||||
}
|
||||
return map[s] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
async function checkOrder() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
result.value = null
|
||||
try {
|
||||
const { data } = await api.get(`/track/${orderId.value.trim()}`)
|
||||
result.value = data
|
||||
} catch (e) {
|
||||
if (e.response?.status === 404) {
|
||||
error.value = 'Заказ с таким номером не найден'
|
||||
} else {
|
||||
error.value = 'Ошибка проверки. Попробуйте позже.'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
152
frontend/src/views/admin/AdminClients.vue
Normal file
152
frontend/src/views/admin/AdminClients.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Клиенты</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-gray-500">Загрузка...</div>
|
||||
|
||||
<div v-else-if="clients.length === 0" class="text-center py-12 text-gray-400">Клиентов пока нет</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-200 bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Клиент</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Контакты</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-600">Заказов</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-600">Потрачено</th>
|
||||
<th class="px-4 py-3 text-center font-semibold text-gray-600">Активен</th>
|
||||
<th class="px-4 py-3 text-left font-semibold text-gray-600">Дата</th>
|
||||
<th class="px-4 py-3 text-right font-semibold text-gray-600">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="c in clients" :key="c.id" class="hover:bg-gray-50 transition-colors cursor-pointer" @click="openClient(c)">
|
||||
<td class="px-4 py-3">
|
||||
<div class="font-medium text-gray-900">{{ c.name }}</div>
|
||||
<div v-if="c.company" class="text-xs text-gray-400">{{ c.company }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-gray-700">{{ c.email }}</div>
|
||||
<div v-if="c.phone" class="text-xs text-gray-400">{{ c.phone }}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-medium text-gray-900">{{ c.orders_count }}</td>
|
||||
<td class="px-4 py-3 text-right font-medium text-gray-900">{{ fmt(c.total_spent) }} ₽</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span v-if="c.is_active" class="text-green-600">✓</span>
|
||||
<span v-else class="text-red-400">✕</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-gray-400">{{ formatDate(c.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-right" @click.stop>
|
||||
<button
|
||||
@click="toggleActive(c)"
|
||||
:class="['rounded-md px-2.5 py-1 text-xs font-medium transition-colors', c.is_active ? 'bg-red-50 text-red-600 hover:bg-red-100' : 'bg-green-50 text-green-600 hover:bg-green-100']"
|
||||
>
|
||||
{{ c.is_active ? 'Деактивировать' : 'Активировать' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Client detail modal -->
|
||||
<Teleport to="body">
|
||||
<div v-if="selectedClient" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30" @click.self="selectedClient = null">
|
||||
<div class="w-full max-w-lg rounded-xl bg-white shadow-2xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
|
||||
<h2 class="text-lg font-bold">{{ selectedClient.client.name }}</h2>
|
||||
<button @click="selectedClient = null" class="rounded-md p-1 text-gray-400 hover:bg-gray-100">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="rounded-lg bg-gray-50 p-3 text-sm space-y-1">
|
||||
<p><span class="text-gray-500">Email:</span> {{ selectedClient.client.email }}</p>
|
||||
<p v-if="selectedClient.client.phone"><span class="text-gray-500">Телефон:</span> {{ selectedClient.client.phone }}</p>
|
||||
<p v-if="selectedClient.client.company"><span class="text-gray-500">Компания:</span> {{ selectedClient.client.company }}</p>
|
||||
<p><span class="text-gray-500">Регистрация:</span> {{ formatDate(selectedClient.client.created_at) }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xs font-bold uppercase text-gray-500 mb-2">Заказы ({{ selectedClient.orders.length }})</h3>
|
||||
<div v-if="selectedClient.orders.length === 0" class="text-sm text-gray-400">Нет заказов</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="o in selectedClient.orders" :key="o.order_id" class="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-900">{{ o.order_id }}</span>
|
||||
<span :class="statusClass(o.status)" class="ml-2 rounded-full px-2 py-0.5 text-xs font-medium">
|
||||
{{ statusLabel(o.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-sm font-bold text-gray-900">{{ fmt(o.total_rub) }} ₽</span>
|
||||
<div class="text-xs text-gray-400">{{ formatDate(o.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import api from '../../api/client'
|
||||
|
||||
const clients = ref([])
|
||||
const loading = ref(true)
|
||||
const selectedClient = ref(null)
|
||||
|
||||
onMounted(() => loadClients())
|
||||
|
||||
async function loadClients() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/admin/clients')
|
||||
clients.value = data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openClient(c) {
|
||||
const { data } = await api.get(`/admin/clients/${c.id}`)
|
||||
selectedClient.value = data
|
||||
}
|
||||
|
||||
async function toggleActive(c) {
|
||||
await api.patch(`/admin/clients/${c.id}/active`, { is_active: !c.is_active })
|
||||
c.is_active = !c.is_active
|
||||
}
|
||||
|
||||
const statuses = {
|
||||
pending: 'Новый', confirmed: 'Подтверждён', printing: 'Печатается',
|
||||
ready: 'Готов', delivered: 'Выдан', cancelled: 'Отменён',
|
||||
}
|
||||
function statusLabel(s) { return statuses[s] || s }
|
||||
|
||||
function statusClass(s) {
|
||||
const map = {
|
||||
pending: 'bg-amber-100 text-amber-700', confirmed: 'bg-blue-100 text-blue-700',
|
||||
printing: 'bg-purple-100 text-purple-700', ready: 'bg-green-100 text-green-700',
|
||||
delivered: 'bg-gray-100 text-gray-600', cancelled: 'bg-red-100 text-red-700',
|
||||
}
|
||||
return map[s] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function fmt(n) {
|
||||
return new Intl.NumberFormat('ru-RU', { maximumFractionDigits: 0 }).format(n || 0)
|
||||
}
|
||||
</script>
|
||||
@@ -25,6 +25,10 @@
|
||||
<p class="text-sm text-gray-500">Расчётов</p>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900">{{ stats.total_calculations }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-sm text-gray-500">Клиентов</p>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900">{{ stats.clients_count }}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<p class="text-sm text-gray-500">Материалов</p>
|
||||
<p class="mt-1 text-3xl font-bold text-gray-900">{{ stats.materials_count }}</p>
|
||||
|
||||
@@ -74,10 +74,12 @@ const IconOrders = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24'
|
||||
const IconMaterials = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9' })])}
|
||||
const IconSettings = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 010 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z' }), h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z' })])}
|
||||
const IconUsers = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z' })])}
|
||||
const IconClients = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '2' }, [h('path', { 'stroke-linecap': 'round', 'stroke-linejoin': 'round', d: 'M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z' })])}
|
||||
|
||||
const nav = [
|
||||
{ to: '/admin', label: 'Дашборд', icon: IconDashboard },
|
||||
{ to: '/admin/orders', label: 'Заказы (CRM)', icon: IconOrders },
|
||||
{ to: '/admin/orders', label: 'Заказы', icon: IconOrders },
|
||||
{ to: '/admin/clients', label: 'Клиенты', icon: IconClients },
|
||||
{ to: '/admin/materials', label: 'Материалы', icon: IconMaterials },
|
||||
{ to: '/admin/users', label: 'Администраторы', icon: IconUsers },
|
||||
{ to: '/admin/settings', label: 'Настройки', icon: IconSettings },
|
||||
|
||||
@@ -154,6 +154,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Colors -->
|
||||
<div>
|
||||
<label class="mb-1.5 block text-xs font-semibold uppercase text-gray-500">Цвета</label>
|
||||
<div class="flex flex-wrap gap-2 mb-2">
|
||||
<div v-for="(c, idx) in form.color_options" :key="idx"
|
||||
class="flex items-center gap-1.5 rounded-full border border-gray-200 bg-white pl-1 pr-2 py-0.5"
|
||||
>
|
||||
<span class="h-5 w-5 rounded-full border border-gray-300 flex-shrink-0" :style="{ backgroundColor: c.hex }"></span>
|
||||
<span class="text-xs text-gray-700">{{ c.name }}</span>
|
||||
<button type="button" @click="form.color_options.splice(idx, 1)" class="ml-0.5 text-gray-400 hover:text-red-500">
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="newColorName" placeholder="Название" class="input-field !py-1 text-xs flex-1" />
|
||||
<div class="flex items-center gap-1">
|
||||
<input v-model="newColorHex" type="color" class="h-8 w-8 rounded cursor-pointer border border-gray-300" />
|
||||
<span class="text-xs text-gray-400 font-mono w-16">{{ newColorHex }}</span>
|
||||
</div>
|
||||
<button type="button" @click="addColor" :disabled="!newColorName"
|
||||
class="rounded-lg bg-gray-100 px-3 py-1 text-xs font-medium text-gray-700 hover:bg-gray-200 disabled:opacity-40"
|
||||
>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Описание</label>
|
||||
<textarea v-model="form.description" rows="2" class="input-field"></textarea>
|
||||
@@ -205,6 +233,9 @@ const saving = ref(false)
|
||||
const editingMaterial = ref(null)
|
||||
const deletingMaterial = ref(null)
|
||||
|
||||
const newColorName = ref('')
|
||||
const newColorHex = ref('#222222')
|
||||
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
category: 'basic',
|
||||
@@ -220,6 +251,7 @@ const defaultForm = () => ({
|
||||
food_safe: false,
|
||||
is_active: true,
|
||||
description: '',
|
||||
color_options: [],
|
||||
})
|
||||
|
||||
const form = ref(defaultForm())
|
||||
@@ -259,6 +291,7 @@ function openEdit(m) {
|
||||
food_safe: m.food_safe,
|
||||
is_active: m.is_active,
|
||||
description: m.description || '',
|
||||
color_options: m.color_options ? [...m.color_options] : [],
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
@@ -278,6 +311,13 @@ async function saveMaterial() {
|
||||
}
|
||||
}
|
||||
|
||||
function addColor() {
|
||||
if (!newColorName.value) return
|
||||
form.value.color_options.push({ name: newColorName.value, hex: newColorHex.value })
|
||||
newColorName.value = ''
|
||||
newColorHex.value = '#222222'
|
||||
}
|
||||
|
||||
function confirmDelete(m) {
|
||||
deletingMaterial.value = m
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user