phase 3 17 admin panel

This commit is contained in:
2026-03-06 19:53:08 +00:00
parent 8fb576afc7
commit 94fa7c2df1
3 changed files with 378 additions and 1 deletions

View File

@@ -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; export default apiClient;

View 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>
);
}

View File

@@ -2,7 +2,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; 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 EventCard, { type EventItem } from "@/components/EventCard";
import { useAuthStore } from "@/store/authStore"; import { useAuthStore } from "@/store/authStore";
@@ -129,6 +129,14 @@ export default function HomePage() {
<User size={22} strokeWidth={1.8} /> <User size={22} strokeWidth={1.8} />
Профиль Профиль
</Link> </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> </div>
</nav> </nav>