From ff78007f0c5b9ad9658aae87bc806dcde0d9dbd1 Mon Sep 17 00:00:00 2001 From: David Gwilliam Date: Fri, 27 Mar 2026 14:55:31 -0700 Subject: [PATCH] feat: add tournament detail page and CSV upload interface --- src/app/admin/matches/upload/page.tsx | 249 ++++++++++++++++++++ src/app/admin/tournaments/[id]/page.tsx | 290 ++++++++++++++++++++++++ 2 files changed, 539 insertions(+) create mode 100644 src/app/admin/matches/upload/page.tsx create mode 100644 src/app/admin/tournaments/[id]/page.tsx diff --git a/src/app/admin/matches/upload/page.tsx b/src/app/admin/matches/upload/page.tsx new file mode 100644 index 0000000..a54a545 --- /dev/null +++ b/src/app/admin/matches/upload/page.tsx @@ -0,0 +1,249 @@ +"use client" + +import { useState, useRef } from "react" +import { useRouter } from "next/navigation" +import Navigation from "@/components/Navigation" +import { auth } from "@/lib/auth" + +export default function UploadMatchesPage() { + const router = useRouter() + const [selectedFile, setSelectedFile] = useState(null) + const [selectedTournament, setSelectedTournament] = useState("") + const [error, setError] = useState("") + const [success, setSuccess] = useState("") + const [isLoading, setIsLoading] = useState(false) + const [tournaments, setTournaments] = useState([]) + const fileInputRef = useRef(null) + + // Fetch tournaments on mount + useState(() => { + fetch("/api/tournaments") + .then((res) => res.json()) + .then((data) => { + if (data.tournaments) { + setTournaments(data.tournaments) + } + }) + .catch((err) => console.error("Failed to fetch tournaments:", err)) + }) + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + if (!file.name.endsWith(".csv")) { + setError("Please upload a CSV file") + return + } + setSelectedFile(file) + setError("") + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + setSuccess("") + + if (!selectedFile) { + setError("Please select a CSV file to upload") + return + } + + if (!selectedTournament) { + setError("Please select a tournament") + return + } + + setIsLoading(true) + + try { + const formData = new FormData() + formData.append("csvFile", selectedFile) + formData.append("eventId", selectedTournament) + + const response = await fetch("/api/matches/upload", { + method: "POST", + body: formData, + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || "Failed to upload CSV") + } + + setSuccess( + `Successfully imported ${data.importedCount} matches. ` + + `${data.errorCount || 0} errors occurred.` + + (data.errors ? `\n\nErrors:\n${data.errors.join("\n")}` : "") + ) + + // Reset form + setSelectedFile(null) + setSelectedTournament("") + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } catch (err: any) { + setError(err.message) + } finally { + setIsLoading(false) + } + } + + return ( +
+ + +
+
+

+ Upload Match Results (CSV) +

+ +
+
+ {error && ( +
+
+ {error} +
+
+ )} + + {success && ( +
+
+ {success} +
+
+ )} + + {/* Tournament Selection */} +
+ + +
+ + {/* File Upload */} +
+ +
+
+ +
+ +

or drag and drop

+
+

+ CSV file with match results +

+ {selectedFile && ( +

+ Selected: {selectedFile.name} +

+ )} +
+
+
+ + {/* CSV Format Guide */} +
+

+ CSV Format Requirements +

+
    +
  • Event #: Tournament ID (optional if selected above)
  • +
  • Round: Round number (1, 2, 3...)
  • +
  • Table: Table name (Clubs, Hearts, Diamonds, Spades, Stars)
  • +
  • Seat 1: Player 1 name (Odds team)
  • +
  • Seat 3: Player 2 name (Odds team)
  • +
  • Odds Points: Score for Odds team
  • +
  • Seat 2: Player 1 name (Evens team)
  • +
  • Seat 4: Player 2 name (Evens team)
  • +
  • Evens Points: Score for Evens team
  • +
  • Winner: "Odds" or "Evens" (optional)
  • +
+
+ + {/* Submit Button */} +
+ +
+
+
+ + {/* Sample CSV */} +
+

+ Sample CSV Format +

+
+              {`Event #,Round,Table,Seat 1,Seat 3,Odds Points,Seat 2,Seat 4,Evens Points,Winner
+1,1,Clubs,Derrick,Jesse C,5,Emma,Alissa,10,Evens
+1,1,Hearts,Kevin,Andy,8,Ellie,Jesse,6,Odds`}
+            
+
+ +
+ +
+
+
+
+ ) +} diff --git a/src/app/admin/tournaments/[id]/page.tsx b/src/app/admin/tournaments/[id]/page.tsx new file mode 100644 index 0000000..216cde1 --- /dev/null +++ b/src/app/admin/tournaments/[id]/page.tsx @@ -0,0 +1,290 @@ +import { prisma } from "@/lib/prisma" +import Navigation from "@/components/Navigation" +import Link from "next/link" +import { notFound, redirect } from "next/navigation" +import { auth } from "@/lib/auth" + +interface PageProps { + params: { + id: string + } +} + +export default async function TournamentDetailPage({ params }: PageProps) { + const session = await auth() + + if (!session || session.user.role !== "club_admin") { + redirect("/auth/login") + } + + 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) { + notFound() + } + + const matches = await prisma.match.findMany({ + where: { eventId: tournamentId }, + include: { + team1P1: true, + team1P2: true, + team2P1: true, + team2P2: true, + }, + orderBy: { playedAt: "desc" }, + }) + + return ( +
+ + +
+
+ {/* Breadcrumb */} + + + {/* Tournament Header */} +
+
+
+

{tournament.name}

+

+ {tournament.format} - {tournament.status} +

+ {tournament.eventDate && ( +

+ {new Date(tournament.eventDate).toLocaleDateString()} +

+ )} +
+
+ + Edit + + + Enter Results + +
+
+ + {/* Quick Stats */} +
+
+

Participants

+

+ {tournament.participants.length} +

+
+
+

Teams

+

+ {tournament.teams.length} +

+
+
+

Rounds

+

+ {tournament.rounds.length} +

+
+
+

Matches

+

+ {matches.length} +

+
+
+
+ + {/* Tabs */} +
+ +
+ + {/* Content */} +
+ {/* Participants Section */} +
+

+ Participants ({tournament.participants.length}) +

+ + {tournament.participants.length > 0 ? ( +
+ {tournament.participants.map((participant) => ( +
+ + {participant.player.name} + +
+ ))} +
+ ) : ( +

No participants registered yet.

+ )} +
+ + {/* Teams Section */} +
+

+ Teams ({tournament.teams.length}) +

+ + {tournament.teams.length > 0 ? ( +
+ {tournament.teams.map((team) => ( +
+
+

+ {team.player1.name} + {team.player2.name} +

+

{team.teamName}

+
+
+ ))} +
+ ) : ( +

No teams created yet.

+ )} +
+ + {/* Recent Matches Section */} +
+

+ Recent Matches ({matches.length}) +

+ + {matches.length > 0 ? ( +
+ {matches.slice(0, 10).map((match) => ( +
+
+
+

+ {match.playedAt?.toLocaleDateString()} +

+

+ {match.team1P1.name} + {match.team1P2.name} vs{" "} + {match.team2P1.name} + {match.team2P2.name} +

+
+
+ match.team2Score + ? 'text-green-600' + : match.team1Score < match.team2Score + ? 'text-red-600' + : 'text-gray-600' + }`}> + {match.team1Score} + + - + match.team1Score + ? 'text-green-600' + : match.team2Score < match.team1Score + ? 'text-red-600' + : 'text-gray-600' + }`}> + {match.team2Score} + +
+
+
+ ))} +
+ ) : ( +

No matches recorded yet.

+ )} +
+
+
+
+
+ ) +}