Merge pull request #13 from Danieli4/codex/add-offline-event-management-feature-euea53
Add admin store management with item images
This commit is contained in:
commit
e1a1dcddf5
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,
|
SubmissionStatus,
|
||||||
)
|
)
|
||||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||||
|
from app.models.store import StoreItem
|
||||||
from app.models.user import Competency, User, UserRole
|
from app.models.user import Competency, User, UserRole
|
||||||
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
|
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
|
||||||
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
|
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
|
||||||
|
|
@ -38,6 +39,8 @@ from app.schemas.rank import (
|
||||||
RankUpdate,
|
RankUpdate,
|
||||||
)
|
)
|
||||||
from app.schemas.user import CompetencyBase
|
from app.schemas.user import CompetencyBase
|
||||||
|
from app.schemas.store import StoreItemCreate, StoreItemRead, StoreItemUpdate
|
||||||
|
|
||||||
from app.services.mission import approve_submission, registration_is_open, reject_submission
|
from app.services.mission import approve_submission, registration_is_open, reject_submission
|
||||||
from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats
|
from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats
|
||||||
|
|
||||||
|
|
@ -138,6 +141,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:
|
def _load_rank(db: Session, rank_id: int) -> Rank:
|
||||||
"""Загружаем ранг с зависимостями."""
|
"""Загружаем ранг с зависимостями."""
|
||||||
|
|
||||||
|
|
@ -176,6 +188,99 @@ def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(requir
|
||||||
return [MissionBase.model_validate(mission) for mission in missions]
|
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="Детали миссии")
|
@router.get("/missions/{mission_id}", response_model=MissionDetail, summary="Детали миссии")
|
||||||
def admin_mission_detail(
|
def admin_mission_detail(
|
||||||
mission_id: int,
|
mission_id: int,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ class StoreItem(Base, TimestampMixin):
|
||||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
cost_mana: Mapped[int] = mapped_column(Integer, nullable=False)
|
cost_mana: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
stock: Mapped[int] = mapped_column(Integer, default=0, 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")
|
orders: Mapped[List["Order"]] = relationship("Order", back_populates="item")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,41 @@ from pydantic import BaseModel
|
||||||
from app.models.store import OrderStatus
|
from app.models.store import OrderStatus
|
||||||
|
|
||||||
|
|
||||||
class StoreItemRead(BaseModel):
|
class StoreItemBase(BaseModel):
|
||||||
"""Товар магазина."""
|
"""Базовые поля товара магазина."""
|
||||||
|
|
||||||
id: int
|
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
cost_mana: int
|
cost_mana: int
|
||||||
stock: int
|
stock: int
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StoreItemRead(StoreItemBase):
|
||||||
|
"""Товар магазина для чтения."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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):
|
class OrderRead(BaseModel):
|
||||||
"""Информация о заказе."""
|
"""Информация о заказе."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||||
import base64
|
import base64
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import shutil
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
|
||||||
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 { AdminRankManager } from '../../components/admin/AdminRankManager';
|
||||||
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
|
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
|
||||||
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
|
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
|
||||||
|
import { AdminStoreManager } from '../../components/admin/AdminStoreManager';
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
import { requireRole } from '../../lib/auth/session';
|
import { requireRole } from '../../lib/auth/session';
|
||||||
|
|
||||||
|
|
@ -61,6 +62,15 @@ interface ArtifactSummary {
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StoreItemSummary {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
cost_mana: number;
|
||||||
|
stock: number;
|
||||||
|
image_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface SubmissionStats {
|
interface SubmissionStats {
|
||||||
pending: number;
|
pending: number;
|
||||||
approved: number;
|
approved: number;
|
||||||
|
|
@ -85,14 +95,24 @@ export default async function AdminPage() {
|
||||||
// Админ-панель доступна только HR-сотрудникам; проверяем роль до загрузки данных.
|
// Админ-панель доступна только HR-сотрудникам; проверяем роль до загрузки данных.
|
||||||
const session = await requireRole('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<Submission[]>('/api/admin/submissions', { authToken: session.token }),
|
||||||
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: session.token }),
|
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: session.token }),
|
||||||
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: session.token }),
|
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: session.token }),
|
||||||
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: session.token }),
|
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: session.token }),
|
||||||
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: session.token }),
|
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: session.token }),
|
||||||
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { 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 (
|
return (
|
||||||
|
|
@ -167,6 +187,7 @@ export default async function AdminPage() {
|
||||||
/>
|
/>
|
||||||
<AdminRankManager token={session.token} ranks={ranks} missions={missions} competencies={competencies} />
|
<AdminRankManager token={session.token} ranks={ranks} missions={missions} competencies={competencies} />
|
||||||
<AdminArtifactManager token={session.token} artifacts={artifacts} />
|
<AdminArtifactManager token={session.token} artifacts={artifacts} />
|
||||||
|
<AdminStoreManager token={session.token} items={storeItems} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ interface StoreItem {
|
||||||
description: string;
|
description: string;
|
||||||
cost_mana: number;
|
cost_mana: number;
|
||||||
stock: number;
|
stock: number;
|
||||||
|
image_url: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchStore(token: string) {
|
async function fetchStore(token: string) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ type StoreItem = {
|
||||||
description: string;
|
description: string;
|
||||||
cost_mana: number;
|
cost_mana: number;
|
||||||
stock: number;
|
stock: number;
|
||||||
|
image_url: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
|
|
@ -68,6 +69,19 @@ export function StoreItems({ items, token }: { items: StoreItem[]; token?: strin
|
||||||
<div className="grid">
|
<div className="grid">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Card key={item.id}>
|
<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>
|
<h3 style={{ marginBottom: '0.5rem' }}>{item.name}</h3>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>{item.description}</p>
|
<p style={{ color: 'var(--text-muted)' }}>{item.description}</p>
|
||||||
<p style={{ marginTop: '1rem' }}>{item.cost_mana} ⚡ · остаток {item.stock}</p>
|
<p style={{ marginTop: '1rem' }}>{item.cost_mana} ⚡ · остаток {item.stock}</p>
|
||||||
|
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -462,12 +462,14 @@ def seed() -> None:
|
||||||
description="Личный тур по цехам Алабуги",
|
description="Личный тур по цехам Алабуги",
|
||||||
cost_mana=200,
|
cost_mana=200,
|
||||||
stock=5,
|
stock=5,
|
||||||
|
image_url="/store/excursion-alabuga.svg",
|
||||||
),
|
),
|
||||||
StoreItem(
|
StoreItem(
|
||||||
name="Мерч экипажа",
|
name="Мерч экипажа",
|
||||||
description="Футболка с эмблемой миссии",
|
description="Футболка с эмблемой миссии",
|
||||||
cost_mana=150,
|
cost_mana=150,
|
||||||
stock=10,
|
stock=10,
|
||||||
|
image_url="/store/alabuga-crew-shirt.svg",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user