feat: add tournament creation wizard with participant management and bulk game entry
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add unique constraint to EventParticipant table
|
||||||
|
CREATE UNIQUE INDEX "event_participants_eventId_playerId_key" ON "event_participants"("eventId", "playerId");
|
||||||
Binary file not shown.
@@ -100,6 +100,7 @@ model EventParticipant {
|
|||||||
player Player @relation(fields: [playerId], references: [id])
|
player Player @relation(fields: [playerId], references: [id])
|
||||||
event Event @relation(fields: [eventId], references: [id])
|
event Event @relation(fields: [eventId], references: [id])
|
||||||
|
|
||||||
|
@@unique([eventId, playerId])
|
||||||
@@map("event_participants")
|
@@map("event_participants")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 1 1 John Smith Jane Doe 10 Mike Johnson Sarah Brown 5 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,83 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import Navigation from "@/components/Navigation"
|
import Navigation from "@/components/Navigation"
|
||||||
|
|
||||||
|
interface Player {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
currentElo: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TournamentFormData {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
eventDate: string
|
||||||
|
format: string
|
||||||
|
maxParticipants: string
|
||||||
|
participants: number[] // Array of player IDs
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewTournamentPage() {
|
export default function NewTournamentPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [formData, setFormData] = useState({
|
const [step, setStep] = useState(1)
|
||||||
|
const [formData, setFormData] = useState<TournamentFormData>({
|
||||||
name: "",
|
name: "",
|
||||||
description: "",
|
description: "",
|
||||||
eventDate: "",
|
eventDate: "",
|
||||||
format: "round_robin",
|
format: "round_robin",
|
||||||
maxParticipants: "",
|
maxParticipants: "",
|
||||||
|
participants: [],
|
||||||
})
|
})
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
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)
|
||||||
|
|
||||||
|
// 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)}`)
|
||||||
|
const data = await response.json()
|
||||||
|
if (response.ok) {
|
||||||
|
// Filter out already selected players
|
||||||
|
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([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePlayer = (playerId: number) => {
|
||||||
|
setSelectedPlayers(selectedPlayers.filter(p => p.id !== playerId))
|
||||||
|
}
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -23,13 +86,34 @@ export default function NewTournamentPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextStep = () => {
|
||||||
|
if (step === 1) {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
setError("Tournament name is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedPlayers.length < 2) {
|
||||||
|
setError("At least 2 participants are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setError("")
|
||||||
|
setStep(step + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevStep = () => {
|
||||||
|
setStep(step - 1)
|
||||||
|
setError("")
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError("")
|
setError("")
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/tournaments", {
|
// First create the tournament
|
||||||
|
const tournamentResponse = await fetch("/api/tournaments", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -43,13 +127,29 @@ export default function NewTournamentPage() {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
const tournamentData = await tournamentResponse.json()
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!tournamentResponse.ok) {
|
||||||
throw new Error(data.error || "Failed to create tournament")
|
throw new Error(tournamentData.error || "Failed to create tournament")
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push(`/admin/tournaments/${data.tournament.id}`)
|
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),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to game entry page
|
||||||
|
router.push(`/admin/tournaments/${tournamentId}/entry`)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -61,12 +161,29 @@ export default function NewTournamentPage() {
|
|||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
|
||||||
<main className="max-w-3xl mx-auto py-6 sm:px-6 lg:px-8">
|
<main className="max-w-4xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
<div className="px-4 py-6 sm:px-0">
|
<div className="px-4 py-6 sm:px-0">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">
|
||||||
Create New Tournament
|
Create New Tournament
|
||||||
</h1>
|
</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">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md bg-red-50 p-4">
|
<div className="rounded-md bg-red-50 p-4">
|
||||||
@@ -74,101 +191,198 @@ export default function NewTournamentPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div>
|
{/* Step 1: Tournament Details */}
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
{step === 1 && (
|
||||||
Tournament Name *
|
<>
|
||||||
</label>
|
<div>
|
||||||
<input
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
type="text"
|
Tournament Name *
|
||||||
name="name"
|
</label>
|
||||||
id="name"
|
<input
|
||||||
required
|
type="text"
|
||||||
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"
|
name="name"
|
||||||
value={formData.name}
|
id="name"
|
||||||
onChange={handleChange}
|
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"
|
||||||
</div>
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="description"
|
name="description"
|
||||||
id="description"
|
id="description"
|
||||||
rows={3}
|
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"
|
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}
|
value={formData.description}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
<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="maxParticipants" className="block text-sm font-medium text-gray-700">
|
||||||
|
Max Participants
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
name="maxParticipants"
|
||||||
|
id="maxParticipants"
|
||||||
|
min="2"
|
||||||
|
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.maxParticipants}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Participants */}
|
||||||
|
{step === 2 && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Add Participants
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search for players..."
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Selected Participants ({selectedPlayers.length})
|
||||||
|
</label>
|
||||||
|
{selectedPlayers.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
{selectedPlayers.map(player => (
|
||||||
|
<div
|
||||||
|
key={player.id}
|
||||||
|
className="bg-green-50 border border-green-200 rounded-md px-3 py-2 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<span className="text-sm">{player.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removePlayer(player.id)}
|
||||||
|
className="text-red-600 hover:text-red-800 ml-2"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-500 text-sm">No participants added yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Navigation Buttons */}
|
||||||
|
<div className="flex justify-between pt-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="eventDate" className="block text-sm font-medium text-gray-700">
|
{step > 1 && (
|
||||||
Event Date
|
<button
|
||||||
</label>
|
type="button"
|
||||||
<input
|
onClick={prevStep}
|
||||||
type="date"
|
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"
|
||||||
name="eventDate"
|
>
|
||||||
id="eventDate"
|
← Back
|
||||||
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"
|
</button>
|
||||||
value={formData.eventDate}
|
)}
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-x-3">
|
||||||
<div>
|
{step < 2 ? (
|
||||||
<label htmlFor="format" className="block text-sm font-medium text-gray-700">
|
<button
|
||||||
Format *
|
type="button"
|
||||||
</label>
|
onClick={nextStep}
|
||||||
<select
|
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"
|
||||||
name="format"
|
>
|
||||||
id="format"
|
Next →
|
||||||
required
|
</button>
|
||||||
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}
|
<button
|
||||||
>
|
type="button"
|
||||||
<option value="round_robin">Round Robin</option>
|
onClick={() => router.back()}
|
||||||
<option value="single_elimination">Single Elimination</option>
|
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"
|
||||||
<option value="double_elimination">Double Elimination</option>
|
>
|
||||||
<option value="swiss">Swiss</option>
|
Cancel
|
||||||
</select>
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{isLoading ? "Creating..." : "Create Tournament & Enter Games"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label htmlFor="maxParticipants" className="block text-sm font-medium text-gray-700">
|
|
||||||
Max Participants
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
name="maxParticipants"
|
|
||||||
id="maxParticipants"
|
|
||||||
min="2"
|
|
||||||
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.maxParticipants}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3">
|
|
||||||
<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}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{isLoading ? "Creating..." : "Create Tournament"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -153,6 +153,23 @@ export async function POST(request: Request) {
|
|||||||
await updatePartnershipStats(players[0].id, players[1].id, team1Won, player1EloChange);
|
await updatePartnershipStats(players[0].id, players[1].id, team1Won, player1EloChange);
|
||||||
await updatePartnershipStats(players[2].id, players[3].id, team2Won, player3EloChange);
|
await updatePartnershipStats(players[2].id, players[3].id, team2Won, player3EloChange);
|
||||||
|
|
||||||
|
// Add players as participants in the tournament (if not already added)
|
||||||
|
for (const player of players) {
|
||||||
|
try {
|
||||||
|
await prisma.eventParticipant.create({
|
||||||
|
data: {
|
||||||
|
eventId: parseInt(eventId),
|
||||||
|
playerId: player.id,
|
||||||
|
status: "participated",
|
||||||
|
registrationDate: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Participant already exists, which is fine
|
||||||
|
console.log(`Participant ${player.id} already exists in tournament ${eventId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
importedCount++;
|
importedCount++;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
errors.push(`Row ${index + 2}: ${err.message}`);
|
errors.push(`Row ${index + 2}: ${err.message}`);
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.nextUrl);
|
||||||
|
const query = searchParams.get("q");
|
||||||
|
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
return NextResponse.json({ players: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for players by name (case-insensitive, partial match)
|
||||||
|
const players = await prisma.player.findMany({
|
||||||
|
where: {
|
||||||
|
name: {
|
||||||
|
contains: query,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ players });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Player search error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to search players" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { canManageTournament } from "@/lib/permissions";
|
||||||
|
import { getSession } from "@/lib/auth-simple";
|
||||||
|
import { calculateTeamElo, calculateTeamEloChange } from "@/lib/elo-utils";
|
||||||
|
|
||||||
|
interface GameEntry {
|
||||||
|
round: number
|
||||||
|
table: string
|
||||||
|
player1: string
|
||||||
|
player2: string
|
||||||
|
score1: number
|
||||||
|
player3: string
|
||||||
|
player4: string
|
||||||
|
score2: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const K_FACTOR = 32;
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const tournamentId = parseInt(params.id);
|
||||||
|
const body = await request.json();
|
||||||
|
const { games } = body;
|
||||||
|
|
||||||
|
if (!games || !Array.isArray(games) || games.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "games array is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
const permission = await canManageTournament(tournamentId);
|
||||||
|
if (!permission.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: permission.reason || 'Insufficient permissions' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
const session = await getSession();
|
||||||
|
const userId = session?.user?.id || null;
|
||||||
|
|
||||||
|
let importedCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Process each game
|
||||||
|
for (const [index, game] of games.entries()) {
|
||||||
|
try {
|
||||||
|
// Find players by name (case-insensitive partial match)
|
||||||
|
const [player1, player2, player3, player4] = await Promise.all([
|
||||||
|
findPlayerByName(game.player1),
|
||||||
|
findPlayerByName(game.player2),
|
||||||
|
findPlayerByName(game.player3),
|
||||||
|
findPlayerByName(game.player4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check if all players were found
|
||||||
|
if (!player1 || !player2 || !player3 || !player4) {
|
||||||
|
const missing = [];
|
||||||
|
if (!player1) missing.push(game.player1);
|
||||||
|
if (!player2) missing.push(game.player2);
|
||||||
|
if (!player3) missing.push(game.player3);
|
||||||
|
if (!player4) missing.push(game.player4);
|
||||||
|
errors.push(`Game ${index + 1}: Player not found: ${missing.join(", ")}`);
|
||||||
|
errorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate Elo changes
|
||||||
|
const team1Rating = calculateTeamElo(player1.currentElo, player2.currentElo);
|
||||||
|
const team2Rating = calculateTeamElo(player3.currentElo, player4.currentElo);
|
||||||
|
|
||||||
|
const team1Won = game.score1 > game.score2;
|
||||||
|
const team2Won = game.score2 > game.score1;
|
||||||
|
const isTie = game.score1 === game.score2;
|
||||||
|
|
||||||
|
const team1ScoreActual = isTie ? 0.5 : (team1Won ? 1 : 0);
|
||||||
|
const team2ScoreActual = isTie ? 0.5 : (team2Won ? 1 : 0);
|
||||||
|
|
||||||
|
const team1EloChange = calculateTeamEloChange(team1Rating, team2Rating, team1ScoreActual, K_FACTOR);
|
||||||
|
const team2EloChange = calculateTeamEloChange(team2Rating, team1Rating, team2ScoreActual, K_FACTOR);
|
||||||
|
|
||||||
|
const player1EloChange = team1EloChange / 2;
|
||||||
|
const player2EloChange = team1EloChange / 2;
|
||||||
|
const player3EloChange = team2EloChange / 2;
|
||||||
|
const player4EloChange = team2EloChange / 2;
|
||||||
|
|
||||||
|
// Create match
|
||||||
|
await prisma.match.create({
|
||||||
|
data: {
|
||||||
|
eventId: tournamentId,
|
||||||
|
team1P1Id: player1.id,
|
||||||
|
team1P2Id: player2.id,
|
||||||
|
team2P1Id: player3.id,
|
||||||
|
team2P2Id: player4.id,
|
||||||
|
team1Score: game.score1,
|
||||||
|
team2Score: game.score2,
|
||||||
|
status: "completed",
|
||||||
|
playedAt: new Date(),
|
||||||
|
createdById: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update player stats
|
||||||
|
await updatePlayerStats(player1.id, team1Won, player1EloChange);
|
||||||
|
await updatePlayerStats(player2.id, team1Won, player2EloChange);
|
||||||
|
await updatePlayerStats(player3.id, team2Won, player3EloChange);
|
||||||
|
await updatePlayerStats(player4.id, team2Won, player4EloChange);
|
||||||
|
|
||||||
|
// Update partnership stats
|
||||||
|
await updatePartnershipStats(player1.id, player2.id, team1Won, player1EloChange);
|
||||||
|
await updatePartnershipStats(player3.id, player4.id, team2Won, player3EloChange);
|
||||||
|
|
||||||
|
// Add players as participants if not already added
|
||||||
|
for (const player of [player1, player2, player3, player4]) {
|
||||||
|
try {
|
||||||
|
// Check if participant already exists
|
||||||
|
const existing = await prisma.eventParticipant.findFirst({
|
||||||
|
where: {
|
||||||
|
eventId: tournamentId,
|
||||||
|
playerId: player.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.eventParticipant.create({
|
||||||
|
data: {
|
||||||
|
eventId: tournamentId,
|
||||||
|
playerId: player.id,
|
||||||
|
status: "participated",
|
||||||
|
registrationDate: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Participant already exists, which is fine
|
||||||
|
console.log(`Participant ${player.id} already exists in tournament ${tournamentId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importedCount++;
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`Game ${index + 1}: ${err.message}`);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
importedCount,
|
||||||
|
errorCount,
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Bulk game submission error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || "Failed to submit games" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findPlayerByName(name: string) {
|
||||||
|
// Try to find existing player with exact name match first (case-sensitive)
|
||||||
|
let player = await prisma.player.findFirst({
|
||||||
|
where: {
|
||||||
|
name: name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
// Try partial match (case-sensitive)
|
||||||
|
player = await prisma.player.findFirst({
|
||||||
|
where: {
|
||||||
|
name: {
|
||||||
|
contains: name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePlayerStats(playerId: number, won: boolean, eloChange: number) {
|
||||||
|
const player = await prisma.player.findUnique({
|
||||||
|
where: { id: playerId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
throw new Error(`Player ${playerId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
where: { id: playerId },
|
||||||
|
data: {
|
||||||
|
currentElo: player.currentElo + Math.round(eloChange),
|
||||||
|
gamesPlayed: player.gamesPlayed + 1,
|
||||||
|
wins: won ? player.wins + 1 : player.wins,
|
||||||
|
losses: won ? player.losses : player.losses + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePartnershipStats(player1Id: number, player2Id: number, won: boolean, eloChange: number) {
|
||||||
|
const [smallId, largeId] = player1Id < player2Id ? [player1Id, player2Id] : [player2Id, player1Id];
|
||||||
|
|
||||||
|
const existing = await prisma.partnershipStat.findFirst({
|
||||||
|
where: {
|
||||||
|
player1Id: smallId,
|
||||||
|
player2Id: largeId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.partnershipStat.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
gamesPlayed: { increment: 1 },
|
||||||
|
wins: won ? { increment: 1 } : undefined,
|
||||||
|
losses: won ? undefined : { increment: 1 },
|
||||||
|
totalEloChange: { increment: eloChange },
|
||||||
|
lastPlayed: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.partnershipStat.create({
|
||||||
|
data: {
|
||||||
|
player1Id: smallId,
|
||||||
|
player2Id: largeId,
|
||||||
|
gamesPlayed: 1,
|
||||||
|
wins: won ? 1 : 0,
|
||||||
|
losses: won ? 0 : 1,
|
||||||
|
totalEloChange: eloChange,
|
||||||
|
lastPlayed: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { canManageTournament } from "@/lib/permissions";
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const tournamentId = parseInt(params.id);
|
||||||
|
const body = await request.json();
|
||||||
|
const { playerIds } = body;
|
||||||
|
|
||||||
|
if (!playerIds || !Array.isArray(playerIds)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "playerIds array is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
const permission = await canManageTournament(tournamentId);
|
||||||
|
if (!permission.allowed) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: permission.reason || 'Insufficient permissions' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add participants to tournament
|
||||||
|
const participants = await Promise.all(
|
||||||
|
playerIds.map(async (playerId: number) => {
|
||||||
|
try {
|
||||||
|
// Check if participant already exists
|
||||||
|
const existing = await prisma.eventParticipant.findFirst({
|
||||||
|
where: {
|
||||||
|
eventId: tournamentId,
|
||||||
|
playerId: playerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new participant
|
||||||
|
return await prisma.eventParticipant.create({
|
||||||
|
data: {
|
||||||
|
eventId: tournamentId,
|
||||||
|
playerId: playerId,
|
||||||
|
status: "registered",
|
||||||
|
registrationDate: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to add participant ${playerId}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const successfulParticipants = participants.filter(p => p !== null);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
added: successfulParticipants.length,
|
||||||
|
participants: successfulParticipants,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add participants:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to add participants" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user