Files
app-padel/apps/web/app/(admin)/tournaments/[id]/page.tsx
Ivan 23ee47fe47 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>
2026-02-01 07:15:04 +00:00

763 lines
24 KiB
TypeScript

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