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:
Ivan
2026-02-01 07:15:04 +00:00
parent 67eb891b47
commit 23ee47fe47
7 changed files with 2418 additions and 0 deletions

View File

@@ -0,0 +1,300 @@
"use client";
import { useMemo } from "react";
import { cn } from "@/lib/utils";
interface Player {
id: string;
firstName: string;
lastName: string;
level?: string | 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 BracketViewProps {
matches: Match[];
players: Map<string, Player>;
onMatchClick?: (match: Match) => void;
}
const statusColors: Record<string, string> = {
SCHEDULED: "bg-gray-50 border-gray-300",
IN_PROGRESS: "bg-blue-50 border-blue-400",
COMPLETED: "bg-green-50 border-green-300",
CANCELLED: "bg-red-50 border-red-300",
WALKOVER: "bg-yellow-50 border-yellow-300",
};
function getRoundName(round: number, totalRounds: number): string {
const roundsFromEnd = totalRounds - round + 1;
switch (roundsFromEnd) {
case 1:
return "Final";
case 2:
return "Semifinal";
case 3:
return "Cuartos";
case 4:
return "Octavos";
case 5:
return "Dieciseisavos";
default:
return `Ronda ${round}`;
}
}
function formatScore(scores: number[] | null): string {
if (!scores || scores.length === 0) return "-";
return scores.join("-");
}
function TeamSlot({
playerIds,
players,
score,
isWinner,
isEmpty,
}: {
playerIds: string[];
players: Map<string, Player>;
score: number[] | null;
isWinner: boolean;
isEmpty: boolean;
}) {
if (isEmpty || playerIds.length === 0) {
return (
<div className="flex items-center justify-between px-3 py-2 bg-gray-100 text-gray-400 rounded text-sm italic">
<span>TBD</span>
<span>-</span>
</div>
);
}
const playerNames = playerIds
.map((id) => {
const player = players.get(id);
if (!player) return "Desconocido";
return `${player.firstName} ${player.lastName.charAt(0)}.`;
})
.join(" / ");
return (
<div
className={cn(
"flex items-center justify-between px-3 py-2 rounded text-sm transition-all",
isWinner
? "bg-green-100 text-green-800 font-medium"
: "bg-white text-primary-700"
)}
>
<span className="truncate max-w-[140px]" title={playerNames}>
{playerNames}
</span>
<span className={cn("font-mono", isWinner && "font-bold")}>
{formatScore(score)}
</span>
</div>
);
}
function MatchBox({
match,
players,
onClick,
}: {
match: Match;
players: Map<string, Player>;
onClick?: () => void;
}) {
const team1IsWinner =
match.winnerId && match.team1Players.includes(match.winnerId);
const team2IsWinner =
match.winnerId && match.team2Players.includes(match.winnerId);
const isClickable =
match.status !== "COMPLETED" &&
match.status !== "WALKOVER" &&
match.team1Players.length > 0 &&
match.team2Players.length > 0;
return (
<div
className={cn(
"w-[220px] rounded-lg border-2 overflow-hidden shadow-sm",
statusColors[match.status] || statusColors.SCHEDULED,
isClickable && "cursor-pointer hover:shadow-md hover:border-primary-400 transition-all"
)}
onClick={isClickable ? onClick : undefined}
>
{/* Match Info Header */}
<div className="px-2 py-1 bg-white/50 border-b text-xs text-primary-500 flex justify-between">
<span>Partido {match.position}</span>
{match.court && <span>{match.court.name}</span>}
</div>
{/* Teams */}
<div className="p-2 space-y-1">
<TeamSlot
playerIds={match.team1Players}
players={players}
score={match.team1Score}
isWinner={!!team1IsWinner}
isEmpty={match.team1Players.length === 0}
/>
<div className="text-center text-xs text-primary-400">vs</div>
<TeamSlot
playerIds={match.team2Players}
players={players}
score={match.team2Score}
isWinner={!!team2IsWinner}
isEmpty={match.team2Players.length === 0}
/>
</div>
{/* Status */}
<div className="px-2 py-1 text-center">
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
match.status === "SCHEDULED" && "bg-gray-200 text-gray-600",
match.status === "IN_PROGRESS" && "bg-blue-200 text-blue-700",
match.status === "COMPLETED" && "bg-green-200 text-green-700",
match.status === "WALKOVER" && "bg-yellow-200 text-yellow-700",
match.status === "CANCELLED" && "bg-red-200 text-red-700"
)}
>
{match.status === "SCHEDULED" && "Programado"}
{match.status === "IN_PROGRESS" && "En Juego"}
{match.status === "COMPLETED" && "Finalizado"}
{match.status === "WALKOVER" && "Walkover"}
{match.status === "CANCELLED" && "Cancelado"}
</span>
</div>
</div>
);
}
export function BracketView({ matches, players, onMatchClick }: BracketViewProps) {
// Group matches by round
const matchesByRound = useMemo(() => {
const grouped = new Map<number, Match[]>();
matches.forEach((match) => {
const roundMatches = grouped.get(match.round) || [];
roundMatches.push(match);
grouped.set(match.round, roundMatches);
});
// Sort matches in each round by position
grouped.forEach((roundMatches) => {
roundMatches.sort((a, b) => a.position - b.position);
});
return grouped;
}, [matches]);
const totalRounds = Math.max(...Array.from(matchesByRound.keys()), 0);
const rounds = Array.from({ length: totalRounds }, (_, i) => i + 1);
if (matches.length === 0) {
return (
<div className="flex items-center justify-center h-64 text-primary-500">
<div className="text-center">
<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 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>
<p>No hay bracket generado</p>
<p className="text-sm mt-1">Genera el bracket para comenzar el torneo</p>
</div>
</div>
);
}
return (
<div className="overflow-x-auto pb-4">
<div className="flex gap-8 min-w-max p-4">
{rounds.map((round) => {
const roundMatches = matchesByRound.get(round) || [];
const roundName = getRoundName(round, totalRounds);
// Calculate vertical spacing for bracket alignment
const matchesInPreviousRound = round > 1 ? matchesByRound.get(round - 1)?.length || 0 : roundMatches.length * 2;
const spacingMultiplier = Math.pow(2, round - 1);
return (
<div key={round} className="flex flex-col">
{/* Round Header */}
<div className="text-center mb-4">
<h3 className="font-semibold text-primary-800">{roundName}</h3>
<p className="text-xs text-primary-500">
{roundMatches.length} {roundMatches.length === 1 ? "partido" : "partidos"}
</p>
</div>
{/* Matches */}
<div
className="flex flex-col justify-around flex-1"
style={{
gap: `${(spacingMultiplier - 1) * 100 + 24}px`,
paddingTop: `${(spacingMultiplier - 1) * 50}px`,
}}
>
{roundMatches.map((match) => (
<MatchBox
key={match.id}
match={match}
players={players}
onClick={() => onMatchClick?.(match)}
/>
))}
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-4 justify-center mt-6 pt-4 border-t border-primary-200">
<div className="flex items-center gap-2 text-xs">
<div className="w-4 h-4 rounded bg-gray-200 border border-gray-300" />
<span>Programado</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-4 h-4 rounded bg-blue-200 border border-blue-400" />
<span>En Juego</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-4 h-4 rounded bg-green-200 border border-green-300" />
<span>Finalizado</span>
</div>
<div className="flex items-center gap-2 text-xs">
<div className="w-4 h-4 rounded bg-yellow-200 border border-yellow-300" />
<span>Walkover</span>
</div>
</div>
</div>
);
}