Add creat missiom
This commit is contained in:
parent
ac7ead09c9
commit
89734706b8
|
|
@ -83,7 +83,13 @@ pytest
|
||||||
- Начисление опыта, маны и повышение ранга по трём условиям из ТЗ.
|
- Начисление опыта, маны и повышение ранга по трём условиям из ТЗ.
|
||||||
- Журнал событий, экспортируемый через API.
|
- Журнал событий, экспортируемый через API.
|
||||||
- Магазин артефактов с оформлением заказа.
|
- Магазин артефактов с оформлением заказа.
|
||||||
- Админ-панель для HR: создание миссий, очередь модерации, список рангов.
|
- Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам.
|
||||||
|
|
||||||
|
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
|
||||||
|
|
||||||
|
- создавать и редактировать ветки миссий с категориями и порядком;
|
||||||
|
- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями;
|
||||||
|
- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций.
|
||||||
|
|
||||||
## План развития
|
## План развития
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
19
backend/app/schemas/artifact.py
Normal file
19
backend/app/schemas/artifact.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
"""Отправка отчёта по миссии."""
|
"""Отправка отчёта по миссии."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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] = []
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
143
frontend/src/components/admin/AdminBranchManager.tsx
Normal file
143
frontend/src/components/admin/AdminBranchManager.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useMemo, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
|
type Branch = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string;
|
||||||
|
branches: Branch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CATEGORY_OPTIONS = ['quest', 'recruiting', 'lecture', 'simulator'];
|
||||||
|
|
||||||
|
export function AdminBranchManager({ token, branches }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [category, setCategory] = useState('quest');
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const categoryOptions = useMemo(() => {
|
||||||
|
const existing = new Set(DEFAULT_CATEGORY_OPTIONS);
|
||||||
|
branches.forEach((branch) => existing.add(branch.category));
|
||||||
|
return Array.from(existing.values());
|
||||||
|
}, [branches]);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setTitle('');
|
||||||
|
setDescription('');
|
||||||
|
setCategory(categoryOptions[0] ?? 'quest');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
if (value === 'new') {
|
||||||
|
setSelectedId('new');
|
||||||
|
resetForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(value);
|
||||||
|
const branch = branches.find((item) => item.id === id);
|
||||||
|
if (!branch) {
|
||||||
|
setSelectedId('new');
|
||||||
|
resetForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedId(id);
|
||||||
|
setTitle(branch.title);
|
||||||
|
setDescription(branch.description);
|
||||||
|
setCategory(branch.category);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const payload = { title, description, category };
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedId === 'new') {
|
||||||
|
await apiFetch('/api/admin/branches', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Ветка создана');
|
||||||
|
} else {
|
||||||
|
await apiFetch(`/api/admin/branches/${selectedId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Ветка обновлена');
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
if (selectedId === 'new') {
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось сохранить ветку');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3>Управление ветками</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Создавайте или обновляйте ветки, чтобы миссии были организованы по сюжетам и категориям.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="admin-form">
|
||||||
|
<label>
|
||||||
|
Выбранная ветка
|
||||||
|
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
|
||||||
|
<option value="new">Новая ветка</option>
|
||||||
|
{branches.map((branch) => (
|
||||||
|
<option key={branch.id} value={branch.id}>
|
||||||
|
{branch.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Название
|
||||||
|
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Описание
|
||||||
|
<textarea value={description} onChange={(event) => setDescription(event.target.value)} required rows={3} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Категория
|
||||||
|
<select value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||||
|
{categoryOptions.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" className="primary">Сохранить</button>
|
||||||
|
|
||||||
|
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
|
||||||
|
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
411
frontend/src/components/admin/AdminMissionManager.tsx
Normal file
411
frontend/src/components/admin/AdminMissionManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
279
frontend/src/components/admin/AdminRankManager.tsx
Normal file
279
frontend/src/components/admin/AdminRankManager.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
|
type RankBase = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
required_xp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MissionOption = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompetencyOption = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
category: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface RankDetail extends RankBase {
|
||||||
|
mission_requirements: Array<{ mission_id: number; mission_title: string }>;
|
||||||
|
competency_requirements: Array<{ competency_id: number; competency_name: string; required_level: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string;
|
||||||
|
ranks: RankBase[];
|
||||||
|
missions: MissionOption[];
|
||||||
|
competencies: CompetencyOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompetencyRequirementInput = { competency_id: number | ''; required_level: number };
|
||||||
|
|
||||||
|
type RankFormState = {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
required_xp: number;
|
||||||
|
mission_ids: number[];
|
||||||
|
competency_requirements: CompetencyRequirementInput[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialRankForm: RankFormState = {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
required_xp: 0,
|
||||||
|
mission_ids: [],
|
||||||
|
competency_requirements: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminRankManager({ token, ranks, missions, competencies }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||||
|
const [form, setForm] = useState<RankFormState>(initialRankForm);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setForm(initialRankForm);
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRank = async (rankId: number) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const rank = await apiFetch<RankDetail>(`/api/admin/ranks/${rankId}`, { authToken: token });
|
||||||
|
setForm({
|
||||||
|
title: rank.title,
|
||||||
|
description: rank.description,
|
||||||
|
required_xp: rank.required_xp,
|
||||||
|
mission_ids: rank.mission_requirements.map((item) => item.mission_id),
|
||||||
|
competency_requirements: rank.competency_requirements.map((item) => ({
|
||||||
|
competency_id: item.competency_id,
|
||||||
|
required_level: item.required_level
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось загрузить ранг');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
if (value === 'new') {
|
||||||
|
setSelectedId('new');
|
||||||
|
resetForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(value);
|
||||||
|
setSelectedId(id);
|
||||||
|
void loadRank(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateField = <K extends keyof RankFormState>(field: K, value: RankFormState[K]) => {
|
||||||
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMissionSelect = (event: FormEvent<HTMLSelectElement>) => {
|
||||||
|
const options = Array.from(event.currentTarget.selectedOptions);
|
||||||
|
updateField(
|
||||||
|
'mission_ids',
|
||||||
|
options.map((option) => Number(option.value))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCompetencyRequirement = () => {
|
||||||
|
updateField('competency_requirements', [...form.competency_requirements, { competency_id: '', required_level: 1 }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCompetencyRequirement = (index: number, value: CompetencyRequirementInput) => {
|
||||||
|
const next = [...form.competency_requirements];
|
||||||
|
next[index] = value;
|
||||||
|
updateField('competency_requirements', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCompetencyRequirement = (index: number) => {
|
||||||
|
const next = [...form.competency_requirements];
|
||||||
|
next.splice(index, 1);
|
||||||
|
updateField('competency_requirements', next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: form.title,
|
||||||
|
description: form.description,
|
||||||
|
required_xp: Number(form.required_xp),
|
||||||
|
mission_ids: form.mission_ids,
|
||||||
|
competency_requirements: form.competency_requirements
|
||||||
|
.filter((item) => item.competency_id !== '')
|
||||||
|
.map((item) => ({
|
||||||
|
competency_id: Number(item.competency_id),
|
||||||
|
required_level: Number(item.required_level)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedId === 'new') {
|
||||||
|
await apiFetch('/api/admin/ranks', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Ранг создан');
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
await apiFetch(`/api/admin/ranks/${selectedId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Ранг обновлён');
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось сохранить ранг');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3>Ранги и условия повышения</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Управляйте требованиями к рангу: задавайте минимальный опыт, ключевые миссии и уровни компетенций.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="admin-form">
|
||||||
|
<label>
|
||||||
|
Выбранный ранг
|
||||||
|
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
|
||||||
|
<option value="new">Новый ранг</option>
|
||||||
|
{ranks.map((rank) => (
|
||||||
|
<option key={rank.id} value={rank.id}>
|
||||||
|
{rank.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Название
|
||||||
|
<input value={form.title} onChange={(event) => updateField('title', event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Описание
|
||||||
|
<textarea value={form.description} onChange={(event) => updateField('description', event.target.value)} required rows={3} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Требуемый опыт
|
||||||
|
<input type="number" min={0} value={form.required_xp} onChange={(event) => updateField('required_xp', Number(event.target.value))} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Ключевые миссии
|
||||||
|
<select multiple value={form.mission_ids.map(String)} onChange={handleMissionSelect} size={Math.min(6, missions.length)}>
|
||||||
|
{missions.map((mission) => (
|
||||||
|
<option key={mission.id} value={mission.id}>
|
||||||
|
{mission.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>Требования к компетенциям</span>
|
||||||
|
<button type="button" onClick={addCompetencyRequirement} className="secondary">
|
||||||
|
Добавить требование
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{form.competency_requirements.length === 0 && (
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}>
|
||||||
|
Пока нет требований. Добавьте компетенции, которые нужно прокачать до определённого уровня.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{form.competency_requirements.map((item, index) => (
|
||||||
|
<div key={index} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginTop: '0.5rem' }}>
|
||||||
|
<select
|
||||||
|
value={item.competency_id === '' ? '' : String(item.competency_id)}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateCompetencyRequirement(index, {
|
||||||
|
competency_id: event.target.value === '' ? '' : Number(event.target.value),
|
||||||
|
required_level: item.required_level
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Выберите компетенцию</option>
|
||||||
|
{competencies.map((competency) => (
|
||||||
|
<option key={competency.id} value={competency.id}>
|
||||||
|
{competency.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={item.required_level}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateCompetencyRequirement(index, {
|
||||||
|
competency_id: item.competency_id,
|
||||||
|
required_level: Number(event.target.value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
style={{ width: '80px' }}
|
||||||
|
/>
|
||||||
|
<button type="button" className="secondary" onClick={() => removeCompetencyRequirement(index)}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="primary" disabled={loading}>
|
||||||
|
{selectedId === 'new' ? 'Создать ранг' : 'Сохранить изменения'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
|
||||||
|
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user