feat: add TeamsSection component for team configuration UI
Add interactive team configuration panel: - Team durability selection (permanent, variable, per_round) - Partner rotation strategy selection - Allow byes configuration - Generate Teams button with participant count - Delete All Teams functionality - Available participants display with ELO ratings Refs #22
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface Player {
|
||||
id: number
|
||||
name: string
|
||||
currentElo: number
|
||||
}
|
||||
|
||||
interface Team {
|
||||
id: number
|
||||
teamName: string | null
|
||||
player1: Player
|
||||
player2: Player
|
||||
}
|
||||
|
||||
interface TeamsSectionProps {
|
||||
tournamentId: number
|
||||
teams: Team[]
|
||||
participants: Player[]
|
||||
teamDurability: string
|
||||
partnerRotation: string
|
||||
allowByes: boolean
|
||||
}
|
||||
|
||||
type TeamDurabilityOption = 'permanent' | 'variable' | 'per_round'
|
||||
type PartnerRotationOption = 'none' | 'minimize_repeat' | 'maximize_even' | 'elo_based'
|
||||
|
||||
export default function TeamsSection({
|
||||
tournamentId,
|
||||
teams: initialTeams,
|
||||
participants,
|
||||
teamDurability: initialTeamDurability,
|
||||
partnerRotation: initialPartnerRotation,
|
||||
allowByes: initialAllowByes,
|
||||
}: TeamsSectionProps) {
|
||||
const router = useRouter()
|
||||
const [teams, setTeams] = useState<Team[]>(initialTeams)
|
||||
const [teamDurability, setTeamDurability] = useState<TeamDurabilityOption>(initialTeamDurability as TeamDurabilityOption)
|
||||
const [partnerRotation, setPartnerRotation] = useState<PartnerRotationOption>(initialPartnerRotation as PartnerRotationOption)
|
||||
const [allowByes, setAllowByes] = useState(initialAllowByes)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [success, setSuccess] = useState("")
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
setError("")
|
||||
setSuccess("")
|
||||
setIsGenerating(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tournaments/${tournamentId}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
teamDurability,
|
||||
partnerRotation,
|
||||
allowByes,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || "Failed to save configuration")
|
||||
} catch {
|
||||
setError(`Failed to save configuration: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setSuccess("Configuration saved successfully!")
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError("An unknown error occurred")
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateTeams = async () => {
|
||||
if (participants.length < 2) {
|
||||
setError("At least 2 participants are required to generate teams")
|
||||
return
|
||||
}
|
||||
|
||||
if (participants.length % 2 !== 0 && !allowByes) {
|
||||
setError("Odd number of participants. Enable 'Allow Byes' to proceed.")
|
||||
return
|
||||
}
|
||||
|
||||
setError("")
|
||||
setSuccess("")
|
||||
setIsGenerating(true)
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tournaments/${tournamentId}/teams/generate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
teamDurability,
|
||||
partnerRotation,
|
||||
allowByes,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || "Failed to generate teams")
|
||||
} catch {
|
||||
setError(`Failed to generate teams: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setTeams(data.teams)
|
||||
setSuccess(`Successfully generated ${data.teams.length} teams!`)
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError("An unknown error occurred")
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTeams = async () => {
|
||||
if (!confirm("Are you sure you want to delete all teams? This will also delete the schedule.")) {
|
||||
return
|
||||
}
|
||||
|
||||
setError("")
|
||||
setSuccess("")
|
||||
setIsGenerating(true)
|
||||
|
||||
try {
|
||||
// First delete schedule if exists
|
||||
await fetch(`/api/tournaments/${tournamentId}/schedule`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
|
||||
// Then delete teams
|
||||
const response = await fetch(`/api/tournaments/${tournamentId}/teams`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
setError(errorData.error || "Failed to delete teams")
|
||||
} catch {
|
||||
setError(`Failed to delete teams: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setTeams([])
|
||||
setSuccess("Teams deleted successfully!")
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message)
|
||||
} else {
|
||||
setError("An unknown error occurred")
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Teams ({teams.length})
|
||||
</h2>
|
||||
|
||||
{/* Configuration Panel */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Team Configuration</h3>
|
||||
|
||||
{/* Team Durability */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">
|
||||
Team Durability
|
||||
</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="teamDurability"
|
||||
value="permanent"
|
||||
checked={teamDurability === 'permanent'}
|
||||
onChange={(e) => setTeamDurability(e.target.value as TeamDurabilityOption)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">Permanent Teams</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="teamDurability"
|
||||
value="variable"
|
||||
checked={teamDurability === 'variable'}
|
||||
onChange={(e) => setTeamDurability(e.target.value as TeamDurabilityOption)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">Variable Teams</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="teamDurability"
|
||||
value="per_round"
|
||||
checked={teamDurability === 'per_round'}
|
||||
onChange={(e) => setTeamDurability(e.target.value as TeamDurabilityOption)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">Per-Round Teams</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{teamDurability === 'permanent'
|
||||
? 'Teams are fixed for the entire tournament. Each team plays together in all rounds.'
|
||||
: teamDurability === 'variable'
|
||||
? 'Teams are generated per round based on configuration. Partners rotate each round.'
|
||||
: 'Teams are created fresh for each round. No persistent teams.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Partner Rotation (for variable/per_round teams) */}
|
||||
{(teamDurability === 'variable' || teamDurability === 'per_round') && (
|
||||
<div className="mb-4">
|
||||
<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={partnerRotation === 'none'}
|
||||
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
|
||||
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={partnerRotation === 'minimize_repeat'}
|
||||
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">Minimize Repeat Partners</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="partnerRotation"
|
||||
value="maximize_even"
|
||||
checked={partnerRotation === 'maximize_even'}
|
||||
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">Maximize Even Matches</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="partnerRotation"
|
||||
value="elo_based"
|
||||
checked={partnerRotation === 'elo_based'}
|
||||
onChange={(e) => setPartnerRotation(e.target.value as PartnerRotationOption)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm">ELO-Based Pairing</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{partnerRotation === 'none'
|
||||
? 'Partners are randomly assigned each round.'
|
||||
: partnerRotation === 'minimize_repeat'
|
||||
? 'Algorithm minimizes how often players partner together.'
|
||||
: partnerRotation === 'maximize_even'
|
||||
? 'Algorithm pairs teams to maximize competitive balance.'
|
||||
: 'Strongest player paired with weakest player each round.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allow Byes (for odd participant counts) */}
|
||||
<div className="mb-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowByes}
|
||||
onChange={(e) => setAllowByes(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-600">Allow Byes (for odd number of participants)</span>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-6">
|
||||
When enabled, one player will have a bye each round if there's an odd number of participants.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3 mt-4">
|
||||
<button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={isGenerating}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{isGenerating ? "Saving..." : "Save Configuration"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error/Success Messages */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Team Generation Controls */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<button
|
||||
onClick={handleGenerateTeams}
|
||||
disabled={isGenerating || participants.length < 2}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{isGenerating ? "Generating..." : `Generate Teams (${participants.length} participants)`}
|
||||
</button>
|
||||
|
||||
{teams.length > 0 && (
|
||||
<button
|
||||
onClick={handleDeleteTeams}
|
||||
disabled={isGenerating}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
Delete All Teams
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Teams Display */}
|
||||
{teams.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{teams.map((team) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="bg-gray-50 rounded p-3 flex justify-between items-center"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">
|
||||
{team.player1.name} + {team.player2.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{team.teamName || `Team ${team.id}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-500">
|
||||
ELO: {team.player1.currentElo} + {team.player2.currentElo} = {team.player1.currentElo + team.player2.currentElo}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">No teams created yet. Configure options above and click Generate Teams.</p>
|
||||
)}
|
||||
|
||||
{/* Participants Summary */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||
Available Participants ({participants.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{participants.map((player) => (
|
||||
<div key={player.id} className="text-sm text-gray-600 bg-gray-100 rounded px-2 py-1">
|
||||
{player.name} ({player.currentElo})
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user