From 94fa7c2df10bff6308a360488b4a0f2cb1fe1f72 Mon Sep 17 00:00:00 2001 From: openit Date: Fri, 6 Mar 2026 19:53:08 +0000 Subject: [PATCH] phase 3 17 admin panel --- frontend-client/src/api/client.ts | 48 ++++ frontend-client/src/app/admin/page.tsx | 321 +++++++++++++++++++++++++ frontend-client/src/app/page.tsx | 10 +- 3 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 frontend-client/src/app/admin/page.tsx diff --git a/frontend-client/src/api/client.ts b/frontend-client/src/api/client.ts index 2877487..fd00bbd 100644 --- a/frontend-client/src/api/client.ts +++ b/frontend-client/src/api/client.ts @@ -120,4 +120,52 @@ export async function processPaymentWebhook(ticketId: number): Promise { }); } +// ─── 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 { + const response = await apiClient.post("/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; diff --git a/frontend-client/src/app/admin/page.tsx b/frontend-client/src/app/admin/page.tsx new file mode 100644 index 0000000..02556a8 --- /dev/null +++ b/frontend-client/src/app/admin/page.tsx @@ -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 ( +
+ {isSuccess ? : } + {toast.message} +
+ ); +} + +// ─── Section card wrapper ───────────────────────────────────────────────────── + +function Section({ + icon: Icon, + title, + subtitle, + children, +}: { + icon: React.ElementType; + title: string; + subtitle?: string; + children: React.ReactNode; +}) { + return ( +
+
+
+ +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+
+ {children} +
+ ); +} + +// ─── Form field ─────────────────────────────────────────────────────────────── + +function Field({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} +
+ ); +} + +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(null); + + // ── Seat generation state ── + const [generating, setGenerating] = useState(false); + const [seatsGenerated, setSeatsGenerated] = useState(false); + + // ── Toast ── + const [toast, setToast] = useState(null); + + function showToast(message: string, variant: ToastVariant) { + setToast({ message, variant }); + setTimeout(() => setToast(null), 3500); + } + + // ── Create tournament ── + async function handleCreateTournament(e: FormEvent) { + 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 ( +
+
+ + + + {/* ── Header ── */} +
+ +
+

+ Панель администратора +

+

Управление турнирами

+
+ +
+ +
+ + {/* ── Section 1: Create tournament ── */} +
+
+ + setTitle(e.target.value)} + placeholder="Чемпионат по ММА" + required + className={INPUT_CLS} + /> + + + +