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,
|
||||
)
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""Информация о заказе."""
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import base64
|
||||
import mimetypes
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from pathlib import Path
|
||||
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 { 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ interface StoreItem {
|
|||
description: string;
|
||||
cost_mana: number;
|
||||
stock: number;
|
||||
image_url: string | null;
|
||||
}
|
||||
|
||||
async function fetchStore(token: string) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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="Личный тур по цехам Алабуги",
|
||||
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