phase 3 17 admin panel
This commit is contained in:
@@ -120,4 +120,52 @@ export async function processPaymentWebhook(ticketId: number): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Admin: Tournaments ───────────────────────────────────────────────────────
|
||||
|
||||
export interface TournamentCreate {
|
||||
title: string;
|
||||
description?: string;
|
||||
event_date: string; // ISO-8601
|
||||
}
|
||||
|
||||
export interface TournamentAdminResponse {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
event_date: string;
|
||||
}
|
||||
|
||||
export interface SectorConfigInput {
|
||||
sector_name: string;
|
||||
rows: number;
|
||||
seats_per_row: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/tournaments
|
||||
* Creates a new tournament. Requires a superuser token (403 otherwise).
|
||||
*/
|
||||
export async function createTournamentApi(
|
||||
data: TournamentCreate
|
||||
): Promise<TournamentAdminResponse> {
|
||||
const response = await apiClient.post<TournamentAdminResponse>("/tournaments", data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/tournaments/{tournament_id}/generate-seats
|
||||
* Bulk-generates seats for a tournament. Requires a superuser token.
|
||||
*/
|
||||
export async function generateSeatsApi(
|
||||
tournamentId: number,
|
||||
sectors: SectorConfigInput[]
|
||||
): Promise<{ message: string }> {
|
||||
const response = await apiClient.post<{ message: string }>(
|
||||
`/tournaments/${tournamentId}/generate-seats`,
|
||||
{ sectors }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export default apiClient;
|
||||
|
||||
321
frontend-client/src/app/admin/page.tsx
Normal file
321
frontend-client/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ShieldCheck,
|
||||
PlusCircle,
|
||||
Zap,
|
||||
Loader2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
createTournamentApi,
|
||||
generateSeatsApi,
|
||||
type SectorConfigInput,
|
||||
} from "@/api/client";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
// ─── Hardcoded octagon hall layout (106 seats total) ─────────────────────────
|
||||
|
||||
const OCTAGON_SECTORS: SectorConfigInput[] = [
|
||||
{ sector_name: "VIP", rows: 1, seats_per_row: 16, price: 15000 },
|
||||
{ sector_name: "Standard", rows: 3, seats_per_row: 30, price: 5000 },
|
||||
];
|
||||
|
||||
// ─── Toast ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ToastVariant = "success" | "error";
|
||||
type ToastState = { message: string; variant: ToastVariant } | null;
|
||||
|
||||
function Toast({ toast }: { toast: ToastState }) {
|
||||
if (!toast) return null;
|
||||
const isSuccess = toast.variant === "success";
|
||||
return (
|
||||
<div
|
||||
className={`fixed top-12 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 px-5 py-2.5 rounded-full shadow-xl text-white text-[13px] font-semibold whitespace-nowrap ${
|
||||
isSuccess ? "bg-[#16A34A]" : "bg-[#E32636]"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{isSuccess ? <Check size={14} strokeWidth={2.5} /> : <AlertCircle size={14} />}
|
||||
{toast.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Section card wrapper ─────────────────────────────────────────────────────
|
||||
|
||||
function Section({
|
||||
icon: Icon,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-[#1C1C1E] rounded-2xl p-5">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-9 h-9 rounded-xl bg-[#2C2C2E] flex items-center justify-center flex-shrink-0">
|
||||
<Icon size={18} className="text-[#E32636]" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[15px] font-bold text-white">{title}</p>
|
||||
{subtitle && <p className="text-[12px] text-[#8E8E93]">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Form field ───────────────────────────────────────────────────────────────
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[12px] font-medium text-[#8E8E93] uppercase tracking-wider">
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const INPUT_CLS =
|
||||
"w-full bg-[#2C2C2E] border border-[#3A3A3C] focus:border-[#E32636] rounded-xl px-4 py-3 text-[14px] text-white placeholder-[#4B5563] outline-none transition-colors";
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
const token = useAuthStore((s) => s.token);
|
||||
|
||||
// Redirect unauthenticated users
|
||||
useEffect(() => {
|
||||
if (!token) router.push("/login");
|
||||
}, [token, router]);
|
||||
|
||||
// ── Tournament creation form state ──
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [eventDate, setEventDate] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [tournamentId, setTournamentId] = useState<number | null>(null);
|
||||
|
||||
// ── Seat generation state ──
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [seatsGenerated, setSeatsGenerated] = useState(false);
|
||||
|
||||
// ── Toast ──
|
||||
const [toast, setToast] = useState<ToastState>(null);
|
||||
|
||||
function showToast(message: string, variant: ToastVariant) {
|
||||
setToast({ message, variant });
|
||||
setTimeout(() => setToast(null), 3500);
|
||||
}
|
||||
|
||||
// ── Create tournament ──
|
||||
async function handleCreateTournament(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (creating) return;
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
const result = await createTournamentApi({
|
||||
title: title.trim(),
|
||||
description: description.trim() || undefined,
|
||||
event_date: new Date(eventDate).toISOString(),
|
||||
});
|
||||
setTournamentId(result.id);
|
||||
showToast(`Турнир #${result.id} создан!`, "success");
|
||||
} catch (err) {
|
||||
const axiosErr = err as AxiosError;
|
||||
if (axiosErr.response?.status === 403) {
|
||||
showToast("Нет прав. Требуется суперпользователь.", "error");
|
||||
} else {
|
||||
showToast("Ошибка при создании турнира.", "error");
|
||||
}
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Generate seats ──
|
||||
async function handleGenerateSeats() {
|
||||
if (!tournamentId || generating) return;
|
||||
|
||||
setGenerating(true);
|
||||
try {
|
||||
const result = await generateSeatsApi(tournamentId, OCTAGON_SECTORS);
|
||||
setSeatsGenerated(true);
|
||||
showToast(result.message, "success");
|
||||
} catch (err) {
|
||||
const axiosErr = err as AxiosError;
|
||||
if (axiosErr.response?.status === 400) {
|
||||
showToast("Места уже сгенерированы для этого турнира.", "error");
|
||||
} else {
|
||||
showToast("Ошибка при генерации мест.", "error");
|
||||
}
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) return null; // Waiting for redirect
|
||||
|
||||
return (
|
||||
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||
<div className="w-full max-w-[390px] flex flex-col bg-[#121212]">
|
||||
|
||||
<Toast toast={toast} />
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex items-center gap-3 px-5 pt-12 pb-6">
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
aria-label="Назад"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full bg-[#1C1C1E] text-white flex-shrink-0"
|
||||
>
|
||||
<ArrowLeft size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-[20px] font-bold text-white leading-tight">
|
||||
Панель администратора
|
||||
</h1>
|
||||
<p className="text-[12px] text-[#8E8E93]">Управление турнирами</p>
|
||||
</div>
|
||||
<ShieldCheck size={20} className="text-[#E32636] ml-auto flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4 pb-10">
|
||||
|
||||
{/* ── Section 1: Create tournament ── */}
|
||||
<Section
|
||||
icon={PlusCircle}
|
||||
title="Новый турнир"
|
||||
subtitle="Заполни данные и создай событие"
|
||||
>
|
||||
<form onSubmit={handleCreateTournament} className="flex flex-col gap-3">
|
||||
<Field label="Название">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Чемпионат по ММА"
|
||||
required
|
||||
className={INPUT_CLS}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Описание (необязательно)">
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Краткое описание события…"
|
||||
rows={2}
|
||||
className={`${INPUT_CLS} resize-none`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Дата и время">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={eventDate}
|
||||
onChange={(e) => setEventDate(e.target.value)}
|
||||
required
|
||||
className={`${INPUT_CLS} [color-scheme:dark]`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating}
|
||||
className="mt-1 w-full flex items-center justify-center gap-2 bg-[#E32636] hover:bg-[#C41E2A] disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all text-white text-[15px] font-bold py-3.5 rounded-xl"
|
||||
>
|
||||
{creating ? (
|
||||
<><Loader2 size={16} className="animate-spin" /> Создание…</>
|
||||
) : (
|
||||
"Создать турнир"
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Success state indicator */}
|
||||
{tournamentId && (
|
||||
<div className="flex items-center gap-2 bg-[#16A34A]/10 border border-[#16A34A]/30 rounded-xl px-4 py-2.5">
|
||||
<Check size={14} className="text-[#16A34A] flex-shrink-0" strokeWidth={2.5} />
|
||||
<p className="text-[13px] text-[#16A34A] font-medium">
|
||||
Турнир создан: ID {tournamentId}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Section>
|
||||
|
||||
{/* ── Section 2: Generate seats (shown only when tournament is created) ── */}
|
||||
{tournamentId && (
|
||||
<Section
|
||||
icon={Zap}
|
||||
title="Генерация зала"
|
||||
subtitle="Стандартный октагон — 106 мест"
|
||||
>
|
||||
{/* Sectors preview */}
|
||||
<div className="mb-4 space-y-2">
|
||||
{OCTAGON_SECTORS.map((s) => (
|
||||
<div
|
||||
key={s.sector_name}
|
||||
className="flex justify-between items-center bg-[#2C2C2E] rounded-xl px-4 py-2.5"
|
||||
>
|
||||
<div>
|
||||
<p className="text-[13px] font-semibold text-white">{s.sector_name}</p>
|
||||
<p className="text-[11px] text-[#8E8E93]">
|
||||
{s.rows} {s.rows === 1 ? "ряд" : "ряда"} × {s.seats_per_row} мест
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[13px] font-bold text-white">
|
||||
{s.price.toLocaleString("ru-RU")} ₽
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex justify-between px-1 pt-1">
|
||||
<p className="text-[12px] text-[#8E8E93]">Итого мест:</p>
|
||||
<p className="text-[12px] font-bold text-white">
|
||||
{OCTAGON_SECTORS.reduce((acc, s) => acc + s.rows * s.seats_per_row, 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleGenerateSeats}
|
||||
disabled={generating || seatsGenerated}
|
||||
className="w-full flex items-center justify-center gap-2 bg-[#E32636] hover:bg-[#C41E2A] disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all text-white text-[15px] font-bold py-3.5 rounded-xl"
|
||||
>
|
||||
{generating ? (
|
||||
<><Loader2 size={16} className="animate-spin" /> Генерация…</>
|
||||
) : seatsGenerated ? (
|
||||
<><Check size={16} strokeWidth={2.5} /> Места созданы</>
|
||||
) : (
|
||||
<><Zap size={16} /> Сгенерировать стандартный октагон</>
|
||||
)}
|
||||
</button>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Search, CalendarDays, Ticket, User, LogIn } from "lucide-react";
|
||||
import { Search, CalendarDays, Ticket, User, LogIn, ShieldCheck } from "lucide-react";
|
||||
import EventCard, { type EventItem } from "@/components/EventCard";
|
||||
import { useAuthStore } from "@/store/authStore";
|
||||
|
||||
@@ -129,6 +129,14 @@ export default function HomePage() {
|
||||
<User size={22} strokeWidth={1.8} />
|
||||
Профиль
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin"
|
||||
className="flex-1 flex flex-col items-center gap-1 py-3 text-[10px] font-medium text-[#8E8E93]"
|
||||
>
|
||||
<ShieldCheck size={22} strokeWidth={1.8} />
|
||||
Админка
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user