feat(tournaments): add tournament management UI
Add complete tournament management interface including: - Tournament card component with status badges and info display - Tournament form for creating/editing tournaments - Bracket view for single elimination visualization - Inscriptions list with payment tracking - Match score dialog for entering results - Tournaments list page with filtering and search - Tournament detail page with tabs (Overview, Inscriptions, Bracket) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
284
apps/web/app/(admin)/tournaments/page.tsx
Normal file
284
apps/web/app/(admin)/tournaments/page.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { TournamentCard } from "@/components/tournaments/tournament-card";
|
||||
import { TournamentForm } from "@/components/tournaments/tournament-form";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Tournament {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
status: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
maxPlayers: number | null;
|
||||
entryFee: number | string;
|
||||
settings: {
|
||||
tournamentFormat?: string;
|
||||
category?: string | null;
|
||||
} | null;
|
||||
site: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
_count: {
|
||||
inscriptions: number;
|
||||
matches: number;
|
||||
};
|
||||
}
|
||||
|
||||
const statusFilters = [
|
||||
{ value: "", label: "Todos" },
|
||||
{ value: "DRAFT", label: "Borrador" },
|
||||
{ value: "REGISTRATION_OPEN", label: "Inscripciones Abiertas" },
|
||||
{ value: "IN_PROGRESS", label: "En Progreso" },
|
||||
{ value: "COMPLETED", label: "Finalizados" },
|
||||
{ value: "CANCELLED", label: "Cancelados" },
|
||||
];
|
||||
|
||||
export default function TournamentsPage() {
|
||||
const router = useRouter();
|
||||
const [tournaments, setTournaments] = useState<Tournament[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
|
||||
const fetchTournaments = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter) {
|
||||
params.append("status", statusFilter);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/tournaments?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error("Error al cargar torneos");
|
||||
}
|
||||
const data = await response.json();
|
||||
setTournaments(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTournaments();
|
||||
}, [fetchTournaments]);
|
||||
|
||||
// Filter tournaments by search query (client-side)
|
||||
const filteredTournaments = tournaments.filter((tournament) => {
|
||||
if (!searchQuery) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
tournament.name.toLowerCase().includes(query) ||
|
||||
tournament.description?.toLowerCase().includes(query) ||
|
||||
tournament.site.name.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const handleCreateTournament = async (data: {
|
||||
name: string;
|
||||
description: string;
|
||||
date: string;
|
||||
endDate: string;
|
||||
type: string;
|
||||
category: string;
|
||||
maxTeams: number;
|
||||
price: number;
|
||||
siteId: string;
|
||||
}) => {
|
||||
setFormLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/tournaments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...data,
|
||||
date: new Date(data.date).toISOString(),
|
||||
endDate: data.endDate ? new Date(data.endDate).toISOString() : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al crear torneo");
|
||||
}
|
||||
|
||||
const newTournament = await response.json();
|
||||
setShowForm(false);
|
||||
router.push(`/tournaments/${newTournament.id}`);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTournamentClick = (tournamentId: string) => {
|
||||
router.push(`/tournaments/${tournamentId}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-800">Torneos</h1>
|
||||
<p className="mt-1 text-primary-600">
|
||||
Gestiona torneos y competiciones de tu club
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Nuevo Torneo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Buscar por nombre..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 sm:pb-0">
|
||||
{statusFilters.map((filter) => (
|
||||
<Button
|
||||
key={filter.value}
|
||||
variant={statusFilter === filter.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter(filter.value)}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{filter.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Create Form Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 overflow-y-auto">
|
||||
<div className="my-8">
|
||||
<TournamentForm
|
||||
onSubmit={handleCreateTournament}
|
||||
onCancel={() => setShowForm(false)}
|
||||
isLoading={formLoading}
|
||||
mode="create"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
|
||||
<p className="text-primary-500">Cargando torneos...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !loading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center text-red-500">
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={fetchTournaments}
|
||||
>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && !error && filteredTournaments.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center text-primary-500">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto mb-3 text-primary-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="font-medium">No hay torneos</p>
|
||||
<p className="text-sm mt-1">
|
||||
{searchQuery || statusFilter
|
||||
? "Intenta con otros filtros"
|
||||
: "Crea tu primer torneo para comenzar"}
|
||||
</p>
|
||||
{!searchQuery && !statusFilter && (
|
||||
<Button className="mt-4" onClick={() => setShowForm(true)}>
|
||||
Crear Torneo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tournaments Grid */}
|
||||
{!loading && !error && filteredTournaments.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredTournaments.map((tournament) => (
|
||||
<TournamentCard
|
||||
key={tournament.id}
|
||||
tournament={tournament}
|
||||
onClick={() => handleTournamentClick(tournament.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results count */}
|
||||
{!loading && !error && filteredTournaments.length > 0 && (
|
||||
<div className="text-center text-sm text-primary-500">
|
||||
Mostrando {filteredTournaments.length} de {tournaments.length} torneos
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user