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;
|
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 { 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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user