From 0396d7387dba53d2f6f839eb8d860ca9a17e749e Mon Sep 17 00:00:00 2001 From: Dana <143515389+danaaalber@users.noreply.github.com> Date: Mon, 29 Sep 2025 23:53:32 +0500 Subject: [PATCH 1/7] Alabuga colors update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Изменения: - Добавлен --accent-dark: #005dac для градиентов - Обновились цвета с фиолетовых на синие: .card border: rgba(106, 207, 246, 0.2) .admin-form input border: rgba(106, 207, 246, 0.3) .badge background: rgba(106, 207, 246, 0.15) .secondary border: rgba(106, 207, 246, 0.4) - Градиент кнопки изменился: var(--accent) → var(--accent-dark) - Добавились новые компоненты: Стили для чекбоксов Сообщения успеха/ошибки (alert) Варианты бэджей (success/error) Анимации при наведении - Улучшенный UX: Фокус-стили для полей ввода Плавные переходы Эффекты при наведении --- frontend/src/styles/globals.css | 98 +++++++++++++++++++++++++++++---- 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index d94a25e..fe77182 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -2,11 +2,12 @@ color-scheme: dark; --bg: #080b1a; --bg-panel: #111633; - --accent: #6c5ce7; - --accent-light: #a29bfe; + --accent: #00AEEF; + --accent-light: #6ACFF6; + --accent-dark: #005dac; --text: #f5f6fa; --text-muted: #b2bec3; - --success: #00b894; + --success: #00B894; --error: #ff7675; font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } @@ -14,9 +15,11 @@ body { margin: 0; padding: 0; - background: radial-gradient(circle at 20% 20%, rgba(108, 92, 231, 0.2), transparent), - radial-gradient(circle at 80% 0%, rgba(0, 184, 148, 0.2), transparent), - var(--bg); + background: radial-gradient(circle at 20% 30%, rgba(106, 207, 246, 0.15), transparent 40%), + radial-gradient(circle at 80% 20%, rgba(0, 174, 239, 0.1), transparent 30%), + radial-gradient(circle at 40% 80%, rgba(0, 93, 172, 0.08), transparent 35%), + linear-gradient(135deg, #1a2a75 0%, #004a8c 50%, #1a2a75 100%), + var(--bg); color: var(--text); min-height: 100vh; position: relative; @@ -50,7 +53,7 @@ main { .card { background: rgba(17, 22, 51, 0.85); - border: 1px solid rgba(162, 155, 254, 0.2); + border: 1px solid rgba(106, 207, 246, 0.2); border-radius: 16px; padding: 1.5rem; backdrop-filter: blur(10px); @@ -59,7 +62,7 @@ main { .admin-form { display: grid; - gap: 1rem; + gap: 1.5rem; } .admin-form label { @@ -67,17 +70,28 @@ main { flex-direction: column; gap: 0.5rem; font-size: 0.9rem; + color: var(--accent-light); } .admin-form input, .admin-form textarea, .admin-form select { border-radius: 12px; - border: 1px solid rgba(162, 155, 254, 0.3); + border: 1px solid rgba(106, 207, 246, 0.3); background: rgba(8, 11, 26, 0.6); padding: 0.75rem; color: var(--text); width: 100%; + font-family: inherit; + transition: all 0.3s ease; +} + +.admin-form input:focus, +.admin-form textarea:focus, +.admin-form select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(0, 174, 239, 0.2); } .admin-form textarea { @@ -89,6 +103,16 @@ main { flex-direction: row; align-items: center; gap: 0.5rem; + cursor: pointer; +} + +.admin-form .checkbox input[type="checkbox"] { + width: auto; + accent-color: var(--accent); +} + +.admin-form .checkbox span { + color: var(--text); } .grid { @@ -98,36 +122,86 @@ main { } .badge { - background: rgba(162, 155, 254, 0.15); + background: rgba(106, 207, 246, 0.15); border-radius: 9999px; padding: 0.25rem 0.75rem; font-size: 0.75rem; color: var(--accent-light); text-transform: uppercase; letter-spacing: 0.08em; + display: inline-block; + border: 1px solid rgba(106, 207, 246, 0.2); +} + +.badge.success { + background: rgba(0, 184, 148, 0.15); + color: var(--success); + border-color: rgba(0, 184, 148, 0.3); +} + +.badge.error { + background: rgba(255, 118, 117, 0.15); + color: var(--error); + border-color: rgba(255, 118, 117, 0.3); } .primary { padding: 0.75rem 1.5rem; border-radius: 12px; border: none; - background: linear-gradient(135deg, var(--accent), #00b894); + background: linear-gradient(135deg, var(--accent), var(--accent-dark)); color: white; font-weight: 600; cursor: pointer; + font-family: inherit; + transition: all 0.3s ease; +} + +.primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(0, 174, 239, 0.3); } .primary:disabled { opacity: 0.4; cursor: not-allowed; + transform: none !important; + box-shadow: none !important; } .secondary { padding: 0.75rem 1.5rem; border-radius: 12px; - border: 1px solid rgba(162, 155, 254, 0.4); + border: 1px solid rgba(106, 207, 246, 0.4); background: transparent; color: var(--text-muted); font-weight: 500; cursor: pointer; + font-family: inherit; + transition: all 0.3s ease; +} + +.secondary:hover { + border-color: var(--accent-light); + color: var(--accent-light); + background: rgba(106, 207, 246, 0.1); +} + +.alert { + padding: 1rem; + border-radius: 12px; + margin: 1rem 0; + border: 1px solid; +} + +.alert.success { + background: rgba(0, 184, 148, 0.1); + border-color: rgba(0, 184, 148, 0.3); + color: var(--success); +} + +.alert.error { + background: rgba(255, 118, 117, 0.1); + border-color: rgba(255, 118, 117, 0.3); + color: var(--error); } From 0c5cf8aa0ce50949d39379754f56678583582a57 Mon Sep 17 00:00:00 2001 From: Danil Gryaznev Date: Tue, 30 Sep 2025 20:38:39 -0600 Subject: [PATCH 2/7] Add offline mission events with registration support --- .../20241010_0008_offline_missions.py | 55 +++++ backend/app/api/routes/admin.py | 53 ++++- backend/app/api/routes/missions.py | 67 +++++- backend/app/models/mission.py | 32 ++- backend/app/schemas/mission.py | 38 +++- backend/app/services/mission.py | 25 ++- frontend/src/app/missions/[id]/page.tsx | 68 ++++++ frontend/src/components/MissionList.tsx | 76 ++++++- .../components/OfflineMissionRegistration.tsx | 205 ++++++++++++++++++ .../components/admin/AdminMissionManager.tsx | 140 ++++++++++++ scripts/seed_data.py | 83 ++++++- 11 files changed, 827 insertions(+), 15 deletions(-) create mode 100644 backend/alembic/versions/20241010_0008_offline_missions.py create mode 100644 frontend/src/components/OfflineMissionRegistration.tsx diff --git a/backend/alembic/versions/20241010_0008_offline_missions.py b/backend/alembic/versions/20241010_0008_offline_missions.py new file mode 100644 index 0000000..1133eec --- /dev/null +++ b/backend/alembic/versions/20241010_0008_offline_missions.py @@ -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) diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py index 8e876b6..b657296 100644 --- a/backend/app/api/routes/admin.py +++ b/backend/app/api/routes/admin.py @@ -38,7 +38,7 @@ from app.schemas.rank import ( RankUpdate, ) from app.schemas.user import CompetencyBase -from app.services.mission import approve_submission, reject_submission +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 +47,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 +59,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 +84,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, ) @@ -143,6 +161,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() @@ -172,6 +191,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 +434,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 +506,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]) diff --git a/backend/app/api/routes/missions.py b/backend/app/api/routes/missions.py index 57efe95..d3c6a19 100644 --- a/backend/app/api/routes/missions.py +++ b/backend/app/api/routes/missions.py @@ -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 @@ -384,6 +412,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 +601,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) diff --git a/backend/app/models/mission.py b/backend/app/models/mission.py index 1885129..3666571 100644 --- a/backend/app/models/mission.py +++ b/backend/app/models/mission.py @@ -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) diff --git a/backend/app/schemas/mission.py b/backend/app/schemas/mission.py index 01e7f02..516023a 100644 --- a/backend/app/schemas/mission.py +++ b/backend/app/schemas/mission.py @@ -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 diff --git a/backend/app/services/mission.py b/backend/app/services/mission.py index 99b407b..26193b0 100644 --- a/backend/app/services/mission.py +++ b/backend/app/services/mission.py @@ -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,25 @@ 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) + + if mission.registration_deadline and mission.registration_deadline < current_time: + return False + + if mission.capacity is not None and participant_count >= mission.capacity: + return False + + return mission.is_active diff --git a/frontend/src/app/missions/[id]/page.tsx b/frontend/src/app/missions/[id]/page.tsx index 5def991..800ea40 100644 --- a/frontend/src/app/missions/[id]/page.tsx +++ b/frontend/src/app/missions/[id]/page.tsx @@ -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) {

{mission.title}

{mission.difficulty}

{mission.description}

+ {mission.format === 'offline' && ( +
+

Офлайн событие

+ +
+ )}

Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡

@@ -143,6 +192,25 @@ export default async function MissionPage({ params }: MissionPageProps) { initialState={codingState} initialCompleted={mission.is_completed} /> + ) : mission.format === 'offline' ? ( + ) : ( @@ -57,6 +90,35 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) { 🗂 Требуется загрузка документов

)} + {mission.format === 'offline' && offlineDetails?.date && ( +
+
📍 {mission.event_location ?? 'Офлайн мероприятие'}
+
+ 🗓 {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' })}`} +
+
+ 👥 {mission.registered_participants} + {mission.capacity ? ` из ${mission.capacity}` : ''} зарегистрировано +
+ {!mission.registration_open && mission.submission_status !== 'approved' && ( +
Регистрация завершена
+ )} + {mission.registration_open && offlineDetails.deadline && ( +
+ ⏳ Запись до{' '} + {offlineDetails.deadline.toLocaleDateString('ru-RU', { + day: 'numeric', + month: 'long' + })}{' '} + {offlineDetails.deadline.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })} +
+ )} +
+ )}

{mission.title}

{mission.description}

{mission.has_coding_challenges && ( diff --git a/frontend/src/components/OfflineMissionRegistration.tsx b/frontend/src/components/OfflineMissionRegistration.tsx new file mode 100644 index 0000000..3f7a910 --- /dev/null +++ b/frontend/src/components/OfflineMissionRegistration.tsx @@ -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(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) { + 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( + `/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 ( +
+

Запись на офлайн-мероприятие

+

+ {eventLocation ?? 'Офлайн событие'} + {eventAddress ? ` · ${eventAddress}` : ''} +

+
+ {eventStartsAt && ( +
+ Начало: {formatDateTime(eventStartsAt)} +
+ )} + {eventEndsAt && ( +
+ Завершение: {formatDateTime(eventEndsAt)} +
+ )} +
+ Участников: {registeredCount} + {capacity ? ` из ${capacity}` : ''} +
+ {registrationDeadline && ( +
+ Запись до: {formatDateTime(registrationDeadline)} +
+ )} + {registrationUrl && ( + + )} + {contactPerson && ( +
+ Контакт HR: {contactPerson} + {contactPhone ? ` · ${contactPhone}` : ''} +
+ )} + {registrationNotes && ( +
{registrationNotes}
+ )} +
+ +