Add store item images and admin management
This commit is contained in:
parent
989a413162
commit
e546dfc319
55
backend/alembic/versions/20241010_0008_offline_missions.py
Normal file
55
backend/alembic/versions/20241010_0008_offline_missions.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""offline missions fields
|
||||
|
||||
Revision ID: 20241010_0008
|
||||
Revises: 3c5430b2cbd3
|
||||
Create Date: 2024-10-10 00:08:00.000000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "20241010_0008"
|
||||
down_revision = "3c5430b2cbd3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
mission_format_enum = sa.Enum("online", "offline", name="missionformat")
|
||||
|
||||
def upgrade() -> None:
|
||||
mission_format_enum.create(op.get_bind(), checkfirst=True)
|
||||
with op.batch_alter_table("missions", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("format", mission_format_enum, nullable=False, server_default="online"))
|
||||
batch_op.add_column(sa.Column("event_location", sa.String(length=160), nullable=True))
|
||||
batch_op.add_column(sa.Column("event_address", sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column("event_starts_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch_op.add_column(sa.Column("event_ends_at", sa.DateTime(timezone=True), nullable=True))
|
||||
batch_op.add_column(sa.Column("registration_deadline", sa.DateTime(timezone=True), nullable=True))
|
||||
batch_op.add_column(sa.Column("registration_url", sa.String(length=512), nullable=True))
|
||||
batch_op.add_column(sa.Column("registration_notes", sa.Text(), nullable=True))
|
||||
batch_op.add_column(sa.Column("capacity", sa.Integer(), nullable=True))
|
||||
batch_op.add_column(sa.Column("contact_person", sa.String(length=120), nullable=True))
|
||||
batch_op.add_column(sa.Column("contact_phone", sa.String(length=64), nullable=True))
|
||||
op.execute("UPDATE missions SET format = 'online' WHERE format IS NULL")
|
||||
with op.batch_alter_table("missions", schema=None) as batch_op:
|
||||
batch_op.alter_column("format", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("missions", schema=None) as batch_op:
|
||||
batch_op.drop_column("contact_phone")
|
||||
batch_op.drop_column("contact_person")
|
||||
batch_op.drop_column("capacity")
|
||||
batch_op.drop_column("registration_notes")
|
||||
batch_op.drop_column("registration_url")
|
||||
batch_op.drop_column("registration_deadline")
|
||||
batch_op.drop_column("event_ends_at")
|
||||
batch_op.drop_column("event_starts_at")
|
||||
batch_op.drop_column("event_address")
|
||||
batch_op.drop_column("event_location")
|
||||
batch_op.drop_column("format")
|
||||
mission_format_enum.drop(op.get_bind(), checkfirst=True)
|
||||
22
backend/alembic/versions/20241012_0009_profile_photos.py
Normal file
22
backend/alembic/versions/20241012_0009_profile_photos.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
"""Добавляем колонку для фото профиля кандидата."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20241012_0009"
|
||||
down_revision = "20241010_0008"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("users", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("profile_photo_path", sa.String(length=512), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("users", schema=None) as batch_op:
|
||||
batch_op.drop_column("profile_photo_path")
|
||||
25
backend/alembic/versions/20241014_0010_store_item_images.py
Normal file
25
backend/alembic/versions/20241014_0010_store_item_images.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""Add image url to store items"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "20241014_0010"
|
||||
down_revision = "20241012_0009"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Добавляем колонку с изображением товара."""
|
||||
|
||||
op.add_column("store_items", sa.Column("image_url", sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Удаляем колонку с изображением товара."""
|
||||
|
||||
op.drop_column("store_items", "image_url")
|
||||
|
|
@ -19,6 +19,7 @@ from app.models.mission import (
|
|||
SubmissionStatus,
|
||||
)
|
||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||
from app.models.store import StoreItem
|
||||
from app.models.user import Competency, User, UserRole
|
||||
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
|
||||
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
|
||||
|
|
@ -38,7 +39,8 @@ from app.schemas.rank import (
|
|||
RankUpdate,
|
||||
)
|
||||
from app.schemas.user import CompetencyBase
|
||||
from app.services.mission import approve_submission, reject_submission
|
||||
from app.schemas.store import StoreItemCreate, StoreItemRead, StoreItemUpdate
|
||||
from app.services.mission import approve_submission, registration_is_open, reject_submission
|
||||
from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
|
@ -47,6 +49,11 @@ router = APIRouter(prefix="/api/admin", tags=["admin"])
|
|||
def _mission_to_detail(mission: Mission) -> MissionDetail:
|
||||
"""Формируем детальную схему миссии."""
|
||||
|
||||
participant_count = sum(
|
||||
1 for submission in mission.submissions if submission.status != SubmissionStatus.REJECTED
|
||||
)
|
||||
is_registration_open = registration_is_open(mission, participant_count=participant_count)
|
||||
|
||||
return MissionDetail(
|
||||
id=mission.id,
|
||||
title=mission.title,
|
||||
|
|
@ -54,6 +61,17 @@ def _mission_to_detail(mission: Mission) -> MissionDetail:
|
|||
xp_reward=mission.xp_reward,
|
||||
mana_reward=mission.mana_reward,
|
||||
difficulty=mission.difficulty,
|
||||
format=mission.format,
|
||||
event_location=mission.event_location,
|
||||
event_address=mission.event_address,
|
||||
event_starts_at=mission.event_starts_at,
|
||||
event_ends_at=mission.event_ends_at,
|
||||
registration_deadline=mission.registration_deadline,
|
||||
registration_url=mission.registration_url,
|
||||
registration_notes=mission.registration_notes,
|
||||
capacity=mission.capacity,
|
||||
contact_person=mission.contact_person,
|
||||
contact_phone=mission.contact_phone,
|
||||
is_active=mission.is_active,
|
||||
minimum_rank_id=mission.minimum_rank_id,
|
||||
artifact_id=mission.artifact_id,
|
||||
|
|
@ -68,6 +86,8 @@ def _mission_to_detail(mission: Mission) -> MissionDetail:
|
|||
],
|
||||
created_at=mission.created_at,
|
||||
updated_at=mission.updated_at,
|
||||
registered_participants=participant_count,
|
||||
registration_open=is_registration_open,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -120,6 +140,15 @@ def _branch_to_read(branch: Branch) -> BranchRead:
|
|||
)
|
||||
|
||||
|
||||
def _sanitize_optional(value: str | None) -> str | None:
|
||||
"""Обрезаем пробелы и заменяем пустые строки на None."""
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
def _load_rank(db: Session, rank_id: int) -> Rank:
|
||||
"""Загружаем ранг с зависимостями."""
|
||||
|
||||
|
|
@ -143,6 +172,7 @@ def _load_mission(db: Session, mission_id: int) -> Mission:
|
|||
selectinload(Mission.prerequisites),
|
||||
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
||||
selectinload(Mission.branches),
|
||||
selectinload(Mission.submissions),
|
||||
)
|
||||
.filter(Mission.id == mission_id)
|
||||
.one()
|
||||
|
|
@ -157,6 +187,99 @@ def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(requir
|
|||
return [MissionBase.model_validate(mission) for mission in missions]
|
||||
|
||||
|
||||
@router.get("/store/items", response_model=list[StoreItemRead], summary="Товары магазина (HR)")
|
||||
def admin_store_items(
|
||||
*, db: Session = Depends(get_db), current_user=Depends(require_hr)
|
||||
) -> list[StoreItemRead]:
|
||||
"""Возвращаем товары магазина для панели HR."""
|
||||
|
||||
items = db.query(StoreItem).order_by(StoreItem.name).all()
|
||||
return [StoreItemRead.model_validate(item) for item in items]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/store/items",
|
||||
response_model=StoreItemRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Создать товар",
|
||||
)
|
||||
def admin_store_create(
|
||||
item_in: StoreItemCreate,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> StoreItemRead:
|
||||
"""Создаём новый товар в магазине."""
|
||||
|
||||
name = item_in.name.strip()
|
||||
description = item_in.description.strip()
|
||||
if not name or not description:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Название и описание не могут быть пустыми",
|
||||
)
|
||||
|
||||
item = StoreItem(
|
||||
name=name,
|
||||
description=description,
|
||||
cost_mana=item_in.cost_mana,
|
||||
stock=item_in.stock,
|
||||
image_url=_sanitize_optional(item_in.image_url),
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return StoreItemRead.model_validate(item)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/store/items/{item_id}",
|
||||
response_model=StoreItemRead,
|
||||
summary="Обновить товар",
|
||||
)
|
||||
def admin_store_update(
|
||||
item_id: int,
|
||||
item_in: StoreItemUpdate,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> StoreItemRead:
|
||||
"""Редактируем существующий товар."""
|
||||
|
||||
item = db.query(StoreItem).filter(StoreItem.id == item_id).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Товар не найден")
|
||||
|
||||
update_data = item_in.model_dump(exclude_unset=True)
|
||||
if "name" in update_data and update_data["name"] is not None:
|
||||
new_name = update_data["name"].strip()
|
||||
if not new_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Название не может быть пустым",
|
||||
)
|
||||
item.name = new_name
|
||||
if "description" in update_data and update_data["description"] is not None:
|
||||
new_description = update_data["description"].strip()
|
||||
if not new_description:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Описание не может быть пустым",
|
||||
)
|
||||
item.description = new_description
|
||||
if "cost_mana" in update_data and update_data["cost_mana"] is not None:
|
||||
item.cost_mana = update_data["cost_mana"]
|
||||
if "stock" in update_data and update_data["stock"] is not None:
|
||||
item.stock = update_data["stock"]
|
||||
if "image_url" in update_data:
|
||||
item.image_url = _sanitize_optional(update_data["image_url"])
|
||||
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return StoreItemRead.model_validate(item)
|
||||
|
||||
|
||||
@router.get("/missions/{mission_id}", response_model=MissionDetail, summary="Детали миссии")
|
||||
def admin_mission_detail(
|
||||
mission_id: int,
|
||||
|
|
@ -172,6 +295,7 @@ def admin_mission_detail(
|
|||
selectinload(Mission.prerequisites),
|
||||
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
||||
selectinload(Mission.branches),
|
||||
selectinload(Mission.submissions),
|
||||
)
|
||||
.filter(Mission.id == mission_id)
|
||||
.first()
|
||||
|
|
@ -414,6 +538,17 @@ def create_mission_endpoint(
|
|||
xp_reward=mission_in.xp_reward,
|
||||
mana_reward=mission_in.mana_reward,
|
||||
difficulty=mission_in.difficulty,
|
||||
format=mission_in.format,
|
||||
event_location=mission_in.event_location,
|
||||
event_address=mission_in.event_address,
|
||||
event_starts_at=mission_in.event_starts_at,
|
||||
event_ends_at=mission_in.event_ends_at,
|
||||
registration_deadline=mission_in.registration_deadline,
|
||||
registration_url=mission_in.registration_url,
|
||||
registration_notes=mission_in.registration_notes,
|
||||
capacity=mission_in.capacity,
|
||||
contact_person=mission_in.contact_person,
|
||||
contact_phone=mission_in.contact_phone,
|
||||
minimum_rank_id=mission_in.minimum_rank_id,
|
||||
artifact_id=mission_in.artifact_id,
|
||||
)
|
||||
|
|
@ -475,7 +610,25 @@ def update_mission_endpoint(
|
|||
|
||||
payload = mission_in.model_dump(exclude_unset=True)
|
||||
|
||||
for attr in ["title", "description", "xp_reward", "mana_reward", "difficulty", "is_active"]:
|
||||
for attr in [
|
||||
"title",
|
||||
"description",
|
||||
"xp_reward",
|
||||
"mana_reward",
|
||||
"difficulty",
|
||||
"is_active",
|
||||
"format",
|
||||
"event_location",
|
||||
"event_address",
|
||||
"event_starts_at",
|
||||
"event_ends_at",
|
||||
"registration_deadline",
|
||||
"registration_url",
|
||||
"registration_notes",
|
||||
"capacity",
|
||||
"contact_person",
|
||||
"contact_phone",
|
||||
]:
|
||||
if attr in payload:
|
||||
setattr(mission, attr, payload[attr])
|
||||
|
||||
|
|
|
|||
|
|
@ -66,7 +66,6 @@ def register(user_in: UserRegister, db: Session = Depends(get_db)) -> Token | di
|
|||
full_name=user_in.full_name,
|
||||
hashed_password=get_password_hash(user_in.password),
|
||||
role=UserRole.PILOT,
|
||||
preferred_branch=user_in.preferred_branch,
|
||||
motivation=user_in.motivation,
|
||||
current_rank_id=base_rank.id if base_rank else None,
|
||||
is_email_confirmed=not settings.require_email_confirmation,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
|
|
@ -27,7 +30,7 @@ from app.schemas.coding import (
|
|||
CodingRunResponse,
|
||||
)
|
||||
from app.services.coding import count_completed_challenges, evaluate_challenge
|
||||
from app.services.mission import UNSET, submit_mission
|
||||
from app.services.mission import UNSET, registration_is_open, submit_mission
|
||||
from app.services.storage import delete_submission_document, save_submission_document
|
||||
from app.core.config import settings
|
||||
|
||||
|
|
@ -282,12 +285,29 @@ def list_missions(
|
|||
|
||||
mission_titles = {mission.id: mission.title for mission in missions}
|
||||
completed_missions = _load_user_progress(current_user)
|
||||
submission_status_map = {
|
||||
submission.mission_id: submission.status for submission in current_user.submissions
|
||||
}
|
||||
coding_progress = count_completed_challenges(
|
||||
db,
|
||||
mission_ids=[mission.id for mission in missions if mission.coding_challenges],
|
||||
user=current_user,
|
||||
)
|
||||
|
||||
mission_ids = [mission.id for mission in missions]
|
||||
registration_counts = {
|
||||
mission_id: count
|
||||
for mission_id, count in (
|
||||
db.query(MissionSubmission.mission_id, func.count(MissionSubmission.id))
|
||||
.filter(
|
||||
MissionSubmission.mission_id.in_(mission_ids),
|
||||
MissionSubmission.status != SubmissionStatus.REJECTED,
|
||||
)
|
||||
.group_by(MissionSubmission.mission_id)
|
||||
.all()
|
||||
)
|
||||
}
|
||||
|
||||
response: list[MissionBase] = []
|
||||
for mission in missions:
|
||||
is_available, reasons = _mission_availability(
|
||||
|
|
@ -310,6 +330,14 @@ def list_missions(
|
|||
dto.has_coding_challenges = bool(mission.coding_challenges)
|
||||
dto.coding_challenge_count = len(mission.coding_challenges)
|
||||
dto.completed_coding_challenges = coding_progress.get(mission.id, 0)
|
||||
dto.submission_status = submission_status_map.get(mission.id)
|
||||
participants = registration_counts.get(mission.id, 0)
|
||||
dto.registered_participants = participants
|
||||
dto.registration_open = registration_is_open(
|
||||
mission,
|
||||
participant_count=participants,
|
||||
now=datetime.now(timezone.utc),
|
||||
)
|
||||
response.append(dto)
|
||||
|
||||
return response
|
||||
|
|
@ -370,6 +398,17 @@ def get_mission(
|
|||
xp_reward=mission.xp_reward,
|
||||
mana_reward=mission.mana_reward,
|
||||
difficulty=mission.difficulty,
|
||||
format=mission.format,
|
||||
event_location=mission.event_location,
|
||||
event_address=mission.event_address,
|
||||
event_starts_at=mission.event_starts_at,
|
||||
event_ends_at=mission.event_ends_at,
|
||||
registration_deadline=mission.registration_deadline,
|
||||
registration_url=mission.registration_url,
|
||||
registration_notes=mission.registration_notes,
|
||||
capacity=mission.capacity,
|
||||
contact_person=mission.contact_person,
|
||||
contact_phone=mission.contact_phone,
|
||||
is_active=mission.is_active,
|
||||
is_available=is_available,
|
||||
locked_reasons=reasons,
|
||||
|
|
@ -384,6 +423,24 @@ def get_mission(
|
|||
data.has_coding_challenges = bool(mission.coding_challenges)
|
||||
data.coding_challenge_count = len(mission.coding_challenges)
|
||||
data.completed_coding_challenges = coding_progress.get(mission.id, 0)
|
||||
data.submission_status = next(
|
||||
(submission.status for submission in current_user.submissions if submission.mission_id == mission.id),
|
||||
None,
|
||||
)
|
||||
participant_count = (
|
||||
db.query(MissionSubmission)
|
||||
.filter(
|
||||
MissionSubmission.mission_id == mission.id,
|
||||
MissionSubmission.status != SubmissionStatus.REJECTED,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
data.registered_participants = participant_count
|
||||
data.registration_open = registration_is_open(
|
||||
mission,
|
||||
participant_count=participant_count,
|
||||
now=datetime.now(timezone.utc),
|
||||
)
|
||||
if mission.id in completed_missions:
|
||||
data.is_completed = True
|
||||
data.is_available = False
|
||||
|
|
@ -555,6 +612,25 @@ async def submit(
|
|||
.first()
|
||||
)
|
||||
|
||||
participant_count = (
|
||||
db.query(MissionSubmission)
|
||||
.filter(
|
||||
MissionSubmission.mission_id == mission.id,
|
||||
MissionSubmission.status != SubmissionStatus.REJECTED,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
registration_open_state = registration_is_open(
|
||||
mission,
|
||||
participant_count=participant_count,
|
||||
now=datetime.now(timezone.utc),
|
||||
)
|
||||
if not registration_open_state and not existing_submission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Регистрация на офлайн-мероприятие закрыта.",
|
||||
)
|
||||
|
||||
def _has_upload(upload: UploadFile | None) -> bool:
|
||||
return bool(upload and upload.filename)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
|
|
@ -12,8 +12,18 @@ from app.models.user import User, UserRole, UserCompetency
|
|||
from app.models.mission import SubmissionStatus
|
||||
from app.schemas.progress import ProgressSnapshot
|
||||
from app.schemas.rank import RankBase
|
||||
from app.schemas.user import LeaderboardEntry, UserCompetencyRead, UserProfile
|
||||
from app.schemas.user import (
|
||||
LeaderboardEntry,
|
||||
ProfilePhotoResponse,
|
||||
UserCompetencyRead,
|
||||
UserProfile,
|
||||
)
|
||||
from app.services.rank import build_progress_snapshot
|
||||
from app.services.storage import (
|
||||
build_photo_data_url,
|
||||
delete_profile_photo,
|
||||
save_profile_photo,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["profile"])
|
||||
|
||||
|
|
@ -29,7 +39,89 @@ def get_profile(
|
|||
_ = item.competency
|
||||
for artifact in current_user.artifacts:
|
||||
_ = artifact.artifact
|
||||
return UserProfile.model_validate(current_user)
|
||||
|
||||
profile = UserProfile.model_validate(current_user)
|
||||
profile.profile_photo_uploaded = bool(current_user.profile_photo_path)
|
||||
profile.profile_photo_updated_at = (
|
||||
current_user.updated_at if current_user.profile_photo_path else None
|
||||
)
|
||||
return profile
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me/photo",
|
||||
response_model=ProfilePhotoResponse,
|
||||
summary="Получаем фото профиля кандидата",
|
||||
)
|
||||
def get_profile_photo(
|
||||
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
) -> ProfilePhotoResponse:
|
||||
"""Читаем сохранённое изображение и возвращаем его в виде data URL."""
|
||||
|
||||
db.refresh(current_user)
|
||||
if not current_user.profile_photo_path:
|
||||
return ProfilePhotoResponse(photo=None, detail="Фотография не загружена")
|
||||
|
||||
try:
|
||||
photo = build_photo_data_url(current_user.profile_photo_path)
|
||||
except FileNotFoundError:
|
||||
# Если файл удалили вручную, сбрасываем ссылку в базе, чтобы не мешать пользователю загрузить новую.
|
||||
current_user.profile_photo_path = None
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
return ProfilePhotoResponse(photo=None, detail="Файл не найден")
|
||||
|
||||
return ProfilePhotoResponse(photo=photo)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/me/photo",
|
||||
response_model=ProfilePhotoResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Загружаем фото профиля",
|
||||
)
|
||||
def upload_profile_photo(
|
||||
photo: UploadFile = File(...),
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> ProfilePhotoResponse:
|
||||
"""Сохраняем изображение и возвращаем обновлённый data URL."""
|
||||
|
||||
try:
|
||||
relative_path = save_profile_photo(upload=photo, user_id=current_user.id)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
delete_profile_photo(current_user.profile_photo_path)
|
||||
current_user.profile_photo_path = relative_path
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
photo_url = build_photo_data_url(relative_path)
|
||||
return ProfilePhotoResponse(photo=photo_url, detail="Фотография обновлена")
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/me/photo",
|
||||
response_model=ProfilePhotoResponse,
|
||||
summary="Удаляем фото профиля",
|
||||
)
|
||||
def delete_profile_photo_endpoint(
|
||||
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
) -> ProfilePhotoResponse:
|
||||
"""Удаляем сохранённое фото и очищаем ссылку в профиле."""
|
||||
|
||||
if not current_user.profile_photo_path:
|
||||
return ProfilePhotoResponse(photo=None, detail="Фотография уже удалена")
|
||||
|
||||
delete_profile_photo(current_user.profile_photo_path)
|
||||
current_user.profile_photo_path = None
|
||||
db.add(current_user)
|
||||
db.commit()
|
||||
|
||||
return ProfilePhotoResponse(photo=None, detail="Фотография удалена")
|
||||
|
||||
|
||||
@router.get("/ranks", response_model=list[RankBase], summary="Перечень рангов")
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ def run_migrations() -> None:
|
|||
|
||||
config = Config(str(ALEMBIC_CONFIG))
|
||||
config.set_main_option("sqlalchemy.url", str(settings.database_url))
|
||||
# Alembic трактует относительный script_location относительно текущей рабочей
|
||||
# директории процесса. В тестах и фронтенд-сервере мы запускаем backend из
|
||||
# корня репозитория, поэтому явно подсказываем абсолютный путь до папки с
|
||||
# миграциями, чтобы `alembic` не падал с "Path doesn't exist: alembic".
|
||||
config.set_main_option(
|
||||
"script_location", str(Path(__file__).resolve().parents[1] / "alembic")
|
||||
)
|
||||
script = ScriptDirectory.from_config(config)
|
||||
head_revision = script.get_current_head()
|
||||
|
||||
|
|
@ -55,11 +62,17 @@ def run_migrations() -> None:
|
|||
if "mission_submissions" in tables:
|
||||
submission_columns = {column["name"] for column in inspector.get_columns("mission_submissions")}
|
||||
|
||||
mission_columns = set()
|
||||
if "missions" in tables:
|
||||
mission_columns = {column["name"] for column in inspector.get_columns("missions")}
|
||||
|
||||
with engine.begin() as conn:
|
||||
if "preferred_branch" not in user_columns:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN preferred_branch VARCHAR(160)"))
|
||||
if "motivation" not in user_columns:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN motivation TEXT"))
|
||||
if "profile_photo_path" not in user_columns:
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN profile_photo_path VARCHAR(512)"))
|
||||
|
||||
if "passport_path" not in submission_columns:
|
||||
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN passport_path VARCHAR(512)"))
|
||||
|
|
@ -70,6 +83,39 @@ def run_migrations() -> None:
|
|||
if "resume_link" not in submission_columns:
|
||||
conn.execute(text("ALTER TABLE mission_submissions ADD COLUMN resume_link VARCHAR(512)"))
|
||||
|
||||
if "missions" in tables:
|
||||
# Легаси-базы без alembic_version пропускали миграцию с офлайн-полями,
|
||||
# поэтому докидываем недостающие колонки вручную, чтобы API /admin не падало.
|
||||
if "format" not in mission_columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE missions ADD COLUMN format VARCHAR(20) NOT NULL DEFAULT 'online'"
|
||||
)
|
||||
)
|
||||
conn.execute(text("UPDATE missions SET format = 'online' WHERE format IS NULL"))
|
||||
if "event_location" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN event_location VARCHAR(160)"))
|
||||
if "event_address" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN event_address VARCHAR(255)"))
|
||||
if "event_starts_at" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN event_starts_at TIMESTAMP"))
|
||||
if "event_ends_at" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN event_ends_at TIMESTAMP"))
|
||||
if "registration_deadline" not in mission_columns:
|
||||
conn.execute(
|
||||
text("ALTER TABLE missions ADD COLUMN registration_deadline TIMESTAMP")
|
||||
)
|
||||
if "registration_url" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN registration_url VARCHAR(512)"))
|
||||
if "registration_notes" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN registration_notes TEXT"))
|
||||
if "capacity" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN capacity INTEGER"))
|
||||
if "contact_person" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN contact_person VARCHAR(120)"))
|
||||
if "contact_phone" not in mission_columns:
|
||||
conn.execute(text("ALTER TABLE missions ADD COLUMN contact_phone VARCHAR(64)"))
|
||||
|
||||
conn.execute(text("CREATE TABLE IF NOT EXISTS alembic_version (version_num VARCHAR(32) NOT NULL)"))
|
||||
conn.execute(text("DELETE FROM alembic_version"))
|
||||
conn.execute(text("INSERT INTO alembic_version (version_num) VALUES (:rev)"), {"rev": head_revision})
|
||||
|
|
|
|||
|
|
@ -2,10 +2,20 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy import Boolean, Enum as SQLEnum, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Enum as SQLEnum,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
|
@ -22,6 +32,13 @@ class MissionDifficulty(str, Enum):
|
|||
HARD = "hard"
|
||||
|
||||
|
||||
class MissionFormat(str, Enum):
|
||||
"""Формат проведения миссии."""
|
||||
|
||||
ONLINE = "online"
|
||||
OFFLINE = "offline"
|
||||
|
||||
|
||||
class Mission(Base, TimestampMixin):
|
||||
"""Игровая миссия."""
|
||||
|
||||
|
|
@ -35,6 +52,19 @@ class Mission(Base, TimestampMixin):
|
|||
difficulty: Mapped[MissionDifficulty] = mapped_column(
|
||||
SQLEnum(MissionDifficulty), default=MissionDifficulty.MEDIUM, nullable=False
|
||||
)
|
||||
format: Mapped[MissionFormat] = mapped_column(
|
||||
SQLEnum(MissionFormat), default=MissionFormat.ONLINE, nullable=False
|
||||
)
|
||||
event_location: Mapped[Optional[str]] = mapped_column(String(160))
|
||||
event_address: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
event_starts_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
event_ends_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
registration_deadline: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
|
||||
registration_url: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
registration_notes: Mapped[Optional[str]] = mapped_column(Text)
|
||||
capacity: Mapped[Optional[int]] = mapped_column(Integer)
|
||||
contact_person: Mapped[Optional[str]] = mapped_column(String(120))
|
||||
contact_phone: Mapped[Optional[str]] = mapped_column(String(64))
|
||||
minimum_rank_id: Mapped[Optional[int]] = mapped_column(ForeignKey("ranks.id"))
|
||||
artifact_id: Mapped[Optional[int]] = mapped_column(ForeignKey("artifacts.id"))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class StoreItem(Base, TimestampMixin):
|
|||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
cost_mana: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
stock: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
image_url: Mapped[Optional[str]] = mapped_column(String(255))
|
||||
|
||||
orders: Mapped[List["Order"]] = relationship("Order", back_populates="item")
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ class User(Base, TimestampMixin):
|
|||
preferred_branch: Mapped[Optional[str]] = mapped_column(String(160), nullable=True)
|
||||
# Короткая заметка с личной мотивацией — помогает HR при первичном контакте.
|
||||
motivation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
profile_photo_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
|
||||
|
||||
current_rank = relationship("Rank", back_populates="pilots")
|
||||
competencies: Mapped[List["UserCompetency"]] = relationship(
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from typing import Optional
|
|||
|
||||
from pydantic import BaseModel, Field, computed_field
|
||||
|
||||
from app.models.mission import MissionDifficulty, SubmissionStatus
|
||||
from app.models.mission import MissionDifficulty, MissionFormat, SubmissionStatus
|
||||
|
||||
|
||||
class MissionBase(BaseModel):
|
||||
|
|
@ -19,6 +19,17 @@ class MissionBase(BaseModel):
|
|||
xp_reward: int
|
||||
mana_reward: int
|
||||
difficulty: MissionDifficulty
|
||||
format: MissionFormat
|
||||
event_location: Optional[str] = None
|
||||
event_address: Optional[str] = None
|
||||
event_starts_at: Optional[datetime] = None
|
||||
event_ends_at: Optional[datetime] = None
|
||||
registration_deadline: Optional[datetime] = None
|
||||
registration_url: Optional[str] = None
|
||||
registration_notes: Optional[str] = None
|
||||
capacity: Optional[int] = None
|
||||
contact_person: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
is_active: bool
|
||||
is_available: bool = True
|
||||
locked_reasons: list[str] = Field(default_factory=list)
|
||||
|
|
@ -27,6 +38,9 @@ class MissionBase(BaseModel):
|
|||
has_coding_challenges: bool = False
|
||||
coding_challenge_count: int = 0
|
||||
completed_coding_challenges: int = 0
|
||||
submission_status: Optional[SubmissionStatus] = None
|
||||
registered_participants: int = 0
|
||||
registration_open: bool = True
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -67,6 +81,17 @@ class MissionCreate(BaseModel):
|
|||
xp_reward: int
|
||||
mana_reward: int
|
||||
difficulty: MissionDifficulty = MissionDifficulty.MEDIUM
|
||||
format: MissionFormat = MissionFormat.ONLINE
|
||||
event_location: Optional[str] = None
|
||||
event_address: Optional[str] = None
|
||||
event_starts_at: Optional[datetime] = None
|
||||
event_ends_at: Optional[datetime] = None
|
||||
registration_deadline: Optional[datetime] = None
|
||||
registration_url: Optional[str] = None
|
||||
registration_notes: Optional[str] = None
|
||||
capacity: Optional[int] = None
|
||||
contact_person: Optional[str] = None
|
||||
contact_phone: Optional[str] = None
|
||||
minimum_rank_id: Optional[int] = None
|
||||
artifact_id: Optional[int] = None
|
||||
prerequisite_ids: list[int] = []
|
||||
|
|
@ -83,6 +108,17 @@ class MissionUpdate(BaseModel):
|
|||
xp_reward: Optional[int] = None
|
||||
mana_reward: Optional[int] = None
|
||||
difficulty: Optional[MissionDifficulty] = None
|
||||
format: Optional[MissionFormat] = None
|
||||
event_location: Optional[str | None] = None
|
||||
event_address: Optional[str | None] = None
|
||||
event_starts_at: Optional[datetime | None] = None
|
||||
event_ends_at: Optional[datetime | None] = None
|
||||
registration_deadline: Optional[datetime | None] = None
|
||||
registration_url: Optional[str | None] = None
|
||||
registration_notes: Optional[str | None] = None
|
||||
capacity: Optional[int | None] = None
|
||||
contact_person: Optional[str | None] = None
|
||||
contact_phone: Optional[str | None] = None
|
||||
minimum_rank_id: Optional[int | None] = None
|
||||
artifact_id: Optional[int | None] = None
|
||||
prerequisite_ids: Optional[list[int]] = None
|
||||
|
|
|
|||
|
|
@ -10,19 +10,41 @@ from pydantic import BaseModel
|
|||
from app.models.store import OrderStatus
|
||||
|
||||
|
||||
class StoreItemRead(BaseModel):
|
||||
"""Товар магазина."""
|
||||
class StoreItemBase(BaseModel):
|
||||
"""Базовые поля товара магазина."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
cost_mana: int
|
||||
stock: int
|
||||
image_url: Optional[str] = None
|
||||
|
||||
|
||||
class StoreItemRead(StoreItemBase):
|
||||
"""Товар магазина для чтения."""
|
||||
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class StoreItemCreate(StoreItemBase):
|
||||
"""Запрос на создание товара."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class StoreItemUpdate(BaseModel):
|
||||
"""Запрос на обновление товара."""
|
||||
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
cost_mana: Optional[int] = None
|
||||
stock: Optional[int] = None
|
||||
image_url: Optional[str] = None
|
||||
|
||||
|
||||
class OrderRead(BaseModel):
|
||||
"""Информация о заказе."""
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,8 @@ class UserProfile(UserRead):
|
|||
|
||||
competencies: list[UserCompetencyRead]
|
||||
artifacts: list[UserArtifactRead]
|
||||
profile_photo_uploaded: bool = False
|
||||
profile_photo_updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class LeaderboardEntry(BaseModel):
|
||||
|
|
@ -108,6 +110,11 @@ class UserRegister(BaseModel):
|
|||
email: EmailStr
|
||||
full_name: str
|
||||
password: str
|
||||
# Дополнительные сведения помогают персонализировать онбординг и связать пилота с куратором.
|
||||
preferred_branch: Optional[str] = None
|
||||
motivation: Optional[str] = None
|
||||
|
||||
|
||||
class ProfilePhotoResponse(BaseModel):
|
||||
"""Ответ с данными загруженной фотографии."""
|
||||
|
||||
photo: Optional[str] = None
|
||||
detail: Optional[str] = None
|
||||
|
|
|
|||
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.journal import JournalEventType
|
||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||
from app.models.mission import Mission, MissionFormat, MissionSubmission, SubmissionStatus
|
||||
from app.models.user import User, UserArtifact, UserCompetency
|
||||
from app.services.journal import log_event
|
||||
from app.services.rank import apply_rank_upgrade
|
||||
|
|
@ -171,3 +172,29 @@ def reject_submission(db: Session, submission: MissionSubmission, comment: str |
|
|||
)
|
||||
|
||||
return submission
|
||||
|
||||
|
||||
def registration_is_open(
|
||||
mission: Mission,
|
||||
*,
|
||||
participant_count: int,
|
||||
now: datetime | None = None,
|
||||
) -> bool:
|
||||
"""Проверяем, доступна ли запись на офлайн-мероприятие."""
|
||||
|
||||
if mission.format != MissionFormat.OFFLINE:
|
||||
return True
|
||||
|
||||
current_time = now or datetime.now(timezone.utc)
|
||||
|
||||
deadline = mission.registration_deadline
|
||||
if deadline and deadline.tzinfo is None:
|
||||
deadline = deadline.replace(tzinfo=timezone.utc)
|
||||
|
||||
if deadline and deadline < current_time:
|
||||
return False
|
||||
|
||||
if mission.capacity is not None and participant_count >= mission.capacity:
|
||||
return False
|
||||
|
||||
return mission.is_active
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import base64
|
||||
import mimetypes
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
|
|
@ -40,8 +42,34 @@ def save_submission_document(
|
|||
return relative_path
|
||||
|
||||
|
||||
def delete_submission_document(relative_path: str | None) -> None:
|
||||
"""Удаляем файл вложения, если он существует."""
|
||||
def save_profile_photo(*, upload: UploadFile, user_id: int) -> str:
|
||||
"""Сохраняем фото профиля кандидата."""
|
||||
|
||||
allowed_types = {
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
|
||||
content_type = upload.content_type or mimetypes.guess_type(upload.filename or "")[0]
|
||||
if content_type not in allowed_types:
|
||||
raise ValueError("Допустимы только изображения JPG, PNG или WEBP")
|
||||
|
||||
extension = allowed_types[content_type]
|
||||
target_dir = settings.uploads_path / f"user_{user_id}" / "profile"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
target_path = target_dir / f"photo{extension}"
|
||||
with target_path.open("wb") as buffer:
|
||||
upload.file.seek(0)
|
||||
shutil.copyfileobj(upload.file, buffer)
|
||||
upload.file.seek(0)
|
||||
|
||||
return target_path.relative_to(settings.uploads_path).as_posix()
|
||||
|
||||
|
||||
def _delete_relative_file(relative_path: str | None) -> None:
|
||||
"""Удаляем файл и очищаем пустые каталоги."""
|
||||
|
||||
if not relative_path:
|
||||
return
|
||||
|
|
@ -57,3 +85,29 @@ def delete_submission_document(relative_path: str | None) -> None:
|
|||
parent = file_path.parent
|
||||
if parent != settings.uploads_path and parent.is_dir() and not any(parent.iterdir()):
|
||||
parent.rmdir()
|
||||
|
||||
|
||||
def delete_submission_document(relative_path: str | None) -> None:
|
||||
"""Удаляем файл вложения, если он существует."""
|
||||
|
||||
_delete_relative_file(relative_path)
|
||||
|
||||
|
||||
def delete_profile_photo(relative_path: str | None) -> None:
|
||||
"""Удаляем сохранённую фотографию профиля."""
|
||||
|
||||
_delete_relative_file(relative_path)
|
||||
|
||||
|
||||
def build_photo_data_url(relative_path: str) -> str:
|
||||
"""Формируем data URL для изображения, чтобы отдать его фронту."""
|
||||
|
||||
file_path = settings.uploads_path / relative_path
|
||||
_ensure_within_base(file_path)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError("Файл не найден")
|
||||
|
||||
mime_type = mimetypes.guess_type(file_path.name)[0] or "image/jpeg"
|
||||
with file_path.open("rb") as fh:
|
||||
encoded = base64.b64encode(fh.read()).decode("ascii")
|
||||
return f"data:{mime_type};base64,{encoded}"
|
||||
|
|
|
|||
38
frontend/public/store/alabuga-crew-shirt.svg
Normal file
38
frontend/public/store/alabuga-crew-shirt.svg
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" role="img" aria-labelledby="shirtTitle shirtDesc">
|
||||
<title id="shirtTitle">Футболка экипажа Алабуги</title>
|
||||
<desc id="shirtDesc">Минималистичная иллюстрация футболки с надписью Алабуга и эмблемой миссии.</desc>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#0f172a" />
|
||||
<stop offset="100%" stop-color="#1e293b" />
|
||||
</linearGradient>
|
||||
<linearGradient id="shirt" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#5c6bc0" />
|
||||
<stop offset="100%" stop-color="#283593" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="640" height="360" fill="url(#bg)" />
|
||||
<g transform="translate(160 40)">
|
||||
<path
|
||||
d="M60 20 L120 0 L180 20 L220 20 C240 20 248 38 244 60 L226 150 C220 200 200 240 160 260 L120 280 L80 260 C40 240 20 200 14 150 L-4 60 C-8 38 0 20 20 20 Z"
|
||||
fill="url(#shirt)"
|
||||
stroke="#c5cae9"
|
||||
stroke-width="6"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<rect x="72" y="92" width="136" height="46" rx="8" fill="#1e88e5" opacity="0.85" />
|
||||
<text x="140" y="122" text-anchor="middle" font-size="32" font-weight="600" fill="#fff" font-family="'Montserrat', 'Arial', sans-serif">
|
||||
АЛАБУГА
|
||||
</text>
|
||||
<circle cx="140" cy="190" r="42" fill="#0d47a1" stroke="#bbdefb" stroke-width="6" />
|
||||
<text x="140" y="200" text-anchor="middle" font-size="26" font-weight="500" fill="#bbdefb" font-family="'Montserrat', 'Arial', sans-serif">
|
||||
⚡42
|
||||
</text>
|
||||
</g>
|
||||
<text x="60" y="320" fill="#e8eaf6" font-family="'Montserrat', 'Arial', sans-serif" font-size="32" font-weight="600">
|
||||
Футболка экипажа
|
||||
</text>
|
||||
<text x="60" y="348" fill="#c5cae9" font-family="'Montserrat', 'Arial', sans-serif" font-size="20">
|
||||
Лимитированная серия для выпускников миссии
|
||||
</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
34
frontend/public/store/excursion-alabuga.svg
Normal file
34
frontend/public/store/excursion-alabuga.svg
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 360" role="img" aria-labelledby="title desc">
|
||||
<title id="title">Экскурсия по Алабуге</title>
|
||||
<desc id="desc">Стилизованный рисунок кампуса Алабуги с отмеченным маршрутом экскурсии.</desc>
|
||||
<defs>
|
||||
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#3f51b5" />
|
||||
<stop offset="100%" stop-color="#1a237e" />
|
||||
</linearGradient>
|
||||
<linearGradient id="sun" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#ffeb3b" />
|
||||
<stop offset="100%" stop-color="#ff9800" />
|
||||
</linearGradient>
|
||||
<linearGradient id="path" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#ff8a80" />
|
||||
<stop offset="100%" stop-color="#ff5252" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="640" height="360" fill="url(#sky)" />
|
||||
<circle cx="520" cy="90" r="46" fill="url(#sun)" opacity="0.9" />
|
||||
<g fill="none" stroke="#90caf9" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0.75">
|
||||
<path d="M80 260 L160 210 L220 240 L320 200 L420 220 L520 190" />
|
||||
<path d="M100 180 L180 150 L260 170 L340 140 L420 160" />
|
||||
</g>
|
||||
<g fill="#e3f2fd" opacity="0.9">
|
||||
<rect x="70" y="190" width="120" height="90" rx="12" />
|
||||
<rect x="210" y="210" width="120" height="70" rx="10" />
|
||||
<rect x="360" y="200" width="140" height="80" rx="14" />
|
||||
</g>
|
||||
<path d="M100 300 C220 280, 360 300, 520 250" stroke="url(#path)" stroke-width="12" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<g fill="#fff" font-family="'Montserrat', 'Arial', sans-serif">
|
||||
<text x="60" y="330" font-size="36" font-weight="600">Экскурсия по Алабуге</text>
|
||||
<text x="60" y="360" font-size="20" opacity="0.85">Тур по производственным цехам и кампусу</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -3,6 +3,7 @@ import { AdminMissionManager } from '../../components/admin/AdminMissionManager'
|
|||
import { AdminRankManager } from '../../components/admin/AdminRankManager';
|
||||
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
|
||||
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
|
||||
import { AdminStoreManager } from '../../components/admin/AdminStoreManager';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import { requireRole } from '../../lib/auth/session';
|
||||
|
||||
|
|
@ -61,6 +62,15 @@ interface ArtifactSummary {
|
|||
image_url?: string | null;
|
||||
}
|
||||
|
||||
interface StoreItemSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
cost_mana: number;
|
||||
stock: number;
|
||||
image_url: string | null;
|
||||
}
|
||||
|
||||
interface SubmissionStats {
|
||||
pending: number;
|
||||
approved: number;
|
||||
|
|
@ -85,14 +95,24 @@ export default async function AdminPage() {
|
|||
// Админ-панель доступна только HR-сотрудникам; проверяем роль до загрузки данных.
|
||||
const session = await requireRole('hr');
|
||||
|
||||
const [submissions, missions, branches, ranks, competencies, artifacts, stats] = await Promise.all([
|
||||
const [
|
||||
submissions,
|
||||
missions,
|
||||
branches,
|
||||
ranks,
|
||||
competencies,
|
||||
artifacts,
|
||||
stats,
|
||||
storeItems,
|
||||
] = await Promise.all([
|
||||
apiFetch<Submission[]>('/api/admin/submissions', { authToken: session.token }),
|
||||
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: session.token }),
|
||||
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: session.token }),
|
||||
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: session.token }),
|
||||
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: session.token }),
|
||||
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: session.token }),
|
||||
apiFetch<AdminStats>('/api/admin/stats', { authToken: session.token })
|
||||
apiFetch<AdminStats>('/api/admin/stats', { authToken: session.token }),
|
||||
apiFetch<StoreItemSummary[]>('/api/admin/store/items', { authToken: session.token }),
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
@ -167,6 +187,7 @@ export default async function AdminPage() {
|
|||
/>
|
||||
<AdminRankManager token={session.token} ranks={ranks} missions={missions} competencies={competencies} />
|
||||
<AdminArtifactManager token={session.token} artifacts={artifacts} />
|
||||
<AdminStoreManager token={session.token} items={storeItems} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { apiFetch } from '../../../lib/api';
|
|||
import { requireSession } from '../../../lib/auth/session';
|
||||
import { MissionSubmissionForm } from '../../../components/MissionSubmissionForm';
|
||||
import { CodingMissionPanel } from '../../../components/CodingMissionPanel';
|
||||
import { OfflineMissionRegistration } from '../../../components/OfflineMissionRegistration';
|
||||
|
||||
interface MissionDetail {
|
||||
id: number;
|
||||
|
|
@ -10,6 +11,7 @@ interface MissionDetail {
|
|||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: string;
|
||||
format: 'online' | 'offline';
|
||||
minimum_rank_id: number | null;
|
||||
artifact_id: number | null;
|
||||
prerequisites: number[];
|
||||
|
|
@ -25,6 +27,19 @@ interface MissionDetail {
|
|||
has_coding_challenges: boolean;
|
||||
coding_challenge_count: number;
|
||||
completed_coding_challenges: number;
|
||||
event_location?: string | null;
|
||||
event_address?: string | null;
|
||||
event_starts_at?: string | null;
|
||||
event_ends_at?: string | null;
|
||||
registration_deadline?: string | null;
|
||||
registration_url?: string | null;
|
||||
registration_notes?: string | null;
|
||||
capacity?: number | null;
|
||||
contact_person?: string | null;
|
||||
contact_phone?: string | null;
|
||||
submission_status?: 'pending' | 'approved' | 'rejected' | null;
|
||||
registered_participants: number;
|
||||
registration_open: boolean;
|
||||
}
|
||||
|
||||
async function fetchMission(id: number, token: string) {
|
||||
|
|
@ -90,6 +105,40 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
|||
<h2>{mission.title}</h2>
|
||||
<span className="badge">{mission.difficulty}</span>
|
||||
<p style={{ marginTop: '1rem', color: 'var(--text-muted)' }}>{mission.description}</p>
|
||||
{mission.format === 'offline' && (
|
||||
<div className="card" style={{ marginTop: '1rem', background: 'rgba(162, 155, 254, 0.08)' }}>
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>Офлайн событие</h3>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0, color: 'var(--text-muted)' }}>
|
||||
{mission.event_location && <li>📍 {mission.event_location}</li>}
|
||||
{mission.event_address && <li>🧭 {mission.event_address}</li>}
|
||||
{mission.event_starts_at && (
|
||||
<li>
|
||||
🗓 Старт: {new Date(mission.event_starts_at).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
|
||||
</li>
|
||||
)}
|
||||
{mission.event_ends_at && (
|
||||
<li>
|
||||
🕘 Завершение: {new Date(mission.event_ends_at).toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
👥 Зарегистрировано: {mission.registered_participants}
|
||||
{mission.capacity ? ` из ${mission.capacity}` : ''}
|
||||
</li>
|
||||
{mission.registration_deadline && (
|
||||
<li>
|
||||
⏳ Запись до: {new Date(mission.registration_deadline).toLocaleString('ru-RU', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' })}
|
||||
</li>
|
||||
)}
|
||||
{mission.registration_notes && <li>ℹ️ {mission.registration_notes}</li>}
|
||||
{mission.registration_open ? (
|
||||
<li style={{ color: 'var(--accent-light)' }}>Регистрация открыта</li>
|
||||
) : (
|
||||
<li style={{ color: 'var(--error)' }}>Регистрация закрыта</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<p style={{ marginTop: '1rem' }}>
|
||||
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
||||
</p>
|
||||
|
|
@ -143,6 +192,25 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
|||
initialState={codingState}
|
||||
initialCompleted={mission.is_completed}
|
||||
/>
|
||||
) : mission.format === 'offline' ? (
|
||||
<OfflineMissionRegistration
|
||||
missionId={mission.id}
|
||||
token={session.token}
|
||||
locked={!mission.is_available && !mission.is_completed}
|
||||
registrationOpen={mission.registration_open}
|
||||
registeredCount={mission.registered_participants}
|
||||
capacity={mission.capacity}
|
||||
submission={submission ? { id: submission.id, comment: submission.comment, status: submission.status } : null}
|
||||
eventLocation={mission.event_location}
|
||||
eventAddress={mission.event_address}
|
||||
eventStartsAt={mission.event_starts_at}
|
||||
eventEndsAt={mission.event_ends_at}
|
||||
registrationDeadline={mission.registration_deadline}
|
||||
registrationUrl={mission.registration_url}
|
||||
registrationNotes={mission.registration_notes}
|
||||
contactPerson={mission.contact_person}
|
||||
contactPhone={mission.contact_phone}
|
||||
/>
|
||||
) : (
|
||||
<MissionSubmissionForm
|
||||
missionId={mission.id}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ interface ProfileResponse {
|
|||
name: string;
|
||||
rarity: string;
|
||||
}>;
|
||||
profile_photo_uploaded: boolean;
|
||||
}
|
||||
|
||||
interface ProgressResponse {
|
||||
|
|
@ -64,6 +65,8 @@ export default async function DashboardPage() {
|
|||
mana={profile.mana}
|
||||
competencies={profile.competencies}
|
||||
artifacts={profile.artifacts}
|
||||
token={session.token}
|
||||
profilePhotoUploaded={profile.profile_photo_uploaded}
|
||||
progress={progress}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ async function registerAction(formData: FormData) {
|
|||
const email = String(formData.get('email') ?? '').trim();
|
||||
const password = String(formData.get('password') ?? '').trim();
|
||||
// Необязательные поля переводим в undefined, чтобы backend не записывал пустые строки.
|
||||
const preferredBranch = String(formData.get('preferredBranch') ?? '').trim() || undefined;
|
||||
const motivation = String(formData.get('motivation') ?? '').trim() || undefined;
|
||||
|
||||
if (!fullName || !email || !password) {
|
||||
|
|
@ -25,7 +24,7 @@ async function registerAction(formData: FormData) {
|
|||
|
||||
try {
|
||||
// 2. Собираем payload в формате, который ожидает FastAPI.
|
||||
const payload = { full_name: fullName, email, password, preferred_branch: preferredBranch, motivation };
|
||||
const payload = { full_name: fullName, email, password, motivation };
|
||||
const response = await apiFetch<any>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
|
|
@ -80,17 +79,6 @@ export default async function RegisterPage({ searchParams }: { searchParams: { e
|
|||
Пароль
|
||||
<input className={styles.input} type="password" name="password" required placeholder="Придумайте пароль" />
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Интересующая ветка (необязательно)
|
||||
<select className={styles.input} name="preferredBranch" defaultValue="">
|
||||
<option value="">Выберите ветку</option>
|
||||
<option value="Получение оффера">Получение оффера</option>
|
||||
<option value="Рекрутинг">Рекрутинг</option>
|
||||
<option value="Квесты">Квесты</option>
|
||||
<option value="Симулятор">Симулятор</option>
|
||||
<option value="Лекторий">Лекторий</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
Что хотите добиться?
|
||||
<textarea className={styles.textarea} name="motivation" rows={3} placeholder="Например: хочу собрать портфолио и познакомиться с командой" />
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ interface StoreItem {
|
|||
description: string;
|
||||
cost_mana: number;
|
||||
stock: number;
|
||||
image_url: string | null;
|
||||
}
|
||||
|
||||
async function fetchStore(token: string) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,17 @@ export interface MissionSummary {
|
|||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: string;
|
||||
format: 'online' | 'offline';
|
||||
event_location?: string | null;
|
||||
event_address?: string | null;
|
||||
event_starts_at?: string | null;
|
||||
event_ends_at?: string | null;
|
||||
registration_deadline?: string | null;
|
||||
registration_url?: string | null;
|
||||
registration_notes?: string | null;
|
||||
capacity?: number | null;
|
||||
contact_person?: string | null;
|
||||
contact_phone?: string | null;
|
||||
is_active: boolean;
|
||||
is_available: boolean;
|
||||
locked_reasons: string[];
|
||||
|
|
@ -17,6 +28,9 @@ export interface MissionSummary {
|
|||
has_coding_challenges: boolean;
|
||||
coding_challenge_count: number;
|
||||
completed_coding_challenges: number;
|
||||
submission_status?: 'pending' | 'approved' | 'rejected' | null;
|
||||
registered_participants: number;
|
||||
registration_open: boolean;
|
||||
}
|
||||
|
||||
const Card = styled.div`
|
||||
|
|
@ -38,13 +52,32 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
|||
const locked = !mission.is_available && !completed;
|
||||
const primaryClass = completed ? 'secondary' : locked ? 'secondary' : 'primary';
|
||||
const linkDisabled = locked;
|
||||
const actionLabel = completed
|
||||
? 'Миссия выполнена'
|
||||
: mission.is_available
|
||||
? mission.has_coding_challenges
|
||||
? 'Решать задачи'
|
||||
: 'Открыть брифинг'
|
||||
: 'Заблокировано';
|
||||
let actionLabel = 'Открыть брифинг';
|
||||
if (completed) {
|
||||
actionLabel = mission.format === 'offline' ? 'Регистрация подтверждена' : 'Миссия выполнена';
|
||||
} else if (!mission.is_available) {
|
||||
actionLabel = 'Заблокировано';
|
||||
} else if (mission.format === 'offline') {
|
||||
if (mission.submission_status === 'pending') {
|
||||
actionLabel = 'Заявка отправлена';
|
||||
} else if (mission.submission_status === 'approved') {
|
||||
actionLabel = 'Регистрация подтверждена';
|
||||
} else if (!mission.registration_open) {
|
||||
actionLabel = 'Регистрация закрыта';
|
||||
} else {
|
||||
actionLabel = 'Записаться';
|
||||
}
|
||||
} else if (mission.has_coding_challenges) {
|
||||
actionLabel = 'Решать задачи';
|
||||
}
|
||||
|
||||
const offlineDetails = mission.format === 'offline'
|
||||
? {
|
||||
date: mission.event_starts_at ? new Date(mission.event_starts_at) : null,
|
||||
end: mission.event_ends_at ? new Date(mission.event_ends_at) : null,
|
||||
deadline: mission.registration_deadline ? new Date(mission.registration_deadline) : null
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card key={mission.id} style={completed ? { opacity: 0.85 } : undefined}>
|
||||
|
|
@ -57,6 +90,35 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
|||
🗂 Требуется загрузка документов
|
||||
</p>
|
||||
)}
|
||||
{mission.format === 'offline' && offlineDetails?.date && (
|
||||
<div style={{ marginTop: '0.5rem', fontSize: '0.9rem', color: 'var(--accent-light)' }}>
|
||||
<div>📍 {mission.event_location ?? 'Офлайн мероприятие'}</div>
|
||||
<div>
|
||||
🗓 {offlineDetails.date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })} ·
|
||||
{' '}
|
||||
{offlineDetails.date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
{offlineDetails.end &&
|
||||
` – ${offlineDetails.end.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}`}
|
||||
</div>
|
||||
<div>
|
||||
👥 {mission.registered_participants}
|
||||
{mission.capacity ? ` из ${mission.capacity}` : ''} зарегистрировано
|
||||
</div>
|
||||
{!mission.registration_open && mission.submission_status !== 'approved' && (
|
||||
<div style={{ color: 'var(--error)' }}>Регистрация завершена</div>
|
||||
)}
|
||||
{mission.registration_open && offlineDetails.deadline && (
|
||||
<div>
|
||||
⏳ Запись до{' '}
|
||||
{offlineDetails.deadline.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})}{' '}
|
||||
{offlineDetails.deadline.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
||||
{mission.has_coding_challenges && (
|
||||
|
|
|
|||
205
frontend/src/components/OfflineMissionRegistration.tsx
Normal file
205
frontend/src/components/OfflineMissionRegistration.tsx
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
type SubmissionStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
interface ExistingSubmission {
|
||||
id: number;
|
||||
comment: string | null;
|
||||
status: SubmissionStatus;
|
||||
}
|
||||
|
||||
interface OfflineMissionRegistrationProps {
|
||||
missionId: number;
|
||||
token?: string;
|
||||
locked?: boolean;
|
||||
registrationOpen: boolean;
|
||||
registeredCount: number;
|
||||
capacity?: number | null;
|
||||
submission?: ExistingSubmission | null;
|
||||
eventLocation?: string | null;
|
||||
eventAddress?: string | null;
|
||||
eventStartsAt?: string | null;
|
||||
eventEndsAt?: string | null;
|
||||
registrationDeadline?: string | null;
|
||||
registrationUrl?: string | null;
|
||||
registrationNotes?: string | null;
|
||||
contactPerson?: string | null;
|
||||
contactPhone?: string | null;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
const formattedDate = date.toLocaleDateString('ru-RU', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
const formattedTime = date.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `${formattedDate} · ${formattedTime}`;
|
||||
}
|
||||
|
||||
export function OfflineMissionRegistration({
|
||||
missionId,
|
||||
token,
|
||||
locked = false,
|
||||
registrationOpen,
|
||||
registeredCount,
|
||||
capacity,
|
||||
submission,
|
||||
eventLocation,
|
||||
eventAddress,
|
||||
eventStartsAt,
|
||||
eventEndsAt,
|
||||
registrationDeadline,
|
||||
registrationUrl,
|
||||
registrationNotes,
|
||||
contactPerson,
|
||||
contactPhone,
|
||||
}: OfflineMissionRegistrationProps) {
|
||||
const [comment, setComment] = useState(submission?.comment ?? '');
|
||||
const initialStatus = (() => {
|
||||
if (submission?.status === 'approved') {
|
||||
return 'Регистрация подтверждена HR. Встретимся офлайн!';
|
||||
}
|
||||
if (submission?.status === 'pending') {
|
||||
return 'Заявка отправлена и ожидает подтверждения HR.';
|
||||
}
|
||||
if (submission?.status === 'rejected') {
|
||||
return 'Предыдущая заявка была отклонена. Проверьте комментарий и отправьте снова.';
|
||||
}
|
||||
if (!registrationOpen) {
|
||||
return 'Регистрация закрыта: лимит мест или срок записи истёк.';
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
const [status, setStatus] = useState<string | null>(initialStatus);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submissionStatus = submission?.status;
|
||||
const isApproved = submissionStatus === 'approved';
|
||||
const isPending = submissionStatus === 'pending';
|
||||
const canSubmit = !locked && (registrationOpen || Boolean(submission));
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!token) {
|
||||
setStatus('Не удалось авторизовать отправку. Перезагрузите страницу.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canSubmit) {
|
||||
setStatus('Регистрация закрыта.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const formData = new FormData();
|
||||
formData.append('comment', comment.trim());
|
||||
const updated = await apiFetch<ExistingSubmission & { status: SubmissionStatus; comment: string | null }>(
|
||||
`/api/missions/${missionId}/submit`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
authToken: token,
|
||||
},
|
||||
);
|
||||
setComment(updated.comment ?? '');
|
||||
if (updated.status === 'approved') {
|
||||
setStatus('Регистрация подтверждена HR.');
|
||||
} else {
|
||||
setStatus('Заявка отправлена! HR свяжется с вами при необходимости.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setStatus(error.message);
|
||||
} else {
|
||||
setStatus('Не удалось отправить заявку. Попробуйте позже.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="card" onSubmit={handleSubmit} style={{ marginTop: '2rem' }}>
|
||||
<h3>Запись на офлайн-мероприятие</h3>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
{eventLocation ?? 'Офлайн событие'}
|
||||
{eventAddress ? ` · ${eventAddress}` : ''}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gap: '0.5rem', margin: '1rem 0' }}>
|
||||
{eventStartsAt && (
|
||||
<div>
|
||||
<strong>Начало:</strong> {formatDateTime(eventStartsAt)}
|
||||
</div>
|
||||
)}
|
||||
{eventEndsAt && (
|
||||
<div>
|
||||
<strong>Завершение:</strong> {formatDateTime(eventEndsAt)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<strong>Участников:</strong> {registeredCount}
|
||||
{capacity ? ` из ${capacity}` : ''}
|
||||
</div>
|
||||
{registrationDeadline && (
|
||||
<div>
|
||||
<strong>Запись до:</strong> {formatDateTime(registrationDeadline)}
|
||||
</div>
|
||||
)}
|
||||
{registrationUrl && (
|
||||
<div>
|
||||
<a href={registrationUrl} target="_blank" rel="noreferrer" className="secondary">
|
||||
Открыть страницу мероприятия
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{contactPerson && (
|
||||
<div>
|
||||
<strong>Контакт HR:</strong> {contactPerson}
|
||||
{contactPhone ? ` · ${contactPhone}` : ''}
|
||||
</div>
|
||||
)}
|
||||
{registrationNotes && (
|
||||
<div style={{ color: 'var(--accent-light)' }}>{registrationNotes}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'block', marginBottom: '0.75rem' }}>
|
||||
Комментарий (опционально)
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={(event) => setComment(event.target.value)}
|
||||
rows={4}
|
||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||
placeholder="Например: приеду с запасным удостоверением"
|
||||
disabled={!canSubmit || isApproved}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button className="primary" type="submit" disabled={!canSubmit || loading || isApproved}>
|
||||
{isApproved ? 'Регистрация подтверждена' : isPending ? 'Заявка отправлена' : registrationOpen ? 'Записаться' : 'Регистрация закрыта'}
|
||||
</button>
|
||||
|
||||
{status && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
color: status.includes('подтверждена') ? 'var(--accent-light)' : status.includes('отправлена') ? 'var(--accent-light)' : 'var(--error)',
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
// Компетенции и артефакты из профиля пользователя.
|
||||
type Competency = {
|
||||
competency: {
|
||||
|
|
@ -18,11 +21,18 @@ type Artifact = {
|
|||
};
|
||||
|
||||
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
|
||||
interface ProfilePhotoResponse {
|
||||
photo: string | null;
|
||||
detail?: string | null;
|
||||
}
|
||||
|
||||
export interface ProfileProps {
|
||||
fullName: string;
|
||||
mana: number;
|
||||
competencies: Competency[];
|
||||
artifacts: Artifact[];
|
||||
token: string;
|
||||
profilePhotoUploaded: boolean;
|
||||
progress: {
|
||||
current_rank: { title: string } | null;
|
||||
next_rank: { title: string } | null;
|
||||
|
|
@ -57,6 +67,44 @@ const Card = styled.div`
|
|||
gap: 1.5rem;
|
||||
`;
|
||||
|
||||
const PhotoSection = styled.div`
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const PhotoPreview = styled.div`
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(108, 92, 231, 0.45);
|
||||
background: rgba(162, 155, 254, 0.18);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
`;
|
||||
|
||||
const PhotoImage = styled.img`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
`;
|
||||
|
||||
const PhotoActions = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
`;
|
||||
|
||||
const StatusMessage = styled.p<{ $kind: 'success' | 'error' }>`
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: ${({ $kind }) => ($kind === 'success' ? 'var(--accent-light)' : 'var(--error)')};
|
||||
`;
|
||||
|
||||
const ProgressBar = styled.div<{ value: number }>`
|
||||
position: relative;
|
||||
height: 12px;
|
||||
|
|
@ -109,12 +157,163 @@ const InlineBadge = styled.span<{ $kind?: 'success' | 'warning' }>`
|
|||
color: ${({ $kind }) => ($kind === 'success' ? '#55efc4' : '#ff7675')};
|
||||
`;
|
||||
|
||||
export function ProgressOverview({ fullName, mana, competencies, artifacts, progress }: ProfileProps) {
|
||||
export function ProgressOverview({
|
||||
fullName,
|
||||
mana,
|
||||
competencies,
|
||||
artifacts,
|
||||
token,
|
||||
profilePhotoUploaded,
|
||||
progress
|
||||
}: ProfileProps) {
|
||||
const xpPercent = Math.round(progress.xp.progress_percent * 100);
|
||||
const hasNextRank = Boolean(progress.next_rank);
|
||||
const [photoData, setPhotoData] = useState<string | null>(null);
|
||||
const [hasPhoto, setHasPhoto] = useState(profilePhotoUploaded);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [statusKind, setStatusKind] = useState<'success' | 'error'>('success');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setHasPhoto(profilePhotoUploaded);
|
||||
}, [profilePhotoUploaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasPhoto) {
|
||||
setPhotoData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
async function loadPhoto() {
|
||||
try {
|
||||
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', { authToken: token });
|
||||
if (!cancelled) {
|
||||
setPhotoData(response.photo ?? null);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('Не удалось загрузить фото профиля', error);
|
||||
setStatusKind('error');
|
||||
setStatus('Не получилось загрузить фото. Попробуйте обновить страницу.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadPhoto();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [hasPhoto, token]);
|
||||
|
||||
async function handleUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
||||
setStatus('Разрешены только изображения в форматах JPG, PNG или WEBP.');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setStatus('Размер файла не должен превышать 5 МБ.');
|
||||
event.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('photo', file);
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setStatus(null);
|
||||
setStatusKind('success');
|
||||
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
authToken: token
|
||||
});
|
||||
setPhotoData(response.photo ?? null);
|
||||
setHasPhoto(Boolean(response.photo));
|
||||
setStatusKind('success');
|
||||
setStatus(response.detail ?? 'Фотография обновлена.');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setStatusKind('error');
|
||||
setStatus(error.message);
|
||||
} else {
|
||||
setStatusKind('error');
|
||||
setStatus('Не удалось сохранить фото. Попробуйте позже.');
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
try {
|
||||
setUploading(true);
|
||||
setStatus(null);
|
||||
setStatusKind('success');
|
||||
const response = await apiFetch<ProfilePhotoResponse>('/api/me/photo', {
|
||||
method: 'DELETE',
|
||||
authToken: token
|
||||
});
|
||||
setPhotoData(null);
|
||||
setHasPhoto(false);
|
||||
setStatusKind('success');
|
||||
setStatus(response.detail ?? 'Фотография удалена.');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setStatusKind('error');
|
||||
setStatus(error.message);
|
||||
} else {
|
||||
setStatusKind('error');
|
||||
setStatus('Не удалось удалить фото. Попробуйте ещё раз.');
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<PhotoSection>
|
||||
<PhotoPreview>
|
||||
{photoData ? <PhotoImage src={photoData} alt="Фото профиля" /> : <span role="img" aria-label="Профиль">🧑🚀</span>}
|
||||
</PhotoPreview>
|
||||
<PhotoActions>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<label className="secondary" style={{ cursor: 'pointer' }}>
|
||||
Загрузить фото
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
/>
|
||||
</label>
|
||||
<button className="ghost" type="button" onClick={handleRemove} disabled={!hasPhoto || uploading}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
<small style={{ color: 'var(--text-muted)' }}>
|
||||
Добавьте свою фотографию, чтобы HR быстрее узнавал вас при общении на офлайн-миссиях.
|
||||
</small>
|
||||
{status && <StatusMessage $kind={statusKind}>{status}</StatusMessage>}
|
||||
</PhotoActions>
|
||||
</PhotoSection>
|
||||
|
||||
<header>
|
||||
<h2 style={{ margin: 0 }}>{fullName}</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ type StoreItem = {
|
|||
description: string;
|
||||
cost_mana: number;
|
||||
stock: number;
|
||||
image_url: string | null;
|
||||
};
|
||||
|
||||
const Card = styled.div`
|
||||
|
|
@ -68,6 +69,19 @@ export function StoreItems({ items, token }: { items: StoreItem[]; token?: strin
|
|||
<div className="grid">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
{item.image_url && (
|
||||
<img
|
||||
src={item.image_url}
|
||||
alt={item.name}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: '180px',
|
||||
borderRadius: '10px',
|
||||
marginBottom: '1rem',
|
||||
objectFit: 'cover',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>{item.name}</h3>
|
||||
<p style={{ color: 'var(--text-muted)' }}>{item.description}</p>
|
||||
<p style={{ marginTop: '1rem' }}>{item.cost_mana} ⚡ · остаток {item.stock}</p>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,17 @@ type MissionBase = {
|
|||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: Difficulty;
|
||||
format: 'online' | 'offline';
|
||||
event_location?: string | null;
|
||||
event_address?: string | null;
|
||||
event_starts_at?: string | null;
|
||||
event_ends_at?: string | null;
|
||||
registration_deadline?: string | null;
|
||||
registration_url?: string | null;
|
||||
registration_notes?: string | null;
|
||||
capacity?: number | null;
|
||||
contact_person?: string | null;
|
||||
contact_phone?: string | null;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -76,6 +87,17 @@ type FormState = {
|
|||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: Difficulty;
|
||||
format: 'online' | 'offline';
|
||||
event_location: string;
|
||||
event_address: string;
|
||||
event_starts_at: string;
|
||||
event_ends_at: string;
|
||||
registration_deadline: string;
|
||||
registration_url: string;
|
||||
registration_notes: string;
|
||||
capacity: number | '';
|
||||
contact_person: string;
|
||||
contact_phone: string;
|
||||
minimum_rank_id: number | '';
|
||||
artifact_id: number | '';
|
||||
branch_id: number | '';
|
||||
|
|
@ -91,6 +113,17 @@ const initialFormState: FormState = {
|
|||
xp_reward: 0,
|
||||
mana_reward: 0,
|
||||
difficulty: 'medium',
|
||||
format: 'online',
|
||||
event_location: '',
|
||||
event_address: '',
|
||||
event_starts_at: '',
|
||||
event_ends_at: '',
|
||||
registration_deadline: '',
|
||||
registration_url: '',
|
||||
registration_notes: '',
|
||||
capacity: '',
|
||||
contact_person: '',
|
||||
contact_phone: '',
|
||||
minimum_rank_id: '',
|
||||
artifact_id: '',
|
||||
branch_id: '',
|
||||
|
|
@ -108,6 +141,25 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const toInputDateTime = (value?: string | null) => {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
const offset = date.getTimezoneOffset();
|
||||
const local = new Date(date.getTime() - offset * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const fromInputDateTime = (value: string) => {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
const sanitizeString = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === '' ? null : trimmed;
|
||||
};
|
||||
|
||||
// Позволяет мгновенно подставлять базовые поля при переключении миссии,
|
||||
// пока загрузка детальной карточки не завершилась.
|
||||
const missionById = useMemo(() => new Map(missions.map((mission) => [mission.id, mission])), [missions]);
|
||||
|
|
@ -126,6 +178,17 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
xp_reward: mission.xp_reward,
|
||||
mana_reward: mission.mana_reward,
|
||||
difficulty: mission.difficulty,
|
||||
format: mission.format,
|
||||
event_location: mission.event_location ?? '',
|
||||
event_address: mission.event_address ?? '',
|
||||
event_starts_at: toInputDateTime(mission.event_starts_at),
|
||||
event_ends_at: toInputDateTime(mission.event_ends_at),
|
||||
registration_deadline: toInputDateTime(mission.registration_deadline),
|
||||
registration_url: mission.registration_url ?? '',
|
||||
registration_notes: mission.registration_notes ?? '',
|
||||
capacity: mission.capacity ?? '',
|
||||
contact_person: mission.contact_person ?? '',
|
||||
contact_phone: mission.contact_phone ?? '',
|
||||
minimum_rank_id: mission.minimum_rank_id ?? '',
|
||||
artifact_id: mission.artifact_id ?? '',
|
||||
branch_id: (() => {
|
||||
|
|
@ -174,6 +237,17 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
xp_reward: baseMission.xp_reward,
|
||||
mana_reward: baseMission.mana_reward,
|
||||
difficulty: baseMission.difficulty,
|
||||
format: baseMission.format,
|
||||
event_location: baseMission.event_location ?? '',
|
||||
event_address: baseMission.event_address ?? '',
|
||||
event_starts_at: toInputDateTime(baseMission.event_starts_at),
|
||||
event_ends_at: toInputDateTime(baseMission.event_ends_at),
|
||||
registration_deadline: toInputDateTime(baseMission.registration_deadline),
|
||||
registration_url: baseMission.registration_url ?? '',
|
||||
registration_notes: baseMission.registration_notes ?? '',
|
||||
capacity: baseMission.capacity ?? '',
|
||||
contact_person: baseMission.contact_person ?? '',
|
||||
contact_phone: baseMission.contact_phone ?? '',
|
||||
is_active: baseMission.is_active
|
||||
}));
|
||||
}
|
||||
|
|
@ -222,6 +296,17 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
xp_reward: Number(form.xp_reward),
|
||||
mana_reward: Number(form.mana_reward),
|
||||
difficulty: form.difficulty,
|
||||
format: form.format,
|
||||
event_location: sanitizeString(form.event_location),
|
||||
event_address: sanitizeString(form.event_address),
|
||||
event_starts_at: fromInputDateTime(form.event_starts_at),
|
||||
event_ends_at: fromInputDateTime(form.event_ends_at),
|
||||
registration_deadline: fromInputDateTime(form.registration_deadline),
|
||||
registration_url: sanitizeString(form.registration_url),
|
||||
registration_notes: sanitizeString(form.registration_notes),
|
||||
capacity: form.capacity === '' ? null : Number(form.capacity),
|
||||
contact_person: sanitizeString(form.contact_person),
|
||||
contact_phone: sanitizeString(form.contact_phone),
|
||||
minimum_rank_id: form.minimum_rank_id === '' ? null : Number(form.minimum_rank_id),
|
||||
artifact_id: form.artifact_id === '' ? null : Number(form.artifact_id),
|
||||
prerequisite_ids: form.prerequisite_ids,
|
||||
|
|
@ -312,6 +397,13 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Формат
|
||||
<select value={form.format} onChange={(event) => updateField('format', event.target.value as 'online' | 'offline')}>
|
||||
<option value="online">Онлайн</option>
|
||||
<option value="offline">Офлайн встреча</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Доступен с ранга
|
||||
<select value={form.minimum_rank_id === '' ? '' : String(form.minimum_rank_id)} onChange={(event) => updateField('minimum_rank_id', event.target.value === '' ? '' : Number(event.target.value))}>
|
||||
|
|
@ -339,6 +431,54 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
|||
</label>
|
||||
</div>
|
||||
|
||||
{form.format === 'offline' && (
|
||||
<fieldset style={{ border: '1px solid rgba(162,155,254,0.3)', borderRadius: '16px', padding: '1rem' }}>
|
||||
<legend style={{ padding: '0 0.5rem' }}>Офлайн-детали</legend>
|
||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
|
||||
<label>
|
||||
Локация / площадка
|
||||
<input value={form.event_location} onChange={(event) => updateField('event_location', event.target.value)} placeholder="Например: Кампус Алабуга" />
|
||||
</label>
|
||||
<label>
|
||||
Адрес
|
||||
<input value={form.event_address} onChange={(event) => updateField('event_address', event.target.value)} placeholder="Город, улица, дом" />
|
||||
</label>
|
||||
<label>
|
||||
Начало
|
||||
<input type="datetime-local" value={form.event_starts_at} onChange={(event) => updateField('event_starts_at', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Завершение
|
||||
<input type="datetime-local" value={form.event_ends_at} onChange={(event) => updateField('event_ends_at', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Дедлайн регистрации
|
||||
<input type="datetime-local" value={form.registration_deadline} onChange={(event) => updateField('registration_deadline', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Вместимость
|
||||
<input type="number" min={0} value={form.capacity === '' ? '' : form.capacity} onChange={(event) => updateField('capacity', event.target.value === '' ? '' : Number(event.target.value))} placeholder="Например: 40" />
|
||||
</label>
|
||||
<label>
|
||||
Ссылка на мероприятие
|
||||
<input type="url" value={form.registration_url} onChange={(event) => updateField('registration_url', event.target.value)} placeholder="https://..." />
|
||||
</label>
|
||||
<label>
|
||||
Дополнительные заметки
|
||||
<textarea value={form.registration_notes} onChange={(event) => updateField('registration_notes', event.target.value)} rows={3} placeholder="Что взять с собой, как пройти и т.д." />
|
||||
</label>
|
||||
<label>
|
||||
Контактное лицо
|
||||
<input value={form.contact_person} onChange={(event) => updateField('contact_person', event.target.value)} placeholder="Имя HR" />
|
||||
</label>
|
||||
<label>
|
||||
Телефон/чат
|
||||
<input value={form.contact_phone} onChange={(event) => updateField('contact_phone', event.target.value)} placeholder="+7..." />
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<label>
|
||||
Ветка
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||
|
|
|
|||
237
frontend/src/components/admin/AdminStoreManager.tsx
Normal file
237
frontend/src/components/admin/AdminStoreManager.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
'use client';
|
||||
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
export type StoreItem = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
cost_mana: number;
|
||||
stock: number;
|
||||
image_url: string | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
items: StoreItem[];
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
description: string;
|
||||
cost_mana: number;
|
||||
stock: number;
|
||||
image_url: string;
|
||||
}
|
||||
|
||||
const emptyForm: FormState = {
|
||||
name: '',
|
||||
description: '',
|
||||
cost_mana: 0,
|
||||
stock: 0,
|
||||
image_url: '',
|
||||
};
|
||||
|
||||
export function AdminStoreManager({ token, items }: Props) {
|
||||
const router = useRouter();
|
||||
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||
const [form, setForm] = useState<FormState>(emptyForm);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const itemById = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
|
||||
|
||||
const resetForm = () => {
|
||||
setForm({ ...emptyForm });
|
||||
setSelectedId('new');
|
||||
};
|
||||
|
||||
const handleSelect = (id: number | 'new') => {
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
setSelectedId(id);
|
||||
if (id === 'new') {
|
||||
setForm({ ...emptyForm });
|
||||
} else {
|
||||
const item = itemById.get(id);
|
||||
if (item) {
|
||||
// Подставляем актуальные значения товара в форму.
|
||||
setForm({
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
cost_mana: item.cost_mana,
|
||||
stock: item.stock,
|
||||
image_url: item.image_url ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setSaving(true);
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
cost_mana: form.cost_mana,
|
||||
stock: form.stock,
|
||||
image_url: form.image_url.trim() === '' ? null : form.image_url.trim(),
|
||||
};
|
||||
|
||||
if (!payload.name || !payload.description) {
|
||||
throw new Error('Название и описание не должны быть пустыми.');
|
||||
}
|
||||
|
||||
if (selectedId === 'new') {
|
||||
await apiFetch('/api/admin/store/items', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
authToken: token,
|
||||
});
|
||||
setStatus('Товар добавлен. Мы обновили список, можно проверить карточку ниже.');
|
||||
resetForm();
|
||||
} else {
|
||||
await apiFetch(`/api/admin/store/items/${selectedId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
authToken: token,
|
||||
});
|
||||
setStatus('Изменения сохранены. Страница магазина обновится автоматически.');
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
} catch (submitError) {
|
||||
const message = submitError instanceof Error ? submitError.message : 'Не удалось сохранить товар.';
|
||||
setError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ gridColumn: '1 / -1' }}>
|
||||
<h3>Магазин</h3>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
Управляйте призами: загружайте изображения, задавайте стоимость в мане и поддерживайте актуальный остаток.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ minWidth: '260px' }}>
|
||||
<label className="label" htmlFor="store-item-select">
|
||||
Выберите товар
|
||||
</label>
|
||||
<select
|
||||
id="store-item-select"
|
||||
value={selectedId === 'new' ? 'new' : selectedId}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value === 'new' ? 'new' : Number(event.target.value);
|
||||
handleSelect(value);
|
||||
}}
|
||||
>
|
||||
<option value="new">Добавить новый</option>
|
||||
{items.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" className="secondary" onClick={resetForm} disabled={saving}>
|
||||
Очистить форму
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ marginTop: '1.5rem', display: 'grid', gap: '1rem' }}>
|
||||
<div>
|
||||
<label className="label" htmlFor="store-name">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
id="store-name"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
|
||||
placeholder="Например, экскурсия по кампусу"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="store-description">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="store-description"
|
||||
value={form.description}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, description: event.target.value }))}
|
||||
rows={4}
|
||||
placeholder="Коротко расскажите, что получит пилот"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '1rem' }}>
|
||||
<div>
|
||||
<label className="label" htmlFor="store-cost">
|
||||
Стоимость (⚡)
|
||||
</label>
|
||||
<input
|
||||
id="store-cost"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.cost_mana}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, cost_mana: Number(event.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="store-stock">
|
||||
Остаток
|
||||
</label>
|
||||
<input
|
||||
id="store-stock"
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.stock}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, stock: Number(event.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="store-image">
|
||||
Ссылка на изображение
|
||||
</label>
|
||||
<input
|
||||
id="store-image"
|
||||
value={form.image_url}
|
||||
onChange={(event) => setForm((prev) => ({ ...prev, image_url: event.target.value }))}
|
||||
placeholder="Например, /store/excursion-alabuga.svg"
|
||||
/>
|
||||
<small style={{ color: 'var(--text-muted)' }}>
|
||||
Изображение можно сохранить в public/store и указать относительный путь.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button className="primary" type="submit" disabled={saving}>
|
||||
{saving ? 'Сохраняем...' : selectedId === 'new' ? 'Добавить товар' : 'Сохранить изменения'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{status && (
|
||||
<p style={{ color: 'var(--accent-light)', marginTop: '1rem' }}>
|
||||
{status}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p style={{ color: 'var(--error)', marginTop: '1rem' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
|
|
@ -17,7 +18,7 @@ from app.db.session import SessionLocal
|
|||
from app.models.artifact import Artifact, ArtifactRarity
|
||||
from app.models.branch import Branch, BranchMission
|
||||
from app.models.coding import CodingChallenge
|
||||
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
|
||||
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty, MissionFormat
|
||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||
from app.models.onboarding import OnboardingSlide
|
||||
from app.models.store import StoreItem
|
||||
|
|
@ -168,7 +169,12 @@ def seed() -> None:
|
|||
description="Мини-курс из 10 задач для проверки синтаксиса и базовой логики.",
|
||||
category="training",
|
||||
)
|
||||
session.add_all([branch, python_branch])
|
||||
offline_branch = Branch(
|
||||
title="Офлайн мероприятия",
|
||||
description="Живые встречи в кампусе и городе",
|
||||
category="event",
|
||||
)
|
||||
session.add_all([branch, python_branch, offline_branch])
|
||||
session.flush()
|
||||
|
||||
# Миссии
|
||||
|
|
@ -214,12 +220,67 @@ def seed() -> None:
|
|||
difficulty=MissionDifficulty.MEDIUM,
|
||||
minimum_rank_id=ranks[0].id,
|
||||
)
|
||||
now = datetime.now(timezone.utc)
|
||||
mission_campus_tour = Mission(
|
||||
title="Экскурсия по кампусу",
|
||||
description="Познакомьтесь с производственными линиями и учебными площадками вместе с наставником.",
|
||||
xp_reward=140,
|
||||
mana_reward=70,
|
||||
difficulty=MissionDifficulty.EASY,
|
||||
format=MissionFormat.OFFLINE,
|
||||
event_location="Кампус Алабуга Политех",
|
||||
event_address="Республика Татарстан, Елабуга, территория ОЭЗ 'Алабуга'",
|
||||
event_starts_at=now + timedelta(days=3, hours=10),
|
||||
event_ends_at=now + timedelta(days=3, hours=13),
|
||||
registration_deadline=now + timedelta(days=2, hours=18),
|
||||
capacity=25,
|
||||
contact_person="Наталья из HR",
|
||||
contact_phone="+7 (900) 123-45-67",
|
||||
registration_notes="Возьмите паспорт для прохода на территорию.",
|
||||
)
|
||||
mission_sport_day = Mission(
|
||||
title="Спортивный день в технопарке",
|
||||
description="Присоединяйтесь к командным активностям и познакомьтесь с будущими коллегами в неформальной обстановке.",
|
||||
xp_reward=160,
|
||||
mana_reward=90,
|
||||
difficulty=MissionDifficulty.MEDIUM,
|
||||
format=MissionFormat.OFFLINE,
|
||||
event_location="Спортивный центр Алабуги",
|
||||
event_address="Елабуга, проспект Строителей, 5",
|
||||
event_starts_at=now + timedelta(days=7, hours=17),
|
||||
event_ends_at=now + timedelta(days=7, hours=20),
|
||||
registration_deadline=now + timedelta(days=6, hours=12),
|
||||
capacity=40,
|
||||
contact_person="Игорь, координатор мероприятий",
|
||||
contact_phone="+7 (900) 765-43-21",
|
||||
registration_notes="Форма одежды – спортивная. Будет организован трансфер от кампуса.",
|
||||
)
|
||||
mission_open_lecture = Mission(
|
||||
title="Лекция капитана по культуре Алабуги",
|
||||
description="Живой рассказ о миссии компании, ценностях и истории от капитана программы.",
|
||||
xp_reward=180,
|
||||
mana_reward=110,
|
||||
difficulty=MissionDifficulty.MEDIUM,
|
||||
format=MissionFormat.OFFLINE,
|
||||
event_location="Конференц-зал HQ",
|
||||
event_address="Елабуга, ул. Промышленная, 2",
|
||||
event_starts_at=now + timedelta(days=10, hours=15),
|
||||
event_ends_at=now + timedelta(days=10, hours=17),
|
||||
registration_deadline=now + timedelta(days=8, hours=18),
|
||||
capacity=60,
|
||||
contact_person="Алина, программа адаптации",
|
||||
contact_phone="+7 (900) 555-19-82",
|
||||
registration_notes="Перед лекцией будет кофе-брейк, приходите на 15 минут раньше.",
|
||||
)
|
||||
session.add_all([
|
||||
mission_documents,
|
||||
mission_resume,
|
||||
mission_interview,
|
||||
mission_onboarding,
|
||||
mission_python_basics,
|
||||
mission_campus_tour,
|
||||
mission_sport_day,
|
||||
mission_open_lecture,
|
||||
])
|
||||
session.flush()
|
||||
|
||||
|
|
@ -250,6 +311,21 @@ def seed() -> None:
|
|||
competency_id=competencies[2].id,
|
||||
level_delta=1,
|
||||
),
|
||||
MissionCompetencyReward(
|
||||
mission_id=mission_campus_tour.id,
|
||||
competency_id=competencies[0].id,
|
||||
level_delta=1,
|
||||
),
|
||||
MissionCompetencyReward(
|
||||
mission_id=mission_sport_day.id,
|
||||
competency_id=competencies[3].id,
|
||||
level_delta=1,
|
||||
),
|
||||
MissionCompetencyReward(
|
||||
mission_id=mission_open_lecture.id,
|
||||
competency_id=competencies[5].id,
|
||||
level_delta=1,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -260,6 +336,9 @@ def seed() -> None:
|
|||
BranchMission(branch_id=branch.id, mission_id=mission_interview.id, order=3),
|
||||
BranchMission(branch_id=branch.id, mission_id=mission_onboarding.id, order=4),
|
||||
BranchMission(branch_id=python_branch.id, mission_id=mission_python_basics.id, order=1),
|
||||
BranchMission(branch_id=offline_branch.id, mission_id=mission_campus_tour.id, order=1),
|
||||
BranchMission(branch_id=offline_branch.id, mission_id=mission_sport_day.id, order=2),
|
||||
BranchMission(branch_id=offline_branch.id, mission_id=mission_open_lecture.id, order=3),
|
||||
]
|
||||
)
|
||||
|
||||
|
|
@ -383,12 +462,14 @@ def seed() -> None:
|
|||
description="Личный тур по цехам Алабуги",
|
||||
cost_mana=200,
|
||||
stock=5,
|
||||
image_url="/store/excursion-alabuga.svg",
|
||||
),
|
||||
StoreItem(
|
||||
name="Мерч экипажа",
|
||||
description="Футболка с эмблемой миссии",
|
||||
cost_mana=150,
|
||||
stock=10,
|
||||
image_url="/store/alabuga-crew-shirt.svg",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user