feat: add tournament creation wizard with participant management and bulk game entry

This commit is contained in:
2026-03-29 19:58:15 -07:00
parent eb381d928c
commit 54fd6abb5e
10 changed files with 984 additions and 97 deletions
@@ -0,0 +1,299 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import Navigation from "@/components/Navigation"
interface Player {
id: number
name: string
currentElo: number
}
interface Tournament {
id: number
name: string
eventDate: string | null
format: string
participants: {
player: Player
}[]
}
interface GameEntry {
round: number
table: string
player1: string
player2: string
score1: number
player3: string
player4: string
score2: number
}
export default function TournamentEntryPage({ params }: { params: { id: string } }) {
const router = useRouter()
const [tournament, setTournament] = useState<Tournament | null>(null)
const [gameText, setGameText] = useState("")
const [parsedGames, setParsedGames] = useState<GameEntry[]>([])
const [error, setError] = useState("")
const [success, setSuccess] = useState("")
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
loadTournament()
}, [])
const loadTournament = async () => {
try {
const response = await fetch(`/api/tournaments/${params.id}`)
const data = await response.json()
if (response.ok) {
setTournament(data.tournament)
}
} catch (err) {
console.error("Failed to load tournament:", err)
}
}
const parseGameText = (text: string): GameEntry[] => {
const lines = text.trim().split("\n")
const games: GameEntry[] = []
for (const line of lines) {
// Skip empty lines and comments
if (!line.trim() || line.trim().startsWith("#")) continue
// Parse tab-separated or comma-separated values
const parts = line.split(/[,\t]/).map(p => p.trim())
if (parts.length >= 7) {
games.push({
round: parseInt(parts[0]) || 1,
table: parts[1] || "",
player1: parts[2],
player2: parts[3],
score1: parseInt(parts[4]) || 0,
player3: parts[5],
player4: parts[6],
score2: parseInt(parts[7]) || 0,
})
}
}
return games
}
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value
setGameText(text)
const games = parseGameText(text)
setParsedGames(games)
}
const submitGames = async () => {
if (parsedGames.length === 0) {
setError("No valid games to submit")
return
}
setError("")
setSuccess("")
setIsLoading(true)
try {
const response = await fetch(`/api/tournaments/${params.id}/games/bulk`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
games: parsedGames,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || "Failed to submit games")
}
setSuccess(`Successfully imported ${data.importedCount} games`)
setGameText("")
setParsedGames([])
} catch (err: any) {
setError(err.message)
} finally {
setIsLoading(false)
}
}
if (!tournament) {
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<main className="max-w-4xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600 mx-auto"></div>
</div>
</main>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<main className="max-w-6xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="mb-6">
<button
onClick={() => router.push(`/admin/tournaments/${params.id}`)}
className="text-green-600 hover:text-green-800 mb-4"
>
Back to Tournament
</button>
<h1 className="text-3xl font-bold text-gray-900">
Game Entry: {tournament.name}
</h1>
{tournament.eventDate && (
<p className="text-gray-600">
{new Date(tournament.eventDate).toLocaleDateString()}
</p>
)}
</div>
{error && (
<div className="rounded-md bg-red-50 p-4 mb-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{success && (
<div className="rounded-md bg-green-50 p-4 mb-4">
<div className="text-sm text-green-700">{success}</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Participants Panel */}
<div className="lg:col-span-1">
<div className="bg-white shadow rounded-lg p-4">
<h2 className="text-lg font-medium text-gray-900 mb-3">
Participants ({tournament.participants.length})
</h2>
<div className="max-h-96 overflow-y-auto">
{tournament.participants.map(({ player }) => (
<div
key={player.id}
className="py-1 text-sm text-gray-700"
>
{player.name}
</div>
))}
</div>
</div>
</div>
{/* Game Entry Panel */}
<div className="lg:col-span-2">
<div className="bg-white shadow rounded-lg p-4">
<h2 className="text-lg font-medium text-gray-900 mb-3">
Enter Games
</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Format Instructions
</label>
<div className="bg-gray-50 rounded-md p-3 text-sm text-gray-600">
<p className="font-medium mb-1">Tab or comma-separated format:</p>
<code className="block bg-white p-2 rounded mb-2">
Round Table Player1 Player2 Score1 Player3 Player4 Score2
</code>
<p className="text-xs text-gray-500">
Example: 1 1 John Smith Jane Doe 10 Mike Johnson Sarah Brown 5
</p>
<p className="text-xs text-gray-500 mt-2">
Lines starting with # are treated as comments
</p>
</div>
</div>
<div className="mb-4">
<label htmlFor="gameText" className="block text-sm font-medium text-gray-700">
Game Data
</label>
<textarea
id="gameText"
rows={15}
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 font-mono text-sm focus:outline-none focus:ring-green-500 focus:border-green-500"
placeholder="Round Table Player1 Player2 Score1 Player3 Player4 Score2&#10;1 1 John Smith Jane Doe 10 Mike Johnson Sarah Brown 5&#10;1 2 Alice Johnson Bob Smith 8 Charlie Brown Diana Davis 7"
value={gameText}
onChange={handleTextChange}
/>
</div>
{/* Parsed Games Preview */}
{parsedGames.length > 0 && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Parsed Games ({parsedGames.length})
</label>
<div className="max-h-48 overflow-y-auto border border-gray-300 rounded-md">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Round</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Table</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Team 1</th>
<th className="px-3 py-2 text-center text-xs font-medium text-gray-500">Score</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500">Team 2</th>
<th className="px-3 py-2 text-center text-xs font-medium text-gray-500">Score</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{parsedGames.map((game, index) => (
<tr key={index}>
<td className="px-3 py-2 text-sm text-gray-900">{game.round}</td>
<td className="px-3 py-2 text-sm text-gray-900">{game.table}</td>
<td className="px-3 py-2 text-sm text-gray-900">
{game.player1} & {game.player2}
</td>
<td className="px-3 py-2 text-sm text-center font-medium">{game.score1}</td>
<td className="px-3 py-2 text-sm text-gray-900">
{game.player3} & {game.player4}
</td>
<td className="px-3 py-2 text-sm text-center font-medium">{game.score2}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className="flex justify-end space-x-3">
<button
onClick={() => router.push(`/admin/tournaments/${params.id}`)}
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 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Cancel
</button>
<button
onClick={submitGames}
disabled={isLoading || parsedGames.length === 0}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? "Submitting..." : `Submit ${parsedGames.length} Games`}
</button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
)
}