Add creat missiom

This commit is contained in:
danilgryaznev 2025-09-24 20:18:46 +02:00
parent ac7ead09c9
commit 89734706b8
11 changed files with 1487 additions and 90 deletions

View File

@ -83,7 +83,13 @@ pytest
- Начисление опыта, маны и повышение ранга по трём условиям из ТЗ. - Начисление опыта, маны и повышение ранга по трём условиям из ТЗ.
- Журнал событий, экспортируемый через API. - Журнал событий, экспортируемый через API.
- Магазин артефактов с оформлением заказа. - Магазин артефактов с оформлением заказа.
- Админ-панель для HR: создание миссий, очередь модерации, список рангов. - Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам.
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
- создавать и редактировать ветки миссий с категориями и порядком;
- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями;
- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций.
## План развития ## План развития

View File

@ -3,74 +3,47 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import Session, selectinload
from app.api.deps import require_hr from app.api.deps import require_hr
from app.db.session import get_db from app.db.session import get_db
from app.models.branch import BranchMission from app.models.artifact import Artifact
from app.models.mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission, SubmissionStatus from app.models.branch import Branch, BranchMission
from app.models.rank import Rank from app.models.mission import (
from app.schemas.mission import MissionBase, MissionCreate, MissionDetail, MissionSubmissionRead Mission,
from app.schemas.rank import RankBase MissionCompetencyReward,
MissionPrerequisite,
MissionSubmission,
SubmissionStatus,
)
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
from app.models.user import Competency
from app.schemas.artifact import ArtifactRead
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
from app.schemas.mission import (
MissionBase,
MissionCreate,
MissionDetail,
MissionSubmissionRead,
MissionUpdate,
)
from app.schemas.rank import (
RankBase,
RankCreate,
RankDetailed,
RankRequirementCompetency,
RankRequirementMission,
RankUpdate,
)
from app.schemas.user import CompetencyBase
from app.services.mission import approve_submission, reject_submission from app.services.mission import approve_submission, reject_submission
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.get("/missions", response_model=list[MissionBase], summary="Миссии (HR)") def _mission_to_detail(mission: Mission) -> MissionDetail:
def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[MissionBase]: """Формируем детальную схему миссии."""
"""Список всех миссий для HR."""
missions = db.query(Mission).order_by(Mission.title).all()
return [MissionBase.model_validate(mission) for mission in missions]
@router.post("/missions", response_model=MissionDetail, summary="Создать миссию")
def create_mission_endpoint(
mission_in: MissionCreate,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> MissionDetail:
"""Создаём новую миссию."""
mission = Mission(
title=mission_in.title,
description=mission_in.description,
xp_reward=mission_in.xp_reward,
mana_reward=mission_in.mana_reward,
difficulty=mission_in.difficulty,
minimum_rank_id=mission_in.minimum_rank_id,
artifact_id=mission_in.artifact_id,
)
db.add(mission)
db.flush()
for reward in mission_in.competency_rewards:
db.add(
MissionCompetencyReward(
mission_id=mission.id,
competency_id=reward.competency_id,
level_delta=reward.level_delta,
)
)
for prerequisite_id in mission_in.prerequisite_ids:
db.add(
MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id)
)
if mission_in.branch_id:
db.add(
BranchMission(
branch_id=mission_in.branch_id,
mission_id=mission.id,
order=mission_in.branch_order,
)
)
db.commit()
db.refresh(mission)
return MissionDetail( return MissionDetail(
id=mission.id, id=mission.id,
@ -96,6 +69,313 @@ 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,
)
for item in missions
],
)
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.post("/missions", response_model=MissionDetail, summary="Создать миссию")
def create_mission_endpoint(
mission_in: MissionCreate,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> MissionDetail:
"""Создаём новую миссию."""
mission = Mission(
title=mission_in.title,
description=mission_in.description,
xp_reward=mission_in.xp_reward,
mana_reward=mission_in.mana_reward,
difficulty=mission_in.difficulty,
minimum_rank_id=mission_in.minimum_rank_id,
artifact_id=mission_in.artifact_id,
)
db.add(mission)
db.flush()
for reward in mission_in.competency_rewards:
mission.competency_rewards.append(
MissionCompetencyReward(
mission_id=mission.id,
competency_id=reward.competency_id,
level_delta=reward.level_delta,
)
)
for prerequisite_id in mission_in.prerequisite_ids:
mission.prerequisites.append(
MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id)
)
if mission_in.branch_id:
mission.branches.append(
BranchMission(
branch_id=mission_in.branch_id,
mission_id=mission.id,
order=mission_in.branch_order,
)
)
db.commit()
mission = _load_mission(db, mission.id)
return _mission_to_detail(mission)
@router.put("/missions/{mission_id}", response_model=MissionDetail, summary="Обновить миссию")
def update_mission_endpoint(
mission_id: int,
mission_in: MissionUpdate,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> MissionDetail:
"""Редактируем миссию."""
mission = (
db.query(Mission)
.options(
selectinload(Mission.prerequisites),
selectinload(Mission.competency_rewards),
selectinload(Mission.branches),
)
.filter(Mission.id == mission_id)
.first()
)
if not mission:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
payload = mission_in.model_dump(exclude_unset=True)
for attr in ["title", "description", "xp_reward", "mana_reward", "difficulty", "is_active"]:
if attr in payload:
setattr(mission, attr, payload[attr])
if "minimum_rank_id" in payload:
mission.minimum_rank_id = payload["minimum_rank_id"]
if "artifact_id" in payload:
mission.artifact_id = payload["artifact_id"]
if "competency_rewards" in payload:
mission.competency_rewards.clear()
for reward in payload["competency_rewards"]:
mission.competency_rewards.append(
MissionCompetencyReward(
mission_id=mission.id,
competency_id=reward.competency_id,
level_delta=reward.level_delta,
)
)
if "prerequisite_ids" in payload:
mission.prerequisites.clear()
for prerequisite_id in payload["prerequisite_ids"]:
mission.prerequisites.append(
MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id)
)
if "branch_id" in payload:
mission.branches.clear()
branch_id = payload["branch_id"]
if branch_id is not None:
order = payload.get("branch_order", 1)
mission.branches.append(
BranchMission(branch_id=branch_id, mission_id=mission.id, order=order)
)
elif "branch_order" in payload and mission.branches:
mission.branches[0].order = payload["branch_order"]
db.commit()
mission = _load_mission(db, mission.id)
return _mission_to_detail(mission)
@router.get("/ranks", response_model=list[RankBase], summary="Список рангов") @router.get("/ranks", response_model=list[RankBase], summary="Список рангов")
def admin_ranks(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[RankBase]: def admin_ranks(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[RankBase]:
"""Перечень рангов.""" """Перечень рангов."""
@ -104,6 +384,103 @@ def admin_ranks(*, db: Session = Depends(get_db), current_user=Depends(require_h
return [RankBase.model_validate(rank) for rank in ranks] return [RankBase.model_validate(rank) for rank in ranks]
@router.get("/ranks/{rank_id}", response_model=RankDetailed, summary="Детали ранга")
def get_rank(
rank_id: int,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> RankDetailed:
"""Возвращаем подробную информацию о ранге."""
try:
rank = _load_rank(db, rank_id)
except NoResultFound as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ранг не найден") from exc
return _rank_to_detailed(rank)
@router.post("/ranks", response_model=RankDetailed, summary="Создать ранг")
def create_rank(
rank_in: RankCreate,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> RankDetailed:
"""Создаём новый ранг с требованиями."""
rank = Rank(title=rank_in.title, description=rank_in.description, required_xp=rank_in.required_xp)
db.add(rank)
db.flush()
for mission_id in rank_in.mission_ids:
rank.mission_requirements.append(
RankMissionRequirement(rank_id=rank.id, mission_id=mission_id)
)
for item in rank_in.competency_requirements:
rank.competency_requirements.append(
RankCompetencyRequirement(
rank_id=rank.id,
competency_id=item.competency_id,
required_level=item.required_level,
)
)
db.commit()
rank = _load_rank(db, rank.id)
return _rank_to_detailed(rank)
@router.put("/ranks/{rank_id}", response_model=RankDetailed, summary="Обновить ранг")
def update_rank(
rank_id: int,
rank_in: RankUpdate,
*,
db: Session = Depends(get_db),
current_user=Depends(require_hr),
) -> RankDetailed:
"""Редактируем параметры ранга."""
rank = (
db.query(Rank)
.options(
selectinload(Rank.mission_requirements),
selectinload(Rank.competency_requirements),
)
.filter(Rank.id == rank_id)
.first()
)
if not rank:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ранг не найден")
rank.title = rank_in.title
rank.description = rank_in.description
rank.required_xp = rank_in.required_xp
rank.mission_requirements.clear()
for mission_id in rank_in.mission_ids:
rank.mission_requirements.append(
RankMissionRequirement(rank_id=rank.id, mission_id=mission_id)
)
rank.competency_requirements.clear()
for item in rank_in.competency_requirements:
rank.competency_requirements.append(
RankCompetencyRequirement(
rank_id=rank.id,
competency_id=item.competency_id,
required_level=item.required_level,
)
)
db.commit()
rank = _load_rank(db, rank.id)
return _rank_to_detailed(rank)
@router.get( @router.get(
"/submissions", "/submissions",
response_model=list[MissionSubmissionRead], response_model=list[MissionSubmissionRead],

View File

@ -3,12 +3,14 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, selectinload
from app.api.deps import get_current_user from app.api.deps import get_current_user
from app.db.session import get_db from app.db.session import get_db
from app.models.branch import Branch, BranchMission
from app.models.mission import Mission, MissionSubmission from app.models.mission import Mission, MissionSubmission
from app.models.user import User from app.models.user import User
from app.schemas.branch import BranchMissionRead, BranchRead
from app.schemas.mission import ( from app.schemas.mission import (
MissionBase, MissionBase,
MissionDetail, MissionDetail,
@ -20,6 +22,38 @@ from app.services.mission import submit_mission
router = APIRouter(prefix="/api/missions", tags=["missions"]) router = APIRouter(prefix="/api/missions", tags=["missions"])
@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]:
"""Возвращаем ветки с упорядоченными миссиями."""
branches = (
db.query(Branch)
.options(selectinload(Branch.missions).selectinload(BranchMission.mission))
.order_by(Branch.title)
.all()
)
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,
)
for item in sorted(branch.missions, key=lambda link: link.order)
],
)
for branch in branches
]
@router.get("/", response_model=list[MissionBase], summary="Список миссий") @router.get("/", response_model=list[MissionBase], summary="Список миссий")
def list_missions( def list_missions(
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) *, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)

View File

@ -0,0 +1,19 @@
"""Схемы артефактов."""
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
class Config:
from_attributes = True

View File

@ -24,3 +24,17 @@ class BranchRead(BaseModel):
class Config: class Config:
from_attributes = True from_attributes = True
class BranchCreate(BaseModel):
"""Создание ветки."""
title: str
description: str
category: str
class BranchUpdate(BranchCreate):
"""Обновление ветки."""
pass

View File

@ -68,6 +68,23 @@ class MissionCreate(BaseModel):
branch_order: int = 1 branch_order: int = 1
class MissionUpdate(BaseModel):
"""Схема обновления миссии."""
title: Optional[str] = None
description: Optional[str] = None
xp_reward: Optional[int] = None
mana_reward: Optional[int] = None
difficulty: Optional[MissionDifficulty] = None
minimum_rank_id: Optional[int | None] = None
artifact_id: Optional[int | None] = None
prerequisite_ids: Optional[list[int]] = None
competency_rewards: Optional[list[MissionCreateReward]] = None
branch_id: Optional[int | None] = None
branch_order: Optional[int] = None
is_active: Optional[bool] = None
class MissionSubmissionCreate(BaseModel): class MissionSubmissionCreate(BaseModel):
"""Отправка отчёта по миссии.""" """Отправка отчёта по миссии."""

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from datetime import datetime from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel, Field
class RankBase(BaseModel): class RankBase(BaseModel):
@ -41,3 +41,36 @@ class RankDetailed(RankBase):
competency_requirements: list[RankRequirementCompetency] competency_requirements: list[RankRequirementCompetency]
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
class RankRequirementMissionInput(BaseModel):
"""Входная схема обязательной миссии."""
mission_id: int
class RankRequirementCompetencyInput(BaseModel):
"""Входная схема требования к компетенции."""
competency_id: int
required_level: int = Field(ge=0)
class RankCreate(BaseModel):
"""Создание нового ранга."""
title: str
description: str
required_xp: int = Field(ge=0)
mission_ids: list[int] = []
competency_requirements: list[RankRequirementCompetencyInput] = []
class RankUpdate(BaseModel):
"""Обновление существующего ранга."""
title: str
description: str
required_xp: int = Field(ge=0)
mission_ids: list[int] = []
competency_requirements: list[RankRequirementCompetencyInput] = []

View File

@ -1,3 +1,6 @@
import { AdminBranchManager } from '../../components/admin/AdminBranchManager';
import { AdminMissionManager } from '../../components/admin/AdminMissionManager';
import { AdminRankManager } from '../../components/admin/AdminRankManager';
import { apiFetch } from '../../lib/api'; import { apiFetch } from '../../lib/api';
import { getDemoToken } from '../../lib/demo-auth'; import { getDemoToken } from '../../lib/demo-auth';
@ -11,42 +14,103 @@ interface Submission {
updated_at: string; updated_at: string;
} }
async function fetchModerationQueue() { interface MissionSummary {
const token = await getDemoToken('hr'); id: number;
const submissions = await apiFetch<Submission[]>('/api/admin/submissions', { authToken: token }); title: string;
return submissions; description: string;
xp_reward: number;
mana_reward: number;
difficulty: string;
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;
} }
export default async function AdminPage() { export default async function AdminPage() {
const submissions = await fetchModerationQueue(); const token = await getDemoToken('hr');
const [submissions, missions, branches, ranks, competencies, artifacts] = 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 })
]);
return ( return (
<section> <section>
<h2>HR-панель: очередь модерации</h2> <h2>HR-панель</h2>
<p style={{ color: 'var(--text-muted)' }}> <p style={{ color: 'var(--text-muted)' }}>
Демонстрационная выборка отправленных миссий. В реальном приложении добавим карточки с деталями пилота и Управляйте миссиями, ветками и рангами, а также следите за очередью модерации отчётов.
кнопки approve/reject непосредственно из UI.
</p> </p>
<div className="grid">
{submissions.map((submission) => ( <div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
<div key={submission.mission_id} className="card"> <div className="card" style={{ gridColumn: '1 / -1' }}>
<h3>Миссия #{submission.mission_id}</h3> <h3>Очередь модерации</h3>
<p>Статус: {submission.status}</p> <p style={{ color: 'var(--text-muted)' }}>
{submission.comment && <p>Комментарий пилота: {submission.comment}</p>} Список последующих отправок миссий. Для полноты UX можно добавить действия approve/reject прямо отсюда.
{submission.proof_url && ( </p>
<p> <div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
Доказательство:{' '} {submissions.map((submission) => (
<a href={submission.proof_url} target="_blank" rel="noreferrer"> <div key={`${submission.mission_id}-${submission.updated_at}`} className="card">
открыть <h4>Миссия #{submission.mission_id}</h4>
</a> <p>Статус: {submission.status}</p>
</p> {submission.comment && <p>Комментарий пилота: {submission.comment}</p>}
)} {submission.proof_url && (
<small style={{ color: 'var(--text-muted)' }}> <p>
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')} Доказательство:{' '}
</small> <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>
))}
{submissions.length === 0 && <p>Очередь пуста все миссии проверены.</p>}
</div> </div>
))} </div>
{submissions.length === 0 && <p>Очередь пуста все миссии проверены.</p>}
<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} />
</div> </div>
</section> </section>
); );

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,411 @@
'use client';
import { FormEvent, 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 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);
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>
);
}