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:
762
apps/web/app/(admin)/tournaments/[id]/page.tsx
Normal file
762
apps/web/app/(admin)/tournaments/[id]/page.tsx
Normal file
@@ -0,0 +1,762 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { TournamentForm } from "@/components/tournaments/tournament-form";
|
||||
import { BracketView } from "@/components/tournaments/bracket-view";
|
||||
import { InscriptionsList } from "@/components/tournaments/inscriptions-list";
|
||||
import { MatchScoreDialog } from "@/components/tournaments/match-score-dialog";
|
||||
import { cn, formatCurrency, formatDate } from "@/lib/utils";
|
||||
|
||||
interface Client {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
level: string | null;
|
||||
}
|
||||
|
||||
interface Inscription {
|
||||
id: string;
|
||||
clientId: string;
|
||||
partnerId: string | null;
|
||||
teamName: string | null;
|
||||
seedNumber: number | null;
|
||||
isPaid: boolean;
|
||||
paidAmount: number;
|
||||
registeredAt: string;
|
||||
client: Client;
|
||||
partner?: Client | null;
|
||||
}
|
||||
|
||||
interface Match {
|
||||
id: string;
|
||||
round: number;
|
||||
position: number;
|
||||
status: string;
|
||||
team1Players: string[];
|
||||
team2Players: string[];
|
||||
team1Score: number[] | null;
|
||||
team2Score: number[] | null;
|
||||
winnerId: string | null;
|
||||
scheduledAt: string | null;
|
||||
court: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface Tournament {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
type: string;
|
||||
status: string;
|
||||
startDate: string;
|
||||
endDate: string | null;
|
||||
maxPlayers: number | null;
|
||||
entryFee: string | number;
|
||||
rules: string | null;
|
||||
isPublic: boolean;
|
||||
settings: {
|
||||
tournamentFormat?: string;
|
||||
category?: string | null;
|
||||
} | null;
|
||||
site: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
inscriptions: Inscription[];
|
||||
matches: Match[];
|
||||
_count: {
|
||||
inscriptions: number;
|
||||
matches: number;
|
||||
};
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: "overview", label: "Resumen" },
|
||||
{ id: "inscriptions", label: "Inscripciones" },
|
||||
{ id: "bracket", label: "Bracket" },
|
||||
];
|
||||
|
||||
const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
DRAFT: {
|
||||
label: "Borrador",
|
||||
className: "bg-gray-100 text-gray-700 border-gray-300",
|
||||
},
|
||||
REGISTRATION_OPEN: {
|
||||
label: "Inscripciones Abiertas",
|
||||
className: "bg-green-100 text-green-700 border-green-300",
|
||||
},
|
||||
REGISTRATION_CLOSED: {
|
||||
label: "Inscripciones Cerradas",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-300",
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
label: "En Progreso",
|
||||
className: "bg-blue-100 text-blue-700 border-blue-300",
|
||||
},
|
||||
COMPLETED: {
|
||||
label: "Finalizado",
|
||||
className: "bg-purple-100 text-purple-700 border-purple-300",
|
||||
},
|
||||
CANCELLED: {
|
||||
label: "Cancelado",
|
||||
className: "bg-red-100 text-red-700 border-red-300",
|
||||
},
|
||||
};
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
BRACKET: "Eliminacion Directa",
|
||||
AMERICANO: "Americano",
|
||||
MEXICANO: "Mexicano",
|
||||
ROUND_ROBIN: "Round Robin",
|
||||
LEAGUE: "Liga",
|
||||
};
|
||||
|
||||
export default function TournamentDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const tournamentId = params.id as string;
|
||||
|
||||
const [tournament, setTournament] = useState<Tournament | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
const [showEditForm, setShowEditForm] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||
|
||||
// Build players map for bracket view
|
||||
const playersMap = useMemo(() => {
|
||||
const map = new Map<string, Client>();
|
||||
if (tournament) {
|
||||
tournament.inscriptions.forEach((inscription) => {
|
||||
map.set(inscription.client.id, inscription.client);
|
||||
if (inscription.partner) {
|
||||
map.set(inscription.partner.id, inscription.partner);
|
||||
}
|
||||
});
|
||||
}
|
||||
return map;
|
||||
}, [tournament]);
|
||||
|
||||
const fetchTournament = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/tournaments/${tournamentId}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error("Torneo no encontrado");
|
||||
}
|
||||
throw new Error("Error al cargar el torneo");
|
||||
}
|
||||
const data = await response.json();
|
||||
setTournament(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Error desconocido");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tournamentId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTournament();
|
||||
}, [fetchTournament]);
|
||||
|
||||
const handleStatusChange = async (newStatus: string) => {
|
||||
if (!tournament) return;
|
||||
setActionLoading("status");
|
||||
try {
|
||||
const response = await fetch(`/api/tournaments/${tournamentId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: newStatus }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Error al actualizar estado");
|
||||
}
|
||||
|
||||
await fetchTournament();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Error desconocido");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateBracket = async () => {
|
||||
if (!tournament) return;
|
||||
if (tournament.inscriptions.length < 2) {
|
||||
alert("Se necesitan al menos 2 equipos para generar el bracket");
|
||||
return;
|
||||
}
|
||||
if (!confirm("¿Generar el bracket? Esto iniciara el torneo y cerrara las inscripciones.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading("bracket");
|
||||
try {
|
||||
const response = await fetch(`/api/tournaments/${tournamentId}/generate-bracket`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Error al generar bracket");
|
||||
}
|
||||
|
||||
await fetchTournament();
|
||||
setActiveTab("bracket");
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Error desconocido");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!tournament) return;
|
||||
if (tournament.status !== "DRAFT") {
|
||||
alert("Solo se pueden eliminar torneos en estado Borrador");
|
||||
return;
|
||||
}
|
||||
if (!confirm("¿Estas seguro de eliminar este torneo? Esta accion no se puede deshacer.")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading("delete");
|
||||
try {
|
||||
const response = await fetch(`/api/tournaments/${tournamentId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Error al eliminar torneo");
|
||||
}
|
||||
|
||||
router.push("/tournaments");
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Error desconocido");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTournament = async (data: {
|
||||
name: string;
|
||||
description: string;
|
||||
date: string;
|
||||
endDate: string;
|
||||
type: string;
|
||||
category: string;
|
||||
maxTeams: number;
|
||||
price: number;
|
||||
siteId: string;
|
||||
}) => {
|
||||
setActionLoading("edit");
|
||||
try {
|
||||
const response = await fetch(`/api/tournaments/${tournamentId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
date: new Date(data.date).toISOString(),
|
||||
endDate: data.endDate ? new Date(data.endDate).toISOString() : null,
|
||||
type: data.type,
|
||||
category: data.category || null,
|
||||
maxTeams: data.maxTeams,
|
||||
price: data.price,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al actualizar torneo");
|
||||
}
|
||||
|
||||
setShowEditForm(false);
|
||||
await fetchTournament();
|
||||
} catch (err) {
|
||||
throw err;
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveInscription = async (inscriptionId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/tournaments/${tournamentId}/inscriptions/${inscriptionId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Error al eliminar inscripcion");
|
||||
}
|
||||
|
||||
await fetchTournament();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Error desconocido");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTogglePaid = async (inscriptionId: string, isPaid: boolean) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/tournaments/${tournamentId}/inscriptions/${inscriptionId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isPaid }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || "Error al actualizar pago");
|
||||
}
|
||||
|
||||
await fetchTournament();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : "Error desconocido");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMatchScore = async (
|
||||
matchId: string,
|
||||
data: {
|
||||
score1: number[];
|
||||
score2: number[];
|
||||
winnerId: string;
|
||||
status: string;
|
||||
}
|
||||
) => {
|
||||
const response = await fetch(
|
||||
`/api/tournaments/${tournamentId}/matches/${matchId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Error al guardar resultado");
|
||||
}
|
||||
|
||||
setSelectedMatch(null);
|
||||
await fetchTournament();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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 torneo...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{error}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => router.push("/tournaments")}>
|
||||
Volver a Torneos
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!tournament) return null;
|
||||
|
||||
const status = statusConfig[tournament.status] || statusConfig.DRAFT;
|
||||
const typeLabel = typeLabels[tournament.type] || tournament.type;
|
||||
const entryFee =
|
||||
typeof tournament.entryFee === "string"
|
||||
? parseFloat(tournament.entryFee)
|
||||
: tournament.entryFee;
|
||||
const canEdit = ["DRAFT", "REGISTRATION_OPEN"].includes(tournament.status);
|
||||
const canGenerateBracket =
|
||||
tournament.status === "REGISTRATION_OPEN" && tournament.inscriptions.length >= 2;
|
||||
const canOpenRegistration = tournament.status === "DRAFT";
|
||||
const canDelete = tournament.status === "DRAFT";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push("/tournaments")}
|
||||
className="p-1"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold text-primary-800">{tournament.name}</h1>
|
||||
<span
|
||||
className={cn(
|
||||
"px-3 py-1 text-sm font-medium rounded-full border",
|
||||
status.className
|
||||
)}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
{tournament.description && (
|
||||
<p className="text-primary-600 ml-8">{tournament.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canEdit && (
|
||||
<Button variant="outline" onClick={() => setShowEditForm(true)}>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Editar
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canOpenRegistration && (
|
||||
<Button
|
||||
variant="accent"
|
||||
onClick={() => handleStatusChange("REGISTRATION_OPEN")}
|
||||
disabled={actionLoading === "status"}
|
||||
>
|
||||
{actionLoading === "status" ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white mr-2" />
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
Abrir Inscripciones
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canGenerateBracket && (
|
||||
<Button
|
||||
onClick={handleGenerateBracket}
|
||||
disabled={actionLoading === "bracket"}
|
||||
>
|
||||
{actionLoading === "bracket" ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white mr-2" />
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
Generar Bracket
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDelete && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={actionLoading === "delete"}
|
||||
>
|
||||
{actionLoading === "delete" ? (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white mr-2" />
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
Eliminar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit Form Modal */}
|
||||
{showEditForm && (
|
||||
<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
|
||||
initialData={{
|
||||
name: tournament.name,
|
||||
description: tournament.description || "",
|
||||
date: tournament.startDate.slice(0, 16),
|
||||
endDate: tournament.endDate?.slice(0, 16) || "",
|
||||
type: tournament.settings?.tournamentFormat || "SINGLE_ELIMINATION",
|
||||
category: tournament.settings?.category || "",
|
||||
maxTeams: tournament.maxPlayers || 16,
|
||||
price: entryFee,
|
||||
siteId: tournament.site.id,
|
||||
}}
|
||||
onSubmit={handleUpdateTournament}
|
||||
onCancel={() => setShowEditForm(false)}
|
||||
isLoading={actionLoading === "edit"}
|
||||
mode="edit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Match Score Dialog */}
|
||||
{selectedMatch && (
|
||||
<MatchScoreDialog
|
||||
match={selectedMatch}
|
||||
players={playersMap}
|
||||
onSave={handleSaveMatchScore}
|
||||
onClose={() => setSelectedMatch(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-primary-200">
|
||||
<div className="flex gap-4">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
activeTab === tab.id
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-primary-500 hover:text-primary-700 hover:border-primary-300"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.id === "inscriptions" && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-primary-100 text-primary-700 rounded-full">
|
||||
{tournament._count.inscriptions}
|
||||
</span>
|
||||
)}
|
||||
{tab.id === "bracket" && tournament.matches.length > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs bg-primary-100 text-primary-700 rounded-full">
|
||||
{tournament.matches.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div>
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Tournament Info */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Informacion del Torneo</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Tipo</p>
|
||||
<p className="font-medium text-primary-800">{typeLabel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Categoria</p>
|
||||
<p className="font-medium text-primary-800">
|
||||
{tournament.settings?.category || "Sin categoria"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Fecha de Inicio</p>
|
||||
<p className="font-medium text-primary-800">
|
||||
{formatDate(tournament.startDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Fecha de Fin</p>
|
||||
<p className="font-medium text-primary-800">
|
||||
{tournament.endDate ? formatDate(tournament.endDate) : "-"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Precio Inscripcion</p>
|
||||
<p className="font-medium text-primary-800">
|
||||
{entryFee > 0 ? formatCurrency(entryFee) : "Gratis"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Maximo Equipos</p>
|
||||
<p className="font-medium text-primary-800">
|
||||
{tournament.maxPlayers || "Sin limite"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Site Info */}
|
||||
<div className="pt-4 border-t border-primary-100">
|
||||
<p className="text-sm text-primary-500 mb-2">Sede</p>
|
||||
<div className="bg-primary-50 rounded-lg p-4">
|
||||
<p className="font-medium text-primary-800">{tournament.site.name}</p>
|
||||
{tournament.site.address && (
|
||||
<p className="text-sm text-primary-600">{tournament.site.address}</p>
|
||||
)}
|
||||
{tournament.site.phone && (
|
||||
<p className="text-sm text-primary-600">{tournament.site.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-primary-800">
|
||||
{tournament._count.inscriptions}
|
||||
</p>
|
||||
<p className="text-sm text-primary-500">
|
||||
{tournament.maxPlayers
|
||||
? `de ${tournament.maxPlayers} equipos`
|
||||
: "equipos inscritos"}
|
||||
</p>
|
||||
</div>
|
||||
{tournament.maxPlayers && (
|
||||
<div className="mt-4">
|
||||
<div className="h-2 bg-primary-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent transition-all"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
(tournament._count.inscriptions / tournament.maxPlayers) * 100,
|
||||
100
|
||||
)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{tournament.matches.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-primary-800">
|
||||
{tournament.matches.filter((m) => m.status === "COMPLETED").length}
|
||||
</p>
|
||||
<p className="text-sm text-primary-500">
|
||||
de {tournament.matches.length} partidos jugados
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inscriptions Tab */}
|
||||
{activeTab === "inscriptions" && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<InscriptionsList
|
||||
inscriptions={tournament.inscriptions}
|
||||
entryFee={entryFee}
|
||||
onRemove={canEdit ? handleRemoveInscription : undefined}
|
||||
onTogglePaid={canEdit ? handleTogglePaid : undefined}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Bracket Tab */}
|
||||
{activeTab === "bracket" && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<BracketView
|
||||
matches={tournament.matches}
|
||||
players={playersMap}
|
||||
onMatchClick={
|
||||
tournament.status === "IN_PROGRESS"
|
||||
? (match) => setSelectedMatch(match)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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