9bfb890143
Adds a visual bracket display showing rounds as columns with matchup cards. Changes: - Created BracketVisualization component with CSS grid layout - Added 'Bracket' tab to tournament detail page (visible when schedule exists) - Matchup cards show team names, scores, and winner highlighting - Current round is highlighted with green styling - Added BDD feature file with 3 scenarios covering bracket display - Added step definitions for bracket interaction tests 3 scenarios, 24 steps, all passing.
591 lines
23 KiB
TypeScript
591 lines
23 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect } from "react"
|
|
import { use } from "react"
|
|
import Link from "next/link"
|
|
import Navigation from "@/components/Navigation"
|
|
import TeamsSection from "@/components/TeamsSection"
|
|
import { DeleteTournamentButton } from "@/components/DeleteTournamentButton"
|
|
import { ScheduleGenerator } from "@/components/ScheduleGenerator"
|
|
import { BracketVisualization } from "@/components/BracketVisualization"
|
|
import MatchEditor from "@/components/MatchEditor"
|
|
|
|
interface PageProps {
|
|
params: Promise<{
|
|
id: string
|
|
}>
|
|
}
|
|
|
|
export default function TournamentDetailPage({ params }: PageProps) {
|
|
const resolvedParams = use(params)
|
|
const tournamentId = resolvedParams.id
|
|
const [activeTab, setActiveTab] = useState<string>("overview")
|
|
const [tournament, setTournament] = useState<any>(null)
|
|
const [matches, setMatches] = useState<any[]>([])
|
|
const [participants, setParticipants] = useState<any[]>([])
|
|
const [rounds, setRounds] = useState<any[]>([])
|
|
const [allPlayers, setAllPlayers] = useState<any[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState("")
|
|
|
|
// Load tournament data
|
|
useEffect(() => {
|
|
const loadTournament = async () => {
|
|
try {
|
|
setLoading(true)
|
|
const response = await fetch(`/api/tournaments/${tournamentId}`)
|
|
if (!response.ok) {
|
|
throw new Error("Failed to load tournament")
|
|
}
|
|
const data = await response.json()
|
|
// API returns { tournament: { ... } } or direct tournament object
|
|
const tournamentData = data.tournament || data
|
|
setTournament(tournamentData)
|
|
} catch (err) {
|
|
setError("Failed to load tournament")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
loadTournament()
|
|
}, [tournamentId])
|
|
|
|
// Load all related data when tournament loads
|
|
useEffect(() => {
|
|
if (!tournament) return
|
|
|
|
const loadRelatedData = async () => {
|
|
try {
|
|
// Load participants
|
|
const pResponse = await fetch(`/api/tournaments/${tournamentId}/participants`)
|
|
if (pResponse.ok) {
|
|
const pData = await pResponse.json()
|
|
setParticipants(pData.participants || [])
|
|
}
|
|
|
|
// Load matches
|
|
const mResponse = await fetch(`/api/tournaments/${tournamentId}/matches`)
|
|
if (mResponse.ok) {
|
|
const mData = await mResponse.json()
|
|
setMatches(mData.matches || [])
|
|
}
|
|
|
|
// Load schedule/rounds
|
|
const sResponse = await fetch(`/api/tournaments/${tournamentId}/schedule`)
|
|
if (sResponse.ok) {
|
|
const sData = await sResponse.json()
|
|
setRounds(sData.rounds || [])
|
|
}
|
|
|
|
// Load all players for selection
|
|
const playersResponse = await fetch("/api/players")
|
|
if (playersResponse.ok) {
|
|
const playersData = await playersResponse.json()
|
|
setAllPlayers(playersData || [])
|
|
}
|
|
} catch (err) {
|
|
console.error("Failed to load related data:", err)
|
|
}
|
|
}
|
|
|
|
loadRelatedData()
|
|
}, [tournamentId, tournament])
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<Navigation />
|
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
|
<div className="px-4 py-6 sm:px-0">
|
|
<div className="bg-white shadow rounded-lg p-6">
|
|
<p className="text-gray-500">Loading...</p>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (error || !tournament) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<Navigation />
|
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
|
<div className="px-4 py-6 sm:px-0">
|
|
<div className="bg-white shadow rounded-lg p-6">
|
|
<p className="text-red-600">{error || "Tournament not found"}</p>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const hasSchedule = rounds.length > 0
|
|
const statusColors: Record<string, string> = {
|
|
pending: "bg-gray-100 text-gray-700",
|
|
in_progress: "bg-yellow-100 text-yellow-800",
|
|
completed: "bg-green-100 text-green-800",
|
|
}
|
|
|
|
// Tab content renderer
|
|
const renderTabContent = () => {
|
|
switch (activeTab) {
|
|
case "overview":
|
|
return (
|
|
<>
|
|
{/* Participants Section */}
|
|
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
|
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
|
Participants ({participants.length})
|
|
</h2>
|
|
|
|
{participants.length > 0 ? (
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
{participants.map((participant) => (
|
|
<div
|
|
key={participant.id}
|
|
className="bg-gray-50 rounded p-2 text-center"
|
|
>
|
|
<Link
|
|
href={`/players/${participant.player.id}/profile`}
|
|
className="text-green-600 hover:text-green-900 text-sm"
|
|
>
|
|
{participant.player.name}
|
|
</Link>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500">No participants registered yet.</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Recent Matches Section */}
|
|
<div className="bg-white shadow rounded-lg p-6 mt-6">
|
|
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
|
Recent Matches ({matches.length})
|
|
</h2>
|
|
|
|
{matches.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{matches.slice(0, 10).map((match) => (
|
|
<div
|
|
key={match.id}
|
|
className="border border-gray-200 rounded p-3"
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex-1">
|
|
<p className="text-sm text-gray-500">
|
|
{match.playedAt ? new Date(match.playedAt).toLocaleDateString() : ''}
|
|
</p>
|
|
<p className="font-medium">
|
|
{match.player1P1?.name} + {match.player1P2?.name} vs{" "}
|
|
{match.player2P1?.name} + {match.player2P2?.name}
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className={`font-bold ${
|
|
match.team1Score > match.team2Score
|
|
? 'text-green-600'
|
|
: match.team1Score < match.team2Score
|
|
? 'text-red-600'
|
|
: 'text-gray-600'
|
|
}`}>
|
|
{match.team1Score}
|
|
</span>
|
|
<span className="text-gray-400 mx-2">-</span>
|
|
<span className={`font-bold ${
|
|
match.team2Score > match.team1Score
|
|
? 'text-green-600'
|
|
: match.team2Score < match.team1Score
|
|
? 'text-red-600'
|
|
: 'text-gray-600'
|
|
}`}>
|
|
{match.team2Score}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500">No matches recorded yet.</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
|
|
case "participants":
|
|
return (
|
|
<div className="bg-white shadow rounded-lg p-6 space-y-6">
|
|
<div>
|
|
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
|
Add Participants
|
|
</h2>
|
|
|
|
{/* Player Search */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Search for existing players
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Type a name to search..."
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Current Participants */}
|
|
<div>
|
|
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
|
Current Participants ({participants.length})
|
|
</h2>
|
|
|
|
{participants.length > 0 ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{participants.map((participant) => (
|
|
<div
|
|
key={participant.id}
|
|
className="flex items-center justify-between bg-gray-50 rounded p-3"
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
<Link
|
|
href={`/players/${participant.player.id}/profile`}
|
|
className="text-green-600 hover:text-green-900 font-medium"
|
|
>
|
|
{participant.player.name}
|
|
</Link>
|
|
<span className="text-sm text-gray-500">
|
|
Elo: {participant.player.currentElo}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500">No participants registered yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
case "matchups":
|
|
return (
|
|
<TeamsSection
|
|
tournamentId={parseInt(tournamentId)}
|
|
participants={participants.map(p => ({
|
|
id: p.player.id,
|
|
name: p.player.name,
|
|
currentElo: p.player.currentElo,
|
|
}))}
|
|
teamDurability={tournament.teamDurability || "permanent"}
|
|
partnerRotation={tournament.partnerRotation || "none"}
|
|
allowByes={tournament.allowByes ?? true}
|
|
/>
|
|
)
|
|
|
|
case "schedule":
|
|
return (
|
|
<>
|
|
{!hasSchedule && (
|
|
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
|
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
|
No Schedule Generated
|
|
</h2>
|
|
<ScheduleGenerator
|
|
tournamentId={parseInt(tournamentId)}
|
|
teamCount={Math.floor(participants.length / 2)}
|
|
existingRounds={rounds.length}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{rounds.map((round) => (
|
|
<div key={round.id} className="bg-white shadow rounded-lg p-6 mb-6">
|
|
<div className="flex justify-between items-center mb-4">
|
|
<h2 className="text-lg font-medium text-gray-900">
|
|
Round {round.roundNumber}
|
|
</h2>
|
|
<span className={`px-2 py-1 text-xs font-medium rounded-full ${statusColors[round.status] || statusColors.pending}`}>
|
|
{round.status.replace("_", " ")}
|
|
</span>
|
|
</div>
|
|
|
|
{round.bracketMatchups?.length === 0 ? (
|
|
<p className="text-gray-500">No matchups in this round.</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{round.bracketMatchups?.map((matchup: any) => {
|
|
const team1Name = matchup.player1P1 && matchup.player1P2
|
|
? `${matchup.player1P1.name} + ${matchup.player1P2.name}`
|
|
: "TBD"
|
|
const team2Name = matchup.player2P1 && matchup.player2P2
|
|
? `${matchup.player2P1.name} + ${matchup.player2P2.name}`
|
|
: "TBD"
|
|
|
|
return (
|
|
<div
|
|
key={matchup.id}
|
|
className="border border-gray-200 rounded p-3"
|
|
>
|
|
<div className="flex justify-between items-center">
|
|
<div className="flex-1">
|
|
<div className="flex items-center space-x-2">
|
|
{matchup.tableNumber && (
|
|
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded">
|
|
Table {matchup.tableNumber}
|
|
</span>
|
|
)}
|
|
<span className={`px-2 py-0.5 text-xs font-medium rounded-full ${statusColors[matchup.status] || statusColors.pending}`}>
|
|
{matchup.status.replace("_", " ")}
|
|
</span>
|
|
</div>
|
|
<p className="font-medium mt-1">
|
|
{team1Name} <span className="text-gray-400">vs</span> {team2Name}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-3">
|
|
{matchup.match ? (
|
|
<div className="flex items-center space-x-2">
|
|
<span className={`font-bold ${
|
|
matchup.match.team1Score > matchup.match.team2Score
|
|
? 'text-green-600'
|
|
: 'text-gray-900'
|
|
}`}>
|
|
{matchup.match.team1Score}
|
|
</span>
|
|
<span className="text-gray-400">-</span>
|
|
<span className={`font-bold ${
|
|
matchup.match.team2Score > matchup.match.team1Score
|
|
? 'text-green-600'
|
|
: 'text-gray-900'
|
|
}`}>
|
|
{matchup.match.team2Score}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
setActiveTab("results")
|
|
// Optionally pass matchup data to results tab
|
|
}}
|
|
className="px-3 py-1 border border-green-300 rounded text-sm font-medium text-green-700 hover:bg-green-50"
|
|
>
|
|
Enter Result
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{hasSchedule && (
|
|
<div className="bg-white shadow rounded-lg p-6">
|
|
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
|
Schedule Actions
|
|
</h2>
|
|
<ScheduleGenerator
|
|
tournamentId={parseInt(tournamentId)}
|
|
teamCount={Math.floor(participants.length / 2)}
|
|
existingRounds={rounds.length}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
case "results":
|
|
return (
|
|
<div className="bg-white shadow rounded-lg p-6">
|
|
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
|
Enter Match Results
|
|
</h2>
|
|
<MatchEditor
|
|
tournamentId={parseInt(tournamentId)}
|
|
players={allPlayers}
|
|
matches={matches}
|
|
targetScore={tournament.targetScore}
|
|
allowTies={tournament.allowTies}
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
case "bracket":
|
|
return (
|
|
<BracketVisualization rounds={rounds} />
|
|
)
|
|
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50">
|
|
<Navigation />
|
|
|
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
|
<div className="px-4 py-6 sm:px-0">
|
|
{/* Breadcrumb */}
|
|
<nav className="mb-4">
|
|
<ol className="flex items-center space-x-2">
|
|
<li>
|
|
<Link href="/admin/tournaments" className="text-green-600 hover:text-green-900">
|
|
Tournaments
|
|
</Link>
|
|
</li>
|
|
<li className="text-gray-400">/</li>
|
|
<li className="text-gray-600">{tournament.name}</li>
|
|
</ol>
|
|
</nav>
|
|
|
|
{/* Tournament Header */}
|
|
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-gray-900">{tournament.name}</h1>
|
|
<p className="text-gray-500 mt-1">
|
|
{tournament.format} - {tournament.status}
|
|
</p>
|
|
{tournament.eventDate && (
|
|
<p className="text-sm text-gray-400 mt-1">
|
|
{new Date(tournament.eventDate).toLocaleDateString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex space-x-2">
|
|
<Link
|
|
href={`/admin/tournaments/${tournament.id}/edit`}
|
|
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
|
>
|
|
Edit
|
|
</Link>
|
|
<a
|
|
href={`/api/tournaments/${tournament.id}/export`}
|
|
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
|
>
|
|
Export CSV
|
|
</a>
|
|
<DeleteTournamentButton
|
|
tournamentId={tournament.id}
|
|
tournamentName={tournament.name}
|
|
matchCount={matches.length}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Quick Stats */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
|
<p className="text-sm text-gray-500">Participants</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{participants.length}
|
|
</p>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
|
<p className="text-sm text-gray-500">Rounds</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{rounds.length}
|
|
</p>
|
|
</div>
|
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
|
<p className="text-sm text-gray-500">Matchups</p>
|
|
<p className="text-2xl font-bold text-gray-900">
|
|
{matches.length}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="border-b border-gray-200">
|
|
<nav className="-mb-px flex space-x-8">
|
|
<button
|
|
onClick={() => setActiveTab("overview")}
|
|
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === "overview"
|
|
? "border-green-500 text-green-600"
|
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
Overview
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("participants")}
|
|
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === "participants"
|
|
? "border-green-500 text-green-600"
|
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
Participants
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("matchups")}
|
|
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === "matchups"
|
|
? "border-green-500 text-green-600"
|
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
Matchups
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("schedule")}
|
|
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === "schedule"
|
|
? "border-green-500 text-green-600"
|
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
Schedule
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("results")}
|
|
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === "results"
|
|
? "border-green-500 text-green-600"
|
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
Results
|
|
</button>
|
|
{hasSchedule && (
|
|
<button
|
|
onClick={() => setActiveTab("bracket")}
|
|
className={`whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm ${
|
|
activeTab === "bracket"
|
|
? "border-green-500 text-green-600"
|
|
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
|
|
}`}
|
|
>
|
|
Bracket
|
|
</button>
|
|
)}
|
|
<span className="border-transparent text-gray-400 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm cursor-not-allowed">
|
|
Analytics
|
|
</span>
|
|
</nav>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="mt-6">
|
|
{renderTabContent()}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|