diff --git a/backend/alembic/versions/20241014_0010_store_item_images.py b/backend/alembic/versions/20241014_0010_store_item_images.py new file mode 100644 index 0000000..325f1e5 --- /dev/null +++ b/backend/alembic/versions/20241014_0010_store_item_images.py @@ -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") diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py index b657296..de14d07 100644 --- a/backend/app/api/routes/admin.py +++ b/backend/app/api/routes/admin.py @@ -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,6 +39,8 @@ from app.schemas.rank import ( RankUpdate, ) 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.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: """Загружаем ранг с зависимостями.""" @@ -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] +@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, diff --git a/backend/app/models/store.py b/backend/app/models/store.py index 8c2251f..9b8d55d 100644 --- a/backend/app/models/store.py +++ b/backend/app/models/store.py @@ -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") diff --git a/backend/app/schemas/store.py b/backend/app/schemas/store.py index 0988635..eeee67b 100644 --- a/backend/app/schemas/store.py +++ b/backend/app/schemas/store.py @@ -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): """Информация о заказе.""" diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index b800fad..f2257d7 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -5,6 +5,7 @@ from __future__ import annotations import base64 import mimetypes import shutil +from pathlib import Path from pathlib import Path import mimetypes diff --git a/frontend/public/store/alabuga-crew-shirt.svg b/frontend/public/store/alabuga-crew-shirt.svg new file mode 100644 index 0000000..ace901b --- /dev/null +++ b/frontend/public/store/alabuga-crew-shirt.svg @@ -0,0 +1,38 @@ + + Футболка экипажа Алабуги + Минималистичная иллюстрация футболки с надписью Алабуга и эмблемой миссии. + + + + + + + + + + + + + + + + АЛАБУГА + + + + ⚡42 + + + + Футболка экипажа + + + Лимитированная серия для выпускников миссии + + diff --git a/frontend/public/store/excursion-alabuga.svg b/frontend/public/store/excursion-alabuga.svg new file mode 100644 index 0000000..98bd9bf --- /dev/null +++ b/frontend/public/store/excursion-alabuga.svg @@ -0,0 +1,34 @@ + + Экскурсия по Алабуге + Стилизованный рисунок кампуса Алабуги с отмеченным маршрутом экскурсии. + + + + + + + + + + + + + + + + + + + + + + + + + + + + Экскурсия по Алабуге + Тур по производственным цехам и кампусу + + diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 9d81408..9166c0e 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -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('/api/admin/submissions', { authToken: session.token }), apiFetch('/api/admin/missions', { authToken: session.token }), apiFetch('/api/admin/branches', { authToken: session.token }), apiFetch('/api/admin/ranks', { authToken: session.token }), apiFetch('/api/admin/competencies', { authToken: session.token }), apiFetch('/api/admin/artifacts', { authToken: session.token }), - apiFetch('/api/admin/stats', { authToken: session.token }) + apiFetch('/api/admin/stats', { authToken: session.token }), + apiFetch('/api/admin/store/items', { authToken: session.token }), ]); return ( @@ -167,6 +187,7 @@ export default async function AdminPage() { /> + ); diff --git a/frontend/src/app/store/page.tsx b/frontend/src/app/store/page.tsx index 8eecf09..63d6862 100644 --- a/frontend/src/app/store/page.tsx +++ b/frontend/src/app/store/page.tsx @@ -8,6 +8,7 @@ interface StoreItem { description: string; cost_mana: number; stock: number; + image_url: string | null; } async function fetchStore(token: string) { diff --git a/frontend/src/components/StoreItems.tsx b/frontend/src/components/StoreItems.tsx index 97695af..1a64ba4 100644 --- a/frontend/src/components/StoreItems.tsx +++ b/frontend/src/components/StoreItems.tsx @@ -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
{items.map((item) => ( + {item.image_url && ( + {item.name} + )}

{item.name}

{item.description}

{item.cost_mana} ⚡ · остаток {item.stock}

diff --git a/frontend/src/components/admin/AdminStoreManager.tsx b/frontend/src/components/admin/AdminStoreManager.tsx new file mode 100644 index 0000000..ae40271 --- /dev/null +++ b/frontend/src/components/admin/AdminStoreManager.tsx @@ -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('new'); + const [form, setForm] = useState(emptyForm); + const [status, setStatus] = useState(null); + const [error, setError] = useState(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) => { + 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 ( +
+

Магазин

+

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

+ +
+
+ + +
+ +
+ +
+
+ + setForm((prev) => ({ ...prev, name: event.target.value }))} + placeholder="Например, экскурсия по кампусу" + /> +
+ +
+ +