This commit is contained in:
xds
2026-03-22 14:26:45 +03:00
parent 33694d68db
commit 466a27907a
28 changed files with 1334 additions and 71 deletions

View File

@@ -21,7 +21,7 @@ async def get_current_admin(
"""Dependency that validates JWT and returns the current admin user.""" """Dependency that validates JWT and returns the current admin user."""
token = credentials.credentials token = credentials.credentials
payload = decode_access_token(token) payload = decode_access_token(token)
if not payload: if not payload or payload.get("type", "admin") != "admin":
logger.warning("Invalid or expired token") logger.warning("Invalid or expired token")
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Невалидный или просроченный токен") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Невалидный или просроченный токен")

View File

@@ -11,7 +11,7 @@ from app.database import async_session, engine, Base
from app.models import Material, AdminUser from app.models import Material, AdminUser
from app.seed.materials import MATERIALS from app.seed.materials import MATERIALS
from app.services.auth import hash_password 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 # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -100,6 +100,8 @@ app.include_router(calculate.router, prefix="/api")
app.include_router(materials.router, prefix="/api") app.include_router(materials.router, prefix="/api")
app.include_router(orders.router, prefix="/api") app.include_router(orders.router, prefix="/api")
app.include_router(ai_advisor.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") app.include_router(admin.router, prefix="/api/admin")

View File

@@ -3,5 +3,6 @@ from app.models.calculation import Calculation
from app.models.order import Order from app.models.order import Order
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.app_settings import AppSettings 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"]

View 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())

View File

@@ -13,6 +13,7 @@ class Order(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
order_id: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) 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) 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_name: Mapped[str] = mapped_column(String(200), nullable=False)
client_phone: Mapped[str] = mapped_column(String(20), nullable=False) client_phone: Mapped[str] = mapped_column(String(20), nullable=False)
client_email: Mapped[str | None] = mapped_column(String(200)) client_email: Mapped[str | None] = mapped_column(String(200))

View File

@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.dependencies import get_current_admin from app.dependencies import get_current_admin
from app.models.admin_user import AdminUser from app.models.admin_user import AdminUser
from app.models.client import Client
from app.models.material import Material from app.models.material import Material
from app.models.calculation import Calculation from app.models.calculation import Calculation
from app.models.order import Order from app.models.order import Order
@@ -230,6 +231,7 @@ class DashboardStats(BaseModel):
total_revenue: float total_revenue: float
total_calculations: int total_calculations: int
materials_count: int materials_count: int
clients_count: int
orders_today: 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_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 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 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) today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
orders_today = (await db.execute( 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_revenue=round(total_revenue, 2),
total_calculations=total_calcs, total_calculations=total_calcs,
materials_count=materials_count, materials_count=materials_count,
clients_count=clients_count,
orders_today=orders_today, orders_today=orders_today,
) )
@@ -276,7 +280,7 @@ class MaterialCreate(BaseModel):
uv_resistance: str | None = None uv_resistance: str | None = None
food_safe: bool = False food_safe: bool = False
description: str | None = None description: str | None = None
color_options: list[str] = [] color_options: list[dict] = []
is_active: bool = True is_active: bool = True
@@ -575,3 +579,96 @@ async def update_setting(
await db.commit() await db.commit()
logger.info("Setting updated: %s = %s", key, data.value) logger.info("Setting updated: %s = %s", key, data.value)
return {"key": key, "value": 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"}

View File

@@ -37,6 +37,8 @@ async def calculate(
layer_height_mm: float = Form(0.2), layer_height_mm: float = Form(0.2),
quantity: int = Form(1), quantity: int = Form(1),
post_processing: str = Form(""), post_processing: str = Form(""),
color: str = Form(""),
multicolor: bool = Form(False),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
logger.info("===== /api/calculate request =====") logger.info("===== /api/calculate request =====")
@@ -51,8 +53,8 @@ async def calculate(
raise HTTPException(400, f"Неподдерживаемый формат файла. Допустимые: {', '.join(SUPPORTED_EXTENSIONS)}") raise HTTPException(400, f"Неподдерживаемый формат файла. Допустимые: {', '.join(SUPPORTED_EXTENSIONS)}")
# Validate params # Validate params
logger.info("Params: material_id=%d, infill=%d%%, layer=%.2fmm, qty=%d, post_processing='%s'", 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, post_processing) material_id, infill_percent, layer_height_mm, quantity, color, multicolor, post_processing)
if not 10 <= infill_percent <= 100: if not 10 <= infill_percent <= 100:
logger.warning("Invalid infill_percent: %d", infill_percent) logger.warning("Invalid infill_percent: %d", infill_percent)
@@ -134,6 +136,7 @@ async def calculate(
layer_height_mm=layer_height_mm, layer_height_mm=layer_height_mm,
quantity=quantity, quantity=quantity,
post_processing=pp_list, post_processing=pp_list,
multicolor=multicolor,
) )
# Save calculation to DB # Save calculation to DB

View 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

View File

@@ -8,9 +8,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models.calculation import Calculation from app.models.calculation import Calculation
from app.models.client import Client
from app.models.material import Material from app.models.material import Material
from app.models.order import Order from app.models.order import Order
from app.schemas.order import OrderCreate, OrderResponse from app.schemas.order import OrderCreate, OrderResponse
from app.services.auth import decode_access_token
from app.services.telegram_notify import notify_new_order from app.services.telegram_notify import notify_new_order
logger = logging.getLogger("app.routers.orders") 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 "Неизвестный" material_name = material.name if material else "Неизвестный"
logger.info("Material for notification: %s", material_name) 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) order_id = await generate_order_id(db)
estimated_ready = datetime.now() + timedelta(days=calc.estimated_days or 3) 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")) 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 = Order(
order_id=order_id, order_id=order_id,
calculation_id=calc.id, calculation_id=calc.id,
client_id=client_id,
client_name=order_data.client_name, client_name=order_data.client_name,
client_phone=order_data.client_phone, client_phone=order_data.client_phone,
client_email=order_data.client_email, client_email=order_data.client_email,

View 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 "",
)

View File

@@ -20,6 +20,6 @@ class MaterialResponse(BaseModel):
flow_rate_mm3_s: float flow_rate_mm3_s: float
properties: MaterialProperties properties: MaterialProperties
description: str | None = None description: str | None = None
color_options: list[str] = [] color_options: list[dict] = []
model_config = {"from_attributes": True} model_config = {"from_attributes": True}

View File

@@ -9,6 +9,7 @@ class OrderCreate(BaseModel):
client_company: str | None = None client_company: str | None = None
delivery_method: str = "pickup" delivery_method: str = "pickup"
comment: str | None = None comment: str | None = None
client_token: str | None = None # JWT token if logged in
class OrderResponse(BaseModel): class OrderResponse(BaseModel):

View File

@@ -3,7 +3,7 @@ MATERIALS = [
"name": "PLA", "name": "PLA",
"category": "basic", "category": "basic",
"density_g_cm3": 1.24, "density_g_cm3": 1.24,
"price_per_gram": 25.0, "price_per_gram": 15.0,
"flow_rate_mm3_s": 15.0, "flow_rate_mm3_s": 15.0,
"max_temp_c": 60, "max_temp_c": 60,
"min_temp_c": -20, "min_temp_c": -20,
@@ -13,13 +13,23 @@ MATERIALS = [
"uv_resistance": "low", "uv_resistance": "low",
"food_safe": True, "food_safe": True,
"description": "Базовый пластик. Лёгкий в печати, хорошая детализация. Для прототипов и декора.", "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", "name": "PETG",
"category": "basic", "category": "basic",
"density_g_cm3": 1.27, "density_g_cm3": 1.27,
"price_per_gram": 28.0, "price_per_gram": 17.0,
"flow_rate_mm3_s": 12.0, "flow_rate_mm3_s": 12.0,
"max_temp_c": 80, "max_temp_c": 80,
"min_temp_c": -40, "min_temp_c": -40,
@@ -29,13 +39,20 @@ MATERIALS = [
"uv_resistance": "medium", "uv_resistance": "medium",
"food_safe": True, "food_safe": True,
"description": "Универсальный инженерный пластик. Прочный, химстойкий, подходит для улицы.", "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", "name": "ABS",
"category": "basic", "category": "basic",
"density_g_cm3": 1.04, "density_g_cm3": 1.04,
"price_per_gram": 25.0, "price_per_gram": 16.0,
"flow_rate_mm3_s": 12.0, "flow_rate_mm3_s": 12.0,
"max_temp_c": 100, "max_temp_c": 100,
"min_temp_c": -30, "min_temp_c": -30,
@@ -45,7 +62,13 @@ MATERIALS = [
"uv_resistance": "low", "uv_resistance": "low",
"food_safe": False, "food_safe": False,
"description": "Термостойкий, ударопрочный. Требует закрытой камеры. Обрабатывается ацетоном.", "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)", "name": "PA (Nylon)",
@@ -61,7 +84,10 @@ MATERIALS = [
"uv_resistance": "medium", "uv_resistance": "medium",
"food_safe": False, "food_safe": False,
"description": "Инженерный пластик. Высокая прочность, износостойкость. Для шестерён, креплений.", "description": "Инженерный пластик. Высокая прочность, износостойкость. Для шестерён, креплений.",
"color_options": ["natural", "black"], "color_options": [
{"name": "Натуральный", "hex": "#F5E6D3"},
{"name": "Чёрный", "hex": "#222222"},
],
}, },
{ {
"name": "PC (Поликарбонат)", "name": "PC (Поликарбонат)",
@@ -77,7 +103,10 @@ MATERIALS = [
"uv_resistance": "high", "uv_resistance": "high",
"food_safe": False, "food_safe": False,
"description": "Максимальная термостойкость и прочность. Для корпусов, работающих при высоких температурах.", "description": "Максимальная термостойкость и прочность. Для корпусов, работающих при высоких температурах.",
"color_options": ["natural", "black"], "color_options": [
{"name": "Натуральный", "hex": "#F5E6D3"},
{"name": "Чёрный", "hex": "#222222"},
],
}, },
{ {
"name": "TPU", "name": "TPU",
@@ -93,7 +122,11 @@ MATERIALS = [
"uv_resistance": "medium", "uv_resistance": "medium",
"food_safe": False, "food_safe": False,
"description": "Эластичный пластик, аналог резины. Для прокладок, амортизаторов, гибких деталей.", "description": "Эластичный пластик, аналог резины. Для прокладок, амортизаторов, гибких деталей.",
"color_options": ["white", "black", "natural"], "color_options": [
{"name": "Белый", "hex": "#FFFFFF"},
{"name": "Чёрный", "hex": "#222222"},
{"name": "Натуральный", "hex": "#F5E6D3"},
],
}, },
{ {
"name": "PA-CF (Нейлон + углеволокно)", "name": "PA-CF (Нейлон + углеволокно)",
@@ -109,6 +142,8 @@ MATERIALS = [
"uv_resistance": "high", "uv_resistance": "high",
"food_safe": False, "food_safe": False,
"description": "Композит с углеволокном. Максимальная жёсткость и прочность. Замена алюминия.", "description": "Композит с углеволокном. Максимальная жёсткость и прочность. Замена алюминия.",
"color_options": ["black"], "color_options": [
{"name": "Чёрный", "hex": "#222222"},
],
}, },
] ]

View File

@@ -17,18 +17,23 @@ def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode()) 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) expire = datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS)
payload = { payload = {
"sub": str(user_id), "sub": str(user_id),
"email": email, "email": email,
"type": token_type,
"exp": expire, "exp": expire,
} }
token = jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM) 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 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: def decode_access_token(token: str) -> dict | None:
try: try:
payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]) payload = jwt.decode(token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM])

View File

@@ -15,6 +15,8 @@ POST_PROCESSING_COSTS = {
"acetone_smoothing": 400.0, "acetone_smoothing": 400.0,
} }
MULTICOLOR_SURCHARGE_PERCENT = 30 # наценка за многоцветную печать
QUANTITY_DISCOUNTS = [ QUANTITY_DISCOUNTS = [
(1, 0), (1, 0),
(2, 5), (2, 5),
@@ -71,14 +73,15 @@ def calculate_price(
layer_height_mm: float = 0.2, layer_height_mm: float = 0.2,
quantity: int = 1, quantity: int = 1,
post_processing: list[str] | None = None, post_processing: list[str] | None = None,
multicolor: bool = False,
) -> PriceResult: ) -> PriceResult:
post_processing = post_processing or [] post_processing = post_processing or []
logger.info("=== Price calculation start ===") logger.info("=== Price calculation start ===")
logger.info("Input: volume=%.2f cm3, density=%.2f g/cm3, price_per_gram=%.1f RUB", 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) file_info.volume_cm3, density_g_cm3, price_per_gram)
logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, post_processing=%s", logger.info("Params: infill=%d%%, layer=%.2fmm, qty=%d, multicolor=%s, post_processing=%s",
infill_percent, layer_height_mm, quantity, post_processing) 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 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)", 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) logger.debug("Total post-processing cost: %.2f RUB", pp_cost)
subtotal = round(material_cost + time_cost + pp_cost, 2) 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) 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) discount_pct = get_quantity_discount(quantity)
total = round(subtotal * quantity * (1 - discount_pct / 100.0), 2) total = round(subtotal * quantity * (1 - discount_pct / 100.0), 2)
logger.info("Total: %.2f RUB (qty=%d, discount=%d%%, subtotal_per_unit=%.2f)", logger.info("Total: %.2f RUB (qty=%d, discount=%d%%, subtotal_per_unit=%.2f)",

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="flex min-h-screen flex-col bg-gray-50"> <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"> <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"> <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"> <div class="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600">
@@ -33,16 +33,46 @@
> >
Блог Блог
</router-link> </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> </nav>
</div> </div>
</header> </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 /> <router-view />
</main> </main>
<SiteFooter /> <SiteFooter v-if="!isAdmin" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { useRoute } from 'vue-router'
import { computed, onMounted } from 'vue'
import SiteFooter from './components/SiteFooter.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> </script>

View File

@@ -16,7 +16,7 @@
<button <button
v-for="mat in materialsByCategory(cat)" v-for="mat in materialsByCategory(cat)"
:key="mat.id" :key="mat.id"
@click="selectMaterial(mat.id)" @click="selectMaterial(mat)"
:class="[ :class="[
'flex flex-col rounded-lg border-2 p-3.5 text-left transition-all', 'flex flex-col rounded-lg border-2 p-3.5 text-left transition-all',
store.materialId === mat.id store.materialId === mat.id
@@ -29,6 +29,18 @@
<span class="text-xs font-medium text-gray-500">{{ mat.price_per_gram }} &#8381;/г</span> <span class="text-xs font-medium text-gray-500">{{ mat.price_per_gram }} &#8381;/г</span>
</div> </div>
<p class="mt-1 text-xs leading-relaxed text-gray-500">{{ mat.description }}</p> <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"> <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"> <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 Food safe
@@ -43,11 +55,35 @@
</button> </button>
</div> </div>
</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> </div>
</template> </template>
<script setup> <script setup>
import { onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { useCalculatorStore } from '../stores/calculator' import { useCalculatorStore } from '../stores/calculator'
import { useMaterialsStore } from '../stores/materials' import { useMaterialsStore } from '../stores/materials'
@@ -63,8 +99,19 @@ function materialsByCategory(cat) {
return materialsStore.materials.filter((m) => m.category === cat) return materialsStore.materials.filter((m) => m.category === cat)
} }
function selectMaterial(id) { const selectedMaterial = computed(() => {
store.materialId = id 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 store.result = null
} }

View File

@@ -1,52 +1,147 @@
<template> <template>
<form @submit.prevent="submitOrder" class="space-y-4"> <div class="space-y-5">
<div> <!-- Auth section -->
<label class="mb-1.5 block text-sm font-medium text-gray-700">Имя *</label> <div v-if="!clientStore.isAuthenticated" class="rounded-lg border border-gray-200 bg-gray-50 p-4">
<input v-model="form.client_name" required class="input-field" placeholder="Иван Петров" /> <div class="flex gap-2 mb-4">
</div> <button
<div> @click="authMode = 'login'"
<label class="mb-1.5 block text-sm font-medium text-gray-700">Телефон *</label> :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']"
<input v-model="form.client_phone" required class="input-field" placeholder="+79001234567" /> >Войти</button>
</div> <button
<div> @click="authMode = 'register'"
<label class="mb-1.5 block text-sm font-medium text-gray-700">Email</label> :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']"
<input v-model="form.client_email" type="email" class="input-field" placeholder="ivan@example.com" /> >Регистрация</button>
</div> <button
<div> @click="authMode = 'guest'"
<label class="mb-1.5 block text-sm font-medium text-gray-700">Компания</label> :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']"
<input v-model="form.client_company" class="input-field" placeholder="ООО Технопарк" /> >Без регистрации</button>
</div> </div>
<div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Способ получения</label> <!-- Login form -->
<select v-model="form.delivery_method" class="input-field"> <form v-if="authMode === 'login'" @submit.prevent="handleLogin" class="space-y-3">
<option value="pickup">Самовывоз</option> <div>
<option value="delivery">Доставка</option> <label class="mb-1 block text-xs font-medium text-gray-600">Email</label>
</select> <input v-model="authForm.email" type="email" required class="input-field" placeholder="your@email.com" />
</div> </div>
<div> <div>
<label class="mb-1.5 block text-sm font-medium text-gray-700">Комментарий</label> <label class="mb-1 block text-xs font-medium text-gray-600">Пароль</label>
<textarea v-model="form.comment" rows="3" class="input-field" placeholder="Дополнительные пожелания"></textarea> <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> </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"> <!-- Order form -->
<svg v-if="loading" class="mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24"> <form @submit.prevent="submitOrder" class="space-y-4">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <template v-if="!clientStore.isAuthenticated">
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> <div>
</svg> <label class="mb-1.5 block text-sm font-medium text-gray-700">Имя *</label>
{{ loading ? 'Оформляем...' : 'Оформить заказ' }} <input v-model="form.client_name" required class="input-field" placeholder="Иван Петров" />
</button> </div>
</form> <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> </template>
<script setup> <script setup>
import { reactive, ref } from 'vue' import { reactive, ref, watch } from 'vue'
import api from '../api/client' import api from '../api/client'
import { useClientStore } from '../stores/client'
const props = defineProps({ calculationId: String }) const props = defineProps({ calculationId: String })
const emit = defineEmits(['success']) 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({ const form = reactive({
client_name: '', client_name: '',
client_phone: '', client_phone: '',
@@ -59,14 +154,70 @@ const form = reactive({
const loading = ref(false) const loading = ref(false)
const error = ref('') 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() { async function submitOrder() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const { data } = await api.post('/orders', { const payload = {
calculation_id: props.calculationId, 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) emit('success', data)
} catch (e) { } catch (e) {
error.value = e.response?.data?.detail || 'Ошибка оформления заказа' error.value = e.response?.data?.detail || 'Ошибка оформления заказа'

View File

@@ -55,6 +55,25 @@
/> />
</div> </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 --> <!-- Post-processing -->
<div> <div>
<label class="mb-2.5 block text-sm font-medium text-gray-700">Постобработка</label> <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 ₽/шт' }, { value: 'acetone_smoothing', label: 'Ацетоновая обработка (ABS)', price: '400 ₽/шт' },
] ]
function toggleMulticolor() {
store.multicolor = !store.multicolor
store.result = null
}
function togglePP(value) { function togglePP(value) {
const idx = store.settings.post_processing.indexOf(value) const idx = store.settings.post_processing.indexOf(value)
if (idx > -1) { if (idx > -1) {

View File

@@ -11,6 +11,9 @@ import AdminOrders from '../views/admin/AdminOrders.vue'
import AdminMaterials from '../views/admin/AdminMaterials.vue' import AdminMaterials from '../views/admin/AdminMaterials.vue'
import AdminSettings from '../views/admin/AdminSettings.vue' import AdminSettings from '../views/admin/AdminSettings.vue'
import AdminUsers from '../views/admin/AdminUsers.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 = [ const routes = [
{ {
@@ -42,6 +45,18 @@ const routes = [
name: 'article', name: 'article',
component: ArticleView, component: ArticleView,
}, },
{
path: '/track',
name: 'track',
component: TrackView,
meta: { title: 'Проверка заказа — Filam3D' },
},
{
path: '/account',
name: 'account',
component: AccountView,
meta: { title: 'Личный кабинет — Filam3D' },
},
{ {
path: '/admin/login', path: '/admin/login',
name: 'admin-login', name: 'admin-login',
@@ -77,6 +92,12 @@ const routes = [
component: AdminSettings, component: AdminSettings,
meta: { title: 'Настройки — Админ-панель Filam3D' }, meta: { title: 'Настройки — Админ-панель Filam3D' },
}, },
{
path: 'clients',
name: 'admin-clients',
component: AdminClients,
meta: { title: 'Клиенты — Админ-панель Filam3D' },
},
{ {
path: 'users', path: 'users',
name: 'admin-users', name: 'admin-users',

View File

@@ -5,6 +5,8 @@ import api from '../api/client'
export const useCalculatorStore = defineStore('calculator', () => { export const useCalculatorStore = defineStore('calculator', () => {
const file = ref(null) const file = ref(null)
const materialId = ref(null) const materialId = ref(null)
const color = ref(null)
const multicolor = ref(false)
const settings = reactive({ const settings = reactive({
infill_percent: 30, infill_percent: 30,
layer_height_mm: 0.2, 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('layer_height_mm', settings.layer_height_mm)
formData.append('quantity', settings.quantity) formData.append('quantity', settings.quantity)
formData.append('post_processing', settings.post_processing.join(',')) formData.append('post_processing', settings.post_processing.join(','))
if (color.value) formData.append('color', color.value)
formData.append('multicolor', multicolor.value)
try { try {
const { data } = await api.post('/calculate', formData, { const { data } = await api.post('/calculate', formData, {
@@ -50,6 +54,8 @@ export const useCalculatorStore = defineStore('calculator', () => {
function reset() { function reset() {
file.value = null file.value = null
materialId.value = null materialId.value = null
color.value = null
multicolor.value = false
settings.infill_percent = 30 settings.infill_percent = 30
settings.layer_height_mm = 0.2 settings.layer_height_mm = 0.2
settings.quantity = 1 settings.quantity = 1
@@ -59,5 +65,5 @@ export const useCalculatorStore = defineStore('calculator', () => {
uploadProgress.value = 0 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 }
}) })

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

View 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">&middot; {{ o.quantity }} шт</span>
<span v-if="o.file_name">&middot; {{ o.file_name }}</span>
<span>&middot; {{ 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) }} &#8381;</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>

View 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>

View 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) }} &#8381;</td>
<td class="px-4 py-3 text-center">
<span v-if="c.is_active" class="text-green-600">&#10003;</span>
<span v-else class="text-red-400">&#10005;</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) }} &#8381;</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>

View File

@@ -25,6 +25,10 @@
<p class="text-sm text-gray-500">Расчётов</p> <p class="text-sm text-gray-500">Расчётов</p>
<p class="mt-1 text-3xl font-bold text-gray-900">{{ stats.total_calculations }}</p> <p class="mt-1 text-3xl font-bold text-gray-900">{{ stats.total_calculations }}</p>
</div> </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"> <div class="rounded-xl border border-gray-200 bg-white p-5">
<p class="text-sm text-gray-500">Материалов</p> <p class="text-sm text-gray-500">Материалов</p>
<p class="mt-1 text-3xl font-bold text-gray-900">{{ stats.materials_count }}</p> <p class="mt-1 text-3xl font-bold text-gray-900">{{ stats.materials_count }}</p>

View File

@@ -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 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 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 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 = [ const nav = [
{ to: '/admin', label: 'Дашборд', icon: IconDashboard }, { 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/materials', label: 'Материалы', icon: IconMaterials },
{ to: '/admin/users', label: 'Администраторы', icon: IconUsers }, { to: '/admin/users', label: 'Администраторы', icon: IconUsers },
{ to: '/admin/settings', label: 'Настройки', icon: IconSettings }, { to: '/admin/settings', label: 'Настройки', icon: IconSettings },

View File

@@ -154,6 +154,34 @@
</div> </div>
</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> <div>
<label class="mb-1 block text-xs font-semibold uppercase text-gray-500">Описание</label> <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> <textarea v-model="form.description" rows="2" class="input-field"></textarea>
@@ -205,6 +233,9 @@ const saving = ref(false)
const editingMaterial = ref(null) const editingMaterial = ref(null)
const deletingMaterial = ref(null) const deletingMaterial = ref(null)
const newColorName = ref('')
const newColorHex = ref('#222222')
const defaultForm = () => ({ const defaultForm = () => ({
name: '', name: '',
category: 'basic', category: 'basic',
@@ -220,6 +251,7 @@ const defaultForm = () => ({
food_safe: false, food_safe: false,
is_active: true, is_active: true,
description: '', description: '',
color_options: [],
}) })
const form = ref(defaultForm()) const form = ref(defaultForm())
@@ -259,6 +291,7 @@ function openEdit(m) {
food_safe: m.food_safe, food_safe: m.food_safe,
is_active: m.is_active, is_active: m.is_active,
description: m.description || '', description: m.description || '',
color_options: m.color_options ? [...m.color_options] : [],
} }
showModal.value = true 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) { function confirmDelete(m) {
deletingMaterial.value = m deletingMaterial.value = m
} }