nextjs-rewrite #5
Generated
+7
@@ -14,6 +14,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
@@ -5731,6 +5732,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/papaparse": {
|
||||||
|
"version": "5.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
||||||
|
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"next": "16.2.1",
|
"next": "16.2.1",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
|
"papaparse": "^5.5.3",
|
||||||
"prisma": "^6.19.2",
|
"prisma": "^6.19.2",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import Navigation from "@/components/Navigation"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
|
||||||
|
export default function NewTournamentPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
eventDate: "",
|
||||||
|
format: "round_robin",
|
||||||
|
maxParticipants: "",
|
||||||
|
})
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
|
setFormData({
|
||||||
|
...formData,
|
||||||
|
[e.target.name]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = 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,
|
||||||
|
maxParticipants: formData.maxParticipants ? parseInt(formData.maxParticipants) : null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || "Failed to create tournament")
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/admin/tournaments/${data.tournament.id}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navigation />
|
||||||
|
|
||||||
|
<main className="max-w-3xl 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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="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>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import Navigation from "@/components/Navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
import { redirect } from "next/navigation"
|
||||||
|
|
||||||
|
export default async function AdminTournamentsPage() {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session || session.user.role !== "club_admin") {
|
||||||
|
redirect("/auth/login")
|
||||||
|
}
|
||||||
|
|
||||||
|
const tournaments = await prisma.event.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
participants: true,
|
||||||
|
teams: {
|
||||||
|
include: {
|
||||||
|
player1: true,
|
||||||
|
player2: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navigation />
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div className="px-4 py-6 sm:px-0">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
|
Tournament Management
|
||||||
|
</h1>
|
||||||
|
<Link
|
||||||
|
href="/admin/tournaments/new"
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
Create Tournament
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Tournament
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Format
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Participants
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{tournaments.map((tournament) => (
|
||||||
|
<tr key={tournament.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<Link
|
||||||
|
href={`/admin/tournaments/${tournament.id}`}
|
||||||
|
className="text-green-600 hover:text-green-900 font-medium"
|
||||||
|
>
|
||||||
|
{tournament.name}
|
||||||
|
</Link>
|
||||||
|
{tournament.eventDate && (
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{new Date(tournament.eventDate).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{tournament.format}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold leading-5 rounded-full ${
|
||||||
|
tournament.status === 'active' || tournament.status === 'in_progress'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: tournament.status === 'completed'
|
||||||
|
? 'bg-gray-100 text-gray-800'
|
||||||
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
|
}`}>
|
||||||
|
{tournament.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{tournament.participants.length}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<Link
|
||||||
|
href={`/admin/tournaments/${tournament.id}`}
|
||||||
|
className="text-green-600 hover:text-green-900 mr-3"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={`/admin/tournaments/${tournament.id}/edit`}
|
||||||
|
className="text-blue-600 hover:text-blue-900"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{tournaments.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-gray-500">No tournaments found.</p>
|
||||||
|
<Link
|
||||||
|
href="/admin/tournaments/new"
|
||||||
|
className="text-green-600 hover:text-green-900 mt-2 inline-block"
|
||||||
|
>
|
||||||
|
Create your first tournament
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
eventId,
|
||||||
|
team1P1Id,
|
||||||
|
team1P2Id,
|
||||||
|
team2P1Id,
|
||||||
|
team2P2Id,
|
||||||
|
team1Score,
|
||||||
|
team2Score,
|
||||||
|
playedAt,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (
|
||||||
|
!team1P1Id ||
|
||||||
|
!team1P2Id ||
|
||||||
|
!team2P1Id ||
|
||||||
|
!team2P2Id ||
|
||||||
|
team1Score === undefined ||
|
||||||
|
team2Score === undefined
|
||||||
|
) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing required fields" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the match
|
||||||
|
const match = await prisma.match.create({
|
||||||
|
data: {
|
||||||
|
eventId: eventId || null,
|
||||||
|
team1P1Id,
|
||||||
|
team1P2Id,
|
||||||
|
team2P1Id,
|
||||||
|
team2P2Id,
|
||||||
|
team1Score,
|
||||||
|
team2Score,
|
||||||
|
playedAt: playedAt ? new Date(playedAt) : new Date(),
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate Elo changes for all players
|
||||||
|
const players = [
|
||||||
|
{ id: team1P1Id },
|
||||||
|
{ id: team1P2Id },
|
||||||
|
{ id: team2P1Id },
|
||||||
|
{ id: team2P2Id },
|
||||||
|
]
|
||||||
|
|
||||||
|
const team1Won = team1Score > team2Score
|
||||||
|
const K = 32 // K-factor for Elo calculation
|
||||||
|
|
||||||
|
for (const player of players) {
|
||||||
|
const playerData = await prisma.player.findUnique({
|
||||||
|
where: { id: player.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!playerData) continue
|
||||||
|
|
||||||
|
const isTeam1 = player.id === team1P1Id || player.id === team1P2Id
|
||||||
|
const expectedScore = 1 / (1 + Math.pow(10, (1200 - playerData.currentElo) / 400))
|
||||||
|
const actualScore = isTeam1 ? (team1Won ? 1 : 0) : (team1Won ? 0 : 1)
|
||||||
|
|
||||||
|
const ratingChange = Math.round(K * (actualScore - expectedScore))
|
||||||
|
const newRating = playerData.currentElo + ratingChange
|
||||||
|
|
||||||
|
// Update player's current Elo
|
||||||
|
await prisma.player.update({
|
||||||
|
where: { id: player.id },
|
||||||
|
data: { currentElo: newRating },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create Elo snapshot
|
||||||
|
await prisma.eloSnapshot.create({
|
||||||
|
data: {
|
||||||
|
playerId: player.id,
|
||||||
|
matchId: match.id,
|
||||||
|
ratingBefore: playerData.currentElo,
|
||||||
|
ratingAfter: newRating,
|
||||||
|
ratingChange,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track partnership games
|
||||||
|
await prisma.partnershipGame.create({
|
||||||
|
data: {
|
||||||
|
player1Id: team1P1Id,
|
||||||
|
player2Id: team1P2Id,
|
||||||
|
matchId: match.id,
|
||||||
|
teamNumber: 1,
|
||||||
|
wonMatch: team1Won ? 1 : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.partnershipGame.create({
|
||||||
|
data: {
|
||||||
|
player1Id: team2P1Id,
|
||||||
|
player2Id: team2P2Id,
|
||||||
|
matchId: match.id,
|
||||||
|
teamNumber: 2,
|
||||||
|
wonMatch: team1Won ? 0 : 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update partnership statistics
|
||||||
|
const partnerships = [
|
||||||
|
[team1P1Id, team1P2Id],
|
||||||
|
[team2P1Id, team2P2Id],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [p1Id, p2Id] of partnerships) {
|
||||||
|
// Ensure p1Id < p2Id for consistent lookup
|
||||||
|
const [player1Id, player2Id] = p1Id < p2Id ? [p1Id, p2Id] : [p2Id, p1Id]
|
||||||
|
|
||||||
|
const existingStat = await prisma.partnershipStat.findFirst({
|
||||||
|
where: {
|
||||||
|
player1Id,
|
||||||
|
player2Id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingStat) {
|
||||||
|
const won = (player1Id === team1P1Id && player2Id === team1P2Id && team1Won) ||
|
||||||
|
(player1Id === team2P1Id && player2Id === team2P2Id && !team1Won)
|
||||||
|
|
||||||
|
await prisma.partnershipStat.update({
|
||||||
|
where: { id: existingStat.id },
|
||||||
|
data: {
|
||||||
|
gamesPlayed: { increment: 1 },
|
||||||
|
wins: { increment: won ? 1 : 0 },
|
||||||
|
losses: { increment: won ? 0 : 1 },
|
||||||
|
totalEloChange: { increment: 0 }, // Would need to calculate per player
|
||||||
|
lastPlayed: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const won = (player1Id === team1P1Id && player2Id === team1P2Id && team1Won) ||
|
||||||
|
(player1Id === team2P1Id && player2Id === team2P2Id && !team1Won)
|
||||||
|
|
||||||
|
await prisma.partnershipStat.create({
|
||||||
|
data: {
|
||||||
|
player1Id,
|
||||||
|
player2Id,
|
||||||
|
gamesPlayed: 1,
|
||||||
|
wins: won ? 1 : 0,
|
||||||
|
losses: won ? 0 : 1,
|
||||||
|
totalEloChange: 0,
|
||||||
|
lastPlayed: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, match })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating match:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create match" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const playerId = searchParams.get("playerId")
|
||||||
|
|
||||||
|
let matches
|
||||||
|
|
||||||
|
if (playerId) {
|
||||||
|
const id = parseInt(playerId)
|
||||||
|
matches = await prisma.match.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ team1P1Id: id },
|
||||||
|
{ team1P2Id: id },
|
||||||
|
{ team2P1Id: id },
|
||||||
|
{ team2P2Id: id },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
event: true,
|
||||||
|
team1P1: true,
|
||||||
|
team1P2: true,
|
||||||
|
team2P1: true,
|
||||||
|
team2P2: true,
|
||||||
|
},
|
||||||
|
orderBy: { playedAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
matches = await prisma.match.findMany({
|
||||||
|
include: {
|
||||||
|
event: true,
|
||||||
|
team1P1: true,
|
||||||
|
team1P2: true,
|
||||||
|
team2P1: true,
|
||||||
|
team2P2: true,
|
||||||
|
},
|
||||||
|
orderBy: { playedAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ matches })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching matches:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch matches" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const Papa = require("papaparse")
|
||||||
|
|
||||||
|
// Map table names to numeric IDs
|
||||||
|
const TABLE_NAME_MAP: Record<string, number> = {
|
||||||
|
"Clubs": 1,
|
||||||
|
"Hearts": 2,
|
||||||
|
"Diamonds": 3,
|
||||||
|
"Spades": 4,
|
||||||
|
"Stars": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = await request.formData()
|
||||||
|
const file = formData.get("csvFile") as File
|
||||||
|
const eventId = formData.get("eventId") as string
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "No file uploaded" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
const fileText = fileBuffer.toString()
|
||||||
|
|
||||||
|
const parseResult = Papa.parse(fileText, {
|
||||||
|
header: true,
|
||||||
|
skipEmptyLines: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (parseResult.errors.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "CSV parsing error", details: parseResult.errors },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = parseResult.data as Record<string, string>[]
|
||||||
|
|
||||||
|
let importedCount = 0
|
||||||
|
let errorCount = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
for (const row of results) {
|
||||||
|
try {
|
||||||
|
// Extract and validate data
|
||||||
|
const roundNumber = parseInt(row.Round)
|
||||||
|
const tableName = row.Table
|
||||||
|
const seat1Name = row["Seat 1"]?.trim()
|
||||||
|
const seat3Name = row["Seat 3"]?.trim()
|
||||||
|
const oddsPoints = parseInt(row["Odds Points"])
|
||||||
|
const seat2Name = row["Seat 2"]?.trim()
|
||||||
|
const seat4Name = row["Seat 4"]?.trim()
|
||||||
|
const evensPoints = parseInt(row["Evens Points"])
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!seat1Name || !seat3Name || !seat2Name || !seat4Name) {
|
||||||
|
errors.push(`Row ${importedCount + errorCount + 2}: Missing player names`)
|
||||||
|
errorCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create players
|
||||||
|
const players = await Promise.all(
|
||||||
|
[seat1Name, seat3Name, seat2Name, seat4Name].map(async (name) => {
|
||||||
|
let player = await prisma.player.findUnique({ where: { name } })
|
||||||
|
if (!player) {
|
||||||
|
player = await prisma.player.create({ data: { name } })
|
||||||
|
}
|
||||||
|
return player
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const [player1, player2, player3, player4] = players
|
||||||
|
|
||||||
|
// Map table name to number
|
||||||
|
const tableNumber = TABLE_NAME_MAP[tableName] || 0
|
||||||
|
|
||||||
|
// Find or create round
|
||||||
|
let round = await prisma.tournamentRound.findFirst({
|
||||||
|
where: { eventId: parseInt(eventId), roundNumber },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!round) {
|
||||||
|
round = await prisma.tournamentRound.create({
|
||||||
|
data: {
|
||||||
|
eventId: parseInt(eventId),
|
||||||
|
roundNumber,
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine winner
|
||||||
|
const team1Won = oddsPoints > evensPoints
|
||||||
|
|
||||||
|
// Create match
|
||||||
|
const match = await prisma.match.create({
|
||||||
|
data: {
|
||||||
|
eventId: parseInt(eventId),
|
||||||
|
team1P1Id: player1.id,
|
||||||
|
team1P2Id: player2.id,
|
||||||
|
team2P1Id: player3.id,
|
||||||
|
team2P2Id: player4.id,
|
||||||
|
team1Score: oddsPoints,
|
||||||
|
team2Score: evensPoints,
|
||||||
|
playedAt: new Date(),
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Calculate Elo for all players
|
||||||
|
const playersForElo = [
|
||||||
|
{ id: player1.id, isTeam1: true },
|
||||||
|
{ id: player2.id, isTeam1: true },
|
||||||
|
{ id: player3.id, isTeam1: false },
|
||||||
|
{ id: player4.id, isTeam1: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
const K = 32
|
||||||
|
|
||||||
|
for (const p of playersForElo) {
|
||||||
|
const playerData = await prisma.player.findUnique({
|
||||||
|
where: { id: p.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!playerData) continue
|
||||||
|
|
||||||
|
const expectedScore = 1 / (1 + Math.pow(10, (1200 - playerData.currentElo) / 400))
|
||||||
|
const actualScore = p.isTeam1 ? (team1Won ? 1 : 0) : (team1Won ? 0 : 1)
|
||||||
|
|
||||||
|
const ratingChange = Math.round(K * (actualScore - expectedScore))
|
||||||
|
const newRating = playerData.currentElo + ratingChange
|
||||||
|
|
||||||
|
await prisma.player.update({
|
||||||
|
where: { id: p.id },
|
||||||
|
data: { currentElo: newRating },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.eloSnapshot.create({
|
||||||
|
data: {
|
||||||
|
playerId: p.id,
|
||||||
|
matchId: match.id,
|
||||||
|
ratingBefore: playerData.currentElo,
|
||||||
|
ratingAfter: newRating,
|
||||||
|
ratingChange,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track partnership games
|
||||||
|
await prisma.partnershipGame.create({
|
||||||
|
data: {
|
||||||
|
player1Id: player1.id,
|
||||||
|
player2Id: player2.id,
|
||||||
|
matchId: match.id,
|
||||||
|
teamNumber: 1,
|
||||||
|
wonMatch: team1Won ? 1 : 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prisma.partnershipGame.create({
|
||||||
|
data: {
|
||||||
|
player1Id: player3.id,
|
||||||
|
player2Id: player4.id,
|
||||||
|
matchId: match.id,
|
||||||
|
teamNumber: 2,
|
||||||
|
wonMatch: team1Won ? 0 : 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update partnership statistics
|
||||||
|
const partnerships = [
|
||||||
|
[player1.id, player2.id],
|
||||||
|
[player3.id, player4.id],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [p1Id, p2Id] of partnerships) {
|
||||||
|
const [player1Id, player2Id] = p1Id < p2Id ? [p1Id, p2Id] : [p2Id, p1Id]
|
||||||
|
|
||||||
|
const existingStat = await prisma.partnershipStat.findFirst({
|
||||||
|
where: { player1Id, player2Id },
|
||||||
|
})
|
||||||
|
|
||||||
|
const won =
|
||||||
|
(player1Id === player1.id && player2Id === player2.id && team1Won) ||
|
||||||
|
(player1Id === player3.id && player2Id === player4.id && !team1Won)
|
||||||
|
|
||||||
|
if (existingStat) {
|
||||||
|
await prisma.partnershipStat.update({
|
||||||
|
where: { id: existingStat.id },
|
||||||
|
data: {
|
||||||
|
gamesPlayed: { increment: 1 },
|
||||||
|
wins: { increment: won ? 1 : 0 },
|
||||||
|
losses: { increment: won ? 0 : 1 },
|
||||||
|
lastPlayed: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await prisma.partnershipStat.create({
|
||||||
|
data: {
|
||||||
|
player1Id,
|
||||||
|
player2Id,
|
||||||
|
gamesPlayed: 1,
|
||||||
|
wins: won ? 1 : 0,
|
||||||
|
losses: won ? 0 : 1,
|
||||||
|
totalEloChange: 0,
|
||||||
|
lastPlayed: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importedCount++
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`Row ${importedCount + errorCount + 2}: ${err.message}`)
|
||||||
|
errorCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
importedCount,
|
||||||
|
errorCount,
|
||||||
|
errors: errorCount > 0 ? errors : undefined,
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error uploading CSV:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to process CSV file", details: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest, { params }: PageProps) {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tournamentId = parseInt(params.id)
|
||||||
|
|
||||||
|
const tournament = await prisma.event.findUnique({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
include: {
|
||||||
|
participants: {
|
||||||
|
include: {
|
||||||
|
player: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
teams: {
|
||||||
|
include: {
|
||||||
|
player1: true,
|
||||||
|
player2: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rounds: {
|
||||||
|
include: {
|
||||||
|
bracketMatchups: {
|
||||||
|
include: {
|
||||||
|
team1: {
|
||||||
|
include: {
|
||||||
|
player1: true,
|
||||||
|
player2: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
team2: {
|
||||||
|
include: {
|
||||||
|
player1: true,
|
||||||
|
player2: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
match: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!tournament) {
|
||||||
|
return NextResponse.json({ error: "Tournament not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ tournament })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching tournament:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch tournament" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: PageProps) {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tournamentId = parseInt(params.id)
|
||||||
|
const body = await request.json()
|
||||||
|
const { name, description, eventDate, format, status, maxParticipants } = body
|
||||||
|
|
||||||
|
const tournament = await prisma.event.update({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
data: {
|
||||||
|
name: name || undefined,
|
||||||
|
description: description || undefined,
|
||||||
|
eventDate: eventDate ? new Date(eventDate) : undefined,
|
||||||
|
format: format || undefined,
|
||||||
|
status: status || undefined,
|
||||||
|
maxParticipants: maxParticipants || undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, tournament })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating tournament:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to update tournament" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: PageProps) {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tournamentId = parseInt(params.id)
|
||||||
|
|
||||||
|
await prisma.event.delete({
|
||||||
|
where: { id: tournamentId },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting tournament:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to delete tournament" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import { auth } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const session = await auth()
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const { name, description, eventDate, format, maxParticipants } = body
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Tournament name is required" },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tournament = await prisma.event.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
description: description || "",
|
||||||
|
eventDate: eventDate ? new Date(eventDate) : null,
|
||||||
|
format: format || "round_robin",
|
||||||
|
status: "planned",
|
||||||
|
maxParticipants: maxParticipants || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, tournament })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating tournament:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create tournament" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const tournaments = await prisma.event.findMany({
|
||||||
|
include: {
|
||||||
|
participants: true,
|
||||||
|
teams: {
|
||||||
|
include: {
|
||||||
|
player1: true,
|
||||||
|
player2: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rounds: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ tournaments })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching tournaments:", error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch tournaments" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import Navigation from "@/components/Navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PlayerProfilePage({ params }: PageProps) {
|
||||||
|
const playerId = parseInt(params.id)
|
||||||
|
|
||||||
|
const player = await prisma.player.findUnique({
|
||||||
|
where: { id: playerId },
|
||||||
|
include: {
|
||||||
|
partnershipStats: {
|
||||||
|
include: {
|
||||||
|
player1: true,
|
||||||
|
player2: true,
|
||||||
|
},
|
||||||
|
orderBy: { gamesPlayed: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate overall statistics
|
||||||
|
const totalGames = player.partnershipStats.reduce(
|
||||||
|
(sum, stat) => sum + stat.gamesPlayed,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const totalWins = player.partnershipStats.reduce(
|
||||||
|
(sum, stat) => sum + stat.wins,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const winRate = totalGames > 0 ? ((totalWins / totalGames) * 100).toFixed(1) : "0.0"
|
||||||
|
|
||||||
|
// Get best partnership
|
||||||
|
const bestPartnership = player.partnershipStats.reduce((best, stat) => {
|
||||||
|
if (stat.gamesPlayed < 3) return best // Need at least 3 games for partnership to be meaningful
|
||||||
|
const currentRate = stat.wins / stat.gamesPlayed
|
||||||
|
const bestRate = best ? best.wins / best.gamesPlayed : 0
|
||||||
|
return currentRate > bestRate ? stat : best
|
||||||
|
}, null as typeof player.partnershipStats[0] | null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navigation />
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div className="px-4 py-6 sm:px-0">
|
||||||
|
{/* Player Header */}
|
||||||
|
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="h-20 w-20 bg-green-100 rounded-full flex items-center justify-center text-green-800 text-2xl font-bold">
|
||||||
|
{player.name.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="ml-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{player.name}</h1>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
Member since {new Date(player.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-sm text-gray-500">Current Elo</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{player.currentElo}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-sm text-gray-500">Total Games</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{totalGames}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-sm text-gray-500">Win Rate</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{winRate}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
||||||
|
<p className="text-sm text-gray-500">Best Partner</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">
|
||||||
|
{bestPartnership
|
||||||
|
? (bestPartnership.player1Id === player.id
|
||||||
|
? bestPartnership.player2.name
|
||||||
|
: bestPartnership.player1.name)
|
||||||
|
: "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Partnership Performance */}
|
||||||
|
<div className="bg-white shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
Partnership Performance
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{player.partnershipStats.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No partnership data available yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Partner
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Games
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Win Rate
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
ELO Change
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Last Played
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{player.partnershipStats.map((stat) => {
|
||||||
|
const partner =
|
||||||
|
stat.player1Id === player.id ? stat.player2 : stat.player1
|
||||||
|
const winRate = stat.gamesPlayed > 0
|
||||||
|
? ((stat.wins / stat.gamesPlayed) * 100).toFixed(1)
|
||||||
|
: "0.0"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={stat.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<Link
|
||||||
|
href={`/players/${partner.id}/profile`}
|
||||||
|
className="text-green-600 hover:text-green-900"
|
||||||
|
>
|
||||||
|
{partner.name}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{stat.gamesPlayed}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{winRate}%
|
||||||
|
</td>
|
||||||
|
<td className={`px-6 py-4 whitespace-nowrap text-sm ${
|
||||||
|
stat.totalEloChange >= 0 ? 'text-green-600' : 'text-red-600'
|
||||||
|
}`}>
|
||||||
|
{stat.totalEloChange >= 0 ? '+' : ''}{stat.totalEloChange}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{stat.lastPlayed
|
||||||
|
? new Date(stat.lastPlayed).toLocaleDateString()
|
||||||
|
: "N/A"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { prisma } from "@/lib/prisma"
|
||||||
|
import Navigation from "@/components/Navigation"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { notFound } from "next/navigation"
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function PlayerSchedulePage({ params }: PageProps) {
|
||||||
|
const playerId = parseInt(params.id)
|
||||||
|
|
||||||
|
const player = await prisma.player.findUnique({
|
||||||
|
where: { id: playerId },
|
||||||
|
include: {
|
||||||
|
// Get upcoming matches
|
||||||
|
matchesAsP1: {
|
||||||
|
where: { playedAt: { gte: new Date() } },
|
||||||
|
include: {
|
||||||
|
event: true,
|
||||||
|
team1P1: true,
|
||||||
|
team1P2: true,
|
||||||
|
team2P1: true,
|
||||||
|
team2P2: true,
|
||||||
|
},
|
||||||
|
orderBy: { playedAt: "asc" },
|
||||||
|
},
|
||||||
|
matchesAsP2: {
|
||||||
|
where: { playedAt: { gte: new Date() } },
|
||||||
|
include: {
|
||||||
|
event: true,
|
||||||
|
team1P1: true,
|
||||||
|
team1P2: true,
|
||||||
|
team2P1: true,
|
||||||
|
team2P2: true,
|
||||||
|
},
|
||||||
|
orderBy: { playedAt: "asc" },
|
||||||
|
},
|
||||||
|
matchesAsP3: {
|
||||||
|
where: { playedAt: { gte: new Date() } },
|
||||||
|
include: {
|
||||||
|
event: true,
|
||||||
|
team1P1: true,
|
||||||
|
team1P2: true,
|
||||||
|
team2P1: true,
|
||||||
|
team2P2: true,
|
||||||
|
},
|
||||||
|
orderBy: { playedAt: "asc" },
|
||||||
|
},
|
||||||
|
matchesAsP4: {
|
||||||
|
where: { playedAt: { gte: new Date() } },
|
||||||
|
include: {
|
||||||
|
event: true,
|
||||||
|
team1P1: true,
|
||||||
|
team1P2: true,
|
||||||
|
team2P1: true,
|
||||||
|
team2P2: true,
|
||||||
|
},
|
||||||
|
orderBy: { playedAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
notFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine all upcoming matches
|
||||||
|
const upcomingMatches = [
|
||||||
|
...player.matchesAsP1,
|
||||||
|
...player.matchesAsP2,
|
||||||
|
...player.matchesAsP3,
|
||||||
|
...player.matchesAsP4,
|
||||||
|
].sort((a, b) => new Date(a.playedAt!).getTime() - new Date(b.playedAt!).getTime())
|
||||||
|
|
||||||
|
// Get active tournaments (events with status 'active' or 'in_progress')
|
||||||
|
const activeTournaments = await prisma.event.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['active', 'in_progress', 'started'] },
|
||||||
|
participants: {
|
||||||
|
some: {
|
||||||
|
playerId: playerId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
participants: true,
|
||||||
|
teams: {
|
||||||
|
include: {
|
||||||
|
player1: true,
|
||||||
|
player2: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
<Navigation />
|
||||||
|
|
||||||
|
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||||
|
<div className="px-4 py-6 sm:px-0">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-6">
|
||||||
|
My Schedule
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Upcoming Matches */}
|
||||||
|
<div className="bg-white shadow rounded-lg p-6 mb-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
Upcoming Matches
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{upcomingMatches.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No upcoming matches scheduled.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{upcomingMatches.map((match) => {
|
||||||
|
const team1Players = [
|
||||||
|
match.team1P1.name,
|
||||||
|
match.team1P2.name,
|
||||||
|
].join(" + ")
|
||||||
|
const team2Players = [
|
||||||
|
match.team2P1.name,
|
||||||
|
match.team2P2.name,
|
||||||
|
].join(" + ")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={match.id}
|
||||||
|
className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{match.event?.name || "Tournament"} - {match.playedAt?.toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{team1Players} vs {team2Players}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{match.playedAt?.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Tournaments */}
|
||||||
|
<div className="bg-white shadow rounded-lg p-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
Active Tournaments
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{activeTournaments.length === 0 ? (
|
||||||
|
<p className="text-gray-500">No active tournaments.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{activeTournaments.map((tournament) => {
|
||||||
|
const playerTeam = tournament.teams.find(
|
||||||
|
(team) =>
|
||||||
|
team.player1Id === playerId || team.player2Id === playerId
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tournament.id}
|
||||||
|
className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<Link
|
||||||
|
href={`/tournaments/${tournament.id}`}
|
||||||
|
className="font-medium text-green-600 hover:text-green-900"
|
||||||
|
>
|
||||||
|
{tournament.name}
|
||||||
|
</Link>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{tournament.format} - {tournament.status}
|
||||||
|
</p>
|
||||||
|
{playerTeam && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Team: {playerTeam.player1.name} + {playerTeam.player2.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{new Date(tournament.eventDate || tournament.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
declare module "papaparse" {
|
||||||
|
export interface ParseResult<T> {
|
||||||
|
data: T[]
|
||||||
|
errors: Array<{
|
||||||
|
type: string
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
row?: number
|
||||||
|
}>
|
||||||
|
meta: {
|
||||||
|
delimiter: string
|
||||||
|
linebreak: string
|
||||||
|
aborted: boolean
|
||||||
|
fields?: string[]
|
||||||
|
truncated: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parse<T = any>(
|
||||||
|
input: string,
|
||||||
|
config?: {
|
||||||
|
header?: boolean
|
||||||
|
skipEmptyLines?: boolean
|
||||||
|
delimiter?: string
|
||||||
|
}
|
||||||
|
): ParseResult<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export {}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
"src/**/*.d.ts",
|
||||||
"**/*.ts",
|
"**/*.ts",
|
||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user