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>
301 lines
8.9 KiB
TypeScript
301 lines
8.9 KiB
TypeScript
"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>
|
|
);
|
|
}
|