Add futures
This commit is contained in:
parent
89734706b8
commit
1ab0cb1f1f
20
README.md
20
README.md
|
|
@ -10,6 +10,14 @@
|
||||||
- `docs/` — документация, дополнительный лор.
|
- `docs/` — документация, дополнительный лор.
|
||||||
- `docker-compose.yaml` — инфраструктура проекта.
|
- `docker-compose.yaml` — инфраструктура проекта.
|
||||||
|
|
||||||
|
## Основные пользовательские сценарии
|
||||||
|
|
||||||
|
- Как кандидат: хочу проходить онбординг с лором и понимать своё место в программе.
|
||||||
|
- Как кандидат: хочу видеть прогресс-бар до следующего ранга и инструкции по ключевым веткам.
|
||||||
|
- Как кандидат: хочу получать награды (опыт, ману, артефакты) и видеть их в журнале.
|
||||||
|
- Как HR: хочу управлять миссиями, ветками, артефактами и требованиями рангов.
|
||||||
|
- Как HR: хочу видеть оперативную аналитику по активности и очереди модерации.
|
||||||
|
|
||||||
## Быстрый старт в Docker
|
## Быстрый старт в Docker
|
||||||
|
|
||||||
1. Установите Docker и Docker Compose.
|
1. Установите Docker и Docker Compose.
|
||||||
|
|
@ -68,6 +76,14 @@ npm run dev
|
||||||
| Пилот | `candidate@alabuga.space` | `orbita123` |
|
| Пилот | `candidate@alabuga.space` | `orbita123` |
|
||||||
| HR | `hr@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
|
```bash
|
||||||
|
|
@ -84,12 +100,16 @@ pytest
|
||||||
- Журнал событий, экспортируемый через API.
|
- Журнал событий, экспортируемый через API.
|
||||||
- Магазин артефактов с оформлением заказа.
|
- Магазин артефактов с оформлением заказа.
|
||||||
- Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам.
|
- Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам.
|
||||||
|
- Онбординг с сохранением прогресса и космическим лором.
|
||||||
|
- Таблица лидеров по опыту и мане за неделю/месяц/год.
|
||||||
|
- Аналитическая сводка для HR: активность пилотов, очередь модерации, завершённость веток.
|
||||||
|
|
||||||
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
|
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
|
||||||
|
|
||||||
- создавать и редактировать ветки миссий с категориями и порядком;
|
- создавать и редактировать ветки миссий с категориями и порядком;
|
||||||
- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями;
|
- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями;
|
||||||
- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций.
|
- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций.
|
||||||
|
- управлять каталогом артефактов и назначать их миссиям.
|
||||||
|
|
||||||
## План развития
|
## План развития
|
||||||
|
|
||||||
|
|
|
||||||
56
backend/alembic/versions/20240611_0002_onboarding.py
Normal file
56
backend/alembic/versions/20240611_0002_onboarding.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Onboarding models"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "20240611_0002"
|
||||||
|
down_revision = "20240609_0001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"onboarding_slides",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("order", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("title", sa.String(length=160), nullable=False),
|
||||||
|
sa.Column("body", sa.Text(), nullable=False),
|
||||||
|
sa.Column("media_url", sa.String(length=512)),
|
||||||
|
sa.Column("cta_text", sa.String(length=120)),
|
||||||
|
sa.Column("cta_link", sa.String(length=512)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
server_onupdate=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("order", name="uq_onboarding_slide_order"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"onboarding_states",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True),
|
||||||
|
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("last_completed_order", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("is_completed", sa.Boolean(), nullable=False, server_default=sa.sql.expression.false()),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
server_onupdate=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", name="uq_onboarding_state_user"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("onboarding_states")
|
||||||
|
op.drop_table("onboarding_slides")
|
||||||
|
|
||||||
|
|
@ -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__ = [
|
__all__ = [
|
||||||
"admin",
|
"admin",
|
||||||
"auth",
|
"auth",
|
||||||
"journal",
|
"journal",
|
||||||
"missions",
|
"missions",
|
||||||
|
"onboarding",
|
||||||
"store",
|
"store",
|
||||||
"users",
|
"users",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import func
|
||||||
from sqlalchemy.exc import NoResultFound
|
from sqlalchemy.exc import NoResultFound
|
||||||
from sqlalchemy.orm import Session, selectinload
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
|
|
@ -18,8 +19,8 @@ from app.models.mission import (
|
||||||
SubmissionStatus,
|
SubmissionStatus,
|
||||||
)
|
)
|
||||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||||
from app.models.user import Competency
|
from app.models.user import Competency, User, UserRole
|
||||||
from app.schemas.artifact import ArtifactRead
|
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
|
||||||
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
|
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
|
||||||
from app.schemas.mission import (
|
from app.schemas.mission import (
|
||||||
MissionBase,
|
MissionBase,
|
||||||
|
|
@ -38,6 +39,7 @@ from app.schemas.rank import (
|
||||||
)
|
)
|
||||||
from app.schemas.user import CompetencyBase
|
from app.schemas.user import CompetencyBase
|
||||||
from app.services.mission import approve_submission, reject_submission
|
from app.services.mission import approve_submission, reject_submission
|
||||||
|
from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
@ -108,9 +110,13 @@ def _branch_to_read(branch: Branch) -> BranchRead:
|
||||||
mission_id=item.mission_id,
|
mission_id=item.mission_id,
|
||||||
mission_title=item.mission.title if item.mission else "",
|
mission_title=item.mission.title if item.mission else "",
|
||||||
order=item.order,
|
order=item.order,
|
||||||
|
is_completed=False,
|
||||||
|
is_available=True,
|
||||||
)
|
)
|
||||||
for item in missions
|
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]
|
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="Создать миссию")
|
@router.post("/missions", response_model=MissionDetail, summary="Создать миссию")
|
||||||
def create_mission_endpoint(
|
def create_mission_endpoint(
|
||||||
mission_in: MissionCreate,
|
mission_in: MissionCreate,
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,17 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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 sqlalchemy.orm import Session
|
||||||
|
|
||||||
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.journal import JournalEntry
|
from app.models.journal import JournalEntry
|
||||||
from app.models.user import User
|
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"])
|
router = APIRouter(prefix="/api/journal", tags=["journal"])
|
||||||
|
|
||||||
|
|
@ -27,3 +30,48 @@ def list_journal(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
return [JournalEntryRead.model_validate(entry) for entry in entries]
|
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)
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.orm import Session, selectinload
|
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.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.models.user import User
|
||||||
from app.schemas.branch import BranchMissionRead, BranchRead
|
from app.schemas.branch import BranchMissionRead, BranchRead
|
||||||
from app.schemas.mission import (
|
from app.schemas.mission import (
|
||||||
|
|
@ -22,12 +24,73 @@ from app.services.mission import submit_mission
|
||||||
router = APIRouter(prefix="/api/missions", tags=["missions"])
|
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="Список веток миссий")
|
@router.get("/branches", response_model=list[BranchRead], summary="Список веток миссий")
|
||||||
def list_branches(
|
def list_branches(
|
||||||
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||||
) -> list[BranchRead]:
|
) -> list[BranchRead]:
|
||||||
"""Возвращаем ветки с упорядоченными миссиями."""
|
"""Возвращаем ветки с упорядоченными миссиями."""
|
||||||
|
|
||||||
|
db.refresh(current_user)
|
||||||
|
_ = current_user.submissions
|
||||||
branches = (
|
branches = (
|
||||||
db.query(Branch)
|
db.query(Branch)
|
||||||
.options(selectinload(Branch.missions).selectinload(BranchMission.mission))
|
.options(selectinload(Branch.missions).selectinload(BranchMission.mission))
|
||||||
|
|
@ -35,23 +98,60 @@ def list_branches(
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
return [
|
completed_missions = _load_user_progress(current_user)
|
||||||
BranchRead(
|
branch_dependencies = _build_branch_dependencies(branches)
|
||||||
id=branch.id,
|
|
||||||
title=branch.title,
|
mission_titles = {
|
||||||
description=branch.description,
|
item.mission_id: item.mission.title if item.mission else ""
|
||||||
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
|
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="Список миссий")
|
@router.get("/", response_model=list[MissionBase], summary="Список миссий")
|
||||||
|
|
@ -60,13 +160,45 @@ def list_missions(
|
||||||
) -> list[MissionBase]:
|
) -> list[MissionBase]:
|
||||||
"""Возвращаем доступные миссии."""
|
"""Возвращаем доступные миссии."""
|
||||||
|
|
||||||
query = db.query(Mission).filter(Mission.is_active.is_(True))
|
db.refresh(current_user)
|
||||||
if current_user.current_rank_id:
|
_ = current_user.submissions
|
||||||
query = query.filter(
|
|
||||||
(Mission.minimum_rank_id.is_(None)) | (Mission.minimum_rank_id <= current_user.current_rank_id)
|
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()
|
.filter(Mission.is_active.is_(True))
|
||||||
return [MissionBase.model_validate(mission) for mission in missions]
|
.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="Карточка миссии")
|
@router.get("/{mission_id}", response_model=MissionDetail, summary="Карточка миссии")
|
||||||
|
|
@ -82,6 +214,25 @@ def get_mission(
|
||||||
if not mission:
|
if not mission:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
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]
|
prerequisites = [link.required_mission_id for link in mission.prerequisites]
|
||||||
rewards = [
|
rewards = [
|
||||||
{
|
{
|
||||||
|
|
@ -100,6 +251,8 @@ def get_mission(
|
||||||
mana_reward=mission.mana_reward,
|
mana_reward=mission.mana_reward,
|
||||||
difficulty=mission.difficulty,
|
difficulty=mission.difficulty,
|
||||||
is_active=mission.is_active,
|
is_active=mission.is_active,
|
||||||
|
is_available=is_available,
|
||||||
|
locked_reasons=reasons,
|
||||||
minimum_rank_id=mission.minimum_rank_id,
|
minimum_rank_id=mission.minimum_rank_id,
|
||||||
artifact_id=mission.artifact_id,
|
artifact_id=mission.artifact_id,
|
||||||
prerequisites=prerequisites,
|
prerequisites=prerequisites,
|
||||||
|
|
@ -120,9 +273,30 @@ def submit(
|
||||||
) -> MissionSubmissionRead:
|
) -> 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:
|
if not mission:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
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(
|
submission = submit_mission(
|
||||||
db=db,
|
db=db,
|
||||||
user=current_user,
|
user=current_user,
|
||||||
|
|
|
||||||
60
backend/app/api/routes/onboarding.py
Normal file
60
backend/app/api/routes/onboarding.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Онбординг и космический лор."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.onboarding import (
|
||||||
|
OnboardingCompleteRequest,
|
||||||
|
OnboardingOverview,
|
||||||
|
OnboardingSlideRead,
|
||||||
|
OnboardingStateRead,
|
||||||
|
)
|
||||||
|
from app.services.onboarding import complete_slide, get_overview
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/onboarding", tags=["onboarding"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=OnboardingOverview, summary="Лор и прогресс онбординга")
|
||||||
|
def read_onboarding(
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> OnboardingOverview:
|
||||||
|
"""Отдаём все слайды вместе с состоянием пользователя."""
|
||||||
|
|
||||||
|
slides, state, next_order = get_overview(db, current_user)
|
||||||
|
return OnboardingOverview(
|
||||||
|
slides=[OnboardingSlideRead.model_validate(slide) for slide in slides],
|
||||||
|
state=OnboardingStateRead(
|
||||||
|
last_completed_order=state.last_completed_order,
|
||||||
|
is_completed=state.is_completed,
|
||||||
|
),
|
||||||
|
next_order=next_order,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/complete", response_model=OnboardingStateRead, summary="Завершаем шаг онбординга")
|
||||||
|
def complete_onboarding_step(
|
||||||
|
payload: OnboardingCompleteRequest,
|
||||||
|
*,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> OnboardingStateRead:
|
||||||
|
"""Фиксируем прохождение очередного шага лора."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
state = complete_slide(db, current_user, payload.order)
|
||||||
|
except ValueError as exc: # pragma: no cover - ошибка бизнес-логики
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
return OnboardingStateRead(
|
||||||
|
last_completed_order=state.last_completed_order,
|
||||||
|
is_completed=state.is_completed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
"""Конфигурация приложения и загрузка окружения."""
|
"""Конфигурация приложения и загрузка окружения."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
@ -20,7 +23,7 @@ class Settings(BaseSettings):
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 60 * 12
|
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")
|
sqlite_path: Path = Path("/data/app.db")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
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.core.config import settings
|
||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
|
|
@ -32,6 +32,7 @@ app.include_router(auth.router)
|
||||||
app.include_router(users.router)
|
app.include_router(users.router)
|
||||||
app.include_router(missions.router)
|
app.include_router(missions.router)
|
||||||
app.include_router(journal.router)
|
app.include_router(journal.router)
|
||||||
|
app.include_router(onboarding.router)
|
||||||
app.include_router(store.router)
|
app.include_router(store.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from .artifact import Artifact # noqa: F401
|
||||||
from .branch import Branch, BranchMission # noqa: F401
|
from .branch import Branch, BranchMission # noqa: F401
|
||||||
from .journal import JournalEntry # noqa: F401
|
from .journal import JournalEntry # noqa: F401
|
||||||
from .mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission # 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 .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401
|
||||||
from .store import Order, StoreItem # noqa: F401
|
from .store import Order, StoreItem # noqa: F401
|
||||||
from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401
|
from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401
|
||||||
|
|
@ -17,6 +18,8 @@ __all__ = [
|
||||||
"MissionCompetencyReward",
|
"MissionCompetencyReward",
|
||||||
"MissionPrerequisite",
|
"MissionPrerequisite",
|
||||||
"MissionSubmission",
|
"MissionSubmission",
|
||||||
|
"OnboardingSlide",
|
||||||
|
"OnboardingState",
|
||||||
"Rank",
|
"Rank",
|
||||||
"RankCompetencyRequirement",
|
"RankCompetencyRequirement",
|
||||||
"RankMissionRequirement",
|
"RankMissionRequirement",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, JSON, String, Text
|
from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, JSON, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
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)
|
event_type: Mapped[JournalEventType] = mapped_column(SQLEnum(JournalEventType), nullable=False)
|
||||||
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
description: Mapped[str] = mapped_column(Text, 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)
|
xp_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
mana_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
mana_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
|
|
||||||
40
backend/app/models/onboarding.py
Normal file
40
backend/app/models/onboarding.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""Модели онбординга и лора."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint, ForeignKey
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingSlide(Base, TimestampMixin):
|
||||||
|
"""Контентный слайд онбординга, который видит пилот."""
|
||||||
|
|
||||||
|
__tablename__ = "onboarding_slides"
|
||||||
|
__table_args__ = (UniqueConstraint("order", name="uq_onboarding_slide_order"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||||
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
media_url: Mapped[Optional[str]] = mapped_column(String(512))
|
||||||
|
cta_text: Mapped[Optional[str]] = mapped_column(String(120))
|
||||||
|
cta_link: Mapped[Optional[str]] = mapped_column(String(512))
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingState(Base, TimestampMixin):
|
||||||
|
"""Прогресс пользователя по онбордингу."""
|
||||||
|
|
||||||
|
__tablename__ = "onboarding_states"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", name="uq_onboarding_state_user"),)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||||
|
last_completed_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
is_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="onboarding_state")
|
||||||
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from enum import Enum
|
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 import Enum as SQLEnum, ForeignKey, Integer, String, Text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
@ -45,7 +45,7 @@ class Order(Base, TimestampMixin):
|
||||||
status: Mapped[OrderStatus] = mapped_column(
|
status: Mapped[OrderStatus] = mapped_column(
|
||||||
SQLEnum(OrderStatus), default=OrderStatus.CREATED, nullable=False
|
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")
|
user = relationship("User", back_populates="orders")
|
||||||
item = relationship("StoreItem", back_populates="orders")
|
item = relationship("StoreItem", back_populates="orders")
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.models.base import Base, TimestampMixin
|
from app.models.base import Base, TimestampMixin
|
||||||
|
|
||||||
|
# Локальные импорты внизу файла, чтобы избежать циклов типов
|
||||||
|
|
||||||
|
|
||||||
class UserRole(str, Enum):
|
class UserRole(str, Enum):
|
||||||
"""Типы ролей в системе."""
|
"""Типы ролей в системе."""
|
||||||
|
|
@ -48,6 +50,9 @@ class User(Base, TimestampMixin):
|
||||||
artifacts: Mapped[List["UserArtifact"]] = relationship(
|
artifacts: Mapped[List["UserArtifact"]] = relationship(
|
||||||
"UserArtifact", back_populates="user", cascade="all, delete-orphan"
|
"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):
|
class CompetencyCategory(str, Enum):
|
||||||
|
|
@ -103,3 +108,7 @@ class UserArtifact(Base, TimestampMixin):
|
||||||
|
|
||||||
user = relationship("User", back_populates="artifacts")
|
user = relationship("User", back_populates="artifacts")
|
||||||
artifact = relationship("Artifact", back_populates="pilots")
|
artifact = relationship("Artifact", back_populates="pilots")
|
||||||
|
|
||||||
|
|
||||||
|
# Импорты в конце файла, чтобы отношения корректно инициализировались
|
||||||
|
from app.models.onboarding import OnboardingState # noqa: E402 pylint: disable=wrong-import-position
|
||||||
|
|
|
||||||
31
backend/app/schemas/admin_stats.py
Normal file
31
backend/app/schemas/admin_stats.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Сводные метрики для HR."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class SubmissionStats(BaseModel):
|
||||||
|
"""Структура статистики по отправкам миссий."""
|
||||||
|
|
||||||
|
pending: int
|
||||||
|
approved: int
|
||||||
|
rejected: int
|
||||||
|
|
||||||
|
|
||||||
|
class BranchCompletionStat(BaseModel):
|
||||||
|
"""Завершённость ветки."""
|
||||||
|
|
||||||
|
branch_id: int
|
||||||
|
branch_title: str
|
||||||
|
completion_rate: float
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDashboardStats(BaseModel):
|
||||||
|
"""Ответ с основными метриками."""
|
||||||
|
|
||||||
|
total_users: int
|
||||||
|
active_pilots: int
|
||||||
|
average_completed_missions: float
|
||||||
|
submission_stats: SubmissionStats
|
||||||
|
branch_completion: list[BranchCompletionStat]
|
||||||
|
|
@ -14,6 +14,25 @@ class ArtifactRead(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
rarity: ArtifactRarity
|
rarity: ArtifactRarity
|
||||||
|
image_url: str | None = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ class BranchMissionRead(BaseModel):
|
||||||
mission_id: int
|
mission_id: int
|
||||||
mission_title: str
|
mission_title: str
|
||||||
order: int
|
order: int
|
||||||
|
is_completed: bool = False
|
||||||
|
is_available: bool = False
|
||||||
|
|
||||||
|
|
||||||
class BranchRead(BaseModel):
|
class BranchRead(BaseModel):
|
||||||
|
|
@ -21,6 +23,8 @@ class BranchRead(BaseModel):
|
||||||
description: str
|
description: str
|
||||||
category: str
|
category: str
|
||||||
missions: list[BranchMissionRead]
|
missions: list[BranchMissionRead]
|
||||||
|
total_missions: int = 0
|
||||||
|
completed_missions: int = 0
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,19 @@ class JournalEntryRead(BaseModel):
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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]
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.models.mission import MissionDifficulty, SubmissionStatus
|
from app.models.mission import MissionDifficulty, SubmissionStatus
|
||||||
|
|
||||||
|
|
@ -20,6 +20,8 @@ class MissionBase(BaseModel):
|
||||||
mana_reward: int
|
mana_reward: int
|
||||||
difficulty: MissionDifficulty
|
difficulty: MissionDifficulty
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
is_available: bool = True
|
||||||
|
locked_reasons: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
@ -95,6 +97,7 @@ class MissionSubmissionCreate(BaseModel):
|
||||||
class MissionSubmissionRead(BaseModel):
|
class MissionSubmissionRead(BaseModel):
|
||||||
"""Получение статуса отправки."""
|
"""Получение статуса отправки."""
|
||||||
|
|
||||||
|
id: int
|
||||||
mission_id: int
|
mission_id: int
|
||||||
status: SubmissionStatus
|
status: SubmissionStatus
|
||||||
comment: Optional[str]
|
comment: Optional[str]
|
||||||
|
|
|
||||||
44
backend/app/schemas/onboarding.py
Normal file
44
backend/app/schemas/onboarding.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Схемы для онбординга."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingSlideRead(BaseModel):
|
||||||
|
"""Отдельный слайд онбординга."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
order: int
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
media_url: Optional[str]
|
||||||
|
cta_text: Optional[str]
|
||||||
|
cta_link: Optional[str]
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingStateRead(BaseModel):
|
||||||
|
"""Прогресс пользователя."""
|
||||||
|
|
||||||
|
last_completed_order: int
|
||||||
|
is_completed: bool
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingOverview(BaseModel):
|
||||||
|
"""Полный ответ с прогрессом и контентом."""
|
||||||
|
|
||||||
|
slides: list[OnboardingSlideRead]
|
||||||
|
state: OnboardingStateRead
|
||||||
|
next_order: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class OnboardingCompleteRequest(BaseModel):
|
||||||
|
"""Запрос на фиксацию завершения шага."""
|
||||||
|
|
||||||
|
order: int
|
||||||
|
|
||||||
|
|
@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models.journal import JournalEventType
|
from app.models.journal import JournalEventType
|
||||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
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.journal import log_event
|
||||||
from app.services.rank import apply_rank_upgrade
|
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)
|
_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.add_all([submission, user])
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(submission)
|
db.refresh(submission)
|
||||||
|
|
|
||||||
61
backend/app/services/onboarding.py
Normal file
61
backend/app/services/onboarding.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Сервисный слой для онбординга."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.onboarding import OnboardingSlide, OnboardingState
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_state(db: Session, user: User) -> OnboardingState:
|
||||||
|
"""Гарантируем наличие записи о прогрессе."""
|
||||||
|
|
||||||
|
if user.onboarding_state:
|
||||||
|
return user.onboarding_state
|
||||||
|
|
||||||
|
state = OnboardingState(user_id=user.id, last_completed_order=0, is_completed=False)
|
||||||
|
db.add(state)
|
||||||
|
db.flush()
|
||||||
|
db.refresh(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def get_overview(db: Session, user: User) -> tuple[list[OnboardingSlide], OnboardingState, int | None]:
|
||||||
|
"""Возвращаем все слайды и текущий прогресс."""
|
||||||
|
|
||||||
|
slides = db.query(OnboardingSlide).order_by(OnboardingSlide.order).all()
|
||||||
|
state = _ensure_state(db, user)
|
||||||
|
|
||||||
|
next_slide = next((slide for slide in slides if slide.order > state.last_completed_order), None)
|
||||||
|
next_order: int | None = next_slide.order if next_slide else None
|
||||||
|
return slides, state, next_order
|
||||||
|
|
||||||
|
|
||||||
|
def complete_slide(db: Session, user: User, order: int) -> OnboardingState:
|
||||||
|
"""Фиксируем завершение шага, если это корректный порядок."""
|
||||||
|
|
||||||
|
slides = db.query(OnboardingSlide).order_by(OnboardingSlide.order).all()
|
||||||
|
if not slides:
|
||||||
|
raise ValueError("Онбординг ещё не настроен")
|
||||||
|
|
||||||
|
state = _ensure_state(db, user)
|
||||||
|
|
||||||
|
allowed_orders = [slide.order for slide in slides]
|
||||||
|
if order not in allowed_orders:
|
||||||
|
raise ValueError("Неизвестный шаг онбординга")
|
||||||
|
|
||||||
|
if order <= state.last_completed_order:
|
||||||
|
return state
|
||||||
|
|
||||||
|
expected_order = next((value for value in allowed_orders if value > state.last_completed_order), None)
|
||||||
|
if expected_order is None or order != expected_order:
|
||||||
|
raise ValueError("Сначала завершите предыдущие шаги")
|
||||||
|
|
||||||
|
state.last_completed_order = order
|
||||||
|
state.is_completed = order == allowed_orders[-1]
|
||||||
|
db.add(state)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(state)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.artifact import Artifact, ArtifactRarity
|
||||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import User, UserRole
|
||||||
from app.services.mission import approve_submission
|
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.xp == mission.xp_reward
|
||||||
assert user.mana == mission.mana_reward
|
assert user.mana == mission.mana_reward
|
||||||
assert submission.status == SubmissionStatus.APPROVED
|
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
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import { AdminBranchManager } from '../../components/admin/AdminBranchManager';
|
import { AdminBranchManager } from '../../components/admin/AdminBranchManager';
|
||||||
import { AdminMissionManager } from '../../components/admin/AdminMissionManager';
|
import { AdminMissionManager } from '../../components/admin/AdminMissionManager';
|
||||||
import { AdminRankManager } from '../../components/admin/AdminRankManager';
|
import { AdminRankManager } from '../../components/admin/AdminRankManager';
|
||||||
|
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
|
||||||
|
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { getDemoToken } from '../../lib/demo-auth';
|
import { getDemoToken } from '../../lib/demo-auth';
|
||||||
|
|
||||||
interface Submission {
|
interface Submission {
|
||||||
|
id: number;
|
||||||
mission_id: number;
|
mission_id: number;
|
||||||
status: string;
|
status: string;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
|
|
@ -51,18 +54,40 @@ interface ArtifactSummary {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
rarity: 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() {
|
export default async function AdminPage() {
|
||||||
const token = await getDemoToken('hr');
|
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<Submission[]>('/api/admin/submissions', { authToken: token }),
|
apiFetch<Submission[]>('/api/admin/submissions', { authToken: token }),
|
||||||
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: token }),
|
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: token }),
|
||||||
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: token }),
|
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: token }),
|
||||||
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: token }),
|
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: token }),
|
||||||
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: token }),
|
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: token }),
|
||||||
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: token })
|
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: token }),
|
||||||
|
apiFetch<AdminStats>('/api/admin/stats', { authToken: token })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -73,6 +98,46 @@ export default async function AdminPage() {
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
|
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
|
||||||
|
<div className="card" style={{ gridColumn: '1 / -1', display: 'grid', gap: '1rem' }}>
|
||||||
|
<h3>Сводка</h3>
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '1rem' }}>
|
||||||
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
|
<span className="badge">Пилоты</span>
|
||||||
|
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.total_users}</p>
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>Всего зарегистрировано</small>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
|
<span className="badge">Активность</span>
|
||||||
|
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.active_pilots}</p>
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>Закрыли хотя бы одну миссию</small>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
|
<span className="badge">Средний прогресс</span>
|
||||||
|
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.average_completed_missions.toFixed(1)}</p>
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>Миссий на пилота</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
|
||||||
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
|
<strong>Очередь модерации</strong>
|
||||||
|
<p style={{ marginTop: '0.5rem' }}>На проверке: {stats.submission_stats.pending}</p>
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Одобрено: {stats.submission_stats.approved} · Отклонено: {stats.submission_stats.rejected}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
|
<strong>Завершённость веток</strong>
|
||||||
|
<ul style={{ listStyle: 'none', margin: '0.75rem 0 0', padding: 0 }}>
|
||||||
|
{stats.branch_completion.map((branchStat) => (
|
||||||
|
<li key={branchStat.branch_id} style={{ marginBottom: '0.25rem' }}>
|
||||||
|
{branchStat.branch_title}: {(branchStat.completion_rate * 100).toFixed(0)}%
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card" style={{ gridColumn: '1 / -1' }}>
|
<div className="card" style={{ gridColumn: '1 / -1' }}>
|
||||||
<h3>Очередь модерации</h3>
|
<h3>Очередь модерации</h3>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
|
|
@ -80,22 +145,7 @@ export default async function AdminPage() {
|
||||||
</p>
|
</p>
|
||||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
|
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||||
{submissions.map((submission) => (
|
{submissions.map((submission) => (
|
||||||
<div key={`${submission.mission_id}-${submission.updated_at}`} className="card">
|
<AdminSubmissionCard key={submission.id} submission={submission} token={token} />
|
||||||
<h4>Миссия #{submission.mission_id}</h4>
|
|
||||||
<p>Статус: {submission.status}</p>
|
|
||||||
{submission.comment && <p>Комментарий пилота: {submission.comment}</p>}
|
|
||||||
{submission.proof_url && (
|
|
||||||
<p>
|
|
||||||
Доказательство:{' '}
|
|
||||||
<a href={submission.proof_url} target="_blank" rel="noreferrer">
|
|
||||||
открыть
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<small style={{ color: 'var(--text-muted)' }}>
|
|
||||||
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
{submissions.length === 0 && <p>Очередь пуста — все миссии проверены.</p>}
|
{submissions.length === 0 && <p>Очередь пуста — все миссии проверены.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -111,6 +161,7 @@ export default async function AdminPage() {
|
||||||
artifacts={artifacts}
|
artifacts={artifacts}
|
||||||
/>
|
/>
|
||||||
<AdminRankManager token={token} ranks={ranks} missions={missions} competencies={competencies} />
|
<AdminRankManager token={token} ranks={ranks} missions={missions} competencies={competencies} />
|
||||||
|
<AdminArtifactManager token={token} artifacts={artifacts} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -12,22 +12,62 @@ interface JournalEntry {
|
||||||
created_at: string;
|
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() {
|
async function fetchJournal() {
|
||||||
const token = await getDemoToken();
|
const token = await getDemoToken();
|
||||||
const entries = await apiFetch<JournalEntry[]>('/api/journal/', { authToken: token });
|
const [entries, week, month, year] = await Promise.all([
|
||||||
return entries;
|
apiFetch<JournalEntry[]>('/api/journal/', { authToken: token }),
|
||||||
|
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=week', { authToken: token }),
|
||||||
|
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=month', { authToken: token }),
|
||||||
|
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=year', { authToken: token })
|
||||||
|
]);
|
||||||
|
return { entries, leaderboards: [week, month, year] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function JournalPage() {
|
export default async function JournalPage() {
|
||||||
const entries = await fetchJournal();
|
const { entries, leaderboards } = await fetchJournal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', gap: '2rem' }}>
|
||||||
<h2>Бортовой журнал</h2>
|
<div>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>
|
<h2>Бортовой журнал</h2>
|
||||||
Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине.
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
</p>
|
Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине.
|
||||||
<JournalTimeline entries={entries} />
|
</p>
|
||||||
|
<JournalTimeline entries={entries} />
|
||||||
|
</div>
|
||||||
|
<aside className="card" style={{ position: 'sticky', top: '1.5rem' }}>
|
||||||
|
<h3>ТОП экипажа</h3>
|
||||||
|
{leaderboards.map((board) => (
|
||||||
|
<div key={board.period} style={{ marginBottom: '1rem' }}>
|
||||||
|
<strong style={{ textTransform: 'capitalize' }}>
|
||||||
|
{board.period === 'week' ? 'Неделя' : board.period === 'month' ? 'Месяц' : 'Год'}
|
||||||
|
</strong>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
|
||||||
|
{board.entries.length === 0 && <li style={{ color: 'var(--text-muted)' }}>Пока нет лидеров.</li>}
|
||||||
|
{board.entries.map((entry, index) => (
|
||||||
|
<li key={entry.user_id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem' }}>
|
||||||
|
<span>
|
||||||
|
#{index + 1} {entry.full_name}
|
||||||
|
</span>
|
||||||
|
<span>{entry.xp_delta} XP · {entry.mana_delta} ⚡</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,29 @@ export const metadata: Metadata = {
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<body>
|
<body style={{ backgroundAttachment: 'fixed' }}>
|
||||||
<StyledComponentsRegistry>
|
<StyledComponentsRegistry>
|
||||||
<header style={{ padding: '1.5rem', display: 'flex', justifyContent: 'space-between' }}>
|
<header
|
||||||
|
style={{
|
||||||
|
padding: '1.5rem',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
background:
|
||||||
|
'linear-gradient(135deg, rgba(108,92,231,0.25), rgba(0,184,148,0.15))',
|
||||||
|
borderBottom: '1px solid rgba(162, 155, 254, 0.2)',
|
||||||
|
boxShadow: '0 10px 30px rgba(0,0,0,0.25)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ margin: 0 }}>Mission Control</h1>
|
<h1 style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>Mission Control</h1>
|
||||||
<p style={{ margin: 0, color: 'var(--text-muted)' }}>
|
<p style={{ margin: 0, color: 'var(--text-muted)' }}>
|
||||||
Путь пилота от искателя до члена экипажа
|
Путь пилота от искателя до командира космической эскадры
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center', fontWeight: 500 }}>
|
||||||
<a href="/">Дашборд</a>
|
<a href="/">Дашборд</a>
|
||||||
|
<a href="/onboarding">Онбординг</a>
|
||||||
<a href="/missions">Миссии</a>
|
<a href="/missions">Миссии</a>
|
||||||
<a href="/journal">Журнал</a>
|
<a href="/journal">Журнал</a>
|
||||||
<a href="/store">Магазин</a>
|
<a href="/store">Магазин</a>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ interface MissionDetail {
|
||||||
competency_name: string;
|
competency_name: string;
|
||||||
level_delta: number;
|
level_delta: number;
|
||||||
}>;
|
}>;
|
||||||
|
is_available: boolean;
|
||||||
|
locked_reasons: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMission(id: number) {
|
async function fetchMission(id: number) {
|
||||||
|
|
@ -41,6 +43,16 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
<p style={{ marginTop: '1rem' }}>
|
<p style={{ marginTop: '1rem' }}>
|
||||||
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
||||||
</p>
|
</p>
|
||||||
|
{!mission.is_available && mission.locked_reasons.length > 0 && (
|
||||||
|
<div className="card" style={{ border: '1px solid rgba(255, 118, 117, 0.5)', background: 'rgba(255,118,117,0.1)' }}>
|
||||||
|
<strong>Миссия заблокирована</strong>
|
||||||
|
<ul style={{ marginTop: '0.5rem' }}>
|
||||||
|
{mission.locked_reasons.map((reason) => (
|
||||||
|
<li key={reason}>{reason}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<h3>Компетенции</h3>
|
<h3>Компетенции</h3>
|
||||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
|
@ -52,7 +64,7 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
||||||
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<MissionSubmissionForm missionId={mission.id} token={token} />
|
<MissionSubmissionForm missionId={mission.id} token={token} locked={!mission.is_available} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,35 @@ import { apiFetch } from '../../lib/api';
|
||||||
import { getDemoToken } from '../../lib/demo-auth';
|
import { getDemoToken } from '../../lib/demo-auth';
|
||||||
import { MissionList, MissionSummary } from '../../components/MissionList';
|
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() {
|
async function fetchMissions() {
|
||||||
const token = await getDemoToken();
|
const token = await getDemoToken();
|
||||||
const missions = await apiFetch<MissionSummary[]>('/api/missions/', { authToken: token });
|
const [missions, branches] = await Promise.all([
|
||||||
return missions;
|
apiFetch<MissionSummary[]>('/api/missions/', { authToken: token }),
|
||||||
|
apiFetch<BranchOverview[]>('/api/missions/branches', { authToken: token })
|
||||||
|
]);
|
||||||
|
return { missions, branches };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function MissionsPage() {
|
export default async function MissionsPage() {
|
||||||
const missions = await fetchMissions();
|
const { missions, branches } = await fetchMissions();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
|
|
@ -18,6 +39,45 @@ export default async function MissionsPage() {
|
||||||
Список обновляется в реальном времени и зависит от вашего ранга и прогресса. HR может добавлять новые
|
Список обновляется в реальном времени и зависит от вашего ранга и прогресса. HR может добавлять новые
|
||||||
задания в админ-панели.
|
задания в админ-панели.
|
||||||
</p>
|
</p>
|
||||||
|
<div className="grid" style={{ marginBottom: '2rem' }}>
|
||||||
|
{branches.map((branch) => {
|
||||||
|
const progress = branch.total_missions
|
||||||
|
? Math.round((branch.completed_missions / branch.total_missions) * 100)
|
||||||
|
: 0;
|
||||||
|
const nextMission = branch.missions.find((mission) => !mission.is_completed);
|
||||||
|
return (
|
||||||
|
<div key={branch.id} className="card">
|
||||||
|
<h3 style={{ marginBottom: '0.5rem' }}>{branch.title}</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{branch.description}</p>
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
<small>Прогресс ветки: {progress}%</small>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '0.5rem',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '999px',
|
||||||
|
background: 'rgba(162, 155, 254, 0.2)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: `${progress}%`,
|
||||||
|
height: '100%',
|
||||||
|
background: 'linear-gradient(90deg, var(--accent), #00b894)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{nextMission && (
|
||||||
|
<p style={{ marginTop: '1rem', color: 'var(--text-muted)' }}>
|
||||||
|
Следующая миссия: «{nextMission.mission_title}»
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<MissionList missions={missions} />
|
<MissionList missions={missions} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
50
frontend/src/app/onboarding/page.tsx
Normal file
50
frontend/src/app/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
import { getDemoToken } from '../../lib/demo-auth';
|
||||||
|
import { OnboardingCarousel, OnboardingSlide } from '../../components/OnboardingCarousel';
|
||||||
|
|
||||||
|
interface OnboardingState {
|
||||||
|
last_completed_order: number;
|
||||||
|
is_completed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnboardingResponse {
|
||||||
|
slides: OnboardingSlide[];
|
||||||
|
state: OnboardingState;
|
||||||
|
next_order: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOnboarding() {
|
||||||
|
const token = await getDemoToken();
|
||||||
|
const data = await apiFetch<OnboardingResponse>('/api/onboarding/', { authToken: token });
|
||||||
|
return { token, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function OnboardingPage() {
|
||||||
|
const { token, data } = await fetchOnboarding();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', alignItems: 'start', gap: '2rem' }}>
|
||||||
|
<div>
|
||||||
|
<OnboardingCarousel
|
||||||
|
token={token}
|
||||||
|
slides={data.slides}
|
||||||
|
initialCompletedOrder={data.state.last_completed_order}
|
||||||
|
nextOrder={data.next_order}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<aside className="card" style={{ position: 'sticky', top: '1.5rem' }}>
|
||||||
|
<h3>Бортовой навигатор</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)', lineHeight: 1.6 }}>
|
||||||
|
Онбординг знакомит вас с космической культурой «Алабуги» и объясняет, как миссии превращают карьерные шаги в
|
||||||
|
единый маршрут. Каждый шаг открывает новые подсказки и призы в магазине.
|
||||||
|
</p>
|
||||||
|
<ul style={{ listStyle: 'none', margin: '1rem 0 0', padding: 0, color: 'var(--text-muted)' }}>
|
||||||
|
<li>• Читайте лор и переходите к миссиям напрямую.</li>
|
||||||
|
<li>• Следите за прогрессом — статус сохраняется автоматически.</li>
|
||||||
|
<li>• Доступ к веткам миссий открывается по мере выполнения шагов.</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -22,18 +22,29 @@ interface RankResponse {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
required_xp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProfile() {
|
async function fetchProfile() {
|
||||||
const token = await getDemoToken();
|
const token = await getDemoToken();
|
||||||
const profile = await apiFetch<ProfileResponse>('/api/me', { authToken: token });
|
const profile = await apiFetch<ProfileResponse>('/api/me', { authToken: token });
|
||||||
const ranks = await apiFetch<RankResponse[]>('/api/ranks', { authToken: token });
|
const ranks = await apiFetch<RankResponse[]>('/api/ranks', { authToken: token });
|
||||||
const currentRank = ranks.find((rank) => rank.id === profile.current_rank_id);
|
const orderedRanks = [...ranks].sort((a, b) => a.required_xp - b.required_xp);
|
||||||
return { token, profile, currentRank };
|
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() {
|
export default async function DashboardPage() {
|
||||||
const { token, profile, currentRank } = await fetchProfile();
|
const { token, profile, currentRank, nextRank, progress, target } = await fetchProfile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
|
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
|
||||||
|
|
@ -45,6 +56,9 @@ export default async function DashboardPage() {
|
||||||
rank={currentRank}
|
rank={currentRank}
|
||||||
competencies={profile.competencies}
|
competencies={profile.competencies}
|
||||||
artifacts={profile.artifacts}
|
artifacts={profile.artifacts}
|
||||||
|
nextRankTitle={nextRank?.title}
|
||||||
|
xpProgress={progress}
|
||||||
|
xpTarget={target}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<aside className="card">
|
<aside className="card">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ export interface MissionSummary {
|
||||||
mana_reward: number;
|
mana_reward: number;
|
||||||
difficulty: string;
|
difficulty: string;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
is_available: boolean;
|
||||||
|
locked_reasons: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
|
|
@ -31,14 +33,24 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
||||||
<span className="badge">{mission.difficulty}</span>
|
<span className="badge">{mission.difficulty}</span>
|
||||||
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
||||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
||||||
<p style={{ marginTop: '1rem' }}>
|
<p style={{ marginTop: '1rem' }}>{mission.xp_reward} XP · {mission.mana_reward} ⚡</p>
|
||||||
{mission.xp_reward} XP · {mission.mana_reward} ⚡
|
{!mission.is_available && mission.locked_reasons.length > 0 && (
|
||||||
</p>
|
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>
|
||||||
<a className="primary" style={{ display: 'inline-block', marginTop: '1rem' }} href={`/missions/${mission.id}`}>
|
)}
|
||||||
Открыть брифинг
|
<a
|
||||||
</a>
|
className={mission.is_available ? 'primary' : 'secondary'}
|
||||||
</Card>
|
style={{
|
||||||
))}
|
display: 'inline-block',
|
||||||
</div>
|
marginTop: '1rem',
|
||||||
|
pointerEvents: mission.is_available ? 'auto' : 'none',
|
||||||
|
opacity: mission.is_available ? 1 : 0.5
|
||||||
|
}}
|
||||||
|
href={mission.is_available ? `/missions/${mission.id}` : '#'}
|
||||||
|
>
|
||||||
|
{mission.is_available ? 'Открыть брифинг' : 'Заблокировано'}
|
||||||
|
</a>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ import { apiFetch } from '../lib/api';
|
||||||
interface MissionSubmissionFormProps {
|
interface MissionSubmissionFormProps {
|
||||||
missionId: number;
|
missionId: number;
|
||||||
token?: string;
|
token?: string;
|
||||||
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFormProps) {
|
export function MissionSubmissionForm({ missionId, token, locked = false }: MissionSubmissionFormProps) {
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState('');
|
||||||
const [proofUrl, setProofUrl] = useState('');
|
const [proofUrl, setProofUrl] = useState('');
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
|
@ -21,6 +22,11 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
setStatus('Миссия пока недоступна — выполните предыдущие условия.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setStatus(null);
|
setStatus(null);
|
||||||
|
|
@ -52,6 +58,7 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
|
||||||
rows={4}
|
rows={4}
|
||||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||||
placeholder="Опишите, что сделали."
|
placeholder="Опишите, что сделали."
|
||||||
|
disabled={locked}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
||||||
|
|
@ -62,10 +69,11 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
|
||||||
onChange={(event) => setProofUrl(event.target.value)}
|
onChange={(event) => setProofUrl(event.target.value)}
|
||||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||||
placeholder="https://..."
|
placeholder="https://..."
|
||||||
|
disabled={locked}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button className="primary" type="submit" disabled={loading}>
|
<button className="primary" type="submit" disabled={loading || locked}>
|
||||||
{loading ? 'Отправляем...' : 'Отправить HR'}
|
{locked ? 'Недоступно' : loading ? 'Отправляем...' : 'Отправить HR'}
|
||||||
</button>
|
</button>
|
||||||
{status && <p style={{ marginTop: '1rem', color: 'var(--accent-light)' }}>{status}</p>}
|
{status && <p style={{ marginTop: '1rem', color: 'var(--accent-light)' }}>{status}</p>}
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
123
frontend/src/components/OnboardingCarousel.tsx
Normal file
123
frontend/src/components/OnboardingCarousel.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
|
||||||
|
export interface OnboardingSlide {
|
||||||
|
id: number;
|
||||||
|
order: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
media_url?: string | null;
|
||||||
|
cta_text?: string | null;
|
||||||
|
cta_link?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnboardingCarouselProps {
|
||||||
|
token?: string;
|
||||||
|
slides: OnboardingSlide[];
|
||||||
|
initialCompletedOrder: number;
|
||||||
|
nextOrder: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingCarousel({ token, slides, initialCompletedOrder, nextOrder }: OnboardingCarouselProps) {
|
||||||
|
const startIndex = nextOrder ? Math.max(slides.findIndex((slide) => slide.order === nextOrder), 0) : slides.length - 1;
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(Math.max(startIndex, 0));
|
||||||
|
const [completedOrder, setCompletedOrder] = useState(initialCompletedOrder);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const currentSlide = slides[currentIndex];
|
||||||
|
const allCompleted = completedOrder >= (slides.at(-1)?.order ?? 0);
|
||||||
|
|
||||||
|
async function handleComplete() {
|
||||||
|
if (!token || !currentSlide) {
|
||||||
|
setStatus('Не удалось определить пользователя. Попробуйте обновить страницу.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await apiFetch('/api/onboarding/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ order: currentSlide.order }),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setCompletedOrder(currentSlide.order);
|
||||||
|
setStatus('Шаг отмечен как выполненный!');
|
||||||
|
if (currentIndex < slides.length - 1) {
|
||||||
|
setCurrentIndex(currentIndex + 1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
setStatus(error.message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentSlide) {
|
||||||
|
return <p>Онбординг недоступен. Свяжитесь с HR.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ display: 'grid', gap: '1.5rem', background: 'rgba(17, 22, 51, 0.9)' }}>
|
||||||
|
<header>
|
||||||
|
<span className="badge">Шаг {currentIndex + 1} из {slides.length}</span>
|
||||||
|
<h2 style={{ margin: '0.75rem 0' }}>{currentSlide.title}</h2>
|
||||||
|
<p style={{ color: 'var(--text-muted)', lineHeight: 1.6 }}>{currentSlide.body}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{currentSlide.media_url && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: '16px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(162, 155, 254, 0.25)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={currentSlide.media_url} alt={currentSlide.title} style={{ width: '100%', height: 'auto' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{currentSlide.cta_text && currentSlide.cta_link && (
|
||||||
|
<a className="primary" href={currentSlide.cta_link}>
|
||||||
|
{currentSlide.cta_text}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!allCompleted && (
|
||||||
|
<button className="primary" onClick={handleComplete} disabled={loading}>
|
||||||
|
{loading ? 'Сохраняем...' : 'Отметить как выполнено'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status && <small style={{ color: 'var(--accent-light)' }}>{status}</small>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setCurrentIndex(Math.max(currentIndex - 1, 0))}
|
||||||
|
disabled={currentIndex === 0}
|
||||||
|
style={{ cursor: currentIndex === 0 ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
Назад
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => setCurrentIndex(Math.min(currentIndex + 1, slides.length - 1))}
|
||||||
|
disabled={currentIndex === slides.length - 1}
|
||||||
|
style={{ cursor: currentIndex === slides.length - 1 ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
Далее
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{allCompleted && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>Вы прошли весь онбординг!</p>}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -28,6 +28,9 @@ export interface ProfileProps {
|
||||||
rank?: Rank;
|
rank?: Rank;
|
||||||
competencies: Competency[];
|
competencies: Competency[];
|
||||||
artifacts: Artifact[];
|
artifacts: Artifact[];
|
||||||
|
nextRankTitle?: string;
|
||||||
|
xpProgress: number;
|
||||||
|
xpTarget: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
|
|
@ -54,8 +57,18 @@ const ProgressBar = styled.div<{ value: number }>`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function ProgressOverview({ fullName, xp, mana, rank, competencies, artifacts }: ProfileProps) {
|
export function ProgressOverview({
|
||||||
const xpPercent = Math.min(100, (xp / 500) * 100);
|
fullName,
|
||||||
|
xp,
|
||||||
|
mana,
|
||||||
|
rank,
|
||||||
|
competencies,
|
||||||
|
artifacts,
|
||||||
|
nextRankTitle,
|
||||||
|
xpProgress,
|
||||||
|
xpTarget
|
||||||
|
}: ProfileProps) {
|
||||||
|
const xpPercent = xpTarget > 0 ? Math.min(100, (xpProgress / xpTarget) * 100) : 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -64,7 +77,13 @@ export function ProgressOverview({ fullName, xp, mana, rank, competencies, artif
|
||||||
<div style={{ marginTop: '1rem' }}>
|
<div style={{ marginTop: '1rem' }}>
|
||||||
<strong>Опыт:</strong>
|
<strong>Опыт:</strong>
|
||||||
<ProgressBar value={xpPercent} />
|
<ProgressBar value={xpPercent} />
|
||||||
<small style={{ color: 'var(--text-muted)' }}>{xp} / 500 XP до следующей цели</small>
|
{nextRankTitle ? (
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Осталось {Math.max(xpTarget - xpProgress, 0)} XP до ранга «{nextRankTitle}»
|
||||||
|
</small>
|
||||||
|
) : (
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>Вы достигли максимального ранга в демо-версии</small>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: '1rem' }}>
|
<div style={{ marginTop: '1rem' }}>
|
||||||
<strong>Мана:</strong>
|
<strong>Мана:</strong>
|
||||||
|
|
|
||||||
192
frontend/src/components/admin/AdminArtifactManager.tsx
Normal file
192
frontend/src/components/admin/AdminArtifactManager.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
|
type Artifact = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
rarity: string;
|
||||||
|
image_url?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RARITY_OPTIONS = [
|
||||||
|
{ value: 'common', label: 'Обычный' },
|
||||||
|
{ value: 'rare', label: 'Редкий' },
|
||||||
|
{ value: 'epic', label: 'Эпический' },
|
||||||
|
{ value: 'legendary', label: 'Легендарный' }
|
||||||
|
];
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string;
|
||||||
|
artifacts: Artifact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminArtifactManager({ token, artifacts }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [rarity, setRarity] = useState('rare');
|
||||||
|
const [imageUrl, setImageUrl] = useState('');
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setName('');
|
||||||
|
setDescription('');
|
||||||
|
setRarity('rare');
|
||||||
|
setImageUrl('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
if (value === 'new') {
|
||||||
|
setSelectedId('new');
|
||||||
|
resetForm();
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Number(value);
|
||||||
|
const artifact = artifacts.find((item) => item.id === id);
|
||||||
|
if (!artifact) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedId(id);
|
||||||
|
setName(artifact.name);
|
||||||
|
setDescription(artifact.description);
|
||||||
|
setRarity(artifact.rarity);
|
||||||
|
setImageUrl(artifact.image_url ?? '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
rarity,
|
||||||
|
image_url: imageUrl || null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (selectedId === 'new') {
|
||||||
|
await apiFetch('/api/admin/artifacts', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Артефакт создан');
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
await apiFetch(`/api/admin/artifacts/${selectedId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Артефакт обновлён');
|
||||||
|
}
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось сохранить артефакт');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (selectedId === 'new') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setStatus(null);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/admin/artifacts/${selectedId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
setStatus('Артефакт удалён');
|
||||||
|
setSelectedId('new');
|
||||||
|
resetForm();
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось удалить артефакт');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3>Артефакты</h3>
|
||||||
|
<p style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Подготовьте коллекционные награды за миссии: укажите название, редкость и изображение. Артефакты можно привязывать
|
||||||
|
в карточке миссии.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="admin-form">
|
||||||
|
<label>
|
||||||
|
Выбранный артефакт
|
||||||
|
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
|
||||||
|
<option value="new">Новый артефакт</option>
|
||||||
|
{artifacts.map((artifact) => (
|
||||||
|
<option key={artifact.id} value={artifact.id}>
|
||||||
|
{artifact.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Название
|
||||||
|
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Описание
|
||||||
|
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={3} required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Редкость
|
||||||
|
<select value={rarity} onChange={(event) => setRarity(event.target.value)}>
|
||||||
|
{RARITY_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Изображение (URL)
|
||||||
|
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||||
|
<button type="submit" className="primary" disabled={loading}>
|
||||||
|
{selectedId === 'new' ? 'Создать артефакт' : 'Сохранить изменения'}
|
||||||
|
</button>
|
||||||
|
{selectedId !== 'new' && (
|
||||||
|
<button type="button" className="secondary" onClick={handleDelete} disabled={loading} style={{ cursor: loading ? 'not-allowed' : 'pointer' }}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
|
||||||
|
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
82
frontend/src/components/admin/AdminSubmissionCard.tsx
Normal file
82
frontend/src/components/admin/AdminSubmissionCard.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
||||||
|
type Submission = {
|
||||||
|
mission_id: number;
|
||||||
|
status: string;
|
||||||
|
comment: string | null;
|
||||||
|
proof_url: string | null;
|
||||||
|
awarded_xp: number;
|
||||||
|
awarded_mana: number;
|
||||||
|
updated_at: string;
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
submission: Submission;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSubmissionCard({ submission, token }: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [loading, setLoading] = useState<'approve' | 'reject' | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleAction = async (action: 'approve' | 'reject') => {
|
||||||
|
setLoading(action);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/admin/submissions/${submission.id}/${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
authToken: token
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Не удалось выполнить действие');
|
||||||
|
} finally {
|
||||||
|
setLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card" style={{ marginBottom: 0 }}>
|
||||||
|
<h4>Миссия #{submission.mission_id}</h4>
|
||||||
|
<p>Статус: {submission.status}</p>
|
||||||
|
{submission.comment && <p>Комментарий пилота: {submission.comment}</p>}
|
||||||
|
{submission.proof_url && (
|
||||||
|
<p>
|
||||||
|
Доказательство:{' '}
|
||||||
|
<a href={submission.proof_url} target="_blank" rel="noreferrer">
|
||||||
|
открыть
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')}
|
||||||
|
</small>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
onClick={() => handleAction('approve')}
|
||||||
|
disabled={loading === 'approve'}
|
||||||
|
>
|
||||||
|
{loading === 'approve' ? 'Одобряем...' : 'Одобрить'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => handleAction('reject')}
|
||||||
|
disabled={loading === 'reject'}
|
||||||
|
style={{ cursor: loading === 'reject' ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
{loading === 'reject' ? 'Отклоняем...' : 'Отклонить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -21,6 +21,15 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
throw new Error(`API error ${response.status}: ${text}`);
|
throw new Error(`API error ${response.status}: ${text}`);
|
||||||
}
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
|
||||||
return response.json() as Promise<T>;
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (contentType && contentType.includes('application/json')) {
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await response.text();
|
||||||
|
return raw as unknown as T;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
--accent-light: #a29bfe;
|
--accent-light: #a29bfe;
|
||||||
--text: #f5f6fa;
|
--text: #f5f6fa;
|
||||||
--text-muted: #b2bec3;
|
--text-muted: #b2bec3;
|
||||||
|
--success: #00b894;
|
||||||
|
--error: #ff7675;
|
||||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,6 +19,18 @@ body {
|
||||||
var(--bg);
|
var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-image: radial-gradient(#ffffff33 1px, transparent 0);
|
||||||
|
background-size: 60px 60px;
|
||||||
|
opacity: 0.25;
|
||||||
|
pointer-events: none;
|
||||||
|
mix-blend-mode: screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
|
@ -41,6 +55,28 @@ main {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form input,
|
||||||
|
.admin-form textarea,
|
||||||
|
.admin-form select {
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(162, 155, 254, 0.3);
|
||||||
|
background: rgba(8, 11, 26, 0.6);
|
||||||
|
padding: 0.75rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
|
@ -71,3 +107,13 @@ main {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.secondary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(162, 155, 254, 0.4);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ from app.models.artifact import Artifact, ArtifactRarity
|
||||||
from app.models.branch import Branch, BranchMission
|
from app.models.branch import Branch, BranchMission
|
||||||
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
|
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
|
||||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||||
|
from app.models.onboarding import OnboardingSlide
|
||||||
from app.models.store import StoreItem
|
from app.models.store import StoreItem
|
||||||
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
|
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
|
||||||
|
|
||||||
|
|
@ -250,6 +251,32 @@ def seed() -> None:
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
OnboardingSlide(
|
||||||
|
order=1,
|
||||||
|
title="Добро пожаловать в орбитальный флот",
|
||||||
|
body="Узнайте, как миссии помогают связать карьерные шаги в единую траекторию.",
|
||||||
|
media_url="https://images.nasa.gov/details-PIA12235",
|
||||||
|
cta_text="Перейти к миссиям",
|
||||||
|
cta_link="/missions",
|
||||||
|
),
|
||||||
|
OnboardingSlide(
|
||||||
|
order=2,
|
||||||
|
title="Получайте опыт и ману",
|
||||||
|
body="Выполняя задания, вы накапливаете опыт для повышения ранга и ману для магазина.",
|
||||||
|
media_url="https://images.nasa.gov/details-PIA23499",
|
||||||
|
),
|
||||||
|
OnboardingSlide(
|
||||||
|
order=3,
|
||||||
|
title="Повышайте ранг до члена экипажа",
|
||||||
|
body="Закройте ключевые миссии ветки «Получение оффера» и прокачайте компетенции.",
|
||||||
|
cta_text="Открыть ветку",
|
||||||
|
cta_link="/missions",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
session.commit()
|
session.commit()
|
||||||
DATA_SENTINEL.write_text("seeded")
|
DATA_SENTINEL.write_text("seeded")
|
||||||
print("Seed data created")
|
print("Seed data created")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user