commit
83174ac9c4
47
README.md
47
README.md
|
|
@ -10,10 +10,22 @@
|
||||||
- `docs/` — документация, дополнительный лор.
|
- `docs/` — документация, дополнительный лор.
|
||||||
- `docker-compose.yaml` — инфраструктура проекта.
|
- `docker-compose.yaml` — инфраструктура проекта.
|
||||||
|
|
||||||
|
## Основные пользовательские сценарии
|
||||||
|
|
||||||
|
- Как кандидат: хочу проходить онбординг с лором и понимать своё место в программе.
|
||||||
|
- Как кандидат: хочу видеть прогресс-бар до следующего ранга и инструкции по ключевым веткам.
|
||||||
|
- Как кандидат: хочу получать награды (опыт, ману, артефакты) и видеть их в журнале.
|
||||||
|
- Как HR: хочу управлять миссиями, ветками, артефактами и требованиями рангов.
|
||||||
|
- Как HR: хочу видеть оперативную аналитику по активности и очереди модерации.
|
||||||
|
|
||||||
## Быстрый старт в Docker
|
## Быстрый старт в Docker
|
||||||
|
|
||||||
1. Установите Docker и Docker Compose.
|
1. Установите Docker и Docker Compose.
|
||||||
2. Скопируйте `.env.example` в `.env` (файл появится после сборки) и при необходимости поменяйте настройки.
|
2. Скопируйте пример конфигурации и при необходимости измените значения:
|
||||||
|
```bash
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
cp frontend/.env.example frontend/.env
|
||||||
|
```
|
||||||
3. Запустите окружение:
|
3. Запустите окружение:
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
|
|
@ -22,17 +34,27 @@
|
||||||
- API: http://localhost:8000 (документация Swagger — `/docs`).
|
- API: http://localhost:8000 (документация Swagger — `/docs`).
|
||||||
- Фронтенд: http://localhost:3000.
|
- Фронтенд: http://localhost:3000.
|
||||||
|
|
||||||
|
Docker Compose автоматически переопределяет `ALABUGA_SQLITE_PATH=/data/app.db`, чтобы база сохранялась во внешнем volume. Для локального запуска вне Docker оставьте путь `./data/app.db` из примера.
|
||||||
|
|
||||||
## Локальная разработка backend
|
## Локальная разработка backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
python -m venv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
|
export PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1
|
||||||
pip install -r requirements-dev.txt
|
pip install -r requirements-dev.txt
|
||||||
|
|
||||||
# применяем миграции и создаём демо-данные
|
# подготовьте переменные окружения (однократно)
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# применяем миграции
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
|
|
||||||
|
# создаём демо-данные (команда выполняется из корня репозитория)
|
||||||
|
cd ..
|
||||||
python -m scripts.seed_data
|
python -m scripts.seed_data
|
||||||
|
cd backend
|
||||||
|
|
||||||
# Запуск API
|
# Запуск API
|
||||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
|
@ -43,6 +65,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
```bash
|
```bash
|
||||||
cd frontend
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -53,6 +76,14 @@ npm run dev
|
||||||
| Пилот | `candidate@alabuga.space` | `orbita123` |
|
| Пилот | `candidate@alabuga.space` | `orbita123` |
|
||||||
| HR | `hr@alabuga.space` | `orbita123` |
|
| HR | `hr@alabuga.space` | `orbita123` |
|
||||||
|
|
||||||
|
## Проверка функционала
|
||||||
|
|
||||||
|
1. **Онбординг и лор**: перейдите в `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохранится и откроет доступ к веткам миссий.
|
||||||
|
2. **Кандидат**: авторизуйтесь под пилотом, изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
|
||||||
|
3. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
|
||||||
|
4. **HR панель**: под пользователем `hr@alabuga.space` проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`).
|
||||||
|
5. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR.
|
||||||
|
|
||||||
## Тестирование
|
## Тестирование
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -68,7 +99,17 @@ pytest
|
||||||
- Начисление опыта, маны и повышение ранга по трём условиям из ТЗ.
|
- Начисление опыта, маны и повышение ранга по трём условиям из ТЗ.
|
||||||
- Журнал событий, экспортируемый через API.
|
- Журнал событий, экспортируемый через API.
|
||||||
- Магазин артефактов с оформлением заказа.
|
- Магазин артефактов с оформлением заказа.
|
||||||
- Админ-панель для HR: создание миссий, очередь модерации, список рангов.
|
- Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам.
|
||||||
|
- Онбординг с сохранением прогресса и космическим лором.
|
||||||
|
- Таблица лидеров по опыту и мане за неделю/месяц/год.
|
||||||
|
- Аналитическая сводка для HR: активность пилотов, очередь модерации, завершённость веток.
|
||||||
|
|
||||||
|
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
|
||||||
|
|
||||||
|
- создавать и редактировать ветки миссий с категориями и порядком;
|
||||||
|
- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями;
|
||||||
|
- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций.
|
||||||
|
- управлять каталогом артефактов и назначать их миссиям.
|
||||||
|
|
||||||
## План развития
|
## План развития
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,14 @@ FROM python:3.13-slim
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
POETRY_VIRTUALENVS_CREATE=false
|
POETRY_VIRTUALENVS_CREATE=false \
|
||||||
|
PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y build-essential libpq-dev && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||||
|
|
||||||
COPY requirements.txt requirements.txt
|
COPY requirements.txt requirements.txt
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
|
||||||
56
backend/alembic/versions/20240611_0002_onboarding.py
Normal file
56
backend/alembic/versions/20240611_0002_onboarding.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Onboarding models"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20240611_0002"
|
||||||
|
down_revision = "20240609_0001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"onboarding_slides",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("order", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("body", sa.Text(), nullable=False),
|
||||||
|
sa.Column("media_url", sa.String(length=512)),
|
||||||
|
sa.Column("cta_text", sa.String(length=120)),
|
||||||
|
sa.Column("cta_link", sa.String(length=512)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
server_onupdate=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("order", name="uq_onboarding_slide_order"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"onboarding_states",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("last_completed_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("is_completed", sa.Boolean(), nullable=False, server_default=sa.sql.expression.false()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
server_onupdate=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", name="uq_onboarding_state_user"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("onboarding_states")
|
||||||
|
op.drop_table("onboarding_slides")
|
||||||
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
"""Экспортируем роутеры для подключения в приложении."""
|
"""Экспортируем роутеры для подключения в приложении."""
|
||||||
|
|
||||||
from . import admin, auth, journal, missions, store, users # noqa: F401
|
from . import admin, auth, journal, missions, onboarding, store, users # noqa: F401
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"admin",
|
"admin",
|
||||||
"auth",
|
"auth",
|
||||||
"journal",
|
"journal",
|
||||||
"missions",
|
"missions",
|
||||||
|
"onboarding",
|
||||||
"store",
|
"store",
|
||||||
"users",
|
"users",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,75 +2,50 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.exc import NoResultFound
|
||||||
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.api.deps import require_hr
|
from app.api.deps import require_hr
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.branch import BranchMission
|
from app.models.artifact import Artifact
|
||||||
from app.models.mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission, SubmissionStatus
|
from app.models.branch import Branch, BranchMission
|
||||||
from app.models.rank import Rank
|
from app.models.mission import (
|
||||||
from app.schemas.mission import MissionBase, MissionCreate, MissionDetail, MissionSubmissionRead
|
Mission,
|
||||||
from app.schemas.rank import RankBase
|
MissionCompetencyReward,
|
||||||
|
MissionPrerequisite,
|
||||||
|
MissionSubmission,
|
||||||
|
SubmissionStatus,
|
||||||
|
)
|
||||||
|
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||||
|
from app.models.user import Competency, User, UserRole
|
||||||
|
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
|
||||||
|
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
|
||||||
|
from app.schemas.mission import (
|
||||||
|
MissionBase,
|
||||||
|
MissionCreate,
|
||||||
|
MissionDetail,
|
||||||
|
MissionSubmissionRead,
|
||||||
|
MissionUpdate,
|
||||||
|
)
|
||||||
|
from app.schemas.rank import (
|
||||||
|
RankBase,
|
||||||
|
RankCreate,
|
||||||
|
RankDetailed,
|
||||||
|
RankRequirementCompetency,
|
||||||
|
RankRequirementMission,
|
||||||
|
RankUpdate,
|
||||||
|
)
|
||||||
|
from app.schemas.user import CompetencyBase
|
||||||
from app.services.mission import approve_submission, reject_submission
|
from app.services.mission import approve_submission, reject_submission
|
||||||
|
from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
@router.get("/missions", response_model=list[MissionBase], summary="Миссии (HR)")
|
def _mission_to_detail(mission: Mission) -> MissionDetail:
|
||||||
def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[MissionBase]:
|
"""Формируем детальную схему миссии."""
|
||||||
"""Список всех миссий для HR."""
|
|
||||||
|
|
||||||
missions = db.query(Mission).order_by(Mission.title).all()
|
|
||||||
return [MissionBase.model_validate(mission) for mission in missions]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/missions", response_model=MissionDetail, summary="Создать миссию")
|
|
||||||
def create_mission_endpoint(
|
|
||||||
mission_in: MissionCreate,
|
|
||||||
*,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
current_user=Depends(require_hr),
|
|
||||||
) -> MissionDetail:
|
|
||||||
"""Создаём новую миссию."""
|
|
||||||
|
|
||||||
mission = Mission(
|
|
||||||
title=mission_in.title,
|
|
||||||
description=mission_in.description,
|
|
||||||
xp_reward=mission_in.xp_reward,
|
|
||||||
mana_reward=mission_in.mana_reward,
|
|
||||||
difficulty=mission_in.difficulty,
|
|
||||||
minimum_rank_id=mission_in.minimum_rank_id,
|
|
||||||
artifact_id=mission_in.artifact_id,
|
|
||||||
)
|
|
||||||
db.add(mission)
|
|
||||||
db.flush()
|
|
||||||
|
|
||||||
for reward in mission_in.competency_rewards:
|
|
||||||
db.add(
|
|
||||||
MissionCompetencyReward(
|
|
||||||
mission_id=mission.id,
|
|
||||||
competency_id=reward.competency_id,
|
|
||||||
level_delta=reward.level_delta,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for prerequisite_id in mission_in.prerequisite_ids:
|
|
||||||
db.add(
|
|
||||||
MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
if mission_in.branch_id:
|
|
||||||
db.add(
|
|
||||||
BranchMission(
|
|
||||||
branch_id=mission_in.branch_id,
|
|
||||||
mission_id=mission.id,
|
|
||||||
order=mission_in.branch_order,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(mission)
|
|
||||||
|
|
||||||
return MissionDetail(
|
return MissionDetail(
|
||||||
id=mission.id,
|
id=mission.id,
|
||||||
|
|
@ -96,6 +71,455 @@ def create_mission_endpoint(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rank_to_detailed(rank: Rank) -> RankDetailed:
|
||||||
|
"""Формируем ранг со списком требований."""
|
||||||
|
|
||||||
|
return RankDetailed(
|
||||||
|
id=rank.id,
|
||||||
|
title=rank.title,
|
||||||
|
description=rank.description,
|
||||||
|
required_xp=rank.required_xp,
|
||||||
|
mission_requirements=[
|
||||||
|
RankRequirementMission(mission_id=req.mission_id, mission_title=req.mission.title)
|
||||||
|
for req in rank.mission_requirements
|
||||||
|
],
|
||||||
|
competency_requirements=[
|
||||||
|
RankRequirementCompetency(
|
||||||
|
competency_id=req.competency_id,
|
||||||
|
competency_name=req.competency.name,
|
||||||
|
required_level=req.required_level,
|
||||||
|
)
|
||||||
|
for req in rank.competency_requirements
|
||||||
|
],
|
||||||
|
created_at=rank.created_at,
|
||||||
|
updated_at=rank.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _branch_to_read(branch: Branch) -> BranchRead:
|
||||||
|
"""Формируем схему ветки с отсортированными миссиями."""
|
||||||
|
|
||||||
|
missions = sorted(branch.missions, key=lambda item: item.order)
|
||||||
|
return BranchRead(
|
||||||
|
id=branch.id,
|
||||||
|
title=branch.title,
|
||||||
|
description=branch.description,
|
||||||
|
category=branch.category,
|
||||||
|
missions=[
|
||||||
|
BranchMissionRead(
|
||||||
|
mission_id=item.mission_id,
|
||||||
|
mission_title=item.mission.title if item.mission else "",
|
||||||
|
order=item.order,
|
||||||
|
is_completed=False,
|
||||||
|
is_available=True,
|
||||||
|
)
|
||||||
|
for item in missions
|
||||||
|
],
|
||||||
|
total_missions=len(missions),
|
||||||
|
completed_missions=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_rank(db: Session, rank_id: int) -> Rank:
|
||||||
|
"""Загружаем ранг с зависимостями."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
db.query(Rank)
|
||||||
|
.options(
|
||||||
|
selectinload(Rank.mission_requirements).selectinload(RankMissionRequirement.mission),
|
||||||
|
selectinload(Rank.competency_requirements).selectinload(RankCompetencyRequirement.competency),
|
||||||
|
)
|
||||||
|
.filter(Rank.id == rank_id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_mission(db: Session, mission_id: int) -> Mission:
|
||||||
|
"""Загружаем миссию с зависимостями."""
|
||||||
|
|
||||||
|
return (
|
||||||
|
db.query(Mission)
|
||||||
|
.options(
|
||||||
|
selectinload(Mission.prerequisites),
|
||||||
|
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
||||||
|
selectinload(Mission.branches),
|
||||||
|
)
|
||||||
|
.filter(Mission.id == mission_id)
|
||||||
|
.one()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/missions", response_model=list[MissionBase], summary="Миссии (HR)")
|
||||||
|
def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[MissionBase]:
|
||||||
|
"""Список всех миссий для HR."""
|
||||||
|
|
||||||
|
missions = db.query(Mission).order_by(Mission.title).all()
|
||||||
|
return [MissionBase.model_validate(mission) for mission in missions]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/missions/{mission_id}", response_model=MissionDetail, summary="Детали миссии")
|
||||||
|
def admin_mission_detail(
|
||||||
|
mission_id: int,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_hr),
|
||||||
|
) -> MissionDetail:
|
||||||
|
"""Детальная карточка миссии."""
|
||||||
|
|
||||||
|
mission = (
|
||||||
|
db.query(Mission)
|
||||||
|
.options(
|
||||||
|
selectinload(Mission.prerequisites),
|
||||||
|
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
||||||
|
selectinload(Mission.branches),
|
||||||
|
)
|
||||||
|
.filter(Mission.id == mission_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not mission:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
||||||
|
return _mission_to_detail(mission)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/branches", response_model=list[BranchRead], summary="Ветки миссий")
|
||||||
|
def admin_branches(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[BranchRead]:
|
||||||
|
"""Возвращаем ветки с миссиями."""
|
||||||
|
|
||||||
|
branches = (
|
||||||
|
db.query(Branch)
|
||||||
|
.options(selectinload(Branch.missions).selectinload(BranchMission.mission))
|
||||||
|
.order_by(Branch.title)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [_branch_to_read(branch) for branch in branches]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/branches", response_model=BranchRead, summary="Создать ветку")
|
||||||
|
def create_branch(
|
||||||
|
branch_in: BranchCreate,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_hr),
|
||||||
|
) -> BranchRead:
|
||||||
|
"""Создаём новую ветку."""
|
||||||
|
|
||||||
|
branch = Branch(
|
||||||
|
title=branch_in.title,
|
||||||
|
description=branch_in.description,
|
||||||
|
category=branch_in.category,
|
||||||
|
)
|
||||||
|
db.add(branch)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(branch)
|
||||||
|
return _branch_to_read(branch)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/branches/{branch_id}", response_model=BranchRead, summary="Обновить ветку")
|
||||||
|
def update_branch(
|
||||||
|
branch_id: int,
|
||||||
|
branch_in: BranchUpdate,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_hr),
|
||||||
|
) -> BranchRead:
|
||||||
|
"""Редактируем ветку."""
|
||||||
|
|
||||||
|
branch = db.query(Branch).filter(Branch.id == branch_id).first()
|
||||||
|
if not branch:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ветка не найдена")
|
||||||
|
|
||||||
|
branch.title = branch_in.title
|
||||||
|
branch.description = branch_in.description
|
||||||
|
branch.category = branch_in.category
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(branch)
|
||||||
|
return _branch_to_read(branch)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/competencies",
|
||||||
|
response_model=list[CompetencyBase],
|
||||||
|
summary="Каталог компетенций",
|
||||||
|
)
|
||||||
|
def list_competencies(
|
||||||
|
*, db: Session = Depends(get_db), current_user=Depends(require_hr)
|
||||||
|
) -> list[CompetencyBase]:
|
||||||
|
"""Справочник компетенций для форм HR."""
|
||||||
|
|
||||||
|
competencies = db.query(Competency).order_by(Competency.name).all()
|
||||||
|
return [CompetencyBase.model_validate(competency) for competency in competencies]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/artifacts", response_model=list[ArtifactRead], summary="Каталог артефактов")
|
||||||
|
def list_artifacts(
|
||||||
|
*, db: Session = Depends(get_db), current_user=Depends(require_hr)
|
||||||
|
) -> list[ArtifactRead]:
|
||||||
|
"""Справочник артефактов."""
|
||||||
|
|
||||||
|
artifacts = db.query(Artifact).order_by(Artifact.name).all()
|
||||||
|
return [ArtifactRead.model_validate(artifact) for artifact in artifacts]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_model=AdminDashboardStats, summary="Сводная аналитика")
|
||||||
|
def dashboard_stats(
|
||||||
|
*, db: Session = Depends(get_db), current_user=Depends(require_hr)
|
||||||
|
) -> AdminDashboardStats:
|
||||||
|
"""Основные метрики прогресса и активности пользователей."""
|
||||||
|
|
||||||
|
total_pilots = db.query(User).filter(User.role == UserRole.PILOT).count()
|
||||||
|
approved_submissions = db.query(MissionSubmission).filter(
|
||||||
|
MissionSubmission.status == SubmissionStatus.APPROVED
|
||||||
|
)
|
||||||
|
active_pilots = (
|
||||||
|
approved_submissions.with_entities(MissionSubmission.user_id).distinct().count()
|
||||||
|
)
|
||||||
|
|
||||||
|
completed_counts = approved_submissions.with_entities(
|
||||||
|
MissionSubmission.user_id, func.count(MissionSubmission.id)
|
||||||
|
).group_by(MissionSubmission.user_id)
|
||||||
|
|
||||||
|
total_completed = sum(row[1] for row in completed_counts)
|
||||||
|
average_completed = total_completed / active_pilots if active_pilots else 0.0
|
||||||
|
|
||||||
|
submission_stats = SubmissionStats(
|
||||||
|
pending=db.query(MissionSubmission).filter(MissionSubmission.status == SubmissionStatus.PENDING).count(),
|
||||||
|
approved=approved_submissions.count(),
|
||||||
|
rejected=db.query(MissionSubmission).filter(MissionSubmission.status == SubmissionStatus.REJECTED).count(),
|
||||||
|
)
|
||||||
|
|
||||||
|
branches = (
|
||||||
|
db.query(Branch)
|
||||||
|
.options(selectinload(Branch.missions))
|
||||||
|
.order_by(Branch.title)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
branch_stats: list[BranchCompletionStat] = []
|
||||||
|
for branch in branches:
|
||||||
|
total_missions = len(branch.missions)
|
||||||
|
if total_missions == 0 or total_pilots == 0:
|
||||||
|
branch_stats.append(
|
||||||
|
BranchCompletionStat(branch_id=branch.id, branch_title=branch.title, completion_rate=0.0)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
approved_count = (
|
||||||
|
db.query(func.count(MissionSubmission.id))
|
||||||
|
.join(Mission, Mission.id == MissionSubmission.mission_id)
|
||||||
|
.join(BranchMission, BranchMission.mission_id == Mission.id)
|
||||||
|
.filter(
|
||||||
|
BranchMission.branch_id == branch.id,
|
||||||
|
MissionSubmission.status == SubmissionStatus.APPROVED,
|
||||||
|
)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
denominator = total_missions * total_pilots
|
||||||
|
rate = min(1.0, approved_count / denominator) if denominator else 0.0
|
||||||
|
branch_stats.append(
|
||||||
|
BranchCompletionStat(branch_id=branch.id, branch_title=branch.title, completion_rate=rate)
|
||||||
|
)
|
||||||
|
|
||||||
|
return AdminDashboardStats(
|
||||||
|
total_users=total_pilots,
|
||||||
|
active_pilots=active_pilots,
|
||||||
|
average_completed_missions=round(average_completed, 2),
|
||||||
|
submission_stats=submission_stats,
|
||||||
|
branch_completion=branch_stats,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/artifacts", response_model=ArtifactRead, summary="Создать артефакт")
|
||||||
|
def create_artifact(
|
||||||
|
artifact_in: ArtifactCreate,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_hr),
|
||||||
|
) -> ArtifactRead:
|
||||||
|
"""Добавляем новый артефакт в каталог."""
|
||||||
|
|
||||||
|
artifact = Artifact(
|
||||||
|
name=artifact_in.name,
|
||||||
|
description=artifact_in.description,
|
||||||
|
rarity=artifact_in.rarity,
|
||||||
|
image_url=artifact_in.image_url,
|
||||||
|
)
|
||||||
|
db.add(artifact)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(artifact)
|
||||||
|
return ArtifactRead.model_validate(artifact)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/artifacts/{artifact_id}", response_model=ArtifactRead, summary="Обновить артефакт")
|
||||||
|
def update_artifact(
|
||||||
|
artifact_id: int,
|
||||||
|
artifact_in: ArtifactUpdate,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_hr),
|
||||||
|
) -> ArtifactRead:
|
||||||
|
"""Редактируем существующий артефакт."""
|
||||||
|
|
||||||
|
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
||||||
|
if not artifact:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Артефакт не найден")
|
||||||
|
|
||||||
|
payload = artifact_in.model_dump(exclude_unset=True)
|
||||||
|
for field, value in payload.items():
|
||||||
|
setattr(artifact, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(artifact)
|
||||||
|
return ArtifactRead.model_validate(artifact)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/artifacts/{artifact_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Удалить артефакт"
|
||||||
|
)
|
||||||
|
def delete_artifact(
|
||||||
|
artifact_id: int,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_hr),
|
||||||
|
) -> Response:
|
||||||
|
"""Удаляем артефакт, если он не привязан к миссиям."""
|
||||||
|
|
||||||
|
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
||||||
|
if not artifact:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Артефакт не найден")
|
||||||
|
|
||||||
|
missions_with_artifact = db.query(Mission).filter(Mission.artifact_id == artifact_id).count()
|
||||||
|
if missions_with_artifact:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Нельзя удалить артефакт, привязанный к миссиям",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(artifact)
|
||||||
|
db.commit()
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/missions", response_model=MissionDetail, summary="Создать миссию")
|
||||||
|
def create_mission_endpoint(
|
||||||
|
mission_in: MissionCreate,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_hr),
|
||||||
|
) -> MissionDetail:
|
||||||
|
"""Создаём новую миссию."""
|
||||||
|
|
||||||
|
mission = Mission(
|
||||||
|
title=mission_in.title,
|
||||||
|
description=mission_in.description,
|
||||||
|
xp_reward=mission_in.xp_reward,
|
||||||
|
mana_reward=mission_in.mana_reward,
|
||||||
|
difficulty=mission_in.difficulty,
|
||||||
|
minimum_rank_id=mission_in.minimum_rank_id,
|
||||||
|
artifact_id=mission_in.artifact_id,
|
||||||
|
)
|
||||||
|
db.add(mission)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
for reward in mission_in.competency_rewards:
|
||||||
|
mission.competency_rewards.append(
|
||||||
|
MissionCompetencyReward(
|
||||||
|
mission_id=mission.id,
|
||||||
|
competency_id=reward.competency_id,
|
||||||
|
level_delta=reward.level_delta,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for prerequisite_id in mission_in.prerequisite_ids:
|
||||||
|
mission.prerequisites.append(
|
||||||
|
MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if mission_in.branch_id:
|
||||||
|
mission.branches.append(
|
||||||
|
BranchMission(
|
||||||
|
branch_id=mission_in.branch_id,
|
||||||
|
mission_id=mission.id,
|
||||||
|
order=mission_in.branch_order,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
mission = _load_mission(db, mission.id)
|
||||||
|
|
||||||
|
return _mission_to_detail(mission)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/missions/{mission_id}", response_model=MissionDetail, summary="Обновить миссию")
|
||||||
|
def update_mission_endpoint(
|
||||||
|
mission_id: int,
|
||||||
|
mission_in: MissionUpdate,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_hr),
|
||||||
|
) -> MissionDetail:
|
||||||
|
"""Редактируем миссию."""
|
||||||
|
|
||||||
|
mission = (
|
||||||
|
db.query(Mission)
|
||||||
|
.options(
|
||||||
|
selectinload(Mission.prerequisites),
|
||||||
|
selectinload(Mission.competency_rewards),
|
||||||
|
selectinload(Mission.branches),
|
||||||
|
)
|
||||||
|
.filter(Mission.id == mission_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not mission:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
||||||
|
|
||||||
|
payload = mission_in.model_dump(exclude_unset=True)
|
||||||
|
|
||||||
|
for attr in ["title", "description", "xp_reward", "mana_reward", "difficulty", "is_active"]:
|
||||||
|
if attr in payload:
|
||||||
|
setattr(mission, attr, payload[attr])
|
||||||
|
|
||||||
|
if "minimum_rank_id" in payload:
|
||||||
|
mission.minimum_rank_id = payload["minimum_rank_id"]
|
||||||
|
|
||||||
|
if "artifact_id" in payload:
|
||||||
|
mission.artifact_id = payload["artifact_id"]
|
||||||
|
|
||||||
|
if "competency_rewards" in payload:
|
||||||
|
mission.competency_rewards.clear()
|
||||||
|
for reward in payload["competency_rewards"]:
|
||||||
|
mission.competency_rewards.append(
|
||||||
|
MissionCompetencyReward(
|
||||||
|
mission_id=mission.id,
|
||||||
|
competency_id=reward.competency_id,
|
||||||
|
level_delta=reward.level_delta,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if "prerequisite_ids" in payload:
|
||||||
|
mission.prerequisites.clear()
|
||||||
|
for prerequisite_id in payload["prerequisite_ids"]:
|
||||||
|
mission.prerequisites.append(
|
||||||
|
MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if "branch_id" in payload:
|
||||||
|
mission.branches.clear()
|
||||||
|
branch_id = payload["branch_id"]
|
||||||
|
if branch_id is not None:
|
||||||
|
order = payload.get("branch_order", 1)
|
||||||
|
mission.branches.append(
|
||||||
|
BranchMission(branch_id=branch_id, mission_id=mission.id, order=order)
|
||||||
|
)
|
||||||
|
elif "branch_order" in payload and mission.branches:
|
||||||
|
mission.branches[0].order = payload["branch_order"]
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
mission = _load_mission(db, mission.id)
|
||||||
|
return _mission_to_detail(mission)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ranks", response_model=list[RankBase], summary="Список рангов")
|
@router.get("/ranks", response_model=list[RankBase], summary="Список рангов")
|
||||||
def admin_ranks(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[RankBase]:
|
def admin_ranks(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[RankBase]:
|
||||||
"""Перечень рангов."""
|
"""Перечень рангов."""
|
||||||
|
|
@ -104,6 +528,103 @@ def admin_ranks(*, db: Session = Depends(get_db), current_user=Depends(require_h
|
||||||
return [RankBase.model_validate(rank) for rank in ranks]
|
return [RankBase.model_validate(rank) for rank in ranks]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ranks/{rank_id}", response_model=RankDetailed, summary="Детали ранга")
|
||||||
|
def get_rank(
|
||||||
|
rank_id: int,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_hr),
|
||||||
|
) -> RankDetailed:
|
||||||
|
"""Возвращаем подробную информацию о ранге."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
rank = _load_rank(db, rank_id)
|
||||||
|
except NoResultFound as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ранг не найден") from exc
|
||||||
|
return _rank_to_detailed(rank)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ranks", response_model=RankDetailed, summary="Создать ранг")
|
||||||
|
def create_rank(
|
||||||
|
rank_in: RankCreate,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_hr),
|
||||||
|
) -> RankDetailed:
|
||||||
|
"""Создаём новый ранг с требованиями."""
|
||||||
|
|
||||||
|
rank = Rank(title=rank_in.title, description=rank_in.description, required_xp=rank_in.required_xp)
|
||||||
|
db.add(rank)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
for mission_id in rank_in.mission_ids:
|
||||||
|
rank.mission_requirements.append(
|
||||||
|
RankMissionRequirement(rank_id=rank.id, mission_id=mission_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in rank_in.competency_requirements:
|
||||||
|
rank.competency_requirements.append(
|
||||||
|
RankCompetencyRequirement(
|
||||||
|
rank_id=rank.id,
|
||||||
|
competency_id=item.competency_id,
|
||||||
|
required_level=item.required_level,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rank = _load_rank(db, rank.id)
|
||||||
|
return _rank_to_detailed(rank)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/ranks/{rank_id}", response_model=RankDetailed, summary="Обновить ранг")
|
||||||
|
def update_rank(
|
||||||
|
rank_id: int,
|
||||||
|
rank_in: RankUpdate,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(require_hr),
|
||||||
|
) -> RankDetailed:
|
||||||
|
"""Редактируем параметры ранга."""
|
||||||
|
|
||||||
|
rank = (
|
||||||
|
db.query(Rank)
|
||||||
|
.options(
|
||||||
|
selectinload(Rank.mission_requirements),
|
||||||
|
selectinload(Rank.competency_requirements),
|
||||||
|
)
|
||||||
|
.filter(Rank.id == rank_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not rank:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ранг не найден")
|
||||||
|
|
||||||
|
rank.title = rank_in.title
|
||||||
|
rank.description = rank_in.description
|
||||||
|
rank.required_xp = rank_in.required_xp
|
||||||
|
|
||||||
|
rank.mission_requirements.clear()
|
||||||
|
for mission_id in rank_in.mission_ids:
|
||||||
|
rank.mission_requirements.append(
|
||||||
|
RankMissionRequirement(rank_id=rank.id, mission_id=mission_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
rank.competency_requirements.clear()
|
||||||
|
for item in rank_in.competency_requirements:
|
||||||
|
rank.competency_requirements.append(
|
||||||
|
RankCompetencyRequirement(
|
||||||
|
rank_id=rank.id,
|
||||||
|
competency_id=item.competency_id,
|
||||||
|
required_level=item.required_level,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rank = _load_rank(db, rank.id)
|
||||||
|
return _rank_to_detailed(rank)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/submissions",
|
"/submissions",
|
||||||
response_model=list[MissionSubmissionRead],
|
response_model=list[MissionSubmissionRead],
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,17 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.journal import JournalEntry
|
from app.models.journal import JournalEntry
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.journal import JournalEntryRead
|
from app.schemas.journal import JournalEntryRead, LeaderboardEntry, LeaderboardResponse
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/journal", tags=["journal"])
|
router = APIRouter(prefix="/api/journal", tags=["journal"])
|
||||||
|
|
||||||
|
|
@ -27,3 +30,48 @@ def list_journal(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [JournalEntryRead.model_validate(entry) for entry in entries]
|
return [JournalEntryRead.model_validate(entry) for entry in entries]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/leaderboard", response_model=LeaderboardResponse, summary="Таблица лидеров")
|
||||||
|
def leaderboard(
|
||||||
|
period: str = "week",
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> LeaderboardResponse:
|
||||||
|
"""Возвращаем топ пилотов по опыту и мане за выбранный период."""
|
||||||
|
|
||||||
|
del current_user # информация используется только для авторизации
|
||||||
|
|
||||||
|
periods = {"week": 7, "month": 30, "year": 365}
|
||||||
|
if period not in periods:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неизвестный период")
|
||||||
|
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(days=periods[period])
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
User.id.label("user_id"),
|
||||||
|
User.full_name,
|
||||||
|
func.sum(JournalEntry.xp_delta).label("xp_sum"),
|
||||||
|
func.sum(JournalEntry.mana_delta).label("mana_sum"),
|
||||||
|
)
|
||||||
|
.join(User, User.id == JournalEntry.user_id)
|
||||||
|
.filter(JournalEntry.created_at >= since)
|
||||||
|
.group_by(User.id, User.full_name)
|
||||||
|
.order_by(func.sum(JournalEntry.xp_delta).desc())
|
||||||
|
.limit(5)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
entries = [
|
||||||
|
LeaderboardEntry(
|
||||||
|
user_id=row.user_id,
|
||||||
|
full_name=row.full_name,
|
||||||
|
xp_delta=int(row.xp_sum or 0),
|
||||||
|
mana_delta=int(row.mana_sum or 0),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return LeaderboardResponse(period=period, entries=entries)
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,17 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.mission import Mission, MissionSubmission
|
from app.models.branch import Branch, BranchMission
|
||||||
|
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.schemas.branch import BranchMissionRead, BranchRead
|
||||||
from app.schemas.mission import (
|
from app.schemas.mission import (
|
||||||
MissionBase,
|
MissionBase,
|
||||||
MissionDetail,
|
MissionDetail,
|
||||||
|
|
@ -20,19 +24,181 @@ from app.services.mission import submit_mission
|
||||||
router = APIRouter(prefix="/api/missions", tags=["missions"])
|
router = APIRouter(prefix="/api/missions", tags=["missions"])
|
||||||
|
|
||||||
|
|
||||||
|
def _load_user_progress(user: User) -> set[int]:
|
||||||
|
"""Возвращаем идентификаторы успешно завершённых миссий."""
|
||||||
|
|
||||||
|
completed = {
|
||||||
|
submission.mission_id
|
||||||
|
for submission in user.submissions
|
||||||
|
if submission.status == SubmissionStatus.APPROVED
|
||||||
|
}
|
||||||
|
return completed
|
||||||
|
|
||||||
|
|
||||||
|
def _build_branch_dependencies(branches: list[Branch]) -> dict[int, set[int]]:
|
||||||
|
"""Строим карту зависимостей миссий по веткам."""
|
||||||
|
|
||||||
|
dependencies: dict[int, set[int]] = defaultdict(set)
|
||||||
|
for branch in branches:
|
||||||
|
ordered = sorted(branch.missions, key=lambda item: item.order)
|
||||||
|
previous: list[int] = []
|
||||||
|
for link in ordered:
|
||||||
|
if previous:
|
||||||
|
dependencies[link.mission_id].update(previous)
|
||||||
|
previous.append(link.mission_id)
|
||||||
|
return dependencies
|
||||||
|
|
||||||
|
|
||||||
|
def _mission_availability(
|
||||||
|
*,
|
||||||
|
mission: Mission,
|
||||||
|
user: User,
|
||||||
|
completed_missions: set[int],
|
||||||
|
branch_dependencies: dict[int, set[int]],
|
||||||
|
mission_titles: dict[int, str],
|
||||||
|
) -> tuple[bool, list[str]]:
|
||||||
|
"""Определяем, доступна ли миссия и формируем причины блокировки."""
|
||||||
|
|
||||||
|
reasons: list[str] = []
|
||||||
|
|
||||||
|
if mission.minimum_rank and user.xp < mission.minimum_rank.required_xp:
|
||||||
|
reasons.append(f"Требуется ранг «{mission.minimum_rank.title}»")
|
||||||
|
|
||||||
|
missing_explicit = [
|
||||||
|
req.required_mission_id
|
||||||
|
for req in mission.prerequisites
|
||||||
|
if req.required_mission_id not in completed_missions
|
||||||
|
]
|
||||||
|
for mission_id in missing_explicit:
|
||||||
|
reasons.append(f"Завершите миссию «{mission_titles.get(mission_id, '#'+str(mission_id))}»")
|
||||||
|
|
||||||
|
for mission_id in branch_dependencies.get(mission.id, set()):
|
||||||
|
if mission_id not in completed_missions:
|
||||||
|
reasons.append(
|
||||||
|
"Продолжение ветки откроется после миссии «"
|
||||||
|
f"{mission_titles.get(mission_id, '#'+str(mission_id))}»"
|
||||||
|
)
|
||||||
|
|
||||||
|
is_available = mission.is_active and not reasons
|
||||||
|
return is_available, reasons
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/branches", response_model=list[BranchRead], summary="Список веток миссий")
|
||||||
|
def list_branches(
|
||||||
|
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
|
) -> list[BranchRead]:
|
||||||
|
"""Возвращаем ветки с упорядоченными миссиями."""
|
||||||
|
|
||||||
|
db.refresh(current_user)
|
||||||
|
_ = current_user.submissions
|
||||||
|
branches = (
|
||||||
|
db.query(Branch)
|
||||||
|
.options(selectinload(Branch.missions).selectinload(BranchMission.mission))
|
||||||
|
.order_by(Branch.title)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
completed_missions = _load_user_progress(current_user)
|
||||||
|
branch_dependencies = _build_branch_dependencies(branches)
|
||||||
|
|
||||||
|
mission_titles = {
|
||||||
|
item.mission_id: item.mission.title if item.mission else ""
|
||||||
|
for branch in branches
|
||||||
|
for item in branch.missions
|
||||||
|
}
|
||||||
|
mission_titles.update(dict(db.query(Mission.id, Mission.title).all()))
|
||||||
|
|
||||||
|
response: list[BranchRead] = []
|
||||||
|
for branch in branches:
|
||||||
|
ordered_links = sorted(branch.missions, key=lambda link: link.order)
|
||||||
|
completed_count = sum(1 for link in ordered_links if link.mission_id in completed_missions)
|
||||||
|
total = len(ordered_links)
|
||||||
|
|
||||||
|
missions_payload = []
|
||||||
|
for link in ordered_links:
|
||||||
|
mission_obj = link.mission
|
||||||
|
mission_title = mission_obj.title if mission_obj else ""
|
||||||
|
is_completed = link.mission_id in completed_missions
|
||||||
|
if mission_obj:
|
||||||
|
is_available, _ = _mission_availability(
|
||||||
|
mission=mission_obj,
|
||||||
|
user=current_user,
|
||||||
|
completed_missions=completed_missions,
|
||||||
|
branch_dependencies=branch_dependencies,
|
||||||
|
mission_titles=mission_titles,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
is_available = False
|
||||||
|
missions_payload.append(
|
||||||
|
BranchMissionRead(
|
||||||
|
mission_id=link.mission_id,
|
||||||
|
mission_title=mission_title,
|
||||||
|
order=link.order,
|
||||||
|
is_completed=is_completed,
|
||||||
|
is_available=is_available,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response.append(
|
||||||
|
BranchRead(
|
||||||
|
id=branch.id,
|
||||||
|
title=branch.title,
|
||||||
|
description=branch.description,
|
||||||
|
category=branch.category,
|
||||||
|
missions=missions_payload,
|
||||||
|
total_missions=total,
|
||||||
|
completed_missions=completed_count,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[MissionBase], summary="Список миссий")
|
@router.get("/", response_model=list[MissionBase], summary="Список миссий")
|
||||||
def list_missions(
|
def list_missions(
|
||||||
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
) -> list[MissionBase]:
|
) -> list[MissionBase]:
|
||||||
"""Возвращаем доступные миссии."""
|
"""Возвращаем доступные миссии."""
|
||||||
|
|
||||||
query = db.query(Mission).filter(Mission.is_active.is_(True))
|
db.refresh(current_user)
|
||||||
if current_user.current_rank_id:
|
_ = current_user.submissions
|
||||||
query = query.filter(
|
|
||||||
(Mission.minimum_rank_id.is_(None)) | (Mission.minimum_rank_id <= current_user.current_rank_id)
|
branches = (
|
||||||
|
db.query(Branch)
|
||||||
|
.options(selectinload(Branch.missions))
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
missions = query.all()
|
branch_dependencies = _build_branch_dependencies(branches)
|
||||||
return [MissionBase.model_validate(mission) for mission in missions]
|
|
||||||
|
missions = (
|
||||||
|
db.query(Mission)
|
||||||
|
.options(
|
||||||
|
selectinload(Mission.prerequisites),
|
||||||
|
selectinload(Mission.minimum_rank),
|
||||||
|
)
|
||||||
|
.filter(Mission.is_active.is_(True))
|
||||||
|
.order_by(Mission.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
mission_titles = {mission.id: mission.title for mission in missions}
|
||||||
|
completed_missions = _load_user_progress(current_user)
|
||||||
|
|
||||||
|
response: list[MissionBase] = []
|
||||||
|
for mission in missions:
|
||||||
|
is_available, reasons = _mission_availability(
|
||||||
|
mission=mission,
|
||||||
|
user=current_user,
|
||||||
|
completed_missions=completed_missions,
|
||||||
|
branch_dependencies=branch_dependencies,
|
||||||
|
mission_titles=mission_titles,
|
||||||
|
)
|
||||||
|
dto = MissionBase.model_validate(mission)
|
||||||
|
dto.is_available = is_available
|
||||||
|
dto.locked_reasons = reasons
|
||||||
|
response.append(dto)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{mission_id}", response_model=MissionDetail, summary="Карточка миссии")
|
@router.get("/{mission_id}", response_model=MissionDetail, summary="Карточка миссии")
|
||||||
|
|
@ -48,6 +214,25 @@ def get_mission(
|
||||||
if not mission:
|
if not mission:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
||||||
|
|
||||||
|
db.refresh(current_user)
|
||||||
|
_ = current_user.submissions
|
||||||
|
branches = (
|
||||||
|
db.query(Branch)
|
||||||
|
.options(selectinload(Branch.missions))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
branch_dependencies = _build_branch_dependencies(branches)
|
||||||
|
completed_missions = _load_user_progress(current_user)
|
||||||
|
mission_titles = dict(db.query(Mission.id, Mission.title).all())
|
||||||
|
|
||||||
|
is_available, reasons = _mission_availability(
|
||||||
|
mission=mission,
|
||||||
|
user=current_user,
|
||||||
|
completed_missions=completed_missions,
|
||||||
|
branch_dependencies=branch_dependencies,
|
||||||
|
mission_titles=mission_titles,
|
||||||
|
)
|
||||||
|
|
||||||
prerequisites = [link.required_mission_id for link in mission.prerequisites]
|
prerequisites = [link.required_mission_id for link in mission.prerequisites]
|
||||||
rewards = [
|
rewards = [
|
||||||
{
|
{
|
||||||
|
|
@ -66,6 +251,8 @@ def get_mission(
|
||||||
mana_reward=mission.mana_reward,
|
mana_reward=mission.mana_reward,
|
||||||
difficulty=mission.difficulty,
|
difficulty=mission.difficulty,
|
||||||
is_active=mission.is_active,
|
is_active=mission.is_active,
|
||||||
|
is_available=is_available,
|
||||||
|
locked_reasons=reasons,
|
||||||
minimum_rank_id=mission.minimum_rank_id,
|
minimum_rank_id=mission.minimum_rank_id,
|
||||||
artifact_id=mission.artifact_id,
|
artifact_id=mission.artifact_id,
|
||||||
prerequisites=prerequisites,
|
prerequisites=prerequisites,
|
||||||
|
|
@ -86,9 +273,30 @@ def submit(
|
||||||
) -> MissionSubmissionRead:
|
) -> MissionSubmissionRead:
|
||||||
"""Пилот отправляет доказательство выполнения миссии."""
|
"""Пилот отправляет доказательство выполнения миссии."""
|
||||||
|
|
||||||
mission = db.query(Mission).filter(Mission.id == mission_id).first()
|
mission = db.query(Mission).filter(Mission.id == mission_id, Mission.is_active.is_(True)).first()
|
||||||
if not mission:
|
if not mission:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
||||||
|
|
||||||
|
db.refresh(current_user)
|
||||||
|
_ = current_user.submissions
|
||||||
|
branches = (
|
||||||
|
db.query(Branch)
|
||||||
|
.options(selectinload(Branch.missions))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
branch_dependencies = _build_branch_dependencies(branches)
|
||||||
|
completed_missions = _load_user_progress(current_user)
|
||||||
|
mission_titles = dict(db.query(Mission.id, Mission.title).all())
|
||||||
|
|
||||||
|
is_available, reasons = _mission_availability(
|
||||||
|
mission=mission,
|
||||||
|
user=current_user,
|
||||||
|
completed_missions=completed_missions,
|
||||||
|
branch_dependencies=branch_dependencies,
|
||||||
|
mission_titles=mission_titles,
|
||||||
|
)
|
||||||
|
if not is_available:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="; ".join(reasons))
|
||||||
submission = submit_mission(
|
submission = submit_mission(
|
||||||
db=db,
|
db=db,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
|
|
|
||||||
60
backend/app/api/routes/onboarding.py
Normal file
60
backend/app/api/routes/onboarding.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Онбординг и космический лор."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.onboarding import (
|
||||||
|
OnboardingCompleteRequest,
|
||||||
|
OnboardingOverview,
|
||||||
|
OnboardingSlideRead,
|
||||||
|
OnboardingStateRead,
|
||||||
|
)
|
||||||
|
from app.services.onboarding import complete_slide, get_overview
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/onboarding", tags=["onboarding"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=OnboardingOverview, summary="Лор и прогресс онбординга")
|
||||||
|
def read_onboarding(
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> OnboardingOverview:
|
||||||
|
"""Отдаём все слайды вместе с состоянием пользователя."""
|
||||||
|
|
||||||
|
slides, state, next_order = get_overview(db, current_user)
|
||||||
|
return OnboardingOverview(
|
||||||
|
slides=[OnboardingSlideRead.model_validate(slide) for slide in slides],
|
||||||
|
state=OnboardingStateRead(
|
||||||
|
last_completed_order=state.last_completed_order,
|
||||||
|
is_completed=state.is_completed,
|
||||||
|
),
|
||||||
|
next_order=next_order,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/complete", response_model=OnboardingStateRead, summary="Завершаем шаг онбординга")
|
||||||
|
def complete_onboarding_step(
|
||||||
|
payload: OnboardingCompleteRequest,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> OnboardingStateRead:
|
||||||
|
"""Фиксируем прохождение очередного шага лора."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = complete_slide(db, current_user, payload.order)
|
||||||
|
except ValueError as exc: # pragma: no cover - ошибка бизнес-логики
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return OnboardingStateRead(
|
||||||
|
last_completed_order=state.last_completed_order,
|
||||||
|
is_completed=state.is_completed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -7,8 +7,12 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import get_current_user
|
from app.api.deps import get_current_user
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
from app.models.rank import Rank
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.schemas.progress import ProgressSnapshot
|
||||||
|
from app.schemas.rank import RankBase
|
||||||
from app.schemas.user import UserProfile
|
from app.schemas.user import UserProfile
|
||||||
|
from app.services.rank import build_progress_snapshot
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["profile"])
|
router = APIRouter(prefix="/api", tags=["profile"])
|
||||||
|
|
||||||
|
|
@ -23,3 +27,26 @@ def get_profile(
|
||||||
_ = current_user.competencies
|
_ = current_user.competencies
|
||||||
_ = current_user.artifacts
|
_ = current_user.artifacts
|
||||||
return UserProfile.model_validate(current_user)
|
return UserProfile.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ranks", response_model=list[RankBase], summary="Перечень рангов")
|
||||||
|
def list_ranks(
|
||||||
|
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
|
) -> list[RankBase]:
|
||||||
|
"""Возвращаем ранги по возрастанию требований."""
|
||||||
|
|
||||||
|
ranks = db.query(Rank).order_by(Rank.required_xp).all()
|
||||||
|
return [RankBase.model_validate(rank) for rank in ranks]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/progress", response_model=ProgressSnapshot, summary="Прогресс до следующего ранга")
|
||||||
|
def get_progress(
|
||||||
|
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
|
) -> ProgressSnapshot:
|
||||||
|
"""Возвращаем агрегированную информацию о выполненных условиях следующего ранга."""
|
||||||
|
|
||||||
|
db.refresh(current_user)
|
||||||
|
_ = current_user.submissions
|
||||||
|
_ = current_user.competencies
|
||||||
|
snapshot = build_progress_snapshot(current_user, db)
|
||||||
|
return snapshot
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
"""Конфигурация приложения и загрузка окружения."""
|
"""Конфигурация приложения и загрузка окружения."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Глобальные настройки сервиса."""
|
"""Глобальные настройки сервиса."""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_prefix="ALABUGA_", extra="ignore")
|
model_config = SettingsConfigDict(env_file=BASE_DIR / ".env", env_prefix="ALABUGA_", extra="ignore")
|
||||||
|
|
||||||
project_name: str = "Alabuga Gamification API"
|
project_name: str = "Alabuga Gamification API"
|
||||||
environment: str = "local"
|
environment: str = "local"
|
||||||
|
|
@ -17,7 +23,11 @@ class Settings(BaseSettings):
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 60 * 12
|
access_token_expire_minutes: int = 60 * 12
|
||||||
|
|
||||||
backend_cors_origins: list[str] = ["http://localhost:3000", "http://frontend:3000"]
|
backend_cors_origins: List[str] = [
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://frontend:3000",
|
||||||
|
"http://0.0.0.0:3000",
|
||||||
|
]
|
||||||
|
|
||||||
sqlite_path: Path = Path("/data/app.db")
|
sqlite_path: Path = Path("/data/app.db")
|
||||||
|
|
||||||
|
|
@ -33,6 +43,10 @@ def get_settings() -> Settings:
|
||||||
"""Кэшируем создание настроек, чтобы не читать файл каждый раз."""
|
"""Кэшируем создание настроек, чтобы не читать файл каждый раз."""
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
|
if not settings.sqlite_path.is_absolute():
|
||||||
|
settings.sqlite_path = (BASE_DIR / settings.sqlite_path).resolve()
|
||||||
|
|
||||||
settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
settings.sqlite_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.api.routes import admin, auth, journal, missions, store, users
|
from app.api.routes import admin, auth, journal, missions, onboarding, store, users
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
|
|
@ -32,6 +32,7 @@ app.include_router(auth.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(missions.router)
|
app.include_router(missions.router)
|
||||||
app.include_router(journal.router)
|
app.include_router(journal.router)
|
||||||
|
app.include_router(onboarding.router)
|
||||||
app.include_router(store.router)
|
app.include_router(store.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from .artifact import Artifact # noqa: F401
|
||||||
from .branch import Branch, BranchMission # noqa: F401
|
from .branch import Branch, BranchMission # noqa: F401
|
||||||
from .journal import JournalEntry # noqa: F401
|
from .journal import JournalEntry # noqa: F401
|
||||||
from .mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission # noqa: F401
|
from .mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission # noqa: F401
|
||||||
|
from .onboarding import OnboardingSlide, OnboardingState # noqa: F401
|
||||||
from .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401
|
from .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401
|
||||||
from .store import Order, StoreItem # noqa: F401
|
from .store import Order, StoreItem # noqa: F401
|
||||||
from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401
|
from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401
|
||||||
|
|
@ -17,6 +18,8 @@ __all__ = [
|
||||||
"MissionCompetencyReward",
|
"MissionCompetencyReward",
|
||||||
"MissionPrerequisite",
|
"MissionPrerequisite",
|
||||||
"MissionSubmission",
|
"MissionSubmission",
|
||||||
|
"OnboardingSlide",
|
||||||
|
"OnboardingState",
|
||||||
"Rank",
|
"Rank",
|
||||||
"RankCompetencyRequirement",
|
"RankCompetencyRequirement",
|
||||||
"RankMissionRequirement",
|
"RankMissionRequirement",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, JSON, String, Text
|
from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, JSON, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
@ -30,7 +31,7 @@ class JournalEntry(Base, TimestampMixin):
|
||||||
event_type: Mapped[JournalEventType] = mapped_column(SQLEnum(JournalEventType), nullable=False)
|
event_type: Mapped[JournalEventType] = mapped_column(SQLEnum(JournalEventType), nullable=False)
|
||||||
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
payload: Mapped[dict | None] = mapped_column(JSON)
|
payload: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||||
xp_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
xp_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
mana_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
mana_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
|
|
||||||
40
backend/app/models/onboarding.py
Normal file
40
backend/app/models/onboarding.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""Модели онбординга и лора."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingSlide(Base, TimestampMixin):
|
||||||
|
"""Контентный слайд онбординга, который видит пилот."""
|
||||||
|
|
||||||
|
__tablename__ = "onboarding_slides"
|
||||||
|
__table_args__ = (UniqueConstraint("order", name="uq_onboarding_slide_order"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
media_url: Mapped[Optional[str]] = mapped_column(String(512))
|
||||||
|
cta_text: Mapped[Optional[str]] = mapped_column(String(120))
|
||||||
|
cta_link: Mapped[Optional[str]] = mapped_column(String(512))
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingState(Base, TimestampMixin):
|
||||||
|
"""Прогресс пользователя по онбордингу."""
|
||||||
|
|
||||||
|
__tablename__ = "onboarding_states"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", name="uq_onboarding_state_user"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
last_completed_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
is_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="onboarding_state")
|
||||||
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
|
||||||
from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, String, Text
|
from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
@ -45,7 +45,7 @@ class Order(Base, TimestampMixin):
|
||||||
status: Mapped[OrderStatus] = mapped_column(
|
status: Mapped[OrderStatus] = mapped_column(
|
||||||
SQLEnum(OrderStatus), default=OrderStatus.CREATED, nullable=False
|
SQLEnum(OrderStatus), default=OrderStatus.CREATED, nullable=False
|
||||||
)
|
)
|
||||||
comment: Mapped[str | None] = mapped_column(Text)
|
comment: Mapped[Optional[str]] = mapped_column(Text)
|
||||||
|
|
||||||
user = relationship("User", back_populates="orders")
|
user = relationship("User", back_populates="orders")
|
||||||
item = relationship("StoreItem", back_populates="orders")
|
item = relationship("StoreItem", back_populates="orders")
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
# Локальные импорты внизу файла, чтобы избежать циклов типов
|
||||||
|
|
||||||
|
|
||||||
class UserRole(str, Enum):
|
class UserRole(str, Enum):
|
||||||
"""Типы ролей в системе."""
|
"""Типы ролей в системе."""
|
||||||
|
|
@ -48,6 +50,9 @@ class User(Base, TimestampMixin):
|
||||||
artifacts: Mapped[List["UserArtifact"]] = relationship(
|
artifacts: Mapped[List["UserArtifact"]] = relationship(
|
||||||
"UserArtifact", back_populates="user", cascade="all, delete-orphan"
|
"UserArtifact", back_populates="user", cascade="all, delete-orphan"
|
||||||
)
|
)
|
||||||
|
onboarding_state: Mapped[Optional["OnboardingState"]] = relationship(
|
||||||
|
"OnboardingState", back_populates="user", cascade="all, delete-orphan", uselist=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CompetencyCategory(str, Enum):
|
class CompetencyCategory(str, Enum):
|
||||||
|
|
@ -103,3 +108,7 @@ class UserArtifact(Base, TimestampMixin):
|
||||||
|
|
||||||
user = relationship("User", back_populates="artifacts")
|
user = relationship("User", back_populates="artifacts")
|
||||||
artifact = relationship("Artifact", back_populates="pilots")
|
artifact = relationship("Artifact", back_populates="pilots")
|
||||||
|
|
||||||
|
|
||||||
|
# Импорты в конце файла, чтобы отношения корректно инициализировались
|
||||||
|
from app.models.onboarding import OnboardingState # noqa: E402 pylint: disable=wrong-import-position
|
||||||
|
|
|
||||||
31
backend/app/schemas/admin_stats.py
Normal file
31
backend/app/schemas/admin_stats.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Сводные метрики для HR."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionStats(BaseModel):
|
||||||
|
"""Структура статистики по отправкам миссий."""
|
||||||
|
|
||||||
|
pending: int
|
||||||
|
approved: int
|
||||||
|
rejected: int
|
||||||
|
|
||||||
|
|
||||||
|
class BranchCompletionStat(BaseModel):
|
||||||
|
"""Завершённость ветки."""
|
||||||
|
|
||||||
|
branch_id: int
|
||||||
|
branch_title: str
|
||||||
|
completion_rate: float
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDashboardStats(BaseModel):
|
||||||
|
"""Ответ с основными метриками."""
|
||||||
|
|
||||||
|
total_users: int
|
||||||
|
active_pilots: int
|
||||||
|
average_completed_missions: float
|
||||||
|
submission_stats: SubmissionStats
|
||||||
|
branch_completion: list[BranchCompletionStat]
|
||||||
38
backend/app/schemas/artifact.py
Normal file
38
backend/app/schemas/artifact.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Схемы артефактов."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.models.artifact import ArtifactRarity
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactRead(BaseModel):
|
||||||
|
"""Краткая информация об артефакте."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
rarity: ArtifactRarity
|
||||||
|
image_url: str | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactCreate(BaseModel):
|
||||||
|
"""Создание артефакта."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
rarity: ArtifactRarity
|
||||||
|
image_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactUpdate(BaseModel):
|
||||||
|
"""Обновление артефакта."""
|
||||||
|
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
rarity: ArtifactRarity | None = None
|
||||||
|
image_url: str | None = None
|
||||||
|
|
@ -11,6 +11,8 @@ class BranchMissionRead(BaseModel):
|
||||||
mission_id: int
|
mission_id: int
|
||||||
mission_title: str
|
mission_title: str
|
||||||
order: int
|
order: int
|
||||||
|
is_completed: bool = False
|
||||||
|
is_available: bool = False
|
||||||
|
|
||||||
|
|
||||||
class BranchRead(BaseModel):
|
class BranchRead(BaseModel):
|
||||||
|
|
@ -21,6 +23,22 @@ class BranchRead(BaseModel):
|
||||||
description: str
|
description: str
|
||||||
category: str
|
category: str
|
||||||
missions: list[BranchMissionRead]
|
missions: list[BranchMissionRead]
|
||||||
|
total_missions: int = 0
|
||||||
|
completed_missions: int = 0
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class BranchCreate(BaseModel):
|
||||||
|
"""Создание ветки."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
category: str
|
||||||
|
|
||||||
|
|
||||||
|
class BranchUpdate(BranchCreate):
|
||||||
|
"""Обновление ветки."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,19 @@ class JournalEntryRead(BaseModel):
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class LeaderboardEntry(BaseModel):
|
||||||
|
"""Участник таблицы лидеров."""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
full_name: str
|
||||||
|
xp_delta: int
|
||||||
|
mana_delta: int
|
||||||
|
|
||||||
|
|
||||||
|
class LeaderboardResponse(BaseModel):
|
||||||
|
"""Ответ для таблицы лидеров."""
|
||||||
|
|
||||||
|
period: str
|
||||||
|
entries: list[LeaderboardEntry]
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.models.mission import MissionDifficulty, SubmissionStatus
|
from app.models.mission import MissionDifficulty, SubmissionStatus
|
||||||
|
|
||||||
|
|
@ -20,6 +20,8 @@ class MissionBase(BaseModel):
|
||||||
mana_reward: int
|
mana_reward: int
|
||||||
difficulty: MissionDifficulty
|
difficulty: MissionDifficulty
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
is_available: bool = True
|
||||||
|
locked_reasons: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
@ -68,6 +70,23 @@ class MissionCreate(BaseModel):
|
||||||
branch_order: int = 1
|
branch_order: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class MissionUpdate(BaseModel):
|
||||||
|
"""Схема обновления миссии."""
|
||||||
|
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
xp_reward: Optional[int] = None
|
||||||
|
mana_reward: Optional[int] = None
|
||||||
|
difficulty: Optional[MissionDifficulty] = None
|
||||||
|
minimum_rank_id: Optional[int | None] = None
|
||||||
|
artifact_id: Optional[int | None] = None
|
||||||
|
prerequisite_ids: Optional[list[int]] = None
|
||||||
|
competency_rewards: Optional[list[MissionCreateReward]] = None
|
||||||
|
branch_id: Optional[int | None] = None
|
||||||
|
branch_order: Optional[int] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class MissionSubmissionCreate(BaseModel):
|
class MissionSubmissionCreate(BaseModel):
|
||||||
"""Отправка отчёта по миссии."""
|
"""Отправка отчёта по миссии."""
|
||||||
|
|
||||||
|
|
@ -78,6 +97,7 @@ class MissionSubmissionCreate(BaseModel):
|
||||||
class MissionSubmissionRead(BaseModel):
|
class MissionSubmissionRead(BaseModel):
|
||||||
"""Получение статуса отправки."""
|
"""Получение статуса отправки."""
|
||||||
|
|
||||||
|
id: int
|
||||||
mission_id: int
|
mission_id: int
|
||||||
status: SubmissionStatus
|
status: SubmissionStatus
|
||||||
comment: Optional[str]
|
comment: Optional[str]
|
||||||
|
|
|
||||||
44
backend/app/schemas/onboarding.py
Normal file
44
backend/app/schemas/onboarding.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Схемы для онбординга."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingSlideRead(BaseModel):
|
||||||
|
"""Отдельный слайд онбординга."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
order: int
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
media_url: Optional[str]
|
||||||
|
cta_text: Optional[str]
|
||||||
|
cta_link: Optional[str]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingStateRead(BaseModel):
|
||||||
|
"""Прогресс пользователя."""
|
||||||
|
|
||||||
|
last_completed_order: int
|
||||||
|
is_completed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingOverview(BaseModel):
|
||||||
|
"""Полный ответ с прогрессом и контентом."""
|
||||||
|
|
||||||
|
slides: list[OnboardingSlideRead]
|
||||||
|
state: OnboardingStateRead
|
||||||
|
next_order: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingCompleteRequest(BaseModel):
|
||||||
|
"""Запрос на фиксацию завершения шага."""
|
||||||
|
|
||||||
|
order: int
|
||||||
|
|
||||||
59
backend/app/schemas/progress.py
Normal file
59
backend/app/schemas/progress.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Pydantic-схемы для отображения прогресса по рангу."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressRank(BaseModel):
|
||||||
|
"""Краткое описание ранга, пригодное для отображения на дашборде."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
required_xp: int
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressXPMetrics(BaseModel):
|
||||||
|
"""Статистика по опыту: от базового значения до целевого порога."""
|
||||||
|
|
||||||
|
baseline: int = Field(description="Количество XP, с которого начинается текущий этап прогресса")
|
||||||
|
current: int = Field(description="Текущее значение XP пользователя")
|
||||||
|
target: int = Field(description="Порог XP, необходимый для следующего ранга")
|
||||||
|
remaining: int = Field(description="Сколько XP осталось набрать")
|
||||||
|
progress_percent: float = Field(description="Прогресс на отрезке от baseline до target в долях единицы")
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressMissionRequirement(BaseModel):
|
||||||
|
"""Статус обязательной миссии для следующего ранга."""
|
||||||
|
|
||||||
|
mission_id: int
|
||||||
|
mission_title: str
|
||||||
|
is_completed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressCompetencyRequirement(BaseModel):
|
||||||
|
"""Статус требования по компетенции."""
|
||||||
|
|
||||||
|
competency_id: int
|
||||||
|
competency_name: str
|
||||||
|
required_level: int
|
||||||
|
current_level: int
|
||||||
|
is_met: bool
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressSnapshot(BaseModel):
|
||||||
|
"""Итоговая структура прогресса: XP, обязательные миссии и компетенции."""
|
||||||
|
|
||||||
|
current_rank: ProgressRank | None
|
||||||
|
next_rank: ProgressRank | None
|
||||||
|
xp: ProgressXPMetrics
|
||||||
|
mission_requirements: list[ProgressMissionRequirement]
|
||||||
|
competency_requirements: list[ProgressCompetencyRequirement]
|
||||||
|
completed_missions: int
|
||||||
|
total_missions: int
|
||||||
|
met_competencies: int
|
||||||
|
total_competencies: int
|
||||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class RankBase(BaseModel):
|
class RankBase(BaseModel):
|
||||||
|
|
@ -41,3 +41,36 @@ class RankDetailed(RankBase):
|
||||||
competency_requirements: list[RankRequirementCompetency]
|
competency_requirements: list[RankRequirementCompetency]
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RankRequirementMissionInput(BaseModel):
|
||||||
|
"""Входная схема обязательной миссии."""
|
||||||
|
|
||||||
|
mission_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class RankRequirementCompetencyInput(BaseModel):
|
||||||
|
"""Входная схема требования к компетенции."""
|
||||||
|
|
||||||
|
competency_id: int
|
||||||
|
required_level: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
|
class RankCreate(BaseModel):
|
||||||
|
"""Создание нового ранга."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
required_xp: int = Field(ge=0)
|
||||||
|
mission_ids: list[int] = []
|
||||||
|
competency_requirements: list[RankRequirementCompetencyInput] = []
|
||||||
|
|
||||||
|
|
||||||
|
class RankUpdate(BaseModel):
|
||||||
|
"""Обновление существующего ранга."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
required_xp: int = Field(ge=0)
|
||||||
|
mission_ids: list[int] = []
|
||||||
|
competency_requirements: list[RankRequirementCompetencyInput] = []
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.journal import JournalEventType
|
from app.models.journal import JournalEventType
|
||||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||||
from app.models.user import User, UserCompetency
|
from app.models.user import User, UserArtifact, UserCompetency
|
||||||
from app.services.journal import log_event
|
from app.services.journal import log_event
|
||||||
from app.services.rank import apply_rank_upgrade
|
from app.services.rank import apply_rank_upgrade
|
||||||
|
|
||||||
|
|
@ -82,6 +82,21 @@ def approve_submission(db: Session, submission: MissionSubmission) -> MissionSub
|
||||||
|
|
||||||
_increase_competencies(db, user, submission.mission)
|
_increase_competencies(db, user, submission.mission)
|
||||||
|
|
||||||
|
if submission.mission.artifact_id:
|
||||||
|
already_has = any(
|
||||||
|
artifact.artifact_id == submission.mission.artifact_id for artifact in user.artifacts
|
||||||
|
)
|
||||||
|
if not already_has:
|
||||||
|
db.add(UserArtifact(user_id=user.id, artifact_id=submission.mission.artifact_id))
|
||||||
|
log_event(
|
||||||
|
db,
|
||||||
|
user_id=user.id,
|
||||||
|
event_type=JournalEventType.MISSION_COMPLETED,
|
||||||
|
title=f"Получен артефакт за миссию «{submission.mission.title}»",
|
||||||
|
description="Новый артефакт добавлен в коллекцию.",
|
||||||
|
payload={"artifact_id": submission.mission.artifact_id},
|
||||||
|
)
|
||||||
|
|
||||||
db.add_all([submission, user])
|
db.add_all([submission, user])
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(submission)
|
db.refresh(submission)
|
||||||
|
|
|
||||||
61
backend/app/services/onboarding.py
Normal file
61
backend/app/services/onboarding.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Сервисный слой для онбординга."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.onboarding import OnboardingSlide, OnboardingState
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_state(db: Session, user: User) -> OnboardingState:
|
||||||
|
"""Гарантируем наличие записи о прогрессе."""
|
||||||
|
|
||||||
|
if user.onboarding_state:
|
||||||
|
return user.onboarding_state
|
||||||
|
|
||||||
|
state = OnboardingState(user_id=user.id, last_completed_order=0, is_completed=False)
|
||||||
|
db.add(state)
|
||||||
|
db.flush()
|
||||||
|
db.refresh(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def get_overview(db: Session, user: User) -> tuple[list[OnboardingSlide], OnboardingState, int | None]:
|
||||||
|
"""Возвращаем все слайды и текущий прогресс."""
|
||||||
|
|
||||||
|
slides = db.query(OnboardingSlide).order_by(OnboardingSlide.order).all()
|
||||||
|
state = _ensure_state(db, user)
|
||||||
|
|
||||||
|
next_slide = next((slide for slide in slides if slide.order > state.last_completed_order), None)
|
||||||
|
next_order: int | None = next_slide.order if next_slide else None
|
||||||
|
return slides, state, next_order
|
||||||
|
|
||||||
|
|
||||||
|
def complete_slide(db: Session, user: User, order: int) -> OnboardingState:
|
||||||
|
"""Фиксируем завершение шага, если это корректный порядок."""
|
||||||
|
|
||||||
|
slides = db.query(OnboardingSlide).order_by(OnboardingSlide.order).all()
|
||||||
|
if not slides:
|
||||||
|
raise ValueError("Онбординг ещё не настроен")
|
||||||
|
|
||||||
|
state = _ensure_state(db, user)
|
||||||
|
|
||||||
|
allowed_orders = [slide.order for slide in slides]
|
||||||
|
if order not in allowed_orders:
|
||||||
|
raise ValueError("Неизвестный шаг онбординга")
|
||||||
|
|
||||||
|
if order <= state.last_completed_order:
|
||||||
|
return state
|
||||||
|
|
||||||
|
expected_order = next((value for value in allowed_orders if value > state.last_completed_order), None)
|
||||||
|
if expected_order is None or order != expected_order:
|
||||||
|
raise ValueError("Сначала завершите предыдущие шаги")
|
||||||
|
|
||||||
|
state.last_completed_order = order
|
||||||
|
state.is_completed = order == allowed_orders[-1]
|
||||||
|
db.add(state)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
@ -2,13 +2,20 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.models.journal import JournalEventType
|
from app.models.journal import JournalEventType
|
||||||
from app.models.mission import SubmissionStatus
|
from app.models.mission import SubmissionStatus
|
||||||
from app.models.rank import Rank
|
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.journal import log_event
|
from app.services.journal import log_event
|
||||||
|
from app.schemas.progress import (
|
||||||
|
ProgressCompetencyRequirement,
|
||||||
|
ProgressMissionRequirement,
|
||||||
|
ProgressRank,
|
||||||
|
ProgressSnapshot,
|
||||||
|
ProgressXPMetrics,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _eligible_rank(user: User, db: Session) -> Rank | None:
|
def _eligible_rank(user: User, db: Session) -> Rank | None:
|
||||||
|
|
@ -60,3 +67,106 @@ def apply_rank_upgrade(user: User, db: Session) -> Rank | None:
|
||||||
payload={"previous_rank_id": previous_rank_id, "new_rank_id": new_rank.id},
|
payload={"previous_rank_id": previous_rank_id, "new_rank_id": new_rank.id},
|
||||||
)
|
)
|
||||||
return new_rank
|
return new_rank
|
||||||
|
|
||||||
|
|
||||||
|
def build_progress_snapshot(user: User, db: Session) -> ProgressSnapshot:
|
||||||
|
"""Собираем агрегированное представление прогресса пользователя."""
|
||||||
|
|
||||||
|
ranks = (
|
||||||
|
db.query(Rank)
|
||||||
|
.options(
|
||||||
|
selectinload(Rank.mission_requirements).selectinload(RankMissionRequirement.mission),
|
||||||
|
selectinload(Rank.competency_requirements).selectinload(RankCompetencyRequirement.competency),
|
||||||
|
)
|
||||||
|
.order_by(Rank.required_xp)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
current_rank_obj = next((rank for rank in ranks if rank.id == user.current_rank_id), None)
|
||||||
|
|
||||||
|
approved_missions = {
|
||||||
|
submission.mission_id
|
||||||
|
for submission in user.submissions
|
||||||
|
if submission.status == SubmissionStatus.APPROVED
|
||||||
|
}
|
||||||
|
competency_levels = {item.competency_id: item.level for item in user.competencies}
|
||||||
|
|
||||||
|
highest_met_rank: Rank | None = None
|
||||||
|
next_rank_obj: Rank | None = None
|
||||||
|
for rank in ranks:
|
||||||
|
missions_ok = all(req.mission_id in approved_missions for req in rank.mission_requirements)
|
||||||
|
competencies_ok = all(
|
||||||
|
competency_levels.get(req.competency_id, 0) >= req.required_level
|
||||||
|
for req in rank.competency_requirements
|
||||||
|
)
|
||||||
|
xp_ok = user.xp >= rank.required_xp
|
||||||
|
|
||||||
|
if xp_ok and missions_ok and competencies_ok:
|
||||||
|
highest_met_rank = rank
|
||||||
|
continue
|
||||||
|
|
||||||
|
if next_rank_obj is None:
|
||||||
|
next_rank_obj = rank
|
||||||
|
break
|
||||||
|
|
||||||
|
baseline_rank = current_rank_obj or highest_met_rank
|
||||||
|
baseline_xp = baseline_rank.required_xp if baseline_rank else 0
|
||||||
|
|
||||||
|
if next_rank_obj:
|
||||||
|
target_xp = next_rank_obj.required_xp
|
||||||
|
remaining_xp = max(0, target_xp - user.xp)
|
||||||
|
denominator = max(target_xp - baseline_xp, 1)
|
||||||
|
progress_percent = min(1.0, max(0.0, (user.xp - baseline_xp) / denominator))
|
||||||
|
else:
|
||||||
|
target_xp = max(user.xp, baseline_xp)
|
||||||
|
remaining_xp = 0
|
||||||
|
progress_percent = 1.0
|
||||||
|
|
||||||
|
mission_requirements: list[ProgressMissionRequirement] = []
|
||||||
|
if next_rank_obj:
|
||||||
|
for requirement in next_rank_obj.mission_requirements:
|
||||||
|
title = requirement.mission.title if requirement.mission else f"Миссия #{requirement.mission_id}"
|
||||||
|
mission_requirements.append(
|
||||||
|
ProgressMissionRequirement(
|
||||||
|
mission_id=requirement.mission_id,
|
||||||
|
mission_title=title,
|
||||||
|
is_completed=requirement.mission_id in approved_missions,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
competency_requirements: list[ProgressCompetencyRequirement] = []
|
||||||
|
if next_rank_obj:
|
||||||
|
for requirement in next_rank_obj.competency_requirements:
|
||||||
|
current_level = competency_levels.get(requirement.competency_id, 0)
|
||||||
|
competency_requirements.append(
|
||||||
|
ProgressCompetencyRequirement(
|
||||||
|
competency_id=requirement.competency_id,
|
||||||
|
competency_name=requirement.competency.name if requirement.competency else "",
|
||||||
|
required_level=requirement.required_level,
|
||||||
|
current_level=current_level,
|
||||||
|
is_met=current_level >= requirement.required_level,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
completed_missions = sum(1 for item in mission_requirements if item.is_completed)
|
||||||
|
met_competencies = sum(1 for item in competency_requirements if item.is_met)
|
||||||
|
|
||||||
|
xp_metrics = ProgressXPMetrics(
|
||||||
|
baseline=baseline_xp,
|
||||||
|
current=user.xp,
|
||||||
|
target=target_xp,
|
||||||
|
remaining=remaining_xp,
|
||||||
|
progress_percent=round(progress_percent, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProgressSnapshot(
|
||||||
|
current_rank=ProgressRank.model_validate(current_rank_obj) if current_rank_obj else None,
|
||||||
|
next_rank=ProgressRank.model_validate(next_rank_obj) if next_rank_obj else None,
|
||||||
|
xp=xp_metrics,
|
||||||
|
mission_requirements=mission_requirements,
|
||||||
|
competency_requirements=competency_requirements,
|
||||||
|
completed_missions=completed_missions,
|
||||||
|
total_missions=len(mission_requirements),
|
||||||
|
met_competencies=met_competencies,
|
||||||
|
total_competencies=len(competency_requirements),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
fastapi==0.111.0
|
fastapi==0.111.0
|
||||||
uvicorn[standard]==0.30.1
|
uvicorn[standard]==0.30.1
|
||||||
SQLAlchemy==2.0.30
|
SQLAlchemy>=2.0.36,<3
|
||||||
alembic==1.13.1
|
alembic>=1.14.0,<2
|
||||||
pydantic==2.7.4
|
pydantic==2.9.2
|
||||||
pydantic-settings==2.3.2
|
pydantic-settings==2.10.1
|
||||||
passlib[bcrypt]==1.7.4
|
passlib[bcrypt]==1.7.4
|
||||||
python-jose[cryptography]==3.3.0
|
python-jose[cryptography]==3.3.0
|
||||||
python-multipart==0.0.9
|
python-multipart==0.0.9
|
||||||
bcrypt==4.1.3
|
bcrypt==4.1.3
|
||||||
email-validator==2.1.1
|
email-validator==2.1.1
|
||||||
pandas==2.2.2
|
|
||||||
openpyxl==3.1.3
|
|
||||||
fastapi-pagination==0.12.24
|
fastapi-pagination==0.12.24
|
||||||
Jinja2==3.1.4
|
Jinja2==3.1.4
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.artifact import Artifact, ArtifactRarity
|
||||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
from app.services.mission import approve_submission
|
from app.services.mission import approve_submission
|
||||||
|
|
@ -33,3 +34,44 @@ def test_approve_submission_rewards(db_session):
|
||||||
assert user.xp == mission.xp_reward
|
assert user.xp == mission.xp_reward
|
||||||
assert user.mana == mission.mana_reward
|
assert user.mana == mission.mana_reward
|
||||||
assert submission.status == SubmissionStatus.APPROVED
|
assert submission.status == SubmissionStatus.APPROVED
|
||||||
|
|
||||||
|
|
||||||
|
def test_approve_submission_grants_artifact(db_session):
|
||||||
|
"""При наличии артефакта пользователь получает его единожды."""
|
||||||
|
|
||||||
|
artifact = Artifact(
|
||||||
|
name="Значок испытателя",
|
||||||
|
description="Выдан за успешную миссию",
|
||||||
|
rarity=ArtifactRarity.RARE,
|
||||||
|
)
|
||||||
|
mission = Mission(
|
||||||
|
title="Тестовая миссия",
|
||||||
|
description="Описание",
|
||||||
|
xp_reward=50,
|
||||||
|
mana_reward=20,
|
||||||
|
artifact=artifact,
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
email="artifact@alabuga.space",
|
||||||
|
full_name="Пилот",
|
||||||
|
role=UserRole.PILOT,
|
||||||
|
hashed_password="hash",
|
||||||
|
)
|
||||||
|
db_session.add_all([artifact, mission, user])
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
submission = MissionSubmission(user_id=user.id, mission_id=mission.id)
|
||||||
|
db_session.add(submission)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(submission)
|
||||||
|
|
||||||
|
approve_submission(db_session, submission)
|
||||||
|
db_session.refresh(user)
|
||||||
|
|
||||||
|
assert len(user.artifacts) == 1
|
||||||
|
assert user.artifacts[0].artifact_id == artifact.id
|
||||||
|
|
||||||
|
# Повторное одобрение не создаёт дубли
|
||||||
|
approve_submission(db_session, submission)
|
||||||
|
db_session.refresh(user)
|
||||||
|
assert len(user.artifacts) == 1
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||||
from app.models.rank import Rank, RankMissionRequirement
|
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
|
||||||
from app.services.rank import apply_rank_upgrade
|
from app.services.rank import apply_rank_upgrade, build_progress_snapshot
|
||||||
|
|
||||||
|
|
||||||
def test_rank_upgrade_after_requirements(db_session):
|
def test_rank_upgrade_after_requirements(db_session):
|
||||||
|
|
@ -46,3 +46,74 @@ def test_rank_upgrade_after_requirements(db_session):
|
||||||
|
|
||||||
assert new_rank is not None
|
assert new_rank is not None
|
||||||
assert user.current_rank_id == pilot.id
|
assert user.current_rank_id == pilot.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_snapshot_highlights_remaining_conditions(db_session):
|
||||||
|
"""Снэпшот прогресса показывает, что ещё нужно закрыть."""
|
||||||
|
|
||||||
|
novice = Rank(title="Новичок", description="Старт", required_xp=0)
|
||||||
|
pilot = Rank(title="Пилот", description="Готов к полёту", required_xp=100)
|
||||||
|
mission = Mission(title="Тренировка", description="Базовое обучение", xp_reward=40, mana_reward=0)
|
||||||
|
competency = Competency(
|
||||||
|
name="Коммуникация",
|
||||||
|
description="Чёткая передача информации",
|
||||||
|
category=CompetencyCategory.COMMUNICATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add_all([novice, pilot, mission, competency])
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
db_session.add_all(
|
||||||
|
[
|
||||||
|
RankMissionRequirement(rank_id=pilot.id, mission_id=mission.id),
|
||||||
|
RankCompetencyRequirement(
|
||||||
|
rank_id=pilot.id,
|
||||||
|
competency_id=competency.id,
|
||||||
|
required_level=1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
email="progress@alabuga.space",
|
||||||
|
full_name="Прогресс Тест",
|
||||||
|
role=UserRole.PILOT,
|
||||||
|
hashed_password="hash",
|
||||||
|
xp=60,
|
||||||
|
current_rank_id=novice.id,
|
||||||
|
)
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.flush()
|
||||||
|
|
||||||
|
db_session.add(UserCompetency(user_id=user.id, competency_id=competency.id, level=0))
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(user)
|
||||||
|
|
||||||
|
snapshot = build_progress_snapshot(user, db_session)
|
||||||
|
|
||||||
|
assert snapshot.next_rank and snapshot.next_rank.title == "Пилот"
|
||||||
|
assert snapshot.xp.remaining == 40
|
||||||
|
assert snapshot.completed_missions == 0
|
||||||
|
assert snapshot.total_missions == 1
|
||||||
|
assert snapshot.met_competencies == 0
|
||||||
|
assert snapshot.total_competencies == 1
|
||||||
|
|
||||||
|
submission = MissionSubmission(
|
||||||
|
user_id=user.id,
|
||||||
|
mission_id=mission.id,
|
||||||
|
status=SubmissionStatus.APPROVED,
|
||||||
|
awarded_xp=mission.xp_reward,
|
||||||
|
)
|
||||||
|
user.xp = 120
|
||||||
|
user_competency = user.competencies[0]
|
||||||
|
user_competency.level = 2
|
||||||
|
|
||||||
|
db_session.add_all([submission, user, user_competency])
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(user)
|
||||||
|
|
||||||
|
snapshot_after = build_progress_snapshot(user, db_session)
|
||||||
|
|
||||||
|
assert snapshot_after.completed_missions == snapshot_after.total_missions
|
||||||
|
assert snapshot_after.met_competencies == snapshot_after.total_competencies
|
||||||
|
assert snapshot_after.xp.remaining == 0
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ services:
|
||||||
env_file:
|
env_file:
|
||||||
- backend/.env.example
|
- backend/.env.example
|
||||||
environment:
|
environment:
|
||||||
ALABUGA_ENVIRONMENT=docker
|
ALABUGA_ENVIRONMENT: docker
|
||||||
depends_on: []
|
depends_on: []
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
|
@ -23,9 +23,12 @@ services:
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '3000:3000'
|
||||||
environment:
|
environment:
|
||||||
NEXT_PUBLIC_API_URL=http://backend:8000
|
NEXT_PUBLIC_API_URL: http://localhost:8000
|
||||||
NEXT_PUBLIC_DEMO_EMAIL=candidate@alabuga.space
|
NEXT_INTERNAL_API_URL: http://backend:8000
|
||||||
NEXT_PUBLIC_DEMO_PASSWORD=orbita123
|
NEXT_PUBLIC_DEMO_EMAIL: candidate@alabuga.space
|
||||||
|
NEXT_PUBLIC_DEMO_PASSWORD: orbita123
|
||||||
|
NEXT_PUBLIC_DEMO_HR_EMAIL: hr@alabuga.space
|
||||||
|
NEXT_PUBLIC_DEMO_HR_PASSWORD: orbita123
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
|
import { AdminBranchManager } from '../../components/admin/AdminBranchManager';
|
||||||
|
import { AdminMissionManager } from '../../components/admin/AdminMissionManager';
|
||||||
|
import { AdminRankManager } from '../../components/admin/AdminRankManager';
|
||||||
|
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
|
||||||
|
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { getDemoToken } from '../../lib/demo-auth';
|
import { getDemoToken } from '../../lib/demo-auth';
|
||||||
|
|
||||||
interface Submission {
|
interface Submission {
|
||||||
|
id: number;
|
||||||
mission_id: number;
|
mission_id: number;
|
||||||
status: string;
|
status: string;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
|
|
@ -11,43 +17,152 @@ interface Submission {
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchModerationQueue() {
|
interface MissionSummary {
|
||||||
const token = await getDemoToken();
|
id: number;
|
||||||
const submissions = await apiFetch<Submission[]>('/api/admin/submissions', { authToken: token });
|
title: string;
|
||||||
return submissions;
|
description: string;
|
||||||
|
xp_reward: number;
|
||||||
|
mana_reward: number;
|
||||||
|
difficulty: 'easy' | 'medium' | 'hard';
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchSummary {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
missions: Array<{ mission_id: number; mission_title: string; order: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RankSummary {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
required_xp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompetencySummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArtifactSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
rarity: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubmissionStats {
|
||||||
|
pending: number;
|
||||||
|
approved: number;
|
||||||
|
rejected: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchCompletionStat {
|
||||||
|
branch_id: number;
|
||||||
|
branch_title: string;
|
||||||
|
completion_rate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AdminStats {
|
||||||
|
total_users: number;
|
||||||
|
active_pilots: number;
|
||||||
|
average_completed_missions: number;
|
||||||
|
submission_stats: SubmissionStats;
|
||||||
|
branch_completion: BranchCompletionStat[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default async function AdminPage() {
|
||||||
const submissions = await fetchModerationQueue();
|
const token = await getDemoToken('hr');
|
||||||
|
|
||||||
|
const [submissions, missions, branches, ranks, competencies, artifacts, stats] = await Promise.all([
|
||||||
|
apiFetch<Submission[]>('/api/admin/submissions', { authToken: token }),
|
||||||
|
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: token }),
|
||||||
|
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: token }),
|
||||||
|
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: token }),
|
||||||
|
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: token }),
|
||||||
|
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: token }),
|
||||||
|
apiFetch<AdminStats>('/api/admin/stats', { authToken: token })
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h2>HR-панель: очередь модерации</h2>
|
<h2>HR-панель</h2>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
Демонстрационная выборка отправленных миссий. В реальном приложении добавим карточки с деталями пилота и
|
Управляйте миссиями, ветками и рангами, а также следите за очередью модерации отчётов.
|
||||||
кнопки approve/reject непосредственно из UI.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="grid">
|
|
||||||
{submissions.map((submission) => (
|
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
|
||||||
<div key={submission.mission_id} className="card">
|
<div className="card" style={{ gridColumn: '1 / -1', display: 'grid', gap: '1rem' }}>
|
||||||
<h3>Миссия #{submission.mission_id}</h3>
|
<h3>Сводка</h3>
|
||||||
<p>Статус: {submission.status}</p>
|
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '1rem' }}>
|
||||||
{submission.comment && <p>Комментарий пилота: {submission.comment}</p>}
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
{submission.proof_url && (
|
<span className="badge">Пилоты</span>
|
||||||
<p>
|
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.total_users}</p>
|
||||||
Доказательство:{' '}
|
<small style={{ color: 'var(--text-muted)' }}>Всего зарегистрировано</small>
|
||||||
<a href={submission.proof_url} target="_blank" rel="noreferrer">
|
</div>
|
||||||
открыть
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
</a>
|
<span className="badge">Активность</span>
|
||||||
</p>
|
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.active_pilots}</p>
|
||||||
)}
|
<small style={{ color: 'var(--text-muted)' }}>Закрыли хотя бы одну миссию</small>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
|
<span className="badge">Средний прогресс</span>
|
||||||
|
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.average_completed_missions.toFixed(1)}</p>
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>Миссий на пилота</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
|
||||||
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
|
<strong>Очередь модерации</strong>
|
||||||
|
<p style={{ marginTop: '0.5rem' }}>На проверке: {stats.submission_stats.pending}</p>
|
||||||
<small style={{ color: 'var(--text-muted)' }}>
|
<small style={{ color: 'var(--text-muted)' }}>
|
||||||
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')}
|
Одобрено: {stats.submission_stats.approved} · Отклонено: {stats.submission_stats.rejected}
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
|
<strong>Завершённость веток</strong>
|
||||||
|
<ul style={{ listStyle: 'none', margin: '0.75rem 0 0', padding: 0 }}>
|
||||||
|
{stats.branch_completion.map((branchStat) => (
|
||||||
|
<li key={branchStat.branch_id} style={{ marginBottom: '0.25rem' }}>
|
||||||
|
{branchStat.branch_title}: {(branchStat.completion_rate * 100).toFixed(0)}%
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card" style={{ gridColumn: '1 / -1' }}>
|
||||||
|
<h3>Очередь модерации</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Список последующих отправок миссий. Для полноты UX можно добавить действия approve/reject прямо отсюда.
|
||||||
|
</p>
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||||
|
{submissions.map((submission) => (
|
||||||
|
<AdminSubmissionCard key={submission.id} submission={submission} token={token} />
|
||||||
))}
|
))}
|
||||||
{submissions.length === 0 && <p>Очередь пуста — все миссии проверены.</p>}
|
{submissions.length === 0 && <p>Очередь пуста — все миссии проверены.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AdminBranchManager token={token} branches={branches} />
|
||||||
|
<AdminMissionManager
|
||||||
|
token={token}
|
||||||
|
missions={missions}
|
||||||
|
branches={branches}
|
||||||
|
ranks={ranks}
|
||||||
|
competencies={competencies}
|
||||||
|
artifacts={artifacts}
|
||||||
|
/>
|
||||||
|
<AdminRankManager token={token} ranks={ranks} missions={missions} competencies={competencies} />
|
||||||
|
<AdminArtifactManager token={token} artifacts={artifacts} />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,62 @@ interface JournalEntry {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LeaderboardEntry {
|
||||||
|
user_id: number;
|
||||||
|
full_name: string;
|
||||||
|
xp_delta: number;
|
||||||
|
mana_delta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LeaderboardResponse {
|
||||||
|
period: string;
|
||||||
|
entries: LeaderboardEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchJournal() {
|
async function fetchJournal() {
|
||||||
const token = await getDemoToken();
|
const token = await getDemoToken();
|
||||||
const entries = await apiFetch<JournalEntry[]>('/api/journal/', { authToken: token });
|
const [entries, week, month, year] = await Promise.all([
|
||||||
return entries;
|
apiFetch<JournalEntry[]>('/api/journal/', { authToken: token }),
|
||||||
|
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=week', { authToken: token }),
|
||||||
|
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=month', { authToken: token }),
|
||||||
|
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=year', { authToken: token })
|
||||||
|
]);
|
||||||
|
return { entries, leaderboards: [week, month, year] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function JournalPage() {
|
export default async function JournalPage() {
|
||||||
const entries = await fetchJournal();
|
const { entries, leaderboards } = await fetchJournal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', gap: '2rem' }}>
|
||||||
|
<div>
|
||||||
<h2>Бортовой журнал</h2>
|
<h2>Бортовой журнал</h2>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине.
|
Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине.
|
||||||
</p>
|
</p>
|
||||||
<JournalTimeline entries={entries} />
|
<JournalTimeline entries={entries} />
|
||||||
|
</div>
|
||||||
|
<aside className="card" style={{ position: 'sticky', top: '1.5rem' }}>
|
||||||
|
<h3>ТОП экипажа</h3>
|
||||||
|
{leaderboards.map((board) => (
|
||||||
|
<div key={board.period} style={{ marginBottom: '1rem' }}>
|
||||||
|
<strong style={{ textTransform: 'capitalize' }}>
|
||||||
|
{board.period === 'week' ? 'Неделя' : board.period === 'month' ? 'Месяц' : 'Год'}
|
||||||
|
</strong>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
|
||||||
|
{board.entries.length === 0 && <li style={{ color: 'var(--text-muted)' }}>Пока нет лидеров.</li>}
|
||||||
|
{board.entries.map((entry, index) => (
|
||||||
|
<li key={entry.user_id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem' }}>
|
||||||
|
<span>
|
||||||
|
#{index + 1} {entry.full_name}
|
||||||
|
</span>
|
||||||
|
<span>{entry.xp_delta} XP · {entry.mana_delta} ⚡</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,29 @@ export const metadata: Metadata = {
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<body>
|
<body style={{ backgroundAttachment: 'fixed' }}>
|
||||||
<StyledComponentsRegistry>
|
<StyledComponentsRegistry>
|
||||||
<header style={{ padding: '1.5rem', display: 'flex', justifyContent: 'space-between' }}>
|
<header
|
||||||
|
style={{
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(108,92,231,0.25), rgba(0,184,148,0.15))',
|
||||||
|
borderBottom: '1px solid rgba(162, 155, 254, 0.2)',
|
||||||
|
boxShadow: '0 10px 30px rgba(0,0,0,0.25)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ margin: 0 }}>Mission Control</h1>
|
<h1 style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>Mission Control</h1>
|
||||||
<p style={{ margin: 0, color: 'var(--text-muted)' }}>
|
<p style={{ margin: 0, color: 'var(--text-muted)' }}>
|
||||||
Путь пилота от искателя до члена экипажа
|
Путь пилота от искателя до командира космической эскадры
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center', fontWeight: 500 }}>
|
||||||
<a href="/">Дашборд</a>
|
<a href="/">Дашборд</a>
|
||||||
|
<a href="/onboarding">Онбординг</a>
|
||||||
<a href="/missions">Миссии</a>
|
<a href="/missions">Миссии</a>
|
||||||
<a href="/journal">Журнал</a>
|
<a href="/journal">Журнал</a>
|
||||||
<a href="/store">Магазин</a>
|
<a href="/store">Магазин</a>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ interface MissionDetail {
|
||||||
competency_name: string;
|
competency_name: string;
|
||||||
level_delta: number;
|
level_delta: number;
|
||||||
}>;
|
}>;
|
||||||
|
is_available: boolean;
|
||||||
|
locked_reasons: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMission(id: number) {
|
async function fetchMission(id: number) {
|
||||||
|
|
@ -41,6 +43,16 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
<p style={{ marginTop: '1rem' }}>
|
<p style={{ marginTop: '1rem' }}>
|
||||||
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
||||||
</p>
|
</p>
|
||||||
|
{!mission.is_available && mission.locked_reasons.length > 0 && (
|
||||||
|
<div className="card" style={{ border: '1px solid rgba(255, 118, 117, 0.5)', background: 'rgba(255,118,117,0.1)' }}>
|
||||||
|
<strong>Миссия заблокирована</strong>
|
||||||
|
<ul style={{ marginTop: '0.5rem' }}>
|
||||||
|
{mission.locked_reasons.map((reason) => (
|
||||||
|
<li key={reason}>{reason}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Компетенции</h3>
|
<h3>Компетенции</h3>
|
||||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
|
@ -52,7 +64,7 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<MissionSubmissionForm missionId={mission.id} token={token} />
|
<MissionSubmissionForm missionId={mission.id} token={token} locked={!mission.is_available} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,35 @@ import { apiFetch } from '../../lib/api';
|
||||||
import { getDemoToken } from '../../lib/demo-auth';
|
import { getDemoToken } from '../../lib/demo-auth';
|
||||||
import { MissionList, MissionSummary } from '../../components/MissionList';
|
import { MissionList, MissionSummary } from '../../components/MissionList';
|
||||||
|
|
||||||
|
interface BranchMission {
|
||||||
|
mission_id: number;
|
||||||
|
mission_title: string;
|
||||||
|
order: number;
|
||||||
|
is_completed: boolean;
|
||||||
|
is_available: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BranchOverview {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
missions: BranchMission[];
|
||||||
|
total_missions: number;
|
||||||
|
completed_missions: number;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchMissions() {
|
async function fetchMissions() {
|
||||||
const token = await getDemoToken();
|
const token = await getDemoToken();
|
||||||
const missions = await apiFetch<MissionSummary[]>('/api/missions/', { authToken: token });
|
const [missions, branches] = await Promise.all([
|
||||||
return missions;
|
apiFetch<MissionSummary[]>('/api/missions/', { authToken: token }),
|
||||||
|
apiFetch<BranchOverview[]>('/api/missions/branches', { authToken: token })
|
||||||
|
]);
|
||||||
|
return { missions, branches };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function MissionsPage() {
|
export default async function MissionsPage() {
|
||||||
const missions = await fetchMissions();
|
const { missions, branches } = await fetchMissions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -18,6 +39,45 @@ export default async function MissionsPage() {
|
||||||
Список обновляется в реальном времени и зависит от вашего ранга и прогресса. HR может добавлять новые
|
Список обновляется в реальном времени и зависит от вашего ранга и прогресса. HR может добавлять новые
|
||||||
задания в админ-панели.
|
задания в админ-панели.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="grid" style={{ marginBottom: '2rem' }}>
|
||||||
|
{branches.map((branch) => {
|
||||||
|
const progress = branch.total_missions
|
||||||
|
? Math.round((branch.completed_missions / branch.total_missions) * 100)
|
||||||
|
: 0;
|
||||||
|
const nextMission = branch.missions.find((mission) => !mission.is_completed);
|
||||||
|
return (
|
||||||
|
<div key={branch.id} className="card">
|
||||||
|
<h3 style={{ marginBottom: '0.5rem' }}>{branch.title}</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{branch.description}</p>
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<small>Прогресс ветки: {progress}%</small>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: 'rgba(162, 155, 254, 0.2)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${progress}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: 'linear-gradient(90deg, var(--accent), #00b894)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{nextMission && (
|
||||||
|
<p style={{ marginTop: '1rem', color: 'var(--text-muted)' }}>
|
||||||
|
Следующая миссия: «{nextMission.mission_title}»
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<MissionList missions={missions} />
|
<MissionList missions={missions} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
50
frontend/src/app/onboarding/page.tsx
Normal file
50
frontend/src/app/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
import { getDemoToken } from '../../lib/demo-auth';
|
||||||
|
import { OnboardingCarousel, OnboardingSlide } from '../../components/OnboardingCarousel';
|
||||||
|
|
||||||
|
interface OnboardingState {
|
||||||
|
last_completed_order: number;
|
||||||
|
is_completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnboardingResponse {
|
||||||
|
slides: OnboardingSlide[];
|
||||||
|
state: OnboardingState;
|
||||||
|
next_order: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOnboarding() {
|
||||||
|
const token = await getDemoToken();
|
||||||
|
const data = await apiFetch<OnboardingResponse>('/api/onboarding/', { authToken: token });
|
||||||
|
return { token, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OnboardingPage() {
|
||||||
|
const { token, data } = await fetchOnboarding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', alignItems: 'start', gap: '2rem' }}>
|
||||||
|
<div>
|
||||||
|
<OnboardingCarousel
|
||||||
|
token={token}
|
||||||
|
slides={data.slides}
|
||||||
|
initialCompletedOrder={data.state.last_completed_order}
|
||||||
|
nextOrder={data.next_order}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<aside className="card" style={{ position: 'sticky', top: '1.5rem' }}>
|
||||||
|
<h3>Бортовой навигатор</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', lineHeight: 1.6 }}>
|
||||||
|
Онбординг знакомит вас с космической культурой «Алабуги» и объясняет, как миссии превращают карьерные шаги в
|
||||||
|
единый маршрут. Каждый шаг открывает новые подсказки и призы в магазине.
|
||||||
|
</p>
|
||||||
|
<ul style={{ listStyle: 'none', margin: '1rem 0 0', padding: 0, color: 'var(--text-muted)' }}>
|
||||||
|
<li>• Читайте лор и переходите к миссиям напрямую.</li>
|
||||||
|
<li>• Следите за прогрессом — статус сохраняется автоматически.</li>
|
||||||
|
<li>• Доступ к веткам миссий открывается по мере выполнения шагов.</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -6,7 +6,6 @@ interface ProfileResponse {
|
||||||
full_name: string;
|
full_name: string;
|
||||||
xp: number;
|
xp: number;
|
||||||
mana: number;
|
mana: number;
|
||||||
current_rank_id: number | null;
|
|
||||||
competencies: Array<{
|
competencies: Array<{
|
||||||
competency: { id: number; name: string };
|
competency: { id: number; name: string };
|
||||||
level: number;
|
level: number;
|
||||||
|
|
@ -18,40 +17,63 @@ interface ProfileResponse {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RankResponse {
|
interface ProgressResponse {
|
||||||
id: number;
|
current_rank: { id: number; title: string; description: string; required_xp: number } | null;
|
||||||
title: string;
|
next_rank: { id: number; title: string; description: string; required_xp: number } | null;
|
||||||
description: string;
|
xp: {
|
||||||
|
baseline: number;
|
||||||
|
current: number;
|
||||||
|
target: number;
|
||||||
|
remaining: number;
|
||||||
|
progress_percent: number;
|
||||||
|
};
|
||||||
|
mission_requirements: Array<{ mission_id: number; mission_title: string; is_completed: boolean }>;
|
||||||
|
competency_requirements: Array<{
|
||||||
|
competency_id: number;
|
||||||
|
competency_name: string;
|
||||||
|
required_level: number;
|
||||||
|
current_level: number;
|
||||||
|
is_met: boolean;
|
||||||
|
}>;
|
||||||
|
completed_missions: number;
|
||||||
|
total_missions: number;
|
||||||
|
met_competencies: number;
|
||||||
|
total_competencies: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProfile() {
|
async function fetchProfile() {
|
||||||
const token = await getDemoToken();
|
const token = await getDemoToken();
|
||||||
const profile = await apiFetch<ProfileResponse>('/api/me', { authToken: token });
|
const [profile, progress] = await Promise.all([
|
||||||
const ranks = await apiFetch<RankResponse[]>('/api/admin/ranks', { authToken: token });
|
apiFetch<ProfileResponse>('/api/me', { authToken: token }),
|
||||||
const currentRank = ranks.find((rank) => rank.id === profile.current_rank_id);
|
apiFetch<ProgressResponse>('/api/progress', { authToken: token })
|
||||||
return { token, profile, currentRank };
|
]);
|
||||||
|
|
||||||
|
return { token, profile, progress };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const { token, profile, currentRank } = await fetchProfile();
|
const { token, profile, progress } = await fetchProfile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
|
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
|
||||||
<div>
|
<div>
|
||||||
<ProgressOverview
|
<ProgressOverview
|
||||||
fullName={profile.full_name}
|
fullName={profile.full_name}
|
||||||
xp={profile.xp}
|
|
||||||
mana={profile.mana}
|
mana={profile.mana}
|
||||||
rank={currentRank}
|
|
||||||
competencies={profile.competencies}
|
competencies={profile.competencies}
|
||||||
artifacts={profile.artifacts}
|
artifacts={profile.artifacts}
|
||||||
|
progress={progress}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<aside className="card">
|
<aside className="card">
|
||||||
<h3>Ближайшая цель</h3>
|
<h3>Ближайшая цель</h3>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>
|
<p style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||||
Выполните миссии ветки «Получение оффера», чтобы закрепиться в экипаже и открыть доступ к
|
Следующий рубеж: {progress.next_rank ? `ранг «${progress.next_rank.title}»` : 'вы на максимальном ранге демо-версии'}.
|
||||||
сложным задачам.
|
Закройте ключевые миссии и подтяните компетенции, чтобы взять оффер.
|
||||||
|
</p>
|
||||||
|
<p style={{ marginTop: '1rem' }}>
|
||||||
|
Осталось {progress.xp.remaining} XP · {progress.completed_missions}/{progress.total_missions} миссий ·{' '}
|
||||||
|
{progress.met_competencies}/{progress.total_competencies} компетенций.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ marginTop: '1rem' }}>Доступ к HR-панели: {token ? 'есть (демо-режим)' : 'нет'}</p>
|
<p style={{ marginTop: '1rem' }}>Доступ к HR-панели: {token ? 'есть (демо-режим)' : 'нет'}</p>
|
||||||
<a className="primary" style={{ marginTop: '1rem', display: 'inline-block' }} href="/missions">
|
<a className="primary" style={{ marginTop: '1rem', display: 'inline-block' }} href="/missions">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export interface MissionSummary {
|
||||||
mana_reward: number;
|
mana_reward: number;
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
is_available: boolean;
|
||||||
|
locked_reasons: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
|
|
@ -31,11 +33,21 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
||||||
<span className="badge">{mission.difficulty}</span>
|
<span className="badge">{mission.difficulty}</span>
|
||||||
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
||||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
||||||
<p style={{ marginTop: '1rem' }}>
|
<p style={{ marginTop: '1rem' }}>{mission.xp_reward} XP · {mission.mana_reward} ⚡</p>
|
||||||
{mission.xp_reward} XP · {mission.mana_reward} ⚡
|
{!mission.is_available && mission.locked_reasons.length > 0 && (
|
||||||
</p>
|
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>
|
||||||
<a className="primary" style={{ display: 'inline-block', marginTop: '1rem' }} href={`/missions/${mission.id}`}>
|
)}
|
||||||
Открыть брифинг
|
<a
|
||||||
|
className={mission.is_available ? 'primary' : 'secondary'}
|
||||||
|
style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
marginTop: '1rem',
|
||||||
|
pointerEvents: mission.is_available ? 'auto' : 'none',
|
||||||
|
opacity: mission.is_available ? 1 : 0.5
|
||||||
|
}}
|
||||||
|
href={mission.is_available ? `/missions/${mission.id}` : '#'}
|
||||||
|
>
|
||||||
|
{mission.is_available ? 'Открыть брифинг' : 'Заблокировано'}
|
||||||
</a>
|
</a>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ import { apiFetch } from '../lib/api';
|
||||||
interface MissionSubmissionFormProps {
|
interface MissionSubmissionFormProps {
|
||||||
missionId: number;
|
missionId: number;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFormProps) {
|
export function MissionSubmissionForm({ missionId, token, locked = false }: MissionSubmissionFormProps) {
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState('');
|
||||||
const [proofUrl, setProofUrl] = useState('');
|
const [proofUrl, setProofUrl] = useState('');
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
@ -21,6 +22,11 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
setStatus('Миссия пока недоступна — выполните предыдущие условия.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
|
|
@ -52,6 +58,7 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
|
||||||
rows={4}
|
rows={4}
|
||||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||||
placeholder="Опишите, что сделали."
|
placeholder="Опишите, что сделали."
|
||||||
|
disabled={locked}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
||||||
|
|
@ -62,10 +69,11 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
|
||||||
onChange={(event) => setProofUrl(event.target.value)}
|
onChange={(event) => setProofUrl(event.target.value)}
|
||||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
|
disabled={locked}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary" type="submit" disabled={loading}>
|
<button className="primary" type="submit" disabled={loading || locked}>
|
||||||
{loading ? 'Отправляем...' : 'Отправить HR'}
|
{locked ? 'Недоступно' : loading ? 'Отправляем...' : 'Отправить HR'}
|
||||||
</button>
|
</button>
|
||||||
{status && <p style={{ marginTop: '1rem', color: 'var(--accent-light)' }}>{status}</p>}
|
{status && <p style={{ marginTop: '1rem', color: 'var(--accent-light)' }}>{status}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
123
frontend/src/components/OnboardingCarousel.tsx
Normal file
123
frontend/src/components/OnboardingCarousel.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
export interface OnboardingSlide {
|
||||||
|
id: number;
|
||||||
|
order: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
media_url?: string | null;
|
||||||
|
cta_text?: string | null;
|
||||||
|
cta_link?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnboardingCarouselProps {
|
||||||
|
token?: string;
|
||||||
|
slides: OnboardingSlide[];
|
||||||
|
initialCompletedOrder: number;
|
||||||
|
nextOrder: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingCarousel({ token, slides, initialCompletedOrder, nextOrder }: OnboardingCarouselProps) {
|
||||||
|
const startIndex = nextOrder ? Math.max(slides.findIndex((slide) => slide.order === nextOrder), 0) : slides.length - 1;
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(Math.max(startIndex, 0));
|
||||||
|
const [completedOrder, setCompletedOrder] = useState(initialCompletedOrder);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const currentSlide = slides[currentIndex];
|
||||||
|
const allCompleted = completedOrder >= (slides.at(-1)?.order ?? 0);
|
||||||
|
|
||||||
|
async function handleComplete() {
|
||||||
|
if (!token || !currentSlide) {
|
||||||
|
setStatus('Не удалось определить пользователя. Попробуйте обновить страницу.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await apiFetch('/api/onboarding/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ order: currentSlide.order }),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setCompletedOrder(currentSlide.order);
|
||||||
|
setStatus('Шаг отмечен как выполненный!');
|
||||||
|
if (currentIndex < slides.length - 1) {
|
||||||
|
setCurrentIndex(currentIndex + 1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
setStatus(error.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSlide) {
|
||||||
|
return <p>Онбординг недоступен. Свяжитесь с HR.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ display: 'grid', gap: '1.5rem', background: 'rgba(17, 22, 51, 0.9)' }}>
|
||||||
|
<header>
|
||||||
|
<span className="badge">Шаг {currentIndex + 1} из {slides.length}</span>
|
||||||
|
<h2 style={{ margin: '0.75rem 0' }}>{currentSlide.title}</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', lineHeight: 1.6 }}>{currentSlide.body}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{currentSlide.media_url && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: '16px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(162, 155, 254, 0.25)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={currentSlide.media_url} alt={currentSlide.title} style={{ width: '100%', height: 'auto' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{currentSlide.cta_text && currentSlide.cta_link && (
|
||||||
|
<a className="primary" href={currentSlide.cta_link}>
|
||||||
|
{currentSlide.cta_text}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!allCompleted && (
|
||||||
|
<button className="primary" onClick={handleComplete} disabled={loading}>
|
||||||
|
{loading ? 'Сохраняем...' : 'Отметить как выполнено'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && <small style={{ color: 'var(--accent-light)' }}>{status}</small>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setCurrentIndex(Math.max(currentIndex - 1, 0))}
|
||||||
|
disabled={currentIndex === 0}
|
||||||
|
style={{ cursor: currentIndex === 0 ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setCurrentIndex(Math.min(currentIndex + 1, slides.length - 1))}
|
||||||
|
disabled={currentIndex === slides.length - 1}
|
||||||
|
style={{ cursor: currentIndex === slides.length - 1 ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
Далее
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{allCompleted && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>Вы прошли весь онбординг!</p>}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -2,11 +2,7 @@
|
||||||
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
type Rank = {
|
// Компетенции и артефакты из профиля пользователя.
|
||||||
id: number | null;
|
|
||||||
title?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Competency = {
|
type Competency = {
|
||||||
competency: {
|
competency: {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -21,13 +17,35 @@ type Artifact = {
|
||||||
rarity: string;
|
rarity: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
|
||||||
export interface ProfileProps {
|
export interface ProfileProps {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
xp: number;
|
|
||||||
mana: number;
|
mana: number;
|
||||||
rank?: Rank;
|
|
||||||
competencies: Competency[];
|
competencies: Competency[];
|
||||||
artifacts: Artifact[];
|
artifacts: Artifact[];
|
||||||
|
progress: {
|
||||||
|
current_rank: { title: string } | null;
|
||||||
|
next_rank: { title: string } | null;
|
||||||
|
xp: {
|
||||||
|
baseline: number;
|
||||||
|
current: number;
|
||||||
|
target: number;
|
||||||
|
remaining: number;
|
||||||
|
progress_percent: number;
|
||||||
|
};
|
||||||
|
mission_requirements: Array<{ mission_id: number; mission_title: string; is_completed: boolean }>;
|
||||||
|
competency_requirements: Array<{
|
||||||
|
competency_id: number;
|
||||||
|
competency_name: string;
|
||||||
|
required_level: number;
|
||||||
|
current_level: number;
|
||||||
|
is_met: boolean;
|
||||||
|
}>;
|
||||||
|
completed_missions: number;
|
||||||
|
total_missions: number;
|
||||||
|
met_competencies: number;
|
||||||
|
total_competencies: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
|
|
@ -35,6 +53,8 @@ const Card = styled.div`
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border: 1px solid rgba(108, 92, 231, 0.4);
|
border: 1px solid rgba(108, 92, 231, 0.4);
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ProgressBar = styled.div<{ value: number }>`
|
const ProgressBar = styled.div<{ value: number }>`
|
||||||
|
|
@ -54,44 +74,153 @@ const ProgressBar = styled.div<{ value: number }>`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function ProgressOverview({ fullName, xp, mana, rank, competencies, artifacts }: ProfileProps) {
|
const RequirementRow = styled.div<{ $completed?: boolean }>`
|
||||||
const xpPercent = Math.min(100, (xp / 500) * 100);
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid ${({ $completed }) => ($completed ? 'rgba(0, 184, 148, 0.35)' : 'rgba(162, 155, 254, 0.25)')};
|
||||||
|
background: ${({ $completed }) => ($completed ? 'rgba(0, 184, 148, 0.18)' : 'rgba(162, 155, 254, 0.12)')};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RequirementTitle = styled.span`
|
||||||
|
font-weight: 500;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ChecklistGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionTitle = styled.h3`
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InlineBadge = styled.span<{ $kind?: 'success' | 'warning' }>`
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: ${({ $kind }) => ($kind === 'success' ? 'rgba(0, 184, 148, 0.25)' : 'rgba(255, 118, 117, 0.18)')};
|
||||||
|
color: ${({ $kind }) => ($kind === 'success' ? '#55efc4' : '#ff7675')};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function ProgressOverview({ fullName, mana, competencies, artifacts, progress }: ProfileProps) {
|
||||||
|
const xpPercent = Math.round(progress.xp.progress_percent * 100);
|
||||||
|
const hasNextRank = Boolean(progress.next_rank);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<h2 style={{ marginTop: 0 }}>{fullName}</h2>
|
<header>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>Текущий ранг: {rank?.title ?? 'не назначен'}</p>
|
<h2 style={{ margin: 0 }}>{fullName}</h2>
|
||||||
<div style={{ marginTop: '1rem' }}>
|
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||||
<strong>Опыт:</strong>
|
Текущий ранг: {progress.current_rank?.title ?? 'не назначен'} · Цель:{' '}
|
||||||
|
{hasNextRank ? `«${progress.next_rank?.title}»` : 'достигнут максимум в демо'}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionTitle>Опыт до следующего ранга</SectionTitle>
|
||||||
|
<p style={{ margin: '0.25rem 0', color: 'var(--text-muted)' }}>
|
||||||
|
{progress.xp.current} XP из {progress.xp.target} · осталось {progress.xp.remaining} XP
|
||||||
|
</p>
|
||||||
<ProgressBar value={xpPercent} />
|
<ProgressBar value={xpPercent} />
|
||||||
<small style={{ color: 'var(--text-muted)' }}>{xp} / 500 XP до следующей цели</small>
|
</section>
|
||||||
|
|
||||||
|
<section className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Ключевые миссии</SectionTitle>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||||
|
{progress.completed_missions}/{progress.total_missions} выполнено.
|
||||||
|
</p>
|
||||||
|
<ChecklistGrid>
|
||||||
|
{progress.mission_requirements.length === 0 && (
|
||||||
|
<RequirementRow $completed>
|
||||||
|
<RequirementTitle>Все миссии для ранга уже зачтены.</RequirementTitle>
|
||||||
|
</RequirementRow>
|
||||||
|
)}
|
||||||
|
{progress.mission_requirements.map((mission) => (
|
||||||
|
<RequirementRow key={mission.mission_id} $completed={mission.is_completed}>
|
||||||
|
<RequirementTitle>{mission.mission_title}</RequirementTitle>
|
||||||
|
<InlineBadge $kind={mission.is_completed ? 'success' : 'warning'}>
|
||||||
|
{mission.is_completed ? 'готово' : 'ожидает'}
|
||||||
|
</InlineBadge>
|
||||||
|
</RequirementRow>
|
||||||
|
))}
|
||||||
|
</ChecklistGrid>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: '1rem' }}>
|
<div>
|
||||||
<strong>Мана:</strong>
|
<SectionTitle>Компетенции ранга</SectionTitle>
|
||||||
<p style={{ margin: '0.5rem 0' }}>{mana} ⚡</p>
|
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||||
|
{progress.met_competencies}/{progress.total_competencies} требований закрыто.
|
||||||
|
</p>
|
||||||
|
<ChecklistGrid>
|
||||||
|
{progress.competency_requirements.length === 0 && (
|
||||||
|
<RequirementRow $completed>
|
||||||
|
<RequirementTitle>Дополнительные требования к компетенциям отсутствуют.</RequirementTitle>
|
||||||
|
</RequirementRow>
|
||||||
|
)}
|
||||||
|
{progress.competency_requirements.map((competency) => {
|
||||||
|
const percentage = competency.required_level
|
||||||
|
? Math.min(100, (competency.current_level / competency.required_level) * 100)
|
||||||
|
: 100;
|
||||||
|
const delta = Math.max(0, competency.required_level - competency.current_level);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RequirementRow key={competency.competency_id} $completed={competency.is_met}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<RequirementTitle>{competency.competency_name}</RequirementTitle>
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Уровень {competency.current_level} / {competency.required_level}
|
||||||
|
</small>
|
||||||
|
<ProgressBar value={percentage} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: '1.5rem' }}>
|
<InlineBadge $kind={competency.is_met ? 'success' : 'warning'}>
|
||||||
<strong>Компетенции</strong>
|
{competency.is_met ? 'готово' : `нужно +${delta}`}
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
|
</InlineBadge>
|
||||||
|
</RequirementRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ChecklistGrid>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}>
|
||||||
|
<div className="card" style={{ margin: 0 }}>
|
||||||
|
<SectionTitle>Мана экипажа</SectionTitle>
|
||||||
|
<p style={{ marginTop: '0.5rem', fontSize: '1.75rem', fontWeight: 600 }}>{mana} ⚡</p>
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>Тратьте в магазине на мерч и бонусы.</small>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ margin: 0 }}>
|
||||||
|
<SectionTitle>Текущие компетенции</SectionTitle>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0.75rem 0 0', display: 'grid', gap: '0.5rem' }}>
|
||||||
{competencies.map((item) => (
|
{competencies.map((item) => (
|
||||||
<li key={item.competency.id} style={{ marginBottom: '0.25rem' }}>
|
<li key={item.competency.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<span className="badge">{item.competency.name}</span> — уровень {item.level}
|
<span className="badge">{item.competency.name}</span>
|
||||||
|
<span>уровень {item.level}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
{competencies.length === 0 && <li style={{ color: 'var(--text-muted)' }}>Компетенции ещё не открыты.</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: '1.5rem' }}>
|
</section>
|
||||||
<strong>Артефакты</strong>
|
|
||||||
|
<section>
|
||||||
|
<SectionTitle>Артефакты</SectionTitle>
|
||||||
<div className="grid" style={{ marginTop: '0.75rem' }}>
|
<div className="grid" style={{ marginTop: '0.75rem' }}>
|
||||||
{artifacts.length === 0 && <p>Ещё нет трофеев — выполните миссии!</p>}
|
{artifacts.length === 0 && <p>Выполните миссии, чтобы собрать коллекцию трофеев.</p>}
|
||||||
{artifacts.map((artifact) => (
|
{artifacts.map((artifact) => (
|
||||||
<div key={artifact.id} className="card">
|
<div key={artifact.id} className="card" style={{ margin: 0 }}>
|
||||||
<span className="badge">{artifact.rarity}</span>
|
<span className="badge">{artifact.rarity}</span>
|
||||||
<h4 style={{ marginBottom: '0.5rem' }}>{artifact.name}</h4>
|
<h4 style={{ marginBottom: '0.5rem' }}>{artifact.name}</h4>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
192
frontend/src/components/admin/AdminArtifactManager.tsx
Normal file
192
frontend/src/components/admin/AdminArtifactManager.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
|
type Artifact = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
rarity: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RARITY_OPTIONS = [
|
||||||
|
{ value: 'common', label: 'Обычный' },
|
||||||
|
{ value: 'rare', label: 'Редкий' },
|
||||||
|
{ value: 'epic', label: 'Эпический' },
|
||||||
|
{ value: 'legendary', label: 'Легендарный' }
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string;
|
||||||
|
artifacts: Artifact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminArtifactManager({ token, artifacts }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [rarity, setRarity] = useState('rare');
|
||||||
|
const [imageUrl, setImageUrl] = useState('');
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setRarity('rare');
|
||||||
|
setImageUrl('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
if (value === 'new') {
|
||||||
|
setSelectedId('new');
|
||||||
|
resetForm();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(value);
|
||||||
|
const artifact = artifacts.find((item) => item.id === id);
|
||||||
|
if (!artifact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedId(id);
|
||||||
|
setName(artifact.name);
|
||||||
|
setDescription(artifact.description);
|
||||||
|
setRarity(artifact.rarity);
|
||||||
|
setImageUrl(artifact.image_url ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
rarity,
|
||||||
|
image_url: imageUrl || null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedId === 'new') {
|
||||||
|
await apiFetch('/api/admin/artifacts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Артефакт создан');
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
await apiFetch(`/api/admin/artifacts/${selectedId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Артефакт обновлён');
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось сохранить артефакт');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (selectedId === 'new') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/admin/artifacts/${selectedId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Артефакт удалён');
|
||||||
|
setSelectedId('new');
|
||||||
|
resetForm();
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось удалить артефакт');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3>Артефакты</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Подготовьте коллекционные награды за миссии: укажите название, редкость и изображение. Артефакты можно привязывать
|
||||||
|
в карточке миссии.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="admin-form">
|
||||||
|
<label>
|
||||||
|
Выбранный артефакт
|
||||||
|
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
|
||||||
|
<option value="new">Новый артефакт</option>
|
||||||
|
{artifacts.map((artifact) => (
|
||||||
|
<option key={artifact.id} value={artifact.id}>
|
||||||
|
{artifact.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Название
|
||||||
|
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Описание
|
||||||
|
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={3} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Редкость
|
||||||
|
<select value={rarity} onChange={(event) => setRarity(event.target.value)}>
|
||||||
|
{RARITY_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Изображение (URL)
|
||||||
|
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||||
|
<button type="submit" className="primary" disabled={loading}>
|
||||||
|
{selectedId === 'new' ? 'Создать артефакт' : 'Сохранить изменения'}
|
||||||
|
</button>
|
||||||
|
{selectedId !== 'new' && (
|
||||||
|
<button type="button" className="secondary" onClick={handleDelete} disabled={loading} style={{ cursor: loading ? 'not-allowed' : 'pointer' }}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
|
||||||
|
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
143
frontend/src/components/admin/AdminBranchManager.tsx
Normal file
143
frontend/src/components/admin/AdminBranchManager.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
|
type Branch = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string;
|
||||||
|
branches: Branch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CATEGORY_OPTIONS = ['quest', 'recruiting', 'lecture', 'simulator'];
|
||||||
|
|
||||||
|
export function AdminBranchManager({ token, branches }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [category, setCategory] = useState('quest');
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const existing = new Set(DEFAULT_CATEGORY_OPTIONS);
|
||||||
|
branches.forEach((branch) => existing.add(branch.category));
|
||||||
|
return Array.from(existing.values());
|
||||||
|
}, [branches]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setCategory(categoryOptions[0] ?? 'quest');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
if (value === 'new') {
|
||||||
|
setSelectedId('new');
|
||||||
|
resetForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(value);
|
||||||
|
const branch = branches.find((item) => item.id === id);
|
||||||
|
if (!branch) {
|
||||||
|
setSelectedId('new');
|
||||||
|
resetForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedId(id);
|
||||||
|
setTitle(branch.title);
|
||||||
|
setDescription(branch.description);
|
||||||
|
setCategory(branch.category);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const payload = { title, description, category };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedId === 'new') {
|
||||||
|
await apiFetch('/api/admin/branches', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Ветка создана');
|
||||||
|
} else {
|
||||||
|
await apiFetch(`/api/admin/branches/${selectedId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Ветка обновлена');
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
if (selectedId === 'new') {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось сохранить ветку');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3>Управление ветками</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Создавайте или обновляйте ветки, чтобы миссии были организованы по сюжетам и категориям.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="admin-form">
|
||||||
|
<label>
|
||||||
|
Выбранная ветка
|
||||||
|
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
|
||||||
|
<option value="new">Новая ветка</option>
|
||||||
|
{branches.map((branch) => (
|
||||||
|
<option key={branch.id} value={branch.id}>
|
||||||
|
{branch.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Название
|
||||||
|
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Описание
|
||||||
|
<textarea value={description} onChange={(event) => setDescription(event.target.value)} required rows={3} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Категория
|
||||||
|
<select value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||||
|
{categoryOptions.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" className="primary">Сохранить</button>
|
||||||
|
|
||||||
|
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
|
||||||
|
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
429
frontend/src/components/admin/AdminMissionManager.tsx
Normal file
429
frontend/src/components/admin/AdminMissionManager.tsx
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
|
const DIFFICULTIES = [
|
||||||
|
{ value: 'easy', label: 'Лёгкая' },
|
||||||
|
{ value: 'medium', label: 'Средняя' },
|
||||||
|
{ value: 'hard', label: 'Сложная' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type Difficulty = (typeof DIFFICULTIES)[number]['value'];
|
||||||
|
|
||||||
|
type MissionBase = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
xp_reward: number;
|
||||||
|
mana_reward: number;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MissionDetail extends MissionBase {
|
||||||
|
minimum_rank_id: number | null;
|
||||||
|
artifact_id: number | null;
|
||||||
|
prerequisites: number[];
|
||||||
|
competency_rewards: Array<{ competency_id: number; competency_name: string; level_delta: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Branch = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
missions: Array<{ mission_id: number; mission_title: string; order: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Rank = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
required_xp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Competency = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Artifact = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
rarity: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string;
|
||||||
|
missions: MissionBase[];
|
||||||
|
branches: Branch[];
|
||||||
|
ranks: Rank[];
|
||||||
|
competencies: Competency[];
|
||||||
|
artifacts: Artifact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type RewardInput = { competency_id: number | ''; level_delta: number };
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
xp_reward: number;
|
||||||
|
mana_reward: number;
|
||||||
|
difficulty: Difficulty;
|
||||||
|
minimum_rank_id: number | '';
|
||||||
|
artifact_id: number | '';
|
||||||
|
branch_id: number | '';
|
||||||
|
branch_order: number;
|
||||||
|
prerequisite_ids: number[];
|
||||||
|
competency_rewards: RewardInput[];
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialFormState: FormState = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
xp_reward: 0,
|
||||||
|
mana_reward: 0,
|
||||||
|
difficulty: 'medium',
|
||||||
|
minimum_rank_id: '',
|
||||||
|
artifact_id: '',
|
||||||
|
branch_id: '',
|
||||||
|
branch_order: 1,
|
||||||
|
prerequisite_ids: [],
|
||||||
|
competency_rewards: [],
|
||||||
|
is_active: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminMissionManager({ token, missions, branches, ranks, competencies, artifacts }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||||
|
const [form, setForm] = useState<FormState>(initialFormState);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Позволяет мгновенно подставлять базовые поля при переключении миссии,
|
||||||
|
// пока загрузка детальной карточки не завершилась.
|
||||||
|
const missionById = useMemo(() => new Map(missions.map((mission) => [mission.id, mission])), [missions]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm(initialFormState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadMission = async (missionId: number) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const mission = await apiFetch<MissionDetail>(`/api/admin/missions/${missionId}`, { authToken: token });
|
||||||
|
setForm({
|
||||||
|
title: mission.title,
|
||||||
|
description: mission.description,
|
||||||
|
xp_reward: mission.xp_reward,
|
||||||
|
mana_reward: mission.mana_reward,
|
||||||
|
difficulty: mission.difficulty,
|
||||||
|
minimum_rank_id: mission.minimum_rank_id ?? '',
|
||||||
|
artifact_id: mission.artifact_id ?? '',
|
||||||
|
branch_id: (() => {
|
||||||
|
const branchLink = branches
|
||||||
|
.flatMap((branch) => branch.missions.map((item) => ({ branch, item })))
|
||||||
|
.find(({ item }) => item.mission_id === mission.id);
|
||||||
|
return branchLink?.branch.id ?? '';
|
||||||
|
})(),
|
||||||
|
branch_order: (() => {
|
||||||
|
const branchLink = branches
|
||||||
|
.flatMap((branch) => branch.missions.map((item) => ({ branch, item })))
|
||||||
|
.find(({ item }) => item.mission_id === mission.id);
|
||||||
|
return branchLink?.item.order ?? 1;
|
||||||
|
})(),
|
||||||
|
prerequisite_ids: mission.prerequisites,
|
||||||
|
competency_rewards: mission.competency_rewards.map((reward) => ({
|
||||||
|
competency_id: reward.competency_id,
|
||||||
|
level_delta: reward.level_delta
|
||||||
|
})),
|
||||||
|
is_active: mission.is_active
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось загрузить миссию');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
if (value === 'new') {
|
||||||
|
setSelectedId('new');
|
||||||
|
resetForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(value);
|
||||||
|
|
||||||
|
const baseMission = missionById.get(id);
|
||||||
|
if (baseMission) {
|
||||||
|
setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
title: baseMission.title,
|
||||||
|
description: baseMission.description,
|
||||||
|
xp_reward: baseMission.xp_reward,
|
||||||
|
mana_reward: baseMission.mana_reward,
|
||||||
|
difficulty: baseMission.difficulty,
|
||||||
|
is_active: baseMission.is_active
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedId(id);
|
||||||
|
void loadMission(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = <K extends keyof FormState>(field: K, value: FormState[K]) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrerequisitesChange = (event: FormEvent<HTMLSelectElement>) => {
|
||||||
|
const options = Array.from(event.currentTarget.selectedOptions);
|
||||||
|
updateField(
|
||||||
|
'prerequisite_ids',
|
||||||
|
options.map((option) => Number(option.value))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addReward = () => {
|
||||||
|
updateField('competency_rewards', [...form.competency_rewards, { competency_id: '', level_delta: 1 }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateReward = (index: number, value: RewardInput) => {
|
||||||
|
const next = [...form.competency_rewards];
|
||||||
|
next[index] = value;
|
||||||
|
updateField('competency_rewards', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeReward = (index: number) => {
|
||||||
|
const next = [...form.competency_rewards];
|
||||||
|
next.splice(index, 1);
|
||||||
|
updateField('competency_rewards', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const payloadBase = {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
xp_reward: Number(form.xp_reward),
|
||||||
|
mana_reward: Number(form.mana_reward),
|
||||||
|
difficulty: form.difficulty,
|
||||||
|
minimum_rank_id: form.minimum_rank_id === '' ? null : Number(form.minimum_rank_id),
|
||||||
|
artifact_id: form.artifact_id === '' ? null : Number(form.artifact_id),
|
||||||
|
prerequisite_ids: form.prerequisite_ids,
|
||||||
|
competency_rewards: form.competency_rewards
|
||||||
|
.filter((reward) => reward.competency_id !== '')
|
||||||
|
.map((reward) => ({
|
||||||
|
competency_id: Number(reward.competency_id),
|
||||||
|
level_delta: Number(reward.level_delta)
|
||||||
|
})),
|
||||||
|
branch_id: form.branch_id === '' ? null : Number(form.branch_id),
|
||||||
|
branch_order: Number(form.branch_order) || 1,
|
||||||
|
is_active: form.is_active
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedId === 'new') {
|
||||||
|
const { is_active, ...createPayload } = payloadBase;
|
||||||
|
await apiFetch('/api/admin/missions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(createPayload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Миссия создана');
|
||||||
|
resetForm();
|
||||||
|
setSelectedId('new');
|
||||||
|
} else {
|
||||||
|
await apiFetch(`/api/admin/missions/${selectedId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payloadBase),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Миссия обновлена');
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось сохранить миссию');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3>Миссии</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Создавайте и обновляйте миссии: настраивайте награды, зависимости, ветки и компетенции. Все изменения мгновенно
|
||||||
|
отражаются в списках пилотов.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="admin-form">
|
||||||
|
<label>
|
||||||
|
Выбранная миссия
|
||||||
|
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
|
||||||
|
<option value="new">Новая миссия</option>
|
||||||
|
{missions.map((mission) => (
|
||||||
|
<option key={mission.id} value={mission.id}>
|
||||||
|
{mission.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Название
|
||||||
|
<input value={form.title} onChange={(event) => updateField('title', event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Описание
|
||||||
|
<textarea value={form.description} onChange={(event) => updateField('description', event.target.value)} required rows={4} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '1rem' }}>
|
||||||
|
<label>
|
||||||
|
Награда (XP)
|
||||||
|
<input type="number" min={0} value={form.xp_reward} onChange={(event) => updateField('xp_reward', Number(event.target.value))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Награда (мана)
|
||||||
|
<input type="number" min={0} value={form.mana_reward} onChange={(event) => updateField('mana_reward', Number(event.target.value))} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Сложность
|
||||||
|
<select value={form.difficulty} onChange={(event) => updateField('difficulty', event.target.value as Difficulty)}>
|
||||||
|
{DIFFICULTIES.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Доступен с ранга
|
||||||
|
<select value={form.minimum_rank_id === '' ? '' : String(form.minimum_rank_id)} onChange={(event) => updateField('minimum_rank_id', event.target.value === '' ? '' : Number(event.target.value))}>
|
||||||
|
<option value="">Любой ранг</option>
|
||||||
|
{ranks.map((rank) => (
|
||||||
|
<option key={rank.id} value={rank.id}>
|
||||||
|
{rank.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Артефакт
|
||||||
|
<select value={form.artifact_id === '' ? '' : String(form.artifact_id)} onChange={(event) => updateField('artifact_id', event.target.value === '' ? '' : Number(event.target.value))}>
|
||||||
|
<option value="">Без артефакта</option>
|
||||||
|
{artifacts.map((artifact) => (
|
||||||
|
<option key={artifact.id} value={artifact.id}>
|
||||||
|
{artifact.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="checkbox">
|
||||||
|
<input type="checkbox" checked={form.is_active} onChange={(event) => updateField('is_active', event.target.checked)} /> Миссия активна
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Ветка
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||||
|
<select value={form.branch_id === '' ? '' : String(form.branch_id)} onChange={(event) => updateField('branch_id', event.target.value === '' ? '' : Number(event.target.value))}>
|
||||||
|
<option value="">Без ветки</option>
|
||||||
|
{branches.map((branch) => (
|
||||||
|
<option key={branch.id} value={branch.id}>
|
||||||
|
{branch.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||||
|
Порядок
|
||||||
|
<input type="number" min={1} value={form.branch_order} onChange={(event) => updateField('branch_order', Number(event.target.value) || 1)} style={{ width: '80px' }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Предварительные миссии
|
||||||
|
<select multiple value={form.prerequisite_ids.map(String)} onChange={handlePrerequisitesChange} size={Math.min(6, missions.length)}>
|
||||||
|
{missions
|
||||||
|
.filter((mission) => selectedId === 'new' || mission.id !== selectedId)
|
||||||
|
.map((mission) => (
|
||||||
|
<option key={mission.id} value={mission.id}>
|
||||||
|
{mission.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>Прокачка компетенций</span>
|
||||||
|
<button type="button" onClick={addReward} className="secondary">
|
||||||
|
Добавить компетенцию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{form.competency_rewards.length === 0 && (
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}>Для миссии пока не назначены компетенции.</p>
|
||||||
|
)}
|
||||||
|
{form.competency_rewards.map((reward, index) => (
|
||||||
|
<div key={index} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginTop: '0.5rem' }}>
|
||||||
|
<select
|
||||||
|
value={reward.competency_id === '' ? '' : String(reward.competency_id)}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateReward(index, {
|
||||||
|
competency_id: event.target.value === '' ? '' : Number(event.target.value),
|
||||||
|
level_delta: reward.level_delta
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Выберите компетенцию</option>
|
||||||
|
{competencies.map((competency) => (
|
||||||
|
<option key={competency.id} value={competency.id}>
|
||||||
|
{competency.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={reward.level_delta}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateReward(index, {
|
||||||
|
competency_id: reward.competency_id,
|
||||||
|
level_delta: Number(event.target.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{ width: '80px' }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="secondary" onClick={() => removeReward(index)}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="primary" disabled={loading}>
|
||||||
|
{selectedId === 'new' ? 'Создать миссию' : 'Сохранить изменения'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
|
||||||
|
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
frontend/src/components/admin/AdminRankManager.tsx
Normal file
279
frontend/src/components/admin/AdminRankManager.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
|
type RankBase = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
required_xp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MissionOption = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompetencyOption = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RankDetail extends RankBase {
|
||||||
|
mission_requirements: Array<{ mission_id: number; mission_title: string }>;
|
||||||
|
competency_requirements: Array<{ competency_id: number; competency_name: string; required_level: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string;
|
||||||
|
ranks: RankBase[];
|
||||||
|
missions: MissionOption[];
|
||||||
|
competencies: CompetencyOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompetencyRequirementInput = { competency_id: number | ''; required_level: number };
|
||||||
|
|
||||||
|
type RankFormState = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
required_xp: number;
|
||||||
|
mission_ids: number[];
|
||||||
|
competency_requirements: CompetencyRequirementInput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialRankForm: RankFormState = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
required_xp: 0,
|
||||||
|
mission_ids: [],
|
||||||
|
competency_requirements: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminRankManager({ token, ranks, missions, competencies }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||||
|
const [form, setForm] = useState<RankFormState>(initialRankForm);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm(initialRankForm);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRank = async (rankId: number) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const rank = await apiFetch<RankDetail>(`/api/admin/ranks/${rankId}`, { authToken: token });
|
||||||
|
setForm({
|
||||||
|
title: rank.title,
|
||||||
|
description: rank.description,
|
||||||
|
required_xp: rank.required_xp,
|
||||||
|
mission_ids: rank.mission_requirements.map((item) => item.mission_id),
|
||||||
|
competency_requirements: rank.competency_requirements.map((item) => ({
|
||||||
|
competency_id: item.competency_id,
|
||||||
|
required_level: item.required_level
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось загрузить ранг');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
if (value === 'new') {
|
||||||
|
setSelectedId('new');
|
||||||
|
resetForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(value);
|
||||||
|
setSelectedId(id);
|
||||||
|
void loadRank(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = <K extends keyof RankFormState>(field: K, value: RankFormState[K]) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMissionSelect = (event: FormEvent<HTMLSelectElement>) => {
|
||||||
|
const options = Array.from(event.currentTarget.selectedOptions);
|
||||||
|
updateField(
|
||||||
|
'mission_ids',
|
||||||
|
options.map((option) => Number(option.value))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCompetencyRequirement = () => {
|
||||||
|
updateField('competency_requirements', [...form.competency_requirements, { competency_id: '', required_level: 1 }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCompetencyRequirement = (index: number, value: CompetencyRequirementInput) => {
|
||||||
|
const next = [...form.competency_requirements];
|
||||||
|
next[index] = value;
|
||||||
|
updateField('competency_requirements', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCompetencyRequirement = (index: number) => {
|
||||||
|
const next = [...form.competency_requirements];
|
||||||
|
next.splice(index, 1);
|
||||||
|
updateField('competency_requirements', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
required_xp: Number(form.required_xp),
|
||||||
|
mission_ids: form.mission_ids,
|
||||||
|
competency_requirements: form.competency_requirements
|
||||||
|
.filter((item) => item.competency_id !== '')
|
||||||
|
.map((item) => ({
|
||||||
|
competency_id: Number(item.competency_id),
|
||||||
|
required_level: Number(item.required_level)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedId === 'new') {
|
||||||
|
await apiFetch('/api/admin/ranks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Ранг создан');
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
await apiFetch(`/api/admin/ranks/${selectedId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Ранг обновлён');
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось сохранить ранг');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3>Ранги и условия повышения</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Управляйте требованиями к рангу: задавайте минимальный опыт, ключевые миссии и уровни компетенций.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="admin-form">
|
||||||
|
<label>
|
||||||
|
Выбранный ранг
|
||||||
|
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
|
||||||
|
<option value="new">Новый ранг</option>
|
||||||
|
{ranks.map((rank) => (
|
||||||
|
<option key={rank.id} value={rank.id}>
|
||||||
|
{rank.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Название
|
||||||
|
<input value={form.title} onChange={(event) => updateField('title', event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Описание
|
||||||
|
<textarea value={form.description} onChange={(event) => updateField('description', event.target.value)} required rows={3} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Требуемый опыт
|
||||||
|
<input type="number" min={0} value={form.required_xp} onChange={(event) => updateField('required_xp', Number(event.target.value))} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Ключевые миссии
|
||||||
|
<select multiple value={form.mission_ids.map(String)} onChange={handleMissionSelect} size={Math.min(6, missions.length)}>
|
||||||
|
{missions.map((mission) => (
|
||||||
|
<option key={mission.id} value={mission.id}>
|
||||||
|
{mission.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>Требования к компетенциям</span>
|
||||||
|
<button type="button" onClick={addCompetencyRequirement} className="secondary">
|
||||||
|
Добавить требование
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{form.competency_requirements.length === 0 && (
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}>
|
||||||
|
Пока нет требований. Добавьте компетенции, которые нужно прокачать до определённого уровня.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{form.competency_requirements.map((item, index) => (
|
||||||
|
<div key={index} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginTop: '0.5rem' }}>
|
||||||
|
<select
|
||||||
|
value={item.competency_id === '' ? '' : String(item.competency_id)}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateCompetencyRequirement(index, {
|
||||||
|
competency_id: event.target.value === '' ? '' : Number(event.target.value),
|
||||||
|
required_level: item.required_level
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Выберите компетенцию</option>
|
||||||
|
{competencies.map((competency) => (
|
||||||
|
<option key={competency.id} value={competency.id}>
|
||||||
|
{competency.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={item.required_level}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateCompetencyRequirement(index, {
|
||||||
|
competency_id: item.competency_id,
|
||||||
|
required_level: Number(event.target.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{ width: '80px' }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="secondary" onClick={() => removeCompetencyRequirement(index)}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="primary" disabled={loading}>
|
||||||
|
{selectedId === 'new' ? 'Создать ранг' : 'Сохранить изменения'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
|
||||||
|
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/components/admin/AdminSubmissionCard.tsx
Normal file
82
frontend/src/components/admin/AdminSubmissionCard.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
|
type Submission = {
|
||||||
|
mission_id: number;
|
||||||
|
status: string;
|
||||||
|
comment: string | null;
|
||||||
|
proof_url: string | null;
|
||||||
|
awarded_xp: number;
|
||||||
|
awarded_mana: number;
|
||||||
|
updated_at: string;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
submission: Submission;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSubmissionCard({ submission, token }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState<'approve' | 'reject' | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleAction = async (action: 'approve' | 'reject') => {
|
||||||
|
setLoading(action);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/admin/submissions/${submission.id}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось выполнить действие');
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
|
<h4>Миссия #{submission.mission_id}</h4>
|
||||||
|
<p>Статус: {submission.status}</p>
|
||||||
|
{submission.comment && <p>Комментарий пилота: {submission.comment}</p>}
|
||||||
|
{submission.proof_url && (
|
||||||
|
<p>
|
||||||
|
Доказательство:{' '}
|
||||||
|
<a href={submission.proof_url} target="_blank" rel="noreferrer">
|
||||||
|
открыть
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')}
|
||||||
|
</small>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
onClick={() => handleAction('approve')}
|
||||||
|
disabled={loading === 'approve'}
|
||||||
|
>
|
||||||
|
{loading === 'approve' ? 'Одобряем...' : 'Одобрить'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => handleAction('reject')}
|
||||||
|
disabled={loading === 'reject'}
|
||||||
|
style={{ cursor: loading === 'reject' ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
{loading === 'reject' ? 'Отклоняем...' : 'Отклонить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
const CLIENT_API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
||||||
|
const SERVER_API_URL = process.env.NEXT_INTERNAL_API_URL || CLIENT_API_URL;
|
||||||
|
|
||||||
export interface RequestOptions extends RequestInit {
|
export interface RequestOptions extends RequestInit {
|
||||||
authToken?: string;
|
authToken?: string;
|
||||||
|
|
@ -11,7 +12,8 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
|
||||||
headers.set('Authorization', `Bearer ${options.authToken}`);
|
headers.set('Authorization', `Bearer ${options.authToken}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const baseUrl = typeof window === 'undefined' ? SERVER_API_URL : CLIENT_API_URL;
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
|
|
@ -21,6 +23,15 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
throw new Error(`API error ${response.status}: ${text}`);
|
throw new Error(`API error ${response.status}: ${text}`);
|
||||||
}
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await response.text();
|
||||||
|
return raw as unknown as T;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,36 @@
|
||||||
import { apiFetch } from './api';
|
import { apiFetch } from './api';
|
||||||
|
|
||||||
let cachedToken: string | null = null;
|
type DemoRole = 'pilot' | 'hr';
|
||||||
|
|
||||||
export async function getDemoToken() {
|
const tokenCache: Partial<Record<DemoRole, string>> = {};
|
||||||
|
|
||||||
|
function resolveCredentials(role: DemoRole) {
|
||||||
|
if (role === 'hr') {
|
||||||
|
const email = process.env.NEXT_PUBLIC_DEMO_HR_EMAIL ?? 'hr@alabuga.space';
|
||||||
|
const password =
|
||||||
|
process.env.NEXT_PUBLIC_DEMO_HR_PASSWORD ?? process.env.NEXT_PUBLIC_DEMO_PASSWORD ?? 'orbita123';
|
||||||
|
return { email, password };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
email: process.env.NEXT_PUBLIC_DEMO_EMAIL ?? 'candidate@alabuga.space',
|
||||||
|
password: process.env.NEXT_PUBLIC_DEMO_PASSWORD ?? 'orbita123'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDemoToken(role: DemoRole = 'pilot') {
|
||||||
|
const cachedToken = tokenCache[role];
|
||||||
if (cachedToken) {
|
if (cachedToken) {
|
||||||
return cachedToken;
|
return cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = process.env.NEXT_PUBLIC_DEMO_EMAIL ?? 'candidate@alabuga.space';
|
const credentials = resolveCredentials(role);
|
||||||
const password = process.env.NEXT_PUBLIC_DEMO_PASSWORD ?? 'orbita123';
|
|
||||||
|
|
||||||
const data = await apiFetch<{ access_token: string }>('/auth/login', {
|
const data = await apiFetch<{ access_token: string }>('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ email, password })
|
body: JSON.stringify(credentials)
|
||||||
});
|
});
|
||||||
|
|
||||||
cachedToken = data.access_token;
|
tokenCache[role] = data.access_token;
|
||||||
return cachedToken;
|
return data.access_token;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
--accent-light: #a29bfe;
|
--accent-light: #a29bfe;
|
||||||
--text: #f5f6fa;
|
--text: #f5f6fa;
|
||||||
--text-muted: #b2bec3;
|
--text-muted: #b2bec3;
|
||||||
|
--success: #00b894;
|
||||||
|
--error: #ff7675;
|
||||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,6 +19,18 @@ body {
|
||||||
var(--bg);
|
var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image: radial-gradient(#ffffff33 1px, transparent 0);
|
||||||
|
background-size: 60px 60px;
|
||||||
|
opacity: 0.25;
|
||||||
|
pointer-events: none;
|
||||||
|
mix-blend-mode: screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|
@ -30,6 +44,8 @@ a {
|
||||||
|
|
||||||
main {
|
main {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|
@ -41,6 +57,40 @@ main {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form input,
|
||||||
|
.admin-form textarea,
|
||||||
|
.admin-form select {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(162, 155, 254, 0.3);
|
||||||
|
background: rgba(8, 11, 26, 0.6);
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: var(--text);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form textarea {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form .checkbox {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
|
@ -71,3 +121,13 @@ main {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(162, 155, 254, 0.4);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,16 +11,18 @@ from sqlalchemy.orm import Session
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.append(str(ROOT / 'backend'))
|
sys.path.append(str(ROOT / 'backend'))
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.core.security import get_password_hash
|
from app.core.security import get_password_hash
|
||||||
from app.db.session import SessionLocal, engine
|
from app.db.session import SessionLocal, engine
|
||||||
from app.models.artifact import Artifact, ArtifactRarity
|
from app.models.artifact import Artifact, ArtifactRarity
|
||||||
from app.models.branch import Branch, BranchMission
|
from app.models.branch import Branch, BranchMission
|
||||||
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
|
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
|
||||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||||
|
from app.models.onboarding import OnboardingSlide
|
||||||
from app.models.store import StoreItem
|
from app.models.store import StoreItem
|
||||||
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
|
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
|
||||||
|
|
||||||
DATA_SENTINEL = Path("/data/.seeded")
|
DATA_SENTINEL = settings.sqlite_path.parent / ".seeded"
|
||||||
|
|
||||||
|
|
||||||
def ensure_database() -> None:
|
def ensure_database() -> None:
|
||||||
|
|
@ -249,6 +251,32 @@ def seed() -> None:
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
OnboardingSlide(
|
||||||
|
order=1,
|
||||||
|
title="Добро пожаловать в орбитальный флот",
|
||||||
|
body="Узнайте, как миссии помогают связать карьерные шаги в единую траекторию.",
|
||||||
|
media_url="https://images.nasa.gov/details-PIA12235",
|
||||||
|
cta_text="Перейти к миссиям",
|
||||||
|
cta_link="/missions",
|
||||||
|
),
|
||||||
|
OnboardingSlide(
|
||||||
|
order=2,
|
||||||
|
title="Получайте опыт и ману",
|
||||||
|
body="Выполняя задания, вы накапливаете опыт для повышения ранга и ману для магазина.",
|
||||||
|
media_url="https://images.nasa.gov/details-PIA23499",
|
||||||
|
),
|
||||||
|
OnboardingSlide(
|
||||||
|
order=3,
|
||||||
|
title="Повышайте ранг до члена экипажа",
|
||||||
|
body="Закройте ключевые миссии ветки «Получение оффера» и прокачайте компетенции.",
|
||||||
|
cta_text="Открыть ветку",
|
||||||
|
cta_link="/missions",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
DATA_SENTINEL.write_text("seeded")
|
DATA_SENTINEL.write_text("seeded")
|
||||||
print("Seed data created")
|
print("Seed data created")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user