From 1ab0cb1f1f15f6f9ba2b87526c3d6d06359400d8 Mon Sep 17 00:00:00 2001 From: danilgryaznev Date: Thu, 25 Sep 2025 04:55:43 +0200 Subject: [PATCH] Add futures --- README.md | 20 ++ .../versions/20240611_0002_onboarding.py | 56 +++++ backend/app/api/routes/__init__.py | 3 +- backend/app/api/routes/admin.py | 148 +++++++++++- backend/app/api/routes/journal.py | 52 +++- backend/app/api/routes/missions.py | 222 ++++++++++++++++-- backend/app/api/routes/onboarding.py | 60 +++++ backend/app/core/config.py | 5 +- backend/app/main.py | 3 +- backend/app/models/__init__.py | 3 + backend/app/models/journal.py | 3 +- backend/app/models/onboarding.py | 40 ++++ backend/app/models/store.py | 4 +- backend/app/models/user.py | 9 + backend/app/schemas/admin_stats.py | 31 +++ backend/app/schemas/artifact.py | 19 ++ backend/app/schemas/branch.py | 4 + backend/app/schemas/journal.py | 16 ++ backend/app/schemas/mission.py | 5 +- backend/app/schemas/onboarding.py | 44 ++++ backend/app/services/mission.py | 17 +- backend/app/services/onboarding.py | 61 +++++ backend/tests/test_mission_submission.py | 42 ++++ frontend/src/app/admin/page.tsx | 87 +++++-- frontend/src/app/journal/page.tsx | 58 ++++- frontend/src/app/layout.tsx | 22 +- frontend/src/app/missions/[id]/page.tsx | 14 +- frontend/src/app/missions/page.tsx | 66 +++++- frontend/src/app/onboarding/page.tsx | 50 ++++ frontend/src/app/page.tsx | 20 +- frontend/src/components/MissionList.tsx | 30 ++- .../src/components/MissionSubmissionForm.tsx | 14 +- .../src/components/OnboardingCarousel.tsx | 123 ++++++++++ frontend/src/components/ProgressOverview.tsx | 25 +- .../components/admin/AdminArtifactManager.tsx | 192 +++++++++++++++ .../components/admin/AdminSubmissionCard.tsx | 82 +++++++ frontend/src/lib/api.ts | 11 +- frontend/src/styles/globals.css | 46 ++++ scripts/seed_data.py | 27 +++ 39 files changed, 1643 insertions(+), 91 deletions(-) create mode 100644 backend/alembic/versions/20240611_0002_onboarding.py create mode 100644 backend/app/api/routes/onboarding.py create mode 100644 backend/app/models/onboarding.py create mode 100644 backend/app/schemas/admin_stats.py create mode 100644 backend/app/schemas/onboarding.py create mode 100644 backend/app/services/onboarding.py create mode 100644 frontend/src/app/onboarding/page.tsx create mode 100644 frontend/src/components/OnboardingCarousel.tsx create mode 100644 frontend/src/components/admin/AdminArtifactManager.tsx create mode 100644 frontend/src/components/admin/AdminSubmissionCard.tsx diff --git a/README.md b/README.md index 35afe15..0855de7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,14 @@ - `docs/` — документация, дополнительный лор. - `docker-compose.yaml` — инфраструктура проекта. +## Основные пользовательские сценарии + +- Как кандидат: хочу проходить онбординг с лором и понимать своё место в программе. +- Как кандидат: хочу видеть прогресс-бар до следующего ранга и инструкции по ключевым веткам. +- Как кандидат: хочу получать награды (опыт, ману, артефакты) и видеть их в журнале. +- Как HR: хочу управлять миссиями, ветками, артефактами и требованиями рангов. +- Как HR: хочу видеть оперативную аналитику по активности и очереди модерации. + ## Быстрый старт в Docker 1. Установите Docker и Docker Compose. @@ -68,6 +76,14 @@ npm run dev | Пилот | `candidate@alabuga.space` | `orbita123` | | HR | `hr@alabuga.space` | `orbita123` | +## Проверка функционала + +1. **Онбординг и лор**: перейдите в `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохранится и откроет доступ к веткам миссий. +2. **Кандидат**: авторизуйтесь под пилотом, изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий. +3. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически. +4. **HR панель**: под пользователем `hr@alabuga.space` проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`). +5. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR. + ## Тестирование ```bash @@ -84,12 +100,16 @@ pytest - Журнал событий, экспортируемый через API. - Магазин артефактов с оформлением заказа. - Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам. +- Онбординг с сохранением прогресса и космическим лором. +- Таблица лидеров по опыту и мане за неделю/месяц/год. +- Аналитическая сводка для HR: активность пилотов, очередь модерации, завершённость веток. Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно: - создавать и редактировать ветки миссий с категориями и порядком; - подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями; - настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций. +- управлять каталогом артефактов и назначать их миссиям. ## План развития diff --git a/backend/alembic/versions/20240611_0002_onboarding.py b/backend/alembic/versions/20240611_0002_onboarding.py new file mode 100644 index 0000000..2e1320d --- /dev/null +++ b/backend/alembic/versions/20240611_0002_onboarding.py @@ -0,0 +1,56 @@ +"""Onboarding models""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + +revision = "20240611_0002" +down_revision = "20240609_0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "onboarding_slides", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("order", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=160), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("media_url", sa.String(length=512)), + sa.Column("cta_text", sa.String(length=120)), + sa.Column("cta_link", sa.String(length=512)), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + server_onupdate=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("order", name="uq_onboarding_slide_order"), + ) + + op.create_table( + "onboarding_states", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False), + sa.Column("last_completed_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("is_completed", sa.Boolean(), nullable=False, server_default=sa.sql.expression.false()), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.func.now(), + server_onupdate=sa.func.now(), + nullable=False, + ), + sa.UniqueConstraint("user_id", name="uq_onboarding_state_user"), + ) + + +def downgrade() -> None: + op.drop_table("onboarding_states") + op.drop_table("onboarding_slides") + diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 24c95ae..c6c035c 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -1,12 +1,13 @@ """Экспортируем роутеры для подключения в приложении.""" -from . import admin, auth, journal, missions, store, users # noqa: F401 +from . import admin, auth, journal, missions, onboarding, store, users # noqa: F401 __all__ = [ "admin", "auth", "journal", "missions", + "onboarding", "store", "users", ] diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py index bb01e4a..d6e529e 100644 --- a/backend/app/api/routes/admin.py +++ b/backend/app/api/routes/admin.py @@ -3,6 +3,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func from sqlalchemy.exc import NoResultFound from sqlalchemy.orm import Session, selectinload @@ -18,8 +19,8 @@ from app.models.mission import ( SubmissionStatus, ) from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement -from app.models.user import Competency -from app.schemas.artifact import ArtifactRead +from app.models.user import Competency, User, UserRole +from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate from app.schemas.mission import ( MissionBase, @@ -38,6 +39,7 @@ from app.schemas.rank import ( ) from app.schemas.user import CompetencyBase from app.services.mission import approve_submission, reject_submission +from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -108,9 +110,13 @@ def _branch_to_read(branch: Branch) -> BranchRead: mission_id=item.mission_id, mission_title=item.mission.title if item.mission else "", order=item.order, + is_completed=False, + is_available=True, ) for item in missions ], + total_missions=len(missions), + completed_missions=0, ) @@ -255,6 +261,144 @@ def list_artifacts( return [ArtifactRead.model_validate(artifact) for artifact in artifacts] +@router.get("/stats", response_model=AdminDashboardStats, summary="Сводная аналитика") +def dashboard_stats( + *, db: Session = Depends(get_db), current_user=Depends(require_hr) +) -> AdminDashboardStats: + """Основные метрики прогресса и активности пользователей.""" + + total_pilots = db.query(User).filter(User.role == UserRole.PILOT).count() + approved_submissions = db.query(MissionSubmission).filter( + MissionSubmission.status == SubmissionStatus.APPROVED + ) + active_pilots = ( + approved_submissions.with_entities(MissionSubmission.user_id).distinct().count() + ) + + completed_counts = approved_submissions.with_entities( + MissionSubmission.user_id, func.count(MissionSubmission.id) + ).group_by(MissionSubmission.user_id) + + total_completed = sum(row[1] for row in completed_counts) + average_completed = total_completed / active_pilots if active_pilots else 0.0 + + submission_stats = SubmissionStats( + pending=db.query(MissionSubmission).filter(MissionSubmission.status == SubmissionStatus.PENDING).count(), + approved=approved_submissions.count(), + rejected=db.query(MissionSubmission).filter(MissionSubmission.status == SubmissionStatus.REJECTED).count(), + ) + + branches = ( + db.query(Branch) + .options(selectinload(Branch.missions)) + .order_by(Branch.title) + .all() + ) + branch_stats: list[BranchCompletionStat] = [] + for branch in branches: + total_missions = len(branch.missions) + if total_missions == 0 or total_pilots == 0: + branch_stats.append( + BranchCompletionStat(branch_id=branch.id, branch_title=branch.title, completion_rate=0.0) + ) + continue + + approved_count = ( + db.query(func.count(MissionSubmission.id)) + .join(Mission, Mission.id == MissionSubmission.mission_id) + .join(BranchMission, BranchMission.mission_id == Mission.id) + .filter( + BranchMission.branch_id == branch.id, + MissionSubmission.status == SubmissionStatus.APPROVED, + ) + .scalar() + ) + denominator = total_missions * total_pilots + rate = min(1.0, approved_count / denominator) if denominator else 0.0 + branch_stats.append( + BranchCompletionStat(branch_id=branch.id, branch_title=branch.title, completion_rate=rate) + ) + + return AdminDashboardStats( + total_users=total_pilots, + active_pilots=active_pilots, + average_completed_missions=round(average_completed, 2), + submission_stats=submission_stats, + branch_completion=branch_stats, + ) + + +@router.post("/artifacts", response_model=ArtifactRead, summary="Создать артефакт") +def create_artifact( + artifact_in: ArtifactCreate, + *, + db: Session = Depends(get_db), + current_user=Depends(require_hr), +) -> ArtifactRead: + """Добавляем новый артефакт в каталог.""" + + artifact = Artifact( + name=artifact_in.name, + description=artifact_in.description, + rarity=artifact_in.rarity, + image_url=artifact_in.image_url, + ) + db.add(artifact) + db.commit() + db.refresh(artifact) + return ArtifactRead.model_validate(artifact) + + +@router.put("/artifacts/{artifact_id}", response_model=ArtifactRead, summary="Обновить артефакт") +def update_artifact( + artifact_id: int, + artifact_in: ArtifactUpdate, + *, + db: Session = Depends(get_db), + current_user=Depends(require_hr), +) -> ArtifactRead: + """Редактируем существующий артефакт.""" + + artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first() + if not artifact: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Артефакт не найден") + + payload = artifact_in.model_dump(exclude_unset=True) + for field, value in payload.items(): + setattr(artifact, field, value) + + db.commit() + db.refresh(artifact) + return ArtifactRead.model_validate(artifact) + + +@router.delete( + "/artifacts/{artifact_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Удалить артефакт" +) +def delete_artifact( + artifact_id: int, + *, + db: Session = Depends(get_db), + current_user=Depends(require_hr), +) -> None: + """Удаляем артефакт, если он не привязан к миссиям.""" + + artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first() + if not artifact: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Артефакт не найден") + + missions_with_artifact = db.query(Mission).filter(Mission.artifact_id == artifact_id).count() + if missions_with_artifact: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Нельзя удалить артефакт, привязанный к миссиям", + ) + + db.delete(artifact) + db.commit() + return None + + @router.post("/missions", response_model=MissionDetail, summary="Создать миссию") def create_mission_endpoint( mission_in: MissionCreate, diff --git a/backend/app/api/routes/journal.py b/backend/app/api/routes/journal.py index 90bf3fb..1a03dfe 100644 --- a/backend/app/api/routes/journal.py +++ b/backend/app/api/routes/journal.py @@ -2,14 +2,17 @@ from __future__ import annotations -from fastapi import APIRouter, Depends +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import func from sqlalchemy.orm import Session from app.api.deps import get_current_user from app.db.session import get_db from app.models.journal import JournalEntry from app.models.user import User -from app.schemas.journal import JournalEntryRead +from app.schemas.journal import JournalEntryRead, LeaderboardEntry, LeaderboardResponse router = APIRouter(prefix="/api/journal", tags=["journal"]) @@ -27,3 +30,48 @@ def list_journal( .all() ) return [JournalEntryRead.model_validate(entry) for entry in entries] + + +@router.get("/leaderboard", response_model=LeaderboardResponse, summary="Таблица лидеров") +def leaderboard( + period: str = "week", + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> LeaderboardResponse: + """Возвращаем топ пилотов по опыту и мане за выбранный период.""" + + del current_user # информация используется только для авторизации + + periods = {"week": 7, "month": 30, "year": 365} + if period not in periods: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неизвестный период") + + since = datetime.now(timezone.utc) - timedelta(days=periods[period]) + + rows = ( + db.query( + User.id.label("user_id"), + User.full_name, + func.sum(JournalEntry.xp_delta).label("xp_sum"), + func.sum(JournalEntry.mana_delta).label("mana_sum"), + ) + .join(User, User.id == JournalEntry.user_id) + .filter(JournalEntry.created_at >= since) + .group_by(User.id, User.full_name) + .order_by(func.sum(JournalEntry.xp_delta).desc()) + .limit(5) + .all() + ) + + entries = [ + LeaderboardEntry( + user_id=row.user_id, + full_name=row.full_name, + xp_delta=int(row.xp_sum or 0), + mana_delta=int(row.mana_sum or 0), + ) + for row in rows + ] + + return LeaderboardResponse(period=period, entries=entries) diff --git a/backend/app/api/routes/missions.py b/backend/app/api/routes/missions.py index c3cf0e2..cb78e85 100644 --- a/backend/app/api/routes/missions.py +++ b/backend/app/api/routes/missions.py @@ -2,13 +2,15 @@ from __future__ import annotations +from collections import defaultdict + from fastapi import APIRouter, Depends, HTTPException, status 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.mission import Mission, MissionSubmission, SubmissionStatus from app.models.user import User from app.schemas.branch import BranchMissionRead, BranchRead from app.schemas.mission import ( @@ -22,12 +24,73 @@ from app.services.mission import submit_mission router = APIRouter(prefix="/api/missions", tags=["missions"]) +def _load_user_progress(user: User) -> set[int]: + """Возвращаем идентификаторы успешно завершённых миссий.""" + + completed = { + submission.mission_id + for submission in user.submissions + if submission.status == SubmissionStatus.APPROVED + } + return completed + + +def _build_branch_dependencies(branches: list[Branch]) -> dict[int, set[int]]: + """Строим карту зависимостей миссий по веткам.""" + + dependencies: dict[int, set[int]] = defaultdict(set) + for branch in branches: + ordered = sorted(branch.missions, key=lambda item: item.order) + previous: list[int] = [] + for link in ordered: + if previous: + dependencies[link.mission_id].update(previous) + previous.append(link.mission_id) + return dependencies + + +def _mission_availability( + *, + mission: Mission, + user: User, + completed_missions: set[int], + branch_dependencies: dict[int, set[int]], + mission_titles: dict[int, str], +) -> tuple[bool, list[str]]: + """Определяем, доступна ли миссия и формируем причины блокировки.""" + + reasons: list[str] = [] + + if mission.minimum_rank and user.xp < mission.minimum_rank.required_xp: + reasons.append(f"Требуется ранг «{mission.minimum_rank.title}»") + + missing_explicit = [ + req.required_mission_id + for req in mission.prerequisites + if req.required_mission_id not in completed_missions + ] + for mission_id in missing_explicit: + reasons.append(f"Завершите миссию «{mission_titles.get(mission_id, '#'+str(mission_id))}»") + + for mission_id in branch_dependencies.get(mission.id, set()): + if mission_id not in completed_missions: + reasons.append( + "Продолжение ветки откроется после миссии «" + f"{mission_titles.get(mission_id, '#'+str(mission_id))}»" + ) + + is_available = mission.is_active and not reasons + return is_available, reasons + + @router.get("/branches", response_model=list[BranchRead], summary="Список веток миссий") def list_branches( *, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ) -> list[BranchRead]: """Возвращаем ветки с упорядоченными миссиями.""" + db.refresh(current_user) + _ = current_user.submissions branches = ( db.query(Branch) .options(selectinload(Branch.missions).selectinload(BranchMission.mission)) @@ -35,23 +98,60 @@ def list_branches( .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) - ], - ) + completed_missions = _load_user_progress(current_user) + branch_dependencies = _build_branch_dependencies(branches) + + mission_titles = { + item.mission_id: item.mission.title if item.mission else "" for branch in branches - ] + for item in branch.missions + } + mission_titles.update(dict(db.query(Mission.id, Mission.title).all())) + + response: list[BranchRead] = [] + for branch in branches: + ordered_links = sorted(branch.missions, key=lambda link: link.order) + completed_count = sum(1 for link in ordered_links if link.mission_id in completed_missions) + total = len(ordered_links) + + missions_payload = [] + for link in ordered_links: + mission_obj = link.mission + mission_title = mission_obj.title if mission_obj else "" + is_completed = link.mission_id in completed_missions + if mission_obj: + is_available, _ = _mission_availability( + mission=mission_obj, + user=current_user, + completed_missions=completed_missions, + branch_dependencies=branch_dependencies, + mission_titles=mission_titles, + ) + else: + is_available = False + missions_payload.append( + BranchMissionRead( + mission_id=link.mission_id, + mission_title=mission_title, + order=link.order, + is_completed=is_completed, + is_available=is_available, + ) + ) + + response.append( + BranchRead( + id=branch.id, + title=branch.title, + description=branch.description, + category=branch.category, + missions=missions_payload, + total_missions=total, + completed_missions=completed_count, + ) + ) + + return response @router.get("/", response_model=list[MissionBase], summary="Список миссий") @@ -60,13 +160,45 @@ def list_missions( ) -> list[MissionBase]: """Возвращаем доступные миссии.""" - query = db.query(Mission).filter(Mission.is_active.is_(True)) - if current_user.current_rank_id: - query = query.filter( - (Mission.minimum_rank_id.is_(None)) | (Mission.minimum_rank_id <= current_user.current_rank_id) + db.refresh(current_user) + _ = current_user.submissions + + branches = ( + db.query(Branch) + .options(selectinload(Branch.missions)) + .all() + ) + branch_dependencies = _build_branch_dependencies(branches) + + missions = ( + db.query(Mission) + .options( + selectinload(Mission.prerequisites), + selectinload(Mission.minimum_rank), ) - missions = query.all() - return [MissionBase.model_validate(mission) for mission in missions] + .filter(Mission.is_active.is_(True)) + .order_by(Mission.id) + .all() + ) + + mission_titles = {mission.id: mission.title for mission in missions} + completed_missions = _load_user_progress(current_user) + + response: list[MissionBase] = [] + for mission in missions: + is_available, reasons = _mission_availability( + mission=mission, + user=current_user, + completed_missions=completed_missions, + branch_dependencies=branch_dependencies, + mission_titles=mission_titles, + ) + dto = MissionBase.model_validate(mission) + dto.is_available = is_available + dto.locked_reasons = reasons + response.append(dto) + + return response @router.get("/{mission_id}", response_model=MissionDetail, summary="Карточка миссии") @@ -82,6 +214,25 @@ def get_mission( if not mission: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена") + db.refresh(current_user) + _ = current_user.submissions + branches = ( + db.query(Branch) + .options(selectinload(Branch.missions)) + .all() + ) + branch_dependencies = _build_branch_dependencies(branches) + completed_missions = _load_user_progress(current_user) + mission_titles = dict(db.query(Mission.id, Mission.title).all()) + + is_available, reasons = _mission_availability( + mission=mission, + user=current_user, + completed_missions=completed_missions, + branch_dependencies=branch_dependencies, + mission_titles=mission_titles, + ) + prerequisites = [link.required_mission_id for link in mission.prerequisites] rewards = [ { @@ -100,6 +251,8 @@ def get_mission( mana_reward=mission.mana_reward, difficulty=mission.difficulty, is_active=mission.is_active, + is_available=is_available, + locked_reasons=reasons, minimum_rank_id=mission.minimum_rank_id, artifact_id=mission.artifact_id, prerequisites=prerequisites, @@ -120,9 +273,30 @@ def submit( ) -> MissionSubmissionRead: """Пилот отправляет доказательство выполнения миссии.""" - mission = db.query(Mission).filter(Mission.id == mission_id).first() + mission = db.query(Mission).filter(Mission.id == mission_id, Mission.is_active.is_(True)).first() if not mission: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена") + + db.refresh(current_user) + _ = current_user.submissions + branches = ( + db.query(Branch) + .options(selectinload(Branch.missions)) + .all() + ) + branch_dependencies = _build_branch_dependencies(branches) + completed_missions = _load_user_progress(current_user) + mission_titles = dict(db.query(Mission.id, Mission.title).all()) + + is_available, reasons = _mission_availability( + mission=mission, + user=current_user, + completed_missions=completed_missions, + branch_dependencies=branch_dependencies, + mission_titles=mission_titles, + ) + if not is_available: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="; ".join(reasons)) submission = submit_mission( db=db, user=current_user, diff --git a/backend/app/api/routes/onboarding.py b/backend/app/api/routes/onboarding.py new file mode 100644 index 0000000..f23f4a4 --- /dev/null +++ b/backend/app/api/routes/onboarding.py @@ -0,0 +1,60 @@ +"""Онбординг и космический лор.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from app.api.deps import get_current_user +from app.db.session import get_db +from app.models.user import User +from app.schemas.onboarding import ( + OnboardingCompleteRequest, + OnboardingOverview, + OnboardingSlideRead, + OnboardingStateRead, +) +from app.services.onboarding import complete_slide, get_overview + + +router = APIRouter(prefix="/api/onboarding", tags=["onboarding"]) + + +@router.get("/", response_model=OnboardingOverview, summary="Лор и прогресс онбординга") +def read_onboarding( + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> OnboardingOverview: + """Отдаём все слайды вместе с состоянием пользователя.""" + + slides, state, next_order = get_overview(db, current_user) + return OnboardingOverview( + slides=[OnboardingSlideRead.model_validate(slide) for slide in slides], + state=OnboardingStateRead( + last_completed_order=state.last_completed_order, + is_completed=state.is_completed, + ), + next_order=next_order, + ) + + +@router.post("/complete", response_model=OnboardingStateRead, summary="Завершаем шаг онбординга") +def complete_onboarding_step( + payload: OnboardingCompleteRequest, + *, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> OnboardingStateRead: + """Фиксируем прохождение очередного шага лора.""" + + try: + state = complete_slide(db, current_user, payload.order) + except ValueError as exc: # pragma: no cover - ошибка бизнес-логики + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + return OnboardingStateRead( + last_completed_order=state.last_completed_order, + is_completed=state.is_completed, + ) + diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 495559b..c4b0a3f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,7 +1,10 @@ """Конфигурация приложения и загрузка окружения.""" +from __future__ import annotations + from functools import lru_cache from pathlib import Path +from typing import List from pydantic_settings import BaseSettings, SettingsConfigDict @@ -20,7 +23,7 @@ class Settings(BaseSettings): jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 12 - backend_cors_origins: list[str] = ["http://localhost:3000", "http://frontend:3000"] + backend_cors_origins: List[str] = ["http://localhost:3000", "http://frontend:3000"] sqlite_path: Path = Path("/data/app.db") diff --git a/backend/app/main.py b/backend/app/main.py index 015453d..ebf8f3e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,7 @@ from __future__ import annotations from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.routes import admin, auth, journal, missions, store, users +from app.api.routes import admin, auth, journal, missions, onboarding, store, users from app.core.config import settings from app.db.session import engine from app.models.base import Base @@ -32,6 +32,7 @@ app.include_router(auth.router) app.include_router(users.router) app.include_router(missions.router) app.include_router(journal.router) +app.include_router(onboarding.router) app.include_router(store.router) app.include_router(admin.router) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index efc876d..8e2ba38 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -4,6 +4,7 @@ from .artifact import Artifact # noqa: F401 from .branch import Branch, BranchMission # noqa: F401 from .journal import JournalEntry # noqa: F401 from .mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission # noqa: F401 +from .onboarding import OnboardingSlide, OnboardingState # noqa: F401 from .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401 from .store import Order, StoreItem # noqa: F401 from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401 @@ -17,6 +18,8 @@ __all__ = [ "MissionCompetencyReward", "MissionPrerequisite", "MissionSubmission", + "OnboardingSlide", + "OnboardingState", "Rank", "RankCompetencyRequirement", "RankMissionRequirement", diff --git a/backend/app/models/journal.py b/backend/app/models/journal.py index b093115..2028ed2 100644 --- a/backend/app/models/journal.py +++ b/backend/app/models/journal.py @@ -3,6 +3,7 @@ from __future__ import annotations from enum import Enum +from typing import Optional from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, JSON, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -30,7 +31,7 @@ class JournalEntry(Base, TimestampMixin): event_type: Mapped[JournalEventType] = mapped_column(SQLEnum(JournalEventType), nullable=False) title: Mapped[str] = mapped_column(String(160), nullable=False) description: Mapped[str] = mapped_column(Text, nullable=False) - payload: Mapped[dict | None] = mapped_column(JSON) + payload: Mapped[Optional[dict]] = mapped_column(JSON) xp_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False) mana_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/backend/app/models/onboarding.py b/backend/app/models/onboarding.py new file mode 100644 index 0000000..a089fd2 --- /dev/null +++ b/backend/app/models/onboarding.py @@ -0,0 +1,40 @@ +"""Модели онбординга и лора.""" + +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import Base, TimestampMixin + + +class OnboardingSlide(Base, TimestampMixin): + """Контентный слайд онбординга, который видит пилот.""" + + __tablename__ = "onboarding_slides" + __table_args__ = (UniqueConstraint("order", name="uq_onboarding_slide_order"),) + + id: Mapped[int] = mapped_column(primary_key=True) + order: Mapped[int] = mapped_column(Integer, nullable=False) + title: Mapped[str] = mapped_column(String(160), nullable=False) + body: Mapped[str] = mapped_column(Text, nullable=False) + media_url: Mapped[Optional[str]] = mapped_column(String(512)) + cta_text: Mapped[Optional[str]] = mapped_column(String(120)) + cta_link: Mapped[Optional[str]] = mapped_column(String(512)) + + +class OnboardingState(Base, TimestampMixin): + """Прогресс пользователя по онбордингу.""" + + __tablename__ = "onboarding_states" + __table_args__ = (UniqueConstraint("user_id", name="uq_onboarding_state_user"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + last_completed_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + is_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + user = relationship("User", back_populates="onboarding_state") + diff --git a/backend/app/models/store.py b/backend/app/models/store.py index f290def..8c2251f 100644 --- a/backend/app/models/store.py +++ b/backend/app/models/store.py @@ -3,7 +3,7 @@ from __future__ import annotations from enum import Enum -from typing import List +from typing import List, Optional from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -45,7 +45,7 @@ class Order(Base, TimestampMixin): status: Mapped[OrderStatus] = mapped_column( SQLEnum(OrderStatus), default=OrderStatus.CREATED, nullable=False ) - comment: Mapped[str | None] = mapped_column(Text) + comment: Mapped[Optional[str]] = mapped_column(Text) user = relationship("User", back_populates="orders") item = relationship("StoreItem", back_populates="orders") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 0360c2a..c962bc5 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -10,6 +10,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.models.base import Base, TimestampMixin +# Локальные импорты внизу файла, чтобы избежать циклов типов + class UserRole(str, Enum): """Типы ролей в системе.""" @@ -48,6 +50,9 @@ class User(Base, TimestampMixin): artifacts: Mapped[List["UserArtifact"]] = relationship( "UserArtifact", back_populates="user", cascade="all, delete-orphan" ) + onboarding_state: Mapped[Optional["OnboardingState"]] = relationship( + "OnboardingState", back_populates="user", cascade="all, delete-orphan", uselist=False + ) class CompetencyCategory(str, Enum): @@ -103,3 +108,7 @@ class UserArtifact(Base, TimestampMixin): user = relationship("User", back_populates="artifacts") artifact = relationship("Artifact", back_populates="pilots") + + +# Импорты в конце файла, чтобы отношения корректно инициализировались +from app.models.onboarding import OnboardingState # noqa: E402 pylint: disable=wrong-import-position diff --git a/backend/app/schemas/admin_stats.py b/backend/app/schemas/admin_stats.py new file mode 100644 index 0000000..fea8c99 --- /dev/null +++ b/backend/app/schemas/admin_stats.py @@ -0,0 +1,31 @@ +"""Сводные метрики для HR.""" + +from __future__ import annotations + +from pydantic import BaseModel + + +class SubmissionStats(BaseModel): + """Структура статистики по отправкам миссий.""" + + pending: int + approved: int + rejected: int + + +class BranchCompletionStat(BaseModel): + """Завершённость ветки.""" + + branch_id: int + branch_title: str + completion_rate: float + + +class AdminDashboardStats(BaseModel): + """Ответ с основными метриками.""" + + total_users: int + active_pilots: int + average_completed_missions: float + submission_stats: SubmissionStats + branch_completion: list[BranchCompletionStat] diff --git a/backend/app/schemas/artifact.py b/backend/app/schemas/artifact.py index b955bd7..116b78b 100644 --- a/backend/app/schemas/artifact.py +++ b/backend/app/schemas/artifact.py @@ -14,6 +14,25 @@ class ArtifactRead(BaseModel): name: str description: str rarity: ArtifactRarity + image_url: str | None = None class Config: from_attributes = True + + +class ArtifactCreate(BaseModel): + """Создание артефакта.""" + + name: str + description: str + rarity: ArtifactRarity + image_url: str | None = None + + +class ArtifactUpdate(BaseModel): + """Обновление артефакта.""" + + name: str | None = None + description: str | None = None + rarity: ArtifactRarity | None = None + image_url: str | None = None diff --git a/backend/app/schemas/branch.py b/backend/app/schemas/branch.py index 3ac776d..d263b29 100644 --- a/backend/app/schemas/branch.py +++ b/backend/app/schemas/branch.py @@ -11,6 +11,8 @@ class BranchMissionRead(BaseModel): mission_id: int mission_title: str order: int + is_completed: bool = False + is_available: bool = False class BranchRead(BaseModel): @@ -21,6 +23,8 @@ class BranchRead(BaseModel): description: str category: str missions: list[BranchMissionRead] + total_missions: int = 0 + completed_missions: int = 0 class Config: from_attributes = True diff --git a/backend/app/schemas/journal.py b/backend/app/schemas/journal.py index 2f5a41c..8a84d61 100644 --- a/backend/app/schemas/journal.py +++ b/backend/app/schemas/journal.py @@ -24,3 +24,19 @@ class JournalEntryRead(BaseModel): class Config: from_attributes = True + + +class LeaderboardEntry(BaseModel): + """Участник таблицы лидеров.""" + + user_id: int + full_name: str + xp_delta: int + mana_delta: int + + +class LeaderboardResponse(BaseModel): + """Ответ для таблицы лидеров.""" + + period: str + entries: list[LeaderboardEntry] diff --git a/backend/app/schemas/mission.py b/backend/app/schemas/mission.py index 4aa2e00..1eb7b15 100644 --- a/backend/app/schemas/mission.py +++ b/backend/app/schemas/mission.py @@ -5,7 +5,7 @@ from __future__ import annotations from datetime import datetime from typing import Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.models.mission import MissionDifficulty, SubmissionStatus @@ -20,6 +20,8 @@ class MissionBase(BaseModel): mana_reward: int difficulty: MissionDifficulty is_active: bool + is_available: bool = True + locked_reasons: list[str] = Field(default_factory=list) class Config: from_attributes = True @@ -95,6 +97,7 @@ class MissionSubmissionCreate(BaseModel): class MissionSubmissionRead(BaseModel): """Получение статуса отправки.""" + id: int mission_id: int status: SubmissionStatus comment: Optional[str] diff --git a/backend/app/schemas/onboarding.py b/backend/app/schemas/onboarding.py new file mode 100644 index 0000000..b3cab2f --- /dev/null +++ b/backend/app/schemas/onboarding.py @@ -0,0 +1,44 @@ +"""Схемы для онбординга.""" + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel + + +class OnboardingSlideRead(BaseModel): + """Отдельный слайд онбординга.""" + + id: int + order: int + title: str + body: str + media_url: Optional[str] + cta_text: Optional[str] + cta_link: Optional[str] + + class Config: + from_attributes = True + + +class OnboardingStateRead(BaseModel): + """Прогресс пользователя.""" + + last_completed_order: int + is_completed: bool + + +class OnboardingOverview(BaseModel): + """Полный ответ с прогрессом и контентом.""" + + slides: list[OnboardingSlideRead] + state: OnboardingStateRead + next_order: int | None + + +class OnboardingCompleteRequest(BaseModel): + """Запрос на фиксацию завершения шага.""" + + order: int + diff --git a/backend/app/services/mission.py b/backend/app/services/mission.py index 85f9413..ee4cfa6 100644 --- a/backend/app/services/mission.py +++ b/backend/app/services/mission.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from app.models.journal import JournalEventType from app.models.mission import Mission, MissionSubmission, SubmissionStatus -from app.models.user import User, UserCompetency +from app.models.user import User, UserArtifact, UserCompetency from app.services.journal import log_event from app.services.rank import apply_rank_upgrade @@ -82,6 +82,21 @@ def approve_submission(db: Session, submission: MissionSubmission) -> MissionSub _increase_competencies(db, user, submission.mission) + if submission.mission.artifact_id: + already_has = any( + artifact.artifact_id == submission.mission.artifact_id for artifact in user.artifacts + ) + if not already_has: + db.add(UserArtifact(user_id=user.id, artifact_id=submission.mission.artifact_id)) + log_event( + db, + user_id=user.id, + event_type=JournalEventType.MISSION_COMPLETED, + title=f"Получен артефакт за миссию «{submission.mission.title}»", + description="Новый артефакт добавлен в коллекцию.", + payload={"artifact_id": submission.mission.artifact_id}, + ) + db.add_all([submission, user]) db.commit() db.refresh(submission) diff --git a/backend/app/services/onboarding.py b/backend/app/services/onboarding.py new file mode 100644 index 0000000..097c44e --- /dev/null +++ b/backend/app/services/onboarding.py @@ -0,0 +1,61 @@ +"""Сервисный слой для онбординга.""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.models.onboarding import OnboardingSlide, OnboardingState +from app.models.user import User + + +def _ensure_state(db: Session, user: User) -> OnboardingState: + """Гарантируем наличие записи о прогрессе.""" + + if user.onboarding_state: + return user.onboarding_state + + state = OnboardingState(user_id=user.id, last_completed_order=0, is_completed=False) + db.add(state) + db.flush() + db.refresh(state) + return state + + +def get_overview(db: Session, user: User) -> tuple[list[OnboardingSlide], OnboardingState, int | None]: + """Возвращаем все слайды и текущий прогресс.""" + + slides = db.query(OnboardingSlide).order_by(OnboardingSlide.order).all() + state = _ensure_state(db, user) + + next_slide = next((slide for slide in slides if slide.order > state.last_completed_order), None) + next_order: int | None = next_slide.order if next_slide else None + return slides, state, next_order + + +def complete_slide(db: Session, user: User, order: int) -> OnboardingState: + """Фиксируем завершение шага, если это корректный порядок.""" + + slides = db.query(OnboardingSlide).order_by(OnboardingSlide.order).all() + if not slides: + raise ValueError("Онбординг ещё не настроен") + + state = _ensure_state(db, user) + + allowed_orders = [slide.order for slide in slides] + if order not in allowed_orders: + raise ValueError("Неизвестный шаг онбординга") + + if order <= state.last_completed_order: + return state + + expected_order = next((value for value in allowed_orders if value > state.last_completed_order), None) + if expected_order is None or order != expected_order: + raise ValueError("Сначала завершите предыдущие шаги") + + state.last_completed_order = order + state.is_completed = order == allowed_orders[-1] + db.add(state) + db.commit() + db.refresh(state) + return state + diff --git a/backend/tests/test_mission_submission.py b/backend/tests/test_mission_submission.py index 5a1f901..2b01686 100644 --- a/backend/tests/test_mission_submission.py +++ b/backend/tests/test_mission_submission.py @@ -2,6 +2,7 @@ from __future__ import annotations +from app.models.artifact import Artifact, ArtifactRarity from app.models.mission import Mission, MissionSubmission, SubmissionStatus from app.models.user import User, UserRole from app.services.mission import approve_submission @@ -33,3 +34,44 @@ def test_approve_submission_rewards(db_session): assert user.xp == mission.xp_reward assert user.mana == mission.mana_reward assert submission.status == SubmissionStatus.APPROVED + + +def test_approve_submission_grants_artifact(db_session): + """При наличии артефакта пользователь получает его единожды.""" + + artifact = Artifact( + name="Значок испытателя", + description="Выдан за успешную миссию", + rarity=ArtifactRarity.RARE, + ) + mission = Mission( + title="Тестовая миссия", + description="Описание", + xp_reward=50, + mana_reward=20, + artifact=artifact, + ) + user = User( + email="artifact@alabuga.space", + full_name="Пилот", + role=UserRole.PILOT, + hashed_password="hash", + ) + db_session.add_all([artifact, mission, user]) + db_session.flush() + + submission = MissionSubmission(user_id=user.id, mission_id=mission.id) + db_session.add(submission) + db_session.commit() + db_session.refresh(submission) + + approve_submission(db_session, submission) + db_session.refresh(user) + + assert len(user.artifacts) == 1 + assert user.artifacts[0].artifact_id == artifact.id + + # Повторное одобрение не создаёт дубли + approve_submission(db_session, submission) + db_session.refresh(user) + assert len(user.artifacts) == 1 diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index ee41ccc..30a032a 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -1,10 +1,13 @@ import { AdminBranchManager } from '../../components/admin/AdminBranchManager'; import { AdminMissionManager } from '../../components/admin/AdminMissionManager'; import { AdminRankManager } from '../../components/admin/AdminRankManager'; +import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager'; +import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard'; import { apiFetch } from '../../lib/api'; import { getDemoToken } from '../../lib/demo-auth'; interface Submission { + id: number; mission_id: number; status: string; comment: string | null; @@ -51,18 +54,40 @@ interface ArtifactSummary { name: string; description: string; rarity: string; + image_url?: string | null; +} + +interface SubmissionStats { + pending: number; + approved: number; + rejected: number; +} + +interface BranchCompletionStat { + branch_id: number; + branch_title: string; + completion_rate: number; +} + +interface AdminStats { + total_users: number; + active_pilots: number; + average_completed_missions: number; + submission_stats: SubmissionStats; + branch_completion: BranchCompletionStat[]; } export default async function AdminPage() { const token = await getDemoToken('hr'); - const [submissions, missions, branches, ranks, competencies, artifacts] = await Promise.all([ + const [submissions, missions, branches, ranks, competencies, artifacts, stats] = 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 }) + apiFetch('/api/admin/artifacts', { authToken: token }), + apiFetch('/api/admin/stats', { authToken: token }) ]); return ( @@ -73,6 +98,46 @@ export default async function AdminPage() {

+
+

Сводка

+
+
+ Пилоты +

{stats.total_users}

+ Всего зарегистрировано +
+
+ Активность +

{stats.active_pilots}

+ Закрыли хотя бы одну миссию +
+
+ Средний прогресс +

{stats.average_completed_missions.toFixed(1)}

+ Миссий на пилота +
+
+
+
+ Очередь модерации +

На проверке: {stats.submission_stats.pending}

+ + Одобрено: {stats.submission_stats.approved} · Отклонено: {stats.submission_stats.rejected} + +
+
+ Завершённость веток +
    + {stats.branch_completion.map((branchStat) => ( +
  • + {branchStat.branch_title}: {(branchStat.completion_rate * 100).toFixed(0)}% +
  • + ))} +
+
+
+
+

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

@@ -80,22 +145,7 @@ export default async function AdminPage() {

{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 &&

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

}
@@ -111,6 +161,7 @@ export default async function AdminPage() { artifacts={artifacts} /> +
); diff --git a/frontend/src/app/journal/page.tsx b/frontend/src/app/journal/page.tsx index 453f26d..dcc3513 100644 --- a/frontend/src/app/journal/page.tsx +++ b/frontend/src/app/journal/page.tsx @@ -12,22 +12,62 @@ interface JournalEntry { created_at: string; } +interface LeaderboardEntry { + user_id: number; + full_name: string; + xp_delta: number; + mana_delta: number; +} + +interface LeaderboardResponse { + period: string; + entries: LeaderboardEntry[]; +} + async function fetchJournal() { const token = await getDemoToken(); - const entries = await apiFetch('/api/journal/', { authToken: token }); - return entries; + const [entries, week, month, year] = await Promise.all([ + apiFetch('/api/journal/', { authToken: token }), + apiFetch('/api/journal/leaderboard?period=week', { authToken: token }), + apiFetch('/api/journal/leaderboard?period=month', { authToken: token }), + apiFetch('/api/journal/leaderboard?period=year', { authToken: token }) + ]); + return { entries, leaderboards: [week, month, year] }; } export default async function JournalPage() { - const entries = await fetchJournal(); + const { entries, leaderboards } = await fetchJournal(); return ( -
-

Бортовой журнал

-

- Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине. -

- +
+
+

Бортовой журнал

+

+ Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине. +

+ +
+
); } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 2301609..4249975 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -10,17 +10,29 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + -
+
-

Mission Control

+

Mission Control

- Путь пилота от искателя до члена экипажа + Путь пилота от искателя до командира космической эскадры

-
); } diff --git a/frontend/src/app/missions/page.tsx b/frontend/src/app/missions/page.tsx index aaec8bf..4918de2 100644 --- a/frontend/src/app/missions/page.tsx +++ b/frontend/src/app/missions/page.tsx @@ -2,14 +2,35 @@ import { apiFetch } from '../../lib/api'; import { getDemoToken } from '../../lib/demo-auth'; import { MissionList, MissionSummary } from '../../components/MissionList'; +interface BranchMission { + mission_id: number; + mission_title: string; + order: number; + is_completed: boolean; + is_available: boolean; +} + +interface BranchOverview { + id: number; + title: string; + description: string; + category: string; + missions: BranchMission[]; + total_missions: number; + completed_missions: number; +} + async function fetchMissions() { const token = await getDemoToken(); - const missions = await apiFetch('/api/missions/', { authToken: token }); - return missions; + const [missions, branches] = await Promise.all([ + apiFetch('/api/missions/', { authToken: token }), + apiFetch('/api/missions/branches', { authToken: token }) + ]); + return { missions, branches }; } export default async function MissionsPage() { - const missions = await fetchMissions(); + const { missions, branches } = await fetchMissions(); return (
@@ -18,6 +39,45 @@ export default async function MissionsPage() { Список обновляется в реальном времени и зависит от вашего ранга и прогресса. HR может добавлять новые задания в админ-панели.

+
+ {branches.map((branch) => { + const progress = branch.total_missions + ? Math.round((branch.completed_missions / branch.total_missions) * 100) + : 0; + const nextMission = branch.missions.find((mission) => !mission.is_completed); + return ( +
+

{branch.title}

+

{branch.description}

+
+ Прогресс ветки: {progress}% +
+
+
+
+ {nextMission && ( +

+ Следующая миссия: «{nextMission.mission_title}» +

+ )} +
+ ); + })} +
); diff --git a/frontend/src/app/onboarding/page.tsx b/frontend/src/app/onboarding/page.tsx new file mode 100644 index 0000000..76d6f87 --- /dev/null +++ b/frontend/src/app/onboarding/page.tsx @@ -0,0 +1,50 @@ +import { apiFetch } from '../../lib/api'; +import { getDemoToken } from '../../lib/demo-auth'; +import { OnboardingCarousel, OnboardingSlide } from '../../components/OnboardingCarousel'; + +interface OnboardingState { + last_completed_order: number; + is_completed: boolean; +} + +interface OnboardingResponse { + slides: OnboardingSlide[]; + state: OnboardingState; + next_order: number | null; +} + +async function fetchOnboarding() { + const token = await getDemoToken(); + const data = await apiFetch('/api/onboarding/', { authToken: token }); + return { token, data }; +} + +export default async function OnboardingPage() { + const { token, data } = await fetchOnboarding(); + + return ( +
+
+ +
+ +
+ ); +} + diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 3622591..d106052 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -22,18 +22,29 @@ interface RankResponse { id: number; title: string; description: string; + required_xp: number; } async function fetchProfile() { const token = await getDemoToken(); const profile = await apiFetch('/api/me', { authToken: token }); const ranks = await apiFetch('/api/ranks', { authToken: token }); - const currentRank = ranks.find((rank) => rank.id === profile.current_rank_id); - return { token, profile, currentRank }; + const orderedRanks = [...ranks].sort((a, b) => a.required_xp - b.required_xp); + const currentIndex = Math.max( + orderedRanks.findIndex((rank) => rank.id === profile.current_rank_id), + 0 + ); + const currentRank = orderedRanks[currentIndex] ?? null; + const nextRank = orderedRanks[currentIndex + 1] ?? null; + const baselineXp = currentRank ? currentRank.required_xp : 0; + const progress = Math.max(profile.xp - baselineXp, 0); + const target = nextRank ? nextRank.required_xp - baselineXp : 0; + + return { token, profile, currentRank, nextRank, progress, target }; } export default async function DashboardPage() { - const { token, profile, currentRank } = await fetchProfile(); + const { token, profile, currentRank, nextRank, progress, target } = await fetchProfile(); return (
@@ -45,6 +56,9 @@ export default async function DashboardPage() { rank={currentRank} competencies={profile.competencies} artifacts={profile.artifacts} + nextRankTitle={nextRank?.title} + xpProgress={progress} + xpTarget={target} />