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:
300
apps/web/components/tournaments/bracket-view.tsx
Normal file
300
apps/web/components/tournaments/bracket-view.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user