bb6be245b7
## Summary This PR implements the tournament schedule tab functionality and fixes all remaining E2E test failures. ### Changes Included 1. **Tournament Schedule Feature** - Added tournament schedule page at `/admin/tournaments/[id]/schedule` - Implemented "Generate Schedule" button functionality - Added schedule generation logic for round-robin tournaments 2. **E2E Test Fixes** - Fixed database connection issues in production builds - Improved test reliability with better error handling and debugging - Updated test infrastructure to use environment variables instead of hardcoded values 3. **CI/CD Updates** - Added E2E test job to PR workflow - Configured tests to run against development database - Moved database password to Gitea secrets 4. **Code Quality** - Removed hardcoded passwords from codebase - Improved Prisma client configuration - Enhanced authentication and navigation components ### Test Results All 16 E2E test scenarios are now passing: - Authentication tests: ✅ - Registration tests: ✅ - Tournament schedule tests: ✅ - Player schedule tests: ✅ - Admin navigation tests: ✅ ### Database Configuration - Tests run against `euchre_camp_dev` database - Production builds use environment variables for database configuration - Database password stored in Gitea secrets as `DB_PASSWORD` ### CI Pipeline The PR workflow now includes: 1. Unit tests 2. E2E tests (using production build) 3. Version bump analysis E2E tests must pass before PR can be merged. Reviewed-on: #27 Co-authored-by: David Gwilliam <dhgwilliam@gmail.com> Co-committed-by: David Gwilliam <dhgwilliam@gmail.com>
880 lines
36 KiB
TypeScript
880 lines
36 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect, useMemo } from "react"
|
||
import { useRouter } from "next/navigation"
|
||
import Navigation from "@/components/Navigation"
|
||
import { expectedRounds, expectedMatchups } from "@/lib/schedule-generator"
|
||
|
||
interface Player {
|
||
id: number
|
||
name: string
|
||
currentElo: number
|
||
}
|
||
|
||
interface TournamentFormData {
|
||
name: string
|
||
description: string
|
||
eventDate: string
|
||
format: string
|
||
tournamentType: 'individual' | 'team'
|
||
participants: number[]
|
||
teamDurability: 'permanent' | 'variable' | 'per_round'
|
||
partnerRotation: 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based'
|
||
allowByes: boolean
|
||
}
|
||
|
||
type PairingMethod = 'elo' | 'manual' | 'random'
|
||
|
||
export default function NewTournamentPage() {
|
||
const router = useRouter()
|
||
const [step, setStep] = useState(1)
|
||
const [formData, setFormData] = useState<TournamentFormData>({
|
||
name: "",
|
||
description: "",
|
||
eventDate: "",
|
||
format: "round_robin",
|
||
tournamentType: "individual",
|
||
participants: [],
|
||
teamDurability: "permanent",
|
||
partnerRotation: "none",
|
||
allowByes: true,
|
||
})
|
||
const [error, setError] = useState("")
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
|
||
// Player search state
|
||
const [searchQuery, setSearchQuery] = useState("")
|
||
const [searchResults, setSearchResults] = useState<Player[]>([])
|
||
const [selectedPlayers, setSelectedPlayers] = useState<Player[]>([])
|
||
const [isSearching, setIsSearching] = useState(false)
|
||
const [showCreatePlayer, setShowCreatePlayer] = useState(false)
|
||
const [newPlayerName, setNewPlayerName] = useState("")
|
||
const [isCreatingPlayer, setIsCreatingPlayer] = useState(false)
|
||
|
||
// Sorting state
|
||
const [sortConfig, setSortConfig] = useState<{ key: 'name' | 'currentElo'; direction: 'asc' | 'desc' }>({
|
||
key: 'name',
|
||
direction: 'asc'
|
||
})
|
||
|
||
// Team pairing state
|
||
const [pairingMethod, setPairingMethod] = useState<PairingMethod>('elo')
|
||
|
||
// Dynamic round preview calculation
|
||
const scheduleInfo = useMemo(() => {
|
||
if (formData.tournamentType === 'team') {
|
||
const teamCount = Math.floor(selectedPlayers.length / 2)
|
||
if (teamCount < 2) return null
|
||
return {
|
||
rounds: expectedRounds(teamCount),
|
||
matchups: expectedMatchups(teamCount),
|
||
teams: teamCount,
|
||
playerCount: selectedPlayers.length,
|
||
}
|
||
} else {
|
||
// Individual: players form teams of 2 for Euchre
|
||
const teamCount = Math.floor(selectedPlayers.length / 2)
|
||
if (teamCount < 2) return null
|
||
return {
|
||
rounds: expectedRounds(teamCount),
|
||
matchups: expectedMatchups(teamCount),
|
||
teams: teamCount,
|
||
playerCount: selectedPlayers.length,
|
||
}
|
||
}
|
||
}, [selectedPlayers.length, formData.tournamentType])
|
||
|
||
// Minimum players by format
|
||
const getMinPlayers = () => {
|
||
if (formData.tournamentType === 'team') {
|
||
return 4 // At least 2 teams
|
||
}
|
||
// Individual tournaments still need pairs for Euchre
|
||
return 4 // At least 2 teams of 2
|
||
}
|
||
|
||
// Search for players as user types
|
||
useEffect(() => {
|
||
const searchPlayers = async () => {
|
||
if (searchQuery.length < 2) {
|
||
setSearchResults([])
|
||
return
|
||
}
|
||
|
||
setIsSearching(true)
|
||
try {
|
||
const response = await fetch(`/api/players/search?q=${encodeURIComponent(searchQuery)}`)
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
const availablePlayers = data.players.filter(
|
||
(p: Player) => !selectedPlayers.find(sp => sp.id === p.id)
|
||
)
|
||
setSearchResults(availablePlayers)
|
||
}
|
||
} catch (err) {
|
||
console.error("Search failed:", err)
|
||
} finally {
|
||
setIsSearching(false)
|
||
}
|
||
}
|
||
|
||
const debounceTimer = setTimeout(searchPlayers, 300)
|
||
return () => clearTimeout(debounceTimer)
|
||
}, [searchQuery, selectedPlayers])
|
||
|
||
const addPlayer = (player: Player) => {
|
||
setSelectedPlayers([...selectedPlayers, player])
|
||
setSearchQuery("")
|
||
setSearchResults([])
|
||
setShowCreatePlayer(false)
|
||
setNewPlayerName("")
|
||
}
|
||
|
||
const createNewPlayer = async () => {
|
||
if (!newPlayerName.trim()) return
|
||
|
||
setIsCreatingPlayer(true)
|
||
try {
|
||
const response = await fetch("/api/players", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({ name: newPlayerName.trim() }),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json()
|
||
throw new Error(error.error || "Failed to create player")
|
||
}
|
||
|
||
const data = await response.json()
|
||
addPlayer(data.player)
|
||
} catch (err) {
|
||
if (err instanceof Error) {
|
||
setError(err.message)
|
||
} else {
|
||
setError("Failed to create player")
|
||
}
|
||
} finally {
|
||
setIsCreatingPlayer(false)
|
||
}
|
||
}
|
||
|
||
const removePlayer = (playerId: number) => {
|
||
setSelectedPlayers(selectedPlayers.filter(p => p.id !== playerId))
|
||
}
|
||
|
||
const handleSort = (key: 'name' | 'currentElo') => {
|
||
setSortConfig(prevConfig => ({
|
||
key,
|
||
direction: prevConfig.key === key && prevConfig.direction === 'asc' ? 'desc' : 'asc'
|
||
}))
|
||
}
|
||
|
||
const getSortedPlayers = () => {
|
||
const sorted = [...selectedPlayers].sort((a, b) => {
|
||
if (sortConfig.key === 'name') {
|
||
return sortConfig.direction === 'asc'
|
||
? a.name.localeCompare(b.name)
|
||
: b.name.localeCompare(a.name)
|
||
} else {
|
||
return sortConfig.direction === 'asc'
|
||
? a.currentElo - b.currentElo
|
||
: b.currentElo - a.currentElo
|
||
}
|
||
})
|
||
return sorted
|
||
}
|
||
|
||
// Generate team pairings based on method
|
||
const getTeamPairings = () => {
|
||
const sorted = [...selectedPlayers]
|
||
|
||
if (pairingMethod === 'elo') {
|
||
sorted.sort((a, b) => b.currentElo - a.currentElo)
|
||
const teams: { player1: Player; player2: Player }[] = []
|
||
for (let i = 0; i < sorted.length - 1; i += 2) {
|
||
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
|
||
}
|
||
return teams
|
||
} else if (pairingMethod === 'random') {
|
||
// Shuffle using Fisher-Yates
|
||
for (let i = sorted.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[sorted[i], sorted[j]] = [sorted[j], sorted[i]]
|
||
}
|
||
const teams: { player1: Player; player2: Player }[] = []
|
||
for (let i = 0; i < sorted.length - 1; i += 2) {
|
||
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
|
||
}
|
||
return teams
|
||
} else {
|
||
// Manual: just use current order
|
||
const teams: { player1: Player; player2: Player }[] = []
|
||
for (let i = 0; i < sorted.length - 1; i += 2) {
|
||
teams.push({ player1: sorted[i], player2: sorted[i + 1] })
|
||
}
|
||
return teams
|
||
}
|
||
}
|
||
|
||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||
setFormData({
|
||
...formData,
|
||
[e.target.name]: e.target.value,
|
||
})
|
||
}
|
||
|
||
const nextStep = () => {
|
||
if (step === 1) {
|
||
if (!formData.name.trim()) {
|
||
setError("Tournament name is required")
|
||
return
|
||
}
|
||
}
|
||
setError("")
|
||
setStep(step + 1)
|
||
}
|
||
|
||
const prevStep = () => {
|
||
setStep(step - 1)
|
||
setError("")
|
||
}
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setError("")
|
||
setIsLoading(true)
|
||
|
||
try {
|
||
const minPlayers = getMinPlayers()
|
||
if (selectedPlayers.length < minPlayers) {
|
||
setError(`At least ${minPlayers} participants are required (${minPlayers / 2} teams)`)
|
||
setIsLoading(false)
|
||
return
|
||
}
|
||
if (selectedPlayers.length % 2 !== 0) {
|
||
setError("An even number of participants is required to form teams")
|
||
setIsLoading(false)
|
||
return
|
||
}
|
||
|
||
// Create the tournament
|
||
const tournamentResponse = await fetch("/api/tournaments", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
name: formData.name,
|
||
description: formData.description,
|
||
eventDate: formData.eventDate || null,
|
||
format: formData.format,
|
||
tournamentType: formData.tournamentType,
|
||
teamDurability: formData.teamDurability,
|
||
partnerRotation: formData.partnerRotation,
|
||
allowByes: formData.allowByes,
|
||
}),
|
||
})
|
||
|
||
const tournamentData = await tournamentResponse.json()
|
||
|
||
if (!tournamentResponse.ok) {
|
||
throw new Error(tournamentData.error || "Failed to create tournament")
|
||
}
|
||
|
||
const tournamentId = tournamentData.tournament.id
|
||
|
||
// Add participants to the tournament
|
||
if (selectedPlayers.length > 0) {
|
||
await fetch(`/api/tournaments/${tournamentId}/participants`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
playerIds: selectedPlayers.map(p => p.id),
|
||
}),
|
||
})
|
||
}
|
||
|
||
// Auto-generate schedule for round_robin
|
||
if (formData.format === 'round_robin') {
|
||
await fetch(`/api/tournaments/${tournamentId}/schedule`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
})
|
||
}
|
||
|
||
// Redirect to schedule view
|
||
router.push(`/admin/tournaments/${tournamentId}/schedule`)
|
||
} catch (err: unknown) {
|
||
const message = err instanceof Error ? err.message : "An unexpected error occurred";
|
||
setError(message)
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
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">
|
||
<h1 className="text-3xl font-bold text-gray-900 mb-6">
|
||
Create New Tournament
|
||
</h1>
|
||
|
||
{/* Step Indicator */}
|
||
<div className="mb-8">
|
||
<div className="flex items-center">
|
||
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${step >= 1 ? 'bg-green-600 text-white' : 'bg-gray-300 text-gray-600'}`}>
|
||
1
|
||
</div>
|
||
<div className={`flex-1 h-1 ${step >= 2 ? 'bg-green-600' : 'bg-gray-300'}`} />
|
||
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${step >= 2 ? 'bg-green-600 text-white' : 'bg-gray-300 text-gray-600'}`}>
|
||
2
|
||
</div>
|
||
</div>
|
||
<div className="flex justify-between mt-2 text-sm text-gray-600">
|
||
<span>Tournament Details</span>
|
||
<span>Participants</span>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="space-y-6">
|
||
{error && (
|
||
<div className="rounded-md bg-red-50 p-4">
|
||
<div className="text-sm text-red-700">{error}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 1: Tournament Details */}
|
||
{step === 1 && (
|
||
<>
|
||
<div>
|
||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||
Tournament Name *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="name"
|
||
id="name"
|
||
required
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={formData.name}
|
||
onChange={handleChange}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||
Description
|
||
</label>
|
||
<textarea
|
||
name="description"
|
||
id="description"
|
||
rows={3}
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={formData.description}
|
||
onChange={handleChange}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||
<div>
|
||
<label htmlFor="eventDate" className="block text-sm font-medium text-gray-700">
|
||
Event Date
|
||
</label>
|
||
<input
|
||
type="date"
|
||
name="eventDate"
|
||
id="eventDate"
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={formData.eventDate}
|
||
onChange={handleChange}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="format" className="block text-sm font-medium text-gray-700">
|
||
Format *
|
||
</label>
|
||
<select
|
||
name="format"
|
||
id="format"
|
||
required
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={formData.format}
|
||
onChange={handleChange}
|
||
>
|
||
<option value="round_robin">Round Robin</option>
|
||
<option value="single_elimination">Single Elimination</option>
|
||
<option value="double_elimination">Double Elimination</option>
|
||
<option value="swiss">Swiss</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label htmlFor="tournamentType" className="block text-sm font-medium text-gray-700">
|
||
Tournament Type *
|
||
</label>
|
||
<select
|
||
name="tournamentType"
|
||
id="tournamentType"
|
||
required
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={formData.tournamentType}
|
||
onChange={handleChange}
|
||
>
|
||
<option value="individual">Individual (players compete as individuals)</option>
|
||
<option value="team">Team (players compete in pairs/teams)</option>
|
||
</select>
|
||
<p className="mt-1 text-sm text-gray-500">
|
||
{formData.tournamentType === 'individual'
|
||
? 'Players register individually and compete on their own.'
|
||
: 'Players are paired into teams of two for competition.'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Team Configuration - shown for round_robin format */}
|
||
{formData.format === 'round_robin' && (
|
||
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
|
||
<h3 className="text-sm font-medium text-gray-700">Team Configuration</h3>
|
||
|
||
{/* Team Durability */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-600 mb-2">
|
||
Team Formation Strategy
|
||
</label>
|
||
<div className="flex gap-4 flex-wrap">
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="teamDurability"
|
||
value="permanent"
|
||
checked={formData.teamDurability === 'permanent'}
|
||
onChange={handleChange}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm">Fixed Teams</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="teamDurability"
|
||
value="variable"
|
||
checked={formData.teamDurability === 'variable'}
|
||
onChange={handleChange}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm">Pre-Planned Variable</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="teamDurability"
|
||
value="per_round"
|
||
checked={formData.teamDurability === 'per_round'}
|
||
onChange={handleChange}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm">Dynamic/Progressive</span>
|
||
</label>
|
||
</div>
|
||
<p className="text-xs text-gray-500 mt-1">
|
||
{formData.teamDurability === 'permanent'
|
||
? 'Teams formed once and stay fixed throughout the tournament.'
|
||
: formData.teamDurability === 'variable'
|
||
? 'Fresh teams each round with partner rotation. Schedule is pre-planned.'
|
||
: 'Teams formed based on results. Schedule progresses as rounds complete.'}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Partner Rotation - only for variable/per_round teams */}
|
||
{(formData.teamDurability === 'variable' || formData.teamDurability === 'per_round') && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-600 mb-2">
|
||
Partner Rotation Strategy
|
||
</label>
|
||
<div className="flex gap-4 flex-wrap">
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="partnerRotation"
|
||
value="none"
|
||
checked={formData.partnerRotation === 'none'}
|
||
onChange={handleChange}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm">None (Random)</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="partnerRotation"
|
||
value="minimize_repeat"
|
||
checked={formData.partnerRotation === 'minimize_repeat'}
|
||
onChange={handleChange}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm">Minimize Repeat</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="partnerRotation"
|
||
value="maximize_even"
|
||
checked={formData.partnerRotation === 'maximize_even'}
|
||
onChange={handleChange}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm">Maximize Even</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="partnerRotation"
|
||
value="elo_based"
|
||
checked={formData.partnerRotation === 'elo_based'}
|
||
onChange={handleChange}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm">ELO-Based</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Allow Byes */}
|
||
<div>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
name="allowByes"
|
||
checked={formData.allowByes}
|
||
onChange={(e) => setFormData(prev => ({ ...prev, allowByes: e.target.checked }))}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm font-medium text-gray-600">Allow Byes (for odd number of participants)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Step 2: Participants */}
|
||
{step === 2 && (
|
||
<>
|
||
{/* Round Preview */}
|
||
{scheduleInfo && (
|
||
<div className="bg-green-50 border border-green-200 rounded-md p-4">
|
||
<h3 className="text-sm font-medium text-green-800 mb-2">Tournament Preview</h3>
|
||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||
<div>
|
||
<span className="text-green-600 font-medium">{scheduleInfo.teams}</span>
|
||
<span className="text-green-700"> teams</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-green-600 font-medium">{scheduleInfo.rounds}</span>
|
||
<span className="text-green-700"> rounds</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-green-600 font-medium">{scheduleInfo.matchups}</span>
|
||
<span className="text-green-700"> matchups</span>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-green-600 mt-2">
|
||
{formData.tournamentType === 'team'
|
||
? 'Players will be paired into teams below.'
|
||
: 'Players will be paired into teams of 2 for each round.'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Minimum Players Warning */}
|
||
{selectedPlayers.length > 0 && selectedPlayers.length < getMinPlayers() && (
|
||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
|
||
<p className="text-sm text-yellow-700">
|
||
Need at least {getMinPlayers()} players ({getMinPlayers() / 2} teams).
|
||
Currently {selectedPlayers.length} player{selectedPlayers.length !== 1 ? 's' : ''}.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Search Players
|
||
</label>
|
||
<div className="relative">
|
||
<input
|
||
type="text"
|
||
placeholder="Type a name to search..."
|
||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-green-500 focus:border-green-500 sm:text-sm"
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
/>
|
||
{isSearching && (
|
||
<div className="absolute right-3 top-3">
|
||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-green-600"></div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{searchResults.length > 0 && (
|
||
<div className="mt-2 border border-gray-300 rounded-md shadow-sm max-h-48 overflow-y-auto">
|
||
{searchResults.map(player => (
|
||
<div
|
||
key={player.id}
|
||
className="px-3 py-2 hover:bg-gray-100 cursor-pointer flex justify-between items-center"
|
||
onClick={() => addPlayer(player)}
|
||
>
|
||
<span>{player.name}</span>
|
||
<span className="text-sm text-gray-500">Elo: {player.currentElo}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Show create player option when search has query but no results */}
|
||
{searchQuery.length >= 2 && searchResults.length === 0 && !showCreatePlayer && (
|
||
<div className="mt-2 border border-gray-300 rounded-md shadow-sm">
|
||
<div
|
||
className="px-3 py-2 hover:bg-gray-100 cursor-pointer text-green-600"
|
||
onClick={() => setShowCreatePlayer(true)}
|
||
>
|
||
+ Create "{searchQuery}" as new player
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Inline player creation form */}
|
||
{showCreatePlayer && (
|
||
<div className="mt-2 border border-gray-300 rounded-md shadow-sm p-3 bg-gray-50">
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={newPlayerName}
|
||
onChange={(e) => setNewPlayerName(e.target.value)}
|
||
placeholder="Enter player name"
|
||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||
autoFocus
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={createNewPlayer}
|
||
disabled={isCreatingPlayer || !newPlayerName.trim()}
|
||
className="px-4 py-2 bg-green-600 text-white rounded-md text-sm hover:bg-green-700 disabled:opacity-50"
|
||
>
|
||
{isCreatingPlayer ? "Creating..." : "Add"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setShowCreatePlayer(false)
|
||
setNewPlayerName("")
|
||
}}
|
||
className="px-3 py-2 bg-gray-300 text-gray-700 rounded-md text-sm hover:bg-gray-400"
|
||
>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Team Pairing Options (only for team tournaments) */}
|
||
{formData.tournamentType === 'team' && selectedPlayers.length >= 4 && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Team Pairing Method
|
||
</label>
|
||
<div className="flex gap-4">
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="pairingMethod"
|
||
value="elo"
|
||
checked={pairingMethod === 'elo'}
|
||
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm">By ELO (best + worst)</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="pairingMethod"
|
||
value="manual"
|
||
checked={pairingMethod === 'manual'}
|
||
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm">Manual order</span>
|
||
</label>
|
||
<label className="flex items-center">
|
||
<input
|
||
type="radio"
|
||
name="pairingMethod"
|
||
value="random"
|
||
checked={pairingMethod === 'random'}
|
||
onChange={(e) => setPairingMethod(e.target.value as PairingMethod)}
|
||
className="mr-2"
|
||
/>
|
||
<span className="text-sm">Random</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Selected Players */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
{formData.tournamentType === 'team'
|
||
? `Selected Players (${selectedPlayers.length})`
|
||
: `Selected Participants (${selectedPlayers.length})`}
|
||
</label>
|
||
{selectedPlayers.length > 0 ? (
|
||
<div className="border border-gray-300 rounded-md overflow-hidden">
|
||
{/* Column Headers */}
|
||
<div className="grid grid-cols-12 bg-gray-100 border-b border-gray-300">
|
||
<div
|
||
className="col-span-6 px-3 py-2 text-xs font-medium text-gray-600 cursor-pointer hover:bg-gray-200 flex items-center"
|
||
onClick={() => handleSort('name')}
|
||
>
|
||
Name
|
||
{sortConfig.key === 'name' && (
|
||
<span className="ml-1">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
||
)}
|
||
</div>
|
||
<div
|
||
className="col-span-4 px-3 py-2 text-xs font-medium text-gray-600 cursor-pointer hover:bg-gray-200 flex items-center"
|
||
onClick={() => handleSort('currentElo')}
|
||
>
|
||
ELO
|
||
{sortConfig.key === 'currentElo' && (
|
||
<span className="ml-1">{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
|
||
)}
|
||
</div>
|
||
<div className="col-span-2 px-3 py-2 text-xs font-medium text-gray-600">
|
||
Actions
|
||
</div>
|
||
</div>
|
||
{/* Player Rows */}
|
||
<div className="max-h-48 overflow-y-auto">
|
||
{getSortedPlayers().map(player => (
|
||
<div
|
||
key={player.id}
|
||
className="grid grid-cols-12 bg-green-50 border-b border-green-100 last:border-b-0"
|
||
>
|
||
<div className="col-span-6 px-3 py-2 text-sm truncate">
|
||
{player.name}
|
||
</div>
|
||
<div className="col-span-4 px-3 py-2 text-sm">
|
||
{player.currentElo}
|
||
</div>
|
||
<div className="col-span-2 px-3 py-2 flex justify-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => removePlayer(player.id)}
|
||
className="text-red-600 hover:text-red-800"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-gray-500 text-sm">No participants added yet</p>
|
||
)}
|
||
</div>
|
||
|
||
{/* Team Preview (for team tournaments) */}
|
||
{formData.tournamentType === 'team' && selectedPlayers.length >= 4 && (
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
Team Pairings Preview ({getTeamPairings().length} teams)
|
||
</label>
|
||
<div className="border border-gray-300 rounded-md overflow-hidden">
|
||
<div className="grid grid-cols-12 bg-gray-100 border-b border-gray-300">
|
||
<div className="col-span-2 px-3 py-2 text-xs font-medium text-gray-600">Team</div>
|
||
<div className="col-span-5 px-3 py-2 text-xs font-medium text-gray-600">Player 1</div>
|
||
<div className="col-span-5 px-3 py-2 text-xs font-medium text-gray-600">Player 2</div>
|
||
</div>
|
||
<div className="max-h-48 overflow-y-auto">
|
||
{getTeamPairings().map((team, index) => (
|
||
<div key={index} className="grid grid-cols-12 bg-blue-50 border-b border-blue-100 last:border-b-0">
|
||
<div className="col-span-2 px-3 py-2 text-sm font-medium text-blue-700">
|
||
Team {index + 1}
|
||
</div>
|
||
<div className="col-span-5 px-3 py-2 text-sm">
|
||
{team.player1.name} <span className="text-gray-400">({team.player1.currentElo})</span>
|
||
</div>
|
||
<div className="col-span-5 px-3 py-2 text-sm">
|
||
{team.player2.name} <span className="text-gray-400">({team.player2.currentElo})</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* Navigation Buttons */}
|
||
<div className="flex justify-between pt-4">
|
||
<div>
|
||
{step > 1 && (
|
||
<button
|
||
type="button"
|
||
onClick={prevStep}
|
||
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"
|
||
>
|
||
← Back
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="space-x-3">
|
||
{step < 2 ? (
|
||
<button
|
||
type="button"
|
||
onClick={nextStep}
|
||
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"
|
||
>
|
||
Next →
|
||
</button>
|
||
) : (
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={() => router.back()}
|
||
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
|
||
type="submit"
|
||
disabled={isLoading || selectedPlayers.length < getMinPlayers()}
|
||
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 ? "Creating..." : "Create Tournament & Generate Schedule"}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|