From 89734706b873b2fb0b4461a93191374bdddaf6da Mon Sep 17 00:00:00 2001 From: danilgryaznev Date: Wed, 24 Sep 2025 20:18:46 +0200 Subject: [PATCH] Add creat missiom --- README.md | 8 +- backend/app/api/routes/admin.py | 497 +++++++++++++++--- backend/app/api/routes/missions.py | 36 +- backend/app/schemas/artifact.py | 19 + backend/app/schemas/branch.py | 14 + backend/app/schemas/mission.py | 17 + backend/app/schemas/rank.py | 35 +- frontend/src/app/admin/page.tsx | 118 ++++- .../components/admin/AdminBranchManager.tsx | 143 +++++ .../components/admin/AdminMissionManager.tsx | 411 +++++++++++++++ .../src/components/admin/AdminRankManager.tsx | 279 ++++++++++ 11 files changed, 1487 insertions(+), 90 deletions(-) create mode 100644 backend/app/schemas/artifact.py create mode 100644 frontend/src/components/admin/AdminBranchManager.tsx create mode 100644 frontend/src/components/admin/AdminMissionManager.tsx create mode 100644 frontend/src/components/admin/AdminRankManager.tsx diff --git a/README.md b/README.md index 8718b25..35afe15 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,13 @@ pytest - Начисление опыта, маны и повышение ранга по трём условиям из ТЗ. - Журнал событий, экспортируемый через API. - Магазин артефактов с оформлением заказа. -- Админ-панель для HR: создание миссий, очередь модерации, список рангов. +- Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам. + +Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно: + +- создавать и редактировать ветки миссий с категориями и порядком; +- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями; +- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций. ## План развития diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py index bc68638..bb01e4a 100644 --- a/backend/app/api/routes/admin.py +++ b/backend/app/api/routes/admin.py @@ -3,74 +3,47 @@ from __future__ import annotations 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.db.session import get_db -from app.models.branch import BranchMission -from app.models.mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission, SubmissionStatus -from app.models.rank import Rank -from app.schemas.mission import MissionBase, MissionCreate, MissionDetail, MissionSubmissionRead -from app.schemas.rank import RankBase +from app.models.artifact import Artifact +from app.models.branch import Branch, BranchMission +from app.models.mission import ( + Mission, + MissionCompetencyReward, + MissionPrerequisite, + MissionSubmission, + SubmissionStatus, +) +from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement +from app.models.user import Competency +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 router = APIRouter(prefix="/api/admin", tags=["admin"]) -@router.get("/missions", response_model=list[MissionBase], summary="Миссии (HR)") -def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[MissionBase]: - """Список всех миссий для HR.""" - - missions = db.query(Mission).order_by(Mission.title).all() - return [MissionBase.model_validate(mission) for mission in missions] - - -@router.post("/missions", response_model=MissionDetail, summary="Создать миссию") -def create_mission_endpoint( - mission_in: MissionCreate, - *, - db: Session = Depends(get_db), - current_user=Depends(require_hr), -) -> MissionDetail: - """Создаём новую миссию.""" - - mission = Mission( - title=mission_in.title, - description=mission_in.description, - xp_reward=mission_in.xp_reward, - mana_reward=mission_in.mana_reward, - difficulty=mission_in.difficulty, - minimum_rank_id=mission_in.minimum_rank_id, - artifact_id=mission_in.artifact_id, - ) - db.add(mission) - db.flush() - - for reward in mission_in.competency_rewards: - db.add( - MissionCompetencyReward( - mission_id=mission.id, - competency_id=reward.competency_id, - level_delta=reward.level_delta, - ) - ) - - for prerequisite_id in mission_in.prerequisite_ids: - db.add( - MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id) - ) - - if mission_in.branch_id: - db.add( - BranchMission( - branch_id=mission_in.branch_id, - mission_id=mission.id, - order=mission_in.branch_order, - ) - ) - - db.commit() - db.refresh(mission) +def _mission_to_detail(mission: Mission) -> MissionDetail: + """Формируем детальную схему миссии.""" return MissionDetail( id=mission.id, @@ -96,6 +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="Список рангов") 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] +@router.get("/ranks/{rank_id}", response_model=RankDetailed, summary="Детали ранга") +def get_rank( + rank_id: int, + *, + db: Session = Depends(get_db), + current_user=Depends(require_hr), +) -> RankDetailed: + """Возвращаем подробную информацию о ранге.""" + + try: + rank = _load_rank(db, rank_id) + except NoResultFound as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ранг не найден") from exc + return _rank_to_detailed(rank) + + +@router.post("/ranks", response_model=RankDetailed, summary="Создать ранг") +def create_rank( + rank_in: RankCreate, + *, + db: Session = Depends(get_db), + current_user=Depends(require_hr), +) -> RankDetailed: + """Создаём новый ранг с требованиями.""" + + rank = Rank(title=rank_in.title, description=rank_in.description, required_xp=rank_in.required_xp) + db.add(rank) + db.flush() + + for mission_id in rank_in.mission_ids: + rank.mission_requirements.append( + RankMissionRequirement(rank_id=rank.id, mission_id=mission_id) + ) + + for item in rank_in.competency_requirements: + rank.competency_requirements.append( + RankCompetencyRequirement( + rank_id=rank.id, + competency_id=item.competency_id, + required_level=item.required_level, + ) + ) + + db.commit() + + rank = _load_rank(db, rank.id) + return _rank_to_detailed(rank) + + +@router.put("/ranks/{rank_id}", response_model=RankDetailed, summary="Обновить ранг") +def update_rank( + rank_id: int, + rank_in: RankUpdate, + *, + db: Session = Depends(get_db), + current_user=Depends(require_hr), +) -> RankDetailed: + """Редактируем параметры ранга.""" + + rank = ( + db.query(Rank) + .options( + selectinload(Rank.mission_requirements), + selectinload(Rank.competency_requirements), + ) + .filter(Rank.id == rank_id) + .first() + ) + if not rank: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ранг не найден") + + rank.title = rank_in.title + rank.description = rank_in.description + rank.required_xp = rank_in.required_xp + + rank.mission_requirements.clear() + for mission_id in rank_in.mission_ids: + rank.mission_requirements.append( + RankMissionRequirement(rank_id=rank.id, mission_id=mission_id) + ) + + rank.competency_requirements.clear() + for item in rank_in.competency_requirements: + rank.competency_requirements.append( + RankCompetencyRequirement( + rank_id=rank.id, + competency_id=item.competency_id, + required_level=item.required_level, + ) + ) + + db.commit() + + rank = _load_rank(db, rank.id) + return _rank_to_detailed(rank) + + @router.get( "/submissions", response_model=list[MissionSubmissionRead], diff --git a/backend/app/api/routes/missions.py b/backend/app/api/routes/missions.py index 0a95930..c3cf0e2 100644 --- a/backend/app/api/routes/missions.py +++ b/backend/app/api/routes/missions.py @@ -3,12 +3,14 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from app.api.deps import get_current_user from app.db.session import get_db +from app.models.branch import Branch, BranchMission from app.models.mission import Mission, MissionSubmission from app.models.user import User +from app.schemas.branch import BranchMissionRead, BranchRead from app.schemas.mission import ( MissionBase, MissionDetail, @@ -20,6 +22,38 @@ from app.services.mission import submit_mission 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="Список миссий") def list_missions( *, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) diff --git a/backend/app/schemas/artifact.py b/backend/app/schemas/artifact.py new file mode 100644 index 0000000..b955bd7 --- /dev/null +++ b/backend/app/schemas/artifact.py @@ -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 diff --git a/backend/app/schemas/branch.py b/backend/app/schemas/branch.py index 8c79f91..3ac776d 100644 --- a/backend/app/schemas/branch.py +++ b/backend/app/schemas/branch.py @@ -24,3 +24,17 @@ class BranchRead(BaseModel): class Config: from_attributes = True + + +class BranchCreate(BaseModel): + """Создание ветки.""" + + title: str + description: str + category: str + + +class BranchUpdate(BranchCreate): + """Обновление ветки.""" + + pass diff --git a/backend/app/schemas/mission.py b/backend/app/schemas/mission.py index 0aad92e..4aa2e00 100644 --- a/backend/app/schemas/mission.py +++ b/backend/app/schemas/mission.py @@ -68,6 +68,23 @@ class MissionCreate(BaseModel): branch_order: int = 1 +class MissionUpdate(BaseModel): + """Схема обновления миссии.""" + + title: Optional[str] = None + description: Optional[str] = None + xp_reward: Optional[int] = None + mana_reward: Optional[int] = None + difficulty: Optional[MissionDifficulty] = None + minimum_rank_id: Optional[int | None] = None + artifact_id: Optional[int | None] = None + prerequisite_ids: Optional[list[int]] = None + competency_rewards: Optional[list[MissionCreateReward]] = None + branch_id: Optional[int | None] = None + branch_order: Optional[int] = None + is_active: Optional[bool] = None + + class MissionSubmissionCreate(BaseModel): """Отправка отчёта по миссии.""" diff --git a/backend/app/schemas/rank.py b/backend/app/schemas/rank.py index 58d0e7b..d5e385f 100644 --- a/backend/app/schemas/rank.py +++ b/backend/app/schemas/rank.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel +from pydantic import BaseModel, Field class RankBase(BaseModel): @@ -41,3 +41,36 @@ class RankDetailed(RankBase): competency_requirements: list[RankRequirementCompetency] created_at: datetime updated_at: datetime + + +class RankRequirementMissionInput(BaseModel): + """Входная схема обязательной миссии.""" + + mission_id: int + + +class RankRequirementCompetencyInput(BaseModel): + """Входная схема требования к компетенции.""" + + competency_id: int + required_level: int = Field(ge=0) + + +class RankCreate(BaseModel): + """Создание нового ранга.""" + + title: str + description: str + required_xp: int = Field(ge=0) + mission_ids: list[int] = [] + competency_requirements: list[RankRequirementCompetencyInput] = [] + + +class RankUpdate(BaseModel): + """Обновление существующего ранга.""" + + title: str + description: str + required_xp: int = Field(ge=0) + mission_ids: list[int] = [] + competency_requirements: list[RankRequirementCompetencyInput] = [] diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 1ce86c6..ee41ccc 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -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 { getDemoToken } from '../../lib/demo-auth'; @@ -11,42 +14,103 @@ interface Submission { updated_at: string; } -async function fetchModerationQueue() { - const token = await getDemoToken('hr'); - const submissions = await apiFetch('/api/admin/submissions', { authToken: token }); - return submissions; +interface MissionSummary { + id: number; + title: string; + 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() { - const submissions = await fetchModerationQueue(); + const token = await getDemoToken('hr'); + + const [submissions, missions, branches, ranks, competencies, artifacts] = await Promise.all([ + apiFetch('/api/admin/submissions', { authToken: token }), + apiFetch('/api/admin/missions', { authToken: token }), + apiFetch('/api/admin/branches', { authToken: token }), + apiFetch('/api/admin/ranks', { authToken: token }), + apiFetch('/api/admin/competencies', { authToken: token }), + apiFetch('/api/admin/artifacts', { authToken: token }) + ]); return (
-

HR-панель: очередь модерации

+

HR-панель

- Демонстрационная выборка отправленных миссий. В реальном приложении добавим карточки с деталями пилота и - кнопки approve/reject непосредственно из UI. + Управляйте миссиями, ветками и рангами, а также следите за очередью модерации отчётов.

-
- {submissions.map((submission) => ( -
-

Миссия #{submission.mission_id}

-

Статус: {submission.status}

- {submission.comment &&

Комментарий пилота: {submission.comment}

} - {submission.proof_url && ( -

- Доказательство:{' '} - - открыть - -

- )} - - Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')} - + +
+
+

Очередь модерации

+

+ Список последующих отправок миссий. Для полноты UX можно добавить действия approve/reject прямо отсюда. +

+
+ {submissions.map((submission) => ( +
+

Миссия #{submission.mission_id}

+

Статус: {submission.status}

+ {submission.comment &&

Комментарий пилота: {submission.comment}

} + {submission.proof_url && ( +

+ Доказательство:{' '} + + открыть + +

+ )} + + Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')} + +
+ ))} + {submissions.length === 0 &&

Очередь пуста — все миссии проверены.

}
- ))} - {submissions.length === 0 &&

Очередь пуста — все миссии проверены.

} +
+ + + +
); diff --git a/frontend/src/components/admin/AdminBranchManager.tsx b/frontend/src/components/admin/AdminBranchManager.tsx new file mode 100644 index 0000000..1ab8afd --- /dev/null +++ b/frontend/src/components/admin/AdminBranchManager.tsx @@ -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('new'); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [category, setCategory] = useState('quest'); + const [status, setStatus] = useState(null); + const [error, setError] = useState(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) => { + 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 ( +
+

Управление ветками

+

+ Создавайте или обновляйте ветки, чтобы миссии были организованы по сюжетам и категориям. +

+
+ + + + +