Merge pull request #2 from Danieli4/1-ver-

1 ver
This commit is contained in:
Danil Gryaznev 2025-09-27 12:16:24 +03:00 committed by GitHub
commit 83174ac9c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 3634 additions and 220 deletions

View File

@ -10,10 +10,22 @@
- `docs/` — документация, дополнительный лор.
- `docker-compose.yaml` — инфраструктура проекта.
## Основные пользовательские сценарии
- Как кандидат: хочу проходить онбординг с лором и понимать своё место в программе.
- Как кандидат: хочу видеть прогресс-бар до следующего ранга и инструкции по ключевым веткам.
- Как кандидат: хочу получать награды (опыт, ману, артефакты) и видеть их в журнале.
- Как HR: хочу управлять миссиями, ветками, артефактами и требованиями рангов.
- Как HR: хочу видеть оперативную аналитику по активности и очереди модерации.
## Быстрый старт в Docker
1. Установите Docker и Docker Compose.
2. Скопируйте `.env.example` в `.env` (файл появится после сборки) и при необходимости поменяйте настройки.
2. Скопируйте пример конфигурации и при необходимости измените значения:
```bash
cp backend/.env.example backend/.env
cp frontend/.env.example frontend/.env
```
3. Запустите окружение:
```bash
docker compose up --build
@ -22,17 +34,27 @@
- API: http://localhost:8000 (документация Swagger — `/docs`).
- Фронтенд: http://localhost:3000.
Docker Compose автоматически переопределяет `ALABUGA_SQLITE_PATH=/data/app.db`, чтобы база сохранялась во внешнем volume. Для локального запуска вне Docker оставьте путь `./data/app.db` из примера.
## Локальная разработка backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
export PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1
pip install -r requirements-dev.txt
# применяем миграции и создаём демо-данные
# подготовьте переменные окружения (однократно)
cp .env.example .env
# применяем миграции
alembic upgrade head
# создаём демо-данные (команда выполняется из корня репозитория)
cd ..
python -m scripts.seed_data
cd backend
# Запуск API
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
cd frontend
npm install
cp .env.example .env
npm run dev
```
@ -53,6 +76,14 @@ npm run dev
| Пилот | `candidate@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
@ -68,7 +99,17 @@ pytest
- Начисление опыта, маны и повышение ранга по трём условиям из ТЗ.
- Журнал событий, экспортируемый через API.
- Магазин артефактов с оформлением заказа.
- Админ-панель для HR: создание миссий, очередь модерации, список рангов.
- Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам.
- Онбординг с сохранением прогресса и космическим лором.
- Таблица лидеров по опыту и мане за неделю/месяц/год.
- Аналитическая сводка для HR: активность пилотов, очередь модерации, завершённость веток.
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
- создавать и редактировать ветки миссий с категориями и порядком;
- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями;
- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций.
- управлять каталогом артефактов и назначать их миссиям.
## План развития

View File

@ -2,11 +2,14 @@ FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
POETRY_VIRTUALENVS_CREATE=false
POETRY_VIRTUALENVS_CREATE=false \
PYO3_USE_ABI3_FORWARD_COMPATIBILITY=1
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
RUN pip install --no-cache-dir -r requirements.txt

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

View File

@ -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__ = [
"admin",
"auth",
"journal",
"missions",
"onboarding",
"store",
"users",
]

View File

@ -2,75 +2,50 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import func
from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import Session, selectinload
from app.api.deps import require_hr
from app.db.session import get_db
from app.models.branch import BranchMission
from app.models.mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission, SubmissionStatus
from app.models.rank import Rank
from app.schemas.mission import MissionBase, MissionCreate, MissionDetail, MissionSubmissionRead
from app.schemas.rank import RankBase
from app.models.artifact import Artifact
from app.models.branch import Branch, BranchMission
from app.models.mission import (
Mission,
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.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats
router = APIRouter(prefix="/api/admin", tags=["admin"])
@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.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)
def _mission_to_detail(mission: Mission) -> MissionDetail:
"""Формируем детальную схему миссии."""
return MissionDetail(
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="Список рангов")
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]
@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(
"/submissions",
response_model=list[MissionSubmissionRead],

View File

@ -2,14 +2,17 @@
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 app.api.deps import get_current_user
from app.db.session import get_db
from app.models.journal import JournalEntry
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"])
@ -27,3 +30,48 @@ def list_journal(
.all()
)
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)

View File

@ -2,13 +2,17 @@
from __future__ import annotations
from collections import defaultdict
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.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.schemas.branch import BranchMissionRead, BranchRead
from app.schemas.mission import (
MissionBase,
MissionDetail,
@ -20,19 +24,181 @@ from app.services.mission import submit_mission
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="Список миссий")
def list_missions(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
) -> list[MissionBase]:
"""Возвращаем доступные миссии."""
query = db.query(Mission).filter(Mission.is_active.is_(True))
if current_user.current_rank_id:
query = query.filter(
(Mission.minimum_rank_id.is_(None)) | (Mission.minimum_rank_id <= current_user.current_rank_id)
db.refresh(current_user)
_ = current_user.submissions
branches = (
db.query(Branch)
.options(selectinload(Branch.missions))
.all()
)
branch_dependencies = _build_branch_dependencies(branches)
missions = (
db.query(Mission)
.options(
selectinload(Mission.prerequisites),
selectinload(Mission.minimum_rank),
)
missions = query.all()
return [MissionBase.model_validate(mission) for mission in missions]
.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="Карточка миссии")
@ -48,6 +214,25 @@ def get_mission(
if not mission:
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]
rewards = [
{
@ -66,6 +251,8 @@ def get_mission(
mana_reward=mission.mana_reward,
difficulty=mission.difficulty,
is_active=mission.is_active,
is_available=is_available,
locked_reasons=reasons,
minimum_rank_id=mission.minimum_rank_id,
artifact_id=mission.artifact_id,
prerequisites=prerequisites,
@ -86,9 +273,30 @@ def submit(
) -> 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:
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(
db=db,
user=current_user,

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

View File

@ -7,8 +7,12 @@ from sqlalchemy.orm import Session
from app.api.deps import get_current_user
from app.db.session import get_db
from app.models.rank import Rank
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.services.rank import build_progress_snapshot
router = APIRouter(prefix="/api", tags=["profile"])
@ -23,3 +27,26 @@ def get_profile(
_ = current_user.competencies
_ = current_user.artifacts
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

View File

@ -1,15 +1,21 @@
"""Конфигурация приложения и загрузка окружения."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import List
from pydantic_settings import BaseSettings, SettingsConfigDict
BASE_DIR = Path(__file__).resolve().parents[2]
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"
environment: str = "local"
@ -17,7 +23,11 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256"
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")
@ -33,6 +43,10 @@ def get_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)
return settings

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from fastapi import FastAPI
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.db.session import engine
from app.models.base import Base
@ -32,6 +32,7 @@ app.include_router(auth.router)
app.include_router(users.router)
app.include_router(missions.router)
app.include_router(journal.router)
app.include_router(onboarding.router)
app.include_router(store.router)
app.include_router(admin.router)

View File

@ -4,6 +4,7 @@ from .artifact import Artifact # noqa: F401
from .branch import Branch, BranchMission # noqa: F401
from .journal import JournalEntry # 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 .store import Order, StoreItem # noqa: F401
from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401
@ -17,6 +18,8 @@ __all__ = [
"MissionCompetencyReward",
"MissionPrerequisite",
"MissionSubmission",
"OnboardingSlide",
"OnboardingState",
"Rank",
"RankCompetencyRequirement",
"RankMissionRequirement",

View File

@ -3,6 +3,7 @@
from __future__ import annotations
from enum import Enum
from typing import Optional
from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, JSON, String, Text
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)
title: Mapped[str] = mapped_column(String(160), 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)
mana_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)

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

View File

@ -3,7 +3,7 @@
from __future__ import annotations
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.orm import Mapped, mapped_column, relationship
@ -45,7 +45,7 @@ class Order(Base, TimestampMixin):
status: Mapped[OrderStatus] = mapped_column(
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")
item = relationship("StoreItem", back_populates="orders")

View File

@ -10,6 +10,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
# Локальные импорты внизу файла, чтобы избежать циклов типов
class UserRole(str, Enum):
"""Типы ролей в системе."""
@ -48,6 +50,9 @@ class User(Base, TimestampMixin):
artifacts: Mapped[List["UserArtifact"]] = relationship(
"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):
@ -103,3 +108,7 @@ class UserArtifact(Base, TimestampMixin):
user = relationship("User", back_populates="artifacts")
artifact = relationship("Artifact", back_populates="pilots")
# Импорты в конце файла, чтобы отношения корректно инициализировались
from app.models.onboarding import OnboardingState # noqa: E402 pylint: disable=wrong-import-position

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

View 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

View File

@ -11,6 +11,8 @@ class BranchMissionRead(BaseModel):
mission_id: int
mission_title: str
order: int
is_completed: bool = False
is_available: bool = False
class BranchRead(BaseModel):
@ -21,6 +23,22 @@ class BranchRead(BaseModel):
description: str
category: str
missions: list[BranchMissionRead]
total_missions: int = 0
completed_missions: int = 0
class Config:
from_attributes = True
class BranchCreate(BaseModel):
"""Создание ветки."""
title: str
description: str
category: str
class BranchUpdate(BranchCreate):
"""Обновление ветки."""
pass

View File

@ -24,3 +24,19 @@ class JournalEntryRead(BaseModel):
class Config:
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]

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from pydantic import BaseModel, Field
from app.models.mission import MissionDifficulty, SubmissionStatus
@ -20,6 +20,8 @@ class MissionBase(BaseModel):
mana_reward: int
difficulty: MissionDifficulty
is_active: bool
is_available: bool = True
locked_reasons: list[str] = Field(default_factory=list)
class Config:
from_attributes = True
@ -68,6 +70,23 @@ class MissionCreate(BaseModel):
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):
"""Отправка отчёта по миссии."""
@ -78,6 +97,7 @@ class MissionSubmissionCreate(BaseModel):
class MissionSubmissionRead(BaseModel):
"""Получение статуса отправки."""
id: int
mission_id: int
status: SubmissionStatus
comment: Optional[str]

View 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

View 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

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel
from pydantic import BaseModel, Field
class RankBase(BaseModel):
@ -41,3 +41,36 @@ class RankDetailed(RankBase):
competency_requirements: list[RankRequirementCompetency]
created_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] = []

View File

@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
from app.models.journal import JournalEventType
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.rank import apply_rank_upgrade
@ -82,6 +82,21 @@ def approve_submission(db: Session, submission: MissionSubmission) -> MissionSub
_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.commit()
db.refresh(submission)

View 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

View File

@ -2,13 +2,20 @@
from __future__ import annotations
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, selectinload
from app.models.journal import JournalEventType
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.services.journal import log_event
from app.schemas.progress import (
ProgressCompetencyRequirement,
ProgressMissionRequirement,
ProgressRank,
ProgressSnapshot,
ProgressXPMetrics,
)
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},
)
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),
)

View File

@ -1,16 +1,13 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
SQLAlchemy==2.0.30
alembic==1.13.1
pydantic==2.7.4
pydantic-settings==2.3.2
SQLAlchemy>=2.0.36,<3
alembic>=1.14.0,<2
pydantic==2.9.2
pydantic-settings==2.10.1
passlib[bcrypt]==1.7.4
python-jose[cryptography]==3.3.0
python-multipart==0.0.9
bcrypt==4.1.3
email-validator==2.1.1
pandas==2.2.2
openpyxl==3.1.3
fastapi-pagination==0.12.24
Jinja2==3.1.4

View File

@ -2,6 +2,7 @@
from __future__ import annotations
from app.models.artifact import Artifact, ArtifactRarity
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
from app.models.user import User, UserRole
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.mana == mission.mana_reward
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

View File

@ -3,9 +3,9 @@
from __future__ import annotations
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
from app.models.rank import Rank, RankMissionRequirement
from app.models.user import User, UserRole
from app.services.rank import apply_rank_upgrade
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
from app.services.rank import apply_rank_upgrade, build_progress_snapshot
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 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

View File

@ -13,7 +13,7 @@ services:
env_file:
- backend/.env.example
environment:
ALABUGA_ENVIRONMENT=docker
ALABUGA_ENVIRONMENT: docker
depends_on: []
frontend:
@ -23,9 +23,12 @@ services:
ports:
- '3000:3000'
environment:
NEXT_PUBLIC_API_URL=http://backend:8000
NEXT_PUBLIC_DEMO_EMAIL=candidate@alabuga.space
NEXT_PUBLIC_DEMO_PASSWORD=orbita123
NEXT_PUBLIC_API_URL: http://localhost:8000
NEXT_INTERNAL_API_URL: http://backend:8000
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:
- ./frontend:/app
- /app/node_modules

View File

@ -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 { getDemoToken } from '../../lib/demo-auth';
interface Submission {
id: number;
mission_id: number;
status: string;
comment: string | null;
@ -11,42 +17,151 @@ interface Submission {
updated_at: string;
}
async function fetchModerationQueue() {
const token = await getDemoToken();
const submissions = await apiFetch<Submission[]>('/api/admin/submissions', { authToken: token });
return submissions;
interface MissionSummary {
id: number;
title: string;
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() {
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 (
<section>
<h2>HR-панель: очередь модерации</h2>
<h2>HR-панель</h2>
<p style={{ color: 'var(--text-muted)' }}>
Демонстрационная выборка отправленных миссий. В реальном приложении добавим карточки с деталями пилота и
кнопки approve/reject непосредственно из UI.
Управляйте миссиями, ветками и рангами, а также следите за очередью модерации отчётов.
</p>
<div className="grid">
{submissions.map((submission) => (
<div key={submission.mission_id} className="card">
<h3>Миссия #{submission.mission_id}</h3>
<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 className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
<div className="card" style={{ gridColumn: '1 / -1', display: 'grid', gap: '1rem' }}>
<h3>Сводка</h3>
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '1rem' }}>
<div className="card" style={{ marginBottom: 0 }}>
<span className="badge">Пилоты</span>
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.total_users}</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.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>
))}
{submissions.length === 0 && <p>Очередь пуста все миссии проверены.</p>}
<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)' }}>
Одобрено: {stats.submission_stats.approved} · Отклонено: {stats.submission_stats.rejected}
</small>
</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>}
</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>
);

View File

@ -12,22 +12,62 @@ interface JournalEntry {
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() {
const token = await getDemoToken();
const entries = await apiFetch<JournalEntry[]>('/api/journal/', { authToken: token });
return entries;
const [entries, week, month, year] = await Promise.all([
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() {
const entries = await fetchJournal();
const { entries, leaderboards } = await fetchJournal();
return (
<section>
<h2>Бортовой журнал</h2>
<p style={{ color: 'var(--text-muted)' }}>
Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине.
</p>
<JournalTimeline entries={entries} />
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', gap: '2rem' }}>
<div>
<h2>Бортовой журнал</h2>
<p style={{ color: 'var(--text-muted)' }}>
Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине.
</p>
<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>
);
}

View File

@ -10,17 +10,29 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>
<body style={{ backgroundAttachment: 'fixed' }}>
<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>
<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>
</div>
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center', fontWeight: 500 }}>
<a href="/">Дашборд</a>
<a href="/onboarding">Онбординг</a>
<a href="/missions">Миссии</a>
<a href="/journal">Журнал</a>
<a href="/store">Магазин</a>

View File

@ -17,6 +17,8 @@ interface MissionDetail {
competency_name: string;
level_delta: number;
}>;
is_available: boolean;
locked_reasons: string[];
}
async function fetchMission(id: number) {
@ -41,6 +43,16 @@ export default async function MissionPage({ params }: MissionPageProps) {
<p style={{ marginTop: '1rem' }}>
Награда: {mission.xp_reward} XP · {mission.mana_reward}
</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">
<h3>Компетенции</h3>
<ul style={{ listStyle: 'none', padding: 0 }}>
@ -52,7 +64,7 @@ export default async function MissionPage({ params }: MissionPageProps) {
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
</ul>
</div>
<MissionSubmissionForm missionId={mission.id} token={token} />
<MissionSubmissionForm missionId={mission.id} token={token} locked={!mission.is_available} />
</section>
);
}

View File

@ -2,14 +2,35 @@ import { apiFetch } from '../../lib/api';
import { getDemoToken } from '../../lib/demo-auth';
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() {
const token = await getDemoToken();
const missions = await apiFetch<MissionSummary[]>('/api/missions/', { authToken: token });
return missions;
const [missions, branches] = await Promise.all([
apiFetch<MissionSummary[]>('/api/missions/', { authToken: token }),
apiFetch<BranchOverview[]>('/api/missions/branches', { authToken: token })
]);
return { missions, branches };
}
export default async function MissionsPage() {
const missions = await fetchMissions();
const { missions, branches } = await fetchMissions();
return (
<section>
@ -18,6 +39,45 @@ export default async function MissionsPage() {
Список обновляется в реальном времени и зависит от вашего ранга и прогресса. HR может добавлять новые
задания в админ-панели.
</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} />
</section>
);

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

View File

@ -6,7 +6,6 @@ interface ProfileResponse {
full_name: string;
xp: number;
mana: number;
current_rank_id: number | null;
competencies: Array<{
competency: { id: number; name: string };
level: number;
@ -18,40 +17,63 @@ interface ProfileResponse {
}>;
}
interface RankResponse {
id: number;
title: string;
description: string;
interface ProgressResponse {
current_rank: { id: number; title: string; description: string; required_xp: number } | null;
next_rank: { id: number; title: string; description: string; required_xp: number } | 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;
}
async function fetchProfile() {
const token = await getDemoToken();
const profile = await apiFetch<ProfileResponse>('/api/me', { authToken: token });
const ranks = await apiFetch<RankResponse[]>('/api/admin/ranks', { authToken: token });
const currentRank = ranks.find((rank) => rank.id === profile.current_rank_id);
return { token, profile, currentRank };
const [profile, progress] = await Promise.all([
apiFetch<ProfileResponse>('/api/me', { authToken: token }),
apiFetch<ProgressResponse>('/api/progress', { authToken: token })
]);
return { token, profile, progress };
}
export default async function DashboardPage() {
const { token, profile, currentRank } = await fetchProfile();
const { token, profile, progress } = await fetchProfile();
return (
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
<div>
<ProgressOverview
fullName={profile.full_name}
xp={profile.xp}
mana={profile.mana}
rank={currentRank}
competencies={profile.competencies}
artifacts={profile.artifacts}
progress={progress}
/>
</div>
<aside className="card">
<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 style={{ marginTop: '1rem' }}>Доступ к HR-панели: {token ? 'есть (демо-режим)' : 'нет'}</p>
<a className="primary" style={{ marginTop: '1rem', display: 'inline-block' }} href="/missions">

View File

@ -10,6 +10,8 @@ export interface MissionSummary {
mana_reward: number;
difficulty: string;
is_active: boolean;
is_available: boolean;
locked_reasons: string[];
}
const Card = styled.div`
@ -31,14 +33,24 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
<span className="badge">{mission.difficulty}</span>
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
<p style={{ marginTop: '1rem' }}>
{mission.xp_reward} XP · {mission.mana_reward}
</p>
<a className="primary" style={{ display: 'inline-block', marginTop: '1rem' }} href={`/missions/${mission.id}`}>
Открыть брифинг
</a>
</Card>
))}
</div>
<p style={{ marginTop: '1rem' }}>{mission.xp_reward} XP · {mission.mana_reward} </p>
{!mission.is_available && mission.locked_reasons.length > 0 && (
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>
)}
<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>
</Card>
))}
</div>
);
}

View File

@ -6,9 +6,10 @@ import { apiFetch } from '../lib/api';
interface MissionSubmissionFormProps {
missionId: number;
token?: string;
locked?: boolean;
}
export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFormProps) {
export function MissionSubmissionForm({ missionId, token, locked = false }: MissionSubmissionFormProps) {
const [comment, setComment] = useState('');
const [proofUrl, setProofUrl] = useState('');
const [status, setStatus] = useState<string | null>(null);
@ -21,6 +22,11 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
return;
}
if (locked) {
setStatus('Миссия пока недоступна — выполните предыдущие условия.');
return;
}
try {
setLoading(true);
setStatus(null);
@ -52,6 +58,7 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
rows={4}
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
placeholder="Опишите, что сделали."
disabled={locked}
/>
</label>
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
@ -62,10 +69,11 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
onChange={(event) => setProofUrl(event.target.value)}
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
placeholder="https://..."
disabled={locked}
/>
</label>
<button className="primary" type="submit" disabled={loading}>
{loading ? 'Отправляем...' : 'Отправить HR'}
<button className="primary" type="submit" disabled={loading || locked}>
{locked ? 'Недоступно' : loading ? 'Отправляем...' : 'Отправить HR'}
</button>
{status && <p style={{ marginTop: '1rem', color: 'var(--accent-light)' }}>{status}</p>}
</form>

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

View File

@ -2,11 +2,7 @@
import styled from 'styled-components';
type Rank = {
id: number | null;
title?: string;
};
// Компетенции и артефакты из профиля пользователя.
type Competency = {
competency: {
id: number;
@ -21,13 +17,35 @@ type Artifact = {
rarity: string;
};
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
export interface ProfileProps {
fullName: string;
xp: number;
mana: number;
rank?: Rank;
competencies: Competency[];
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`
@ -35,6 +53,8 @@ const Card = styled.div`
border-radius: 16px;
padding: 1.5rem;
border: 1px solid rgba(108, 92, 231, 0.4);
display: grid;
gap: 1.5rem;
`;
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 xpPercent = Math.min(100, (xp / 500) * 100);
const RequirementRow = styled.div<{ $completed?: boolean }>`
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 (
<Card>
<h2 style={{ marginTop: 0 }}>{fullName}</h2>
<p style={{ color: 'var(--text-muted)' }}>Текущий ранг: {rank?.title ?? 'не назначен'}</p>
<div style={{ marginTop: '1rem' }}>
<strong>Опыт:</strong>
<header>
<h2 style={{ margin: 0 }}>{fullName}</h2>
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
Текущий ранг: {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} />
<small style={{ color: 'var(--text-muted)' }}>{xp} / 500 XP до следующей цели</small>
</div>
<div style={{ marginTop: '1rem' }}>
<strong>Мана:</strong>
<p style={{ margin: '0.5rem 0' }}>{mana} </p>
</div>
<div style={{ marginTop: '1.5rem' }}>
<strong>Компетенции</strong>
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
{competencies.map((item) => (
<li key={item.competency.id} style={{ marginBottom: '0.25rem' }}>
<span className="badge">{item.competency.name}</span> уровень {item.level}
</li>
))}
</ul>
</div>
<div style={{ marginTop: '1.5rem' }}>
<strong>Артефакты</strong>
</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>
<SectionTitle>Компетенции ранга</SectionTitle>
<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>
<InlineBadge $kind={competency.is_met ? 'success' : 'warning'}>
{competency.is_met ? 'готово' : `нужно +${delta}`}
</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) => (
<li key={item.competency.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span className="badge">{item.competency.name}</span>
<span>уровень {item.level}</span>
</li>
))}
{competencies.length === 0 && <li style={{ color: 'var(--text-muted)' }}>Компетенции ещё не открыты.</li>}
</ul>
</div>
</section>
<section>
<SectionTitle>Артефакты</SectionTitle>
<div className="grid" style={{ marginTop: '0.75rem' }}>
{artifacts.length === 0 && <p>Ещё нет трофеев выполните миссии!</p>}
{artifacts.length === 0 && <p>Выполните миссии, чтобы собрать коллекцию трофеев.</p>}
{artifacts.map((artifact) => (
<div key={artifact.id} className="card">
<div key={artifact.id} className="card" style={{ margin: 0 }}>
<span className="badge">{artifact.rarity}</span>
<h4 style={{ marginBottom: '0.5rem' }}>{artifact.name}</h4>
</div>
))}
</div>
</div>
</section>
</Card>
);
}

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

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

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

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

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

View File

@ -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 {
authToken?: string;
@ -11,7 +12,8 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
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,
headers,
cache: 'no-store'
@ -21,6 +23,15 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
const text = await response.text();
throw new Error(`API error ${response.status}: ${text}`);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json() as Promise<T>;
}
const raw = await response.text();
return raw as unknown as T;
}

View File

@ -1,20 +1,36 @@
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) {
return cachedToken;
}
const email = process.env.NEXT_PUBLIC_DEMO_EMAIL ?? 'candidate@alabuga.space';
const password = process.env.NEXT_PUBLIC_DEMO_PASSWORD ?? 'orbita123';
const credentials = resolveCredentials(role);
const data = await apiFetch<{ access_token: string }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password })
body: JSON.stringify(credentials)
});
cachedToken = data.access_token;
return cachedToken;
tokenCache[role] = data.access_token;
return data.access_token;
}

View File

@ -6,6 +6,8 @@
--accent-light: #a29bfe;
--text: #f5f6fa;
--text-muted: #b2bec3;
--success: #00b894;
--error: #ff7675;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
@ -17,6 +19,18 @@ body {
var(--bg);
color: var(--text);
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 {
@ -30,6 +44,8 @@ a {
main {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.card {
@ -41,6 +57,40 @@ main {
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 {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@ -71,3 +121,13 @@ main {
opacity: 0.4;
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;
}

View File

@ -11,16 +11,18 @@ from sqlalchemy.orm import Session
ROOT = Path(__file__).resolve().parents[1]
sys.path.append(str(ROOT / 'backend'))
from app.core.config import settings
from app.core.security import get_password_hash
from app.db.session import SessionLocal, engine
from app.models.artifact import Artifact, ArtifactRarity
from app.models.branch import Branch, BranchMission
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
from app.models.onboarding import OnboardingSlide
from app.models.store import StoreItem
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:
@ -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()
DATA_SENTINEL.write_text("seeded")
print("Seed data created")