teecup/frontend/components/individual-tournament-detail.tsx
Erol Haagenrud 007588fbbf Nå presenterer jeg utrullingsplanen:
Frontend for individuelle turneringer er ferdig håndkodet og grundig browserverifisert — inkludert ekte innlogging, håndregnet HCP-kryssjekk (stemte eksakt), databasebekreftet slagregistrering, og full opprett-fra-bunnen-flyt.

Underveis fant og fikset jeg tre reelle feil:

Et backend-hull som ville rammet alle brukere: list_tournaments (brukt av dashbordet) manglet de to nye kolonnene i sin egen SELECT-spørring — ga en rå 500 på hele turneringslisten, ikke bare individuelle turneringer.
Et layout-problem der turneringsnavnet ble avkuttet på smal mobilskjerm.
Manglende form-språk i scorekortet (kun farge, ikke sirkel/firkant som resten av appen bruker).
Alle tre er rettet og re-verifisert.

Utrullingsplan (denne gangen berører det både backend og frontend):

Redeploy teecup_api — for list_tournaments-fiksen (ingen migrasjon, ren kodefiks).
Redeploy teecup_frontend — den nye individuell-turnering-skjermen + dashbordets format-valg.
Verifiser: begge containere booter rent, /health//dashboard fortsatt 200, teeoff.no upåvirket.
2026-07-30 07:55:35 +02:00

1653 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
// Individuell/flerrunde-turnering (ADR-037) -- flatt felt av spillere over
// én eller flere runder, ingen lag. Egen skjerm, adskilt fra
// tournament-detail.tsx (lagformat, ADR-011) -- rutet dit av
// tournament-router.tsx basert på tournament.format_type.
//
// Håndkodet (ikke V0) på brukerens eksplisitte instruks. Bruker
// --info (blå) for "Individuell"-merket og runde-sammenheng, og --gold
// for lederen på leaderboardet -- samme etablerte, validerte tokens som
// round-card.tsx allerede bruker (HCP-spilt-til/personlig rekord), ikke
// nye, ukalibrerte farger.
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import {
ArrowLeft,
Check,
ChevronDown,
Copy,
Flag,
KeyRound,
Medal,
Plus,
Trash2,
Trophy,
UserPlus,
Users,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
import { cn } from "@/lib/utils"
// --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) --
type ApiPlayer = {
id: string
display_name: string
handicap_index: number | null
gender: "m" | "f" | "x" | null
}
type ApiTournamentInfo = {
id: string
status: TournamentStatus
join_code: string
format_type: string
scoring_method: string | null
}
type ApiParticipant = {
id: string
player_id: string
player_name: string
handicap_index_snapshot: number | null
}
type ApiRound = {
id: string
tournament_id: string
sequence: number
name: string | null
hole_config: "full_18" | "front_9" | "back_9"
course_id: string
course_name: string
scheduled_at: string | null
tee_interval_minutes: number | null
start_hole: number
}
type ApiRoundParticipant = {
id: string
tournament_participant_id: string
player_name: string
tee_id: string
tee_name: string
course_handicap: number | null
playing_handicap: number | null
}
type ApiHole = {
hole_number: number
par: number
stroke_index: number
gross_strokes: number | null
strokes_received: number | null
}
type ApiLeaderboardEntry = {
tournament_participant_id: string
player_name: string
rounds_played: number
gross_total: number | null
net_total: number | null
stableford_total: number | null
}
type ApiCourse = { id: string; name: string; source: string }
type ApiTeeRating = { gender: string; course_rating: number; slope_rating: number; par: number }
type ApiTee = { id: string; name: string; ratings: ApiTeeRating[] }
const HOLE_CONFIG_LABELS: Record<ApiRound["hole_config"], string> = {
full_18: "18 hull",
front_9: "Hull 19",
back_9: "Hull 1018",
}
const SCORING_METHOD_LABELS: Record<string, string> = {
stroke_gross: "Bruttoslagspill",
stroke_net: "Nettoslagspill",
stableford: "Stableford",
}
function holeNumbersFor(config: ApiRound["hole_config"]): number[] {
if (config === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
if (config === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
return Array.from({ length: 18 }, (_, i) => i + 1)
}
async function getJson<T>(url: string): Promise<T | null> {
const res = await fetch(url, { credentials: "include" })
if (!res.ok) return null
return (await res.json()) as T
}
async function errorMessage(res: Response, fallback: string): Promise<string> {
try {
const body = await res.json()
return body?.detail?.message ?? fallback
} catch {
return fallback
}
}
// --- Component ---------------------------------------------------------
export function IndividualTournamentDetail({
organizationId,
tournamentId,
tournamentName,
}: {
organizationId: string
tournamentId: string
tournamentName: string
}) {
const [tournament, setTournament] = useState<ApiTournamentInfo | null>(null)
const [participants, setParticipants] = useState<ApiParticipant[]>([])
const [rounds, setRounds] = useState<ApiRound[]>([])
const [courses, setCourses] = useState<ApiCourse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState<"setup" | "score" | "leaderboard">("setup")
const base = `/orgs/${organizationId}/tournaments/${tournamentId}`
useEffect(() => {
let cancelled = false
async function load() {
try {
const [tournamentsData, participantsData, roundsData, coursesData] = await Promise.all([
getJson<ApiTournamentInfo[]>(`/orgs/${organizationId}/tournaments`),
getJson<ApiParticipant[]>(`${base}/participants`),
getJson<ApiRound[]>(`${base}/rounds`),
getJson<ApiCourse[]>(`/orgs/${organizationId}/courses`),
])
if (cancelled) return
const mine = tournamentsData?.find((t) => t.id === tournamentId) ?? null
if (!mine) {
setError("Fant ikke turneringen.")
return
}
setTournament(mine)
setParticipants(participantsData ?? [])
setRounds(roundsData ?? [])
setCourses(coursesData ?? [])
} catch {
if (!cancelled) setError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId, base])
async function updateStatus(newStatus: TournamentStatus) {
if (!tournament) return
const previous = tournament
setTournament({ ...tournament, status: newStatus })
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ status: newStatus }),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å endre turnering-status. Prøv igjen.")
}
}
async function updateScoringMethod(method: string) {
if (!tournament) return
const previous = tournament
setTournament({ ...tournament, scoring_method: method })
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ scoring_method: method }),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å endre scoringsmetode. Prøv igjen.")
}
}
async function addParticipant(playerId: string) {
setError(null)
const res = await fetch(`${base}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ player_id: playerId }),
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å legge til deltakeren."))
return
}
const created: ApiParticipant = await res.json()
setParticipants((prev) => [...prev, created].sort((a, b) => a.player_name.localeCompare(b.player_name)))
}
async function addNewPlayerAndParticipant(name: string, handicap?: number, gender?: "m" | "f" | "x") {
setError(null)
const playerRes = await fetch(`/orgs/${organizationId}/players`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ display_name: name, handicap_index: handicap ?? null, gender: gender ?? null }),
})
if (!playerRes.ok) {
setError("Klarte ikke å opprette spilleren.")
return
}
const player: ApiPlayer = await playerRes.json()
await addParticipant(player.id)
}
async function removeParticipant(participantId: string) {
setError(null)
const res = await fetch(`${base}/participants/${participantId}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å fjerne deltakeren -- kanskje fortsatt lagt til i en runde?"))
return
}
setParticipants((prev) => prev.filter((p) => p.id !== participantId))
}
async function addRound(name: string, courseId: string, holeConfig: ApiRound["hole_config"]) {
setError(null)
const res = await fetch(`${base}/rounds`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
sequence: rounds.length + 1,
name: name.trim() || null,
course_id: courseId,
hole_config: holeConfig,
}),
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å opprette runden."))
return
}
const created: ApiRound = await res.json()
setRounds((prev) => [...prev, created].sort((a, b) => a.sequence - b.sequence))
}
async function deleteRound(roundId: string) {
setError(null)
const res = await fetch(`${base}/rounds/${roundId}`, { method: "DELETE", credentials: "include" })
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å slette runden -- har den allerede deltakere?"))
return
}
setRounds((prev) => prev.filter((r) => r.id !== roundId))
}
async function addCourse(name: string): Promise<ApiCourse | null> {
const res = await fetch(`/orgs/${organizationId}/courses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name }),
})
if (!res.ok) {
setError("Klarte ikke å opprette banen.")
return null
}
const created: ApiCourse = await res.json()
setCourses((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
return created
}
if (loading) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
if (error && !tournament) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-3 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">{error}</p>
<Link href="/dashboard" className="text-sm font-semibold text-primary underline underline-offset-2">
Tilbake til dashbordet
</Link>
</main>
)
}
if (!tournament) return null
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3 px-5 py-4">
<div className="flex items-center gap-3">
<Link
href="/dashboard"
aria-label="Tilbake til dashbord"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-info/15 px-2 py-0.5 text-info">
<Users aria-hidden="true" className="size-3" />
Individuell
</span>
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{tournamentName}</h1>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pl-13">
<TournamentStatusPicker status={tournament.status} onChange={updateStatus} />
<JoinCodeChip code={tournament.join_code} />
</div>
</div>
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
{(
[
["setup", "Oppsett"],
["score", "Scorekort"],
["leaderboard", "Leaderboard"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => setTab(key)}
aria-current={tab === key ? "page" : undefined}
className={cn(
"min-h-11 rounded-full px-4 py-2 text-sm font-bold transition-colors",
tab === key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
{label}
</button>
))}
</nav>
</header>
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
{error && (
<div className="mb-5 rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm font-medium text-destructive">
{error}
</div>
)}
{tab === "setup" && (
<SetupTab
base={base}
organizationId={organizationId}
tournament={tournament}
participants={participants}
rounds={rounds}
courses={courses}
onUpdateScoringMethod={updateScoringMethod}
onAddParticipant={addParticipant}
onAddNewPlayer={addNewPlayerAndParticipant}
onRemoveParticipant={removeParticipant}
onAddRound={addRound}
onDeleteRound={deleteRound}
onAddCourse={addCourse}
onError={setError}
/>
)}
{tab === "score" && (
<ScoreTab base={base} rounds={rounds} onError={setError} />
)}
{tab === "leaderboard" && (
<LeaderboardTab base={base} tournament={tournament} />
)}
</main>
</div>
)
}
function JoinCodeChip({ code }: { code: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// stille -- ikke kritisk
}
}
return (
<button
type="button"
onClick={handleCopy}
className="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 text-sm font-bold tabular-nums text-foreground transition-colors hover:bg-accent/50"
aria-label={`Invitasjonskode ${code}, trykk for å kopiere`}
>
{copied ? (
<Check aria-hidden="true" className="size-4 text-primary" />
) : (
<KeyRound aria-hidden="true" className="size-4 text-muted-foreground" />
)}
{code}
{!copied && <Copy aria-hidden="true" className="size-3.5 text-muted-foreground" />}
</button>
)
}
// =========================================================================
// Oppsett-fane: scoringsmetode, deltakere, runder
// =========================================================================
function SetupTab({
base,
organizationId,
tournament,
participants,
rounds,
courses,
onUpdateScoringMethod,
onAddParticipant,
onAddNewPlayer,
onRemoveParticipant,
onAddRound,
onDeleteRound,
onAddCourse,
onError,
}: {
base: string
organizationId: string
tournament: ApiTournamentInfo
participants: ApiParticipant[]
rounds: ApiRound[]
courses: ApiCourse[]
onUpdateScoringMethod: (method: string) => void
onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
onRemoveParticipant: (id: string) => Promise<void>
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onDeleteRound: (id: string) => Promise<void>
onAddCourse: (name: string) => Promise<ApiCourse | null>
onError: (message: string) => void
}) {
const [pool, setPool] = useState<ApiPlayer[]>([])
useEffect(() => {
let cancelled = false
getJson<ApiPlayer[]>(`/orgs/${organizationId}/players`).then((data) => {
if (!cancelled) setPool(data ?? [])
})
return () => {
cancelled = true
}
}, [organizationId])
return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<h2 className="text-base font-bold text-foreground">Scoringsmetode</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Avgjør hvordan totalen leaderboardet regnes ut, og om deltakere trenger
registrert handicap og kjønn.
</p>
<DropdownMenu>
<DropdownMenuTrigger className="flex min-h-11 w-fit items-center gap-2 rounded-xl border border-border bg-background px-4 text-sm font-bold text-foreground transition-colors hover:bg-accent/50">
{tournament.scoring_method ? SCORING_METHOD_LABELS[tournament.scoring_method] : "Ikke satt"}
<ChevronDown aria-hidden="true" className="size-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 rounded-2xl p-1.5">
{Object.entries(SCORING_METHOD_LABELS).map(([value, label]) => (
<DropdownMenuItem
key={value}
onClick={() => onUpdateScoringMethod(value)}
className="cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold"
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</section>
<ParticipantsCard
participants={participants}
pool={pool}
onAddParticipant={onAddParticipant}
onAddNewPlayer={onAddNewPlayer}
onRemoveParticipant={onRemoveParticipant}
/>
<RoundsCard
base={base}
organizationId={organizationId}
rounds={rounds}
courses={courses}
participants={participants}
scoringMethod={tournament.scoring_method}
onAddRound={onAddRound}
onDeleteRound={onDeleteRound}
onAddCourse={onAddCourse}
onError={onError}
/>
</div>
)
}
function ParticipantsCard({
participants,
pool,
onAddParticipant,
onAddNewPlayer,
onRemoveParticipant,
}: {
participants: ApiParticipant[]
pool: ApiPlayer[]
onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
onRemoveParticipant: (id: string) => Promise<void>
}) {
return (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-bold text-foreground">Deltakere</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
{participants.length}
</span>
</div>
{participants.length === 0 && (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller.
</p>
)}
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{participants.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-bold text-foreground">{p.player_name}</span>
<span className="text-xs font-semibold text-muted-foreground">
{p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"}
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemoveParticipant(p.id)}
aria-label={`Fjern ${p.player_name}`}
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
<AddParticipantControl
pool={pool}
alreadyIn={new Set(participants.map((p) => p.player_id))}
onAddExisting={onAddParticipant}
onAddNew={onAddNewPlayer}
/>
</section>
)
}
function AddParticipantControl({
pool,
alreadyIn,
onAddExisting,
onAddNew,
}: {
pool: ApiPlayer[]
alreadyIn: Set<string>
onAddExisting: (playerId: string) => Promise<void>
onAddNew: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [creating, setCreating] = useState(false)
const [newHandicap, setNewHandicap] = useState("")
const [newGender, setNewGender] = useState<"m" | "f" | "x" | "">("")
const trimmed = query.trim()
const matches = useMemo(() => {
if (!trimmed) return []
const q = trimmed.toLowerCase()
return pool.filter((p) => !alreadyIn.has(p.id) && p.display_name.toLowerCase().includes(q))
}, [pool, trimmed, alreadyIn])
const exactMatch = useMemo(
() => pool.some((p) => p.display_name.toLowerCase() === trimmed.toLowerCase()),
[pool, trimmed],
)
const showCreate = trimmed.length >= 2 && !exactMatch
function reset() {
setQuery("")
setCreating(false)
setNewHandicap("")
setNewGender("")
}
async function handleAddExisting(playerId: string) {
await onAddExisting(playerId)
reset()
setOpen(false)
}
async function handleCreate() {
if (trimmed.length < 2) return
const hcpValue = newHandicap.trim() === "" ? undefined : Number(newHandicap.replace(",", "."))
const hcp = hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined
await onAddNew(trimmed, hcp, newGender || undefined)
reset()
setOpen(false)
}
if (!open) {
return (
<Button
type="button"
variant="secondary"
onClick={() => setOpen(true)}
className="h-11 w-full rounded-2xl text-sm font-bold"
>
<UserPlus aria-hidden="true" className="size-4" />
Legg til deltaker
</Button>
)
}
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="Søk eller skriv navn…"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setCreating(false)
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false)
reset()
}
}}
className="h-11 flex-1 rounded-2xl text-base"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
setOpen(false)
reset()
}}
aria-label="Lukk"
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
>
<X aria-hidden="true" className="size-5" />
</Button>
</div>
{matches.length > 0 && (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{matches.slice(0, 6).map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => handleAddExisting(p.id)}
className="flex min-h-11 w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
>
{p.display_name}
<Plus aria-hidden="true" className="size-4 shrink-0 text-primary" />
</button>
</li>
))}
</ul>
)}
{showCreate && !creating && (
<button
type="button"
onClick={() => setCreating(true)}
className="flex min-h-11 items-center gap-2 rounded-xl border border-dashed border-border px-3 py-2.5 text-left text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<UserPlus aria-hidden="true" className="size-4" />
Opprett ny spiller: «{trimmed}»
</button>
)}
{showCreate && creating && (
<div className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3">
<div className="flex gap-2">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-hcp" className="text-xs font-semibold">
HCP (valgfritt)
</Label>
<Input
id="new-participant-hcp"
inputMode="decimal"
placeholder="F.eks. 18,4"
value={newHandicap}
onChange={(e) => setNewHandicap(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-gender" className="text-xs font-semibold">
Kjønn (for HCP-beregning)
</Label>
<select
id="new-participant-gender"
value={newGender}
onChange={(e) => setNewGender(e.target.value as "m" | "f" | "x" | "")}
className="h-10 rounded-xl border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
<option value="">Ikke satt</option>
<option value="f">Kvinne</option>
<option value="m">Mann</option>
<option value="x">Annet</option>
</select>
</div>
</div>
<Button type="button" onClick={handleCreate} className="h-10 rounded-xl text-sm font-bold">
Opprett og legg til «{trimmed}»
</Button>
</div>
)}
</div>
)
}
function RoundsCard({
base,
organizationId,
rounds,
courses,
participants,
scoringMethod,
onAddRound,
onDeleteRound,
onAddCourse,
onError,
}: {
base: string
organizationId: string
rounds: ApiRound[]
courses: ApiCourse[]
participants: ApiParticipant[]
scoringMethod: string | null
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onDeleteRound: (id: string) => Promise<void>
onAddCourse: (name: string) => Promise<ApiCourse | null>
onError: (message: string) => void
}) {
const [creating, setCreating] = useState(false)
return (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-bold text-foreground">Runder</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
{rounds.length}
</span>
</div>
{rounds.length === 0 && (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen runder ennå -- en enkeltdags turnering trenger kun én, en flerdagers
turnering kan ha flere.
</p>
)}
<div className="flex flex-col gap-3">
{rounds.map((r) => (
<RoundCard
key={r.id}
base={base}
organizationId={organizationId}
round={r}
participants={participants}
scoringMethod={scoringMethod}
onDelete={() => onDeleteRound(r.id)}
onError={onError}
/>
))}
</div>
{!creating ? (
<Button
type="button"
variant="secondary"
onClick={() => setCreating(true)}
className="h-11 w-full rounded-2xl text-sm font-bold"
>
<Plus aria-hidden="true" className="size-4" />
Ny runde
</Button>
) : (
<NewRoundForm
courses={courses}
onCreate={async (name, courseId, holeConfig) => {
await onAddRound(name, courseId, holeConfig)
setCreating(false)
}}
onCreateCourse={onAddCourse}
onCancel={() => setCreating(false)}
/>
)}
</section>
)
}
function NewRoundForm({
courses,
onCreate,
onCreateCourse,
onCancel,
}: {
courses: ApiCourse[]
onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onCreateCourse: (name: string) => Promise<ApiCourse | null>
onCancel: () => void
}) {
const [name, setName] = useState("")
const [courseId, setCourseId] = useState(courses[0]?.id ?? "")
const [holeConfig, setHoleConfig] = useState<ApiRound["hole_config"]>("full_18")
const [newCourseName, setNewCourseName] = useState("")
const [addingCourse, setAddingCourse] = useState(false)
const [submitting, setSubmitting] = useState(false)
async function handleCreateCourse() {
const trimmed = newCourseName.trim()
if (trimmed.length < 2) return
const created = await onCreateCourse(trimmed)
if (created) {
setCourseId(created.id)
setAddingCourse(false)
setNewCourseName("")
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!courseId || submitting) return
setSubmitting(true)
await onCreate(name, courseId, holeConfig)
setSubmitting(false)
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-xl border border-border bg-background p-3">
<div className="flex flex-col gap-1">
<Label htmlFor="round-name" className="text-xs font-semibold">
Navn (valgfritt)
</Label>
<Input
id="round-name"
autoFocus
placeholder="F.eks. Runde 1 -- fredag"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="round-course" className="text-xs font-semibold">
Bane
</Label>
{courses.length > 0 && !addingCourse ? (
<div className="flex gap-2">
<select
id="round-course"
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
className="h-10 flex-1 rounded-xl border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Button
type="button"
variant="ghost"
onClick={() => setAddingCourse(true)}
className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold text-muted-foreground"
>
+ Ny bane
</Button>
</div>
) : (
<div className="flex gap-2">
<Input
placeholder="Banens navn"
value={newCourseName}
onChange={(e) => setNewCourseName(e.target.value)}
className="h-10 flex-1 rounded-xl text-sm"
/>
<Button
type="button"
onClick={handleCreateCourse}
className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold"
>
Opprett
</Button>
{courses.length > 0 && (
<Button
type="button"
variant="ghost"
onClick={() => setAddingCourse(false)}
className="h-10 shrink-0 rounded-xl px-2 text-xs font-bold text-muted-foreground"
>
Avbryt
</Button>
)}
</div>
)}
<p className="text-xs text-muted-foreground">
Banen ha registrert hull (par/hcp-indeks) og utslag før scoring kan
begynne -- sett opp dette under organisasjonens baner om det mangler.
</p>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="round-hole-config" className="text-xs font-semibold">
Hullomfang
</Label>
<select
id="round-hole-config"
value={holeConfig}
onChange={(e) => setHoleConfig(e.target.value as ApiRound["hole_config"])}
className="h-10 rounded-xl border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{Object.entries(HOLE_CONFIG_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={!courseId || submitting} className="h-10 flex-1 rounded-xl text-sm font-bold">
{submitting ? "Oppretter…" : "Opprett runde"}
</Button>
<Button type="button" variant="ghost" onClick={onCancel} className="h-10 rounded-xl text-sm font-bold text-muted-foreground">
Avbryt
</Button>
</div>
</form>
)
}
function RoundCard({
base,
organizationId,
round,
participants,
scoringMethod,
onDelete,
onError,
}: {
base: string
organizationId: string
round: ApiRound
participants: ApiParticipant[]
scoringMethod: string | null
onDelete: () => Promise<void>
onError: (message: string) => void
}) {
const [expanded, setExpanded] = useState(false)
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null)
const [tees, setTees] = useState<ApiTee[]>([])
const [confirmingDelete, setConfirmingDelete] = useState(false)
async function loadRoundParticipants() {
const [rpData, teeData] = await Promise.all([
getJson<ApiRoundParticipant[]>(`${base}/rounds/${round.id}/participants`),
getJson<ApiTee[]>(`/orgs/${organizationId}/courses/${round.course_id}/tees`),
])
setRoundParticipants(rpData ?? [])
setTees(teeData ?? [])
}
useEffect(() => {
if (expanded && roundParticipants === null) {
loadRoundParticipants()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expanded])
async function addRoundParticipant(tournamentParticipantId: string, teeId: string) {
onError("")
const res = await fetch(`${base}/rounds/${round.id}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ tournament_participant_id: tournamentParticipantId, tee_id: teeId }),
})
if (!res.ok) {
onError(await errorMessage(res, "Klarte ikke å legge til deltakeren i runden."))
return
}
const created: ApiRoundParticipant = await res.json()
setRoundParticipants((prev) => [...(prev ?? []), created])
}
async function removeRoundParticipant(id: string) {
const res = await fetch(`${base}/rounds/${round.id}/participants/${id}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok) {
onError("Klarte ikke å fjerne deltakeren fra runden.")
return
}
setRoundParticipants((prev) => (prev ?? []).filter((rp) => rp.id !== id))
}
const assignedIds = new Set((roundParticipants ?? []).map((rp) => rp.tournament_participant_id))
const unassigned = participants.filter((p) => !assignedIds.has(p.id))
return (
<div className="overflow-hidden rounded-xl border border-border">
<div className="flex items-center gap-3 bg-background px-4 py-3">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
aria-expanded={expanded}
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-info/15 text-sm font-extrabold tabular-nums text-info">
{round.sequence}
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-bold text-foreground">
{round.name || `Runde ${round.sequence}`}
</span>
<span className="truncate text-xs font-semibold text-muted-foreground">
{round.course_name} · {HOLE_CONFIG_LABELS[round.hole_config]}
</span>
</span>
<ChevronDown
aria-hidden="true"
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")}
/>
</button>
{!confirmingDelete ? (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setConfirmingDelete(true)}
aria-label={`Slett ${round.name || `runde ${round.sequence}`}`}
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
) : (
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="destructive"
size="sm"
onClick={onDelete}
className="h-9 rounded-lg text-xs font-bold"
>
Slett
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setConfirmingDelete(false)}
className="h-9 rounded-lg text-xs font-bold text-muted-foreground"
>
Avbryt
</Button>
</div>
)}
</div>
{expanded && (
<div className="flex flex-col gap-2 border-t border-border p-3">
{roundParticipants === null ? (
<p className="text-xs text-muted-foreground">Laster</p>
) : (
<>
{roundParticipants.length === 0 && (
<p className="text-xs text-muted-foreground">Ingen deltakere i denne runden ennå.</p>
)}
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{roundParticipants.map((rp) => (
<li key={rp.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-bold text-foreground">{rp.player_name}</span>
<span className="truncate text-xs font-semibold text-muted-foreground">
{rp.tee_name}
{rp.playing_handicap !== null ? ` · HCP ${rp.playing_handicap}` : ""}
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeRoundParticipant(rp.id)}
aria-label={`Fjern ${rp.player_name} fra runden`}
className="size-8 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
>
<X aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
{unassigned.length > 0 && tees.length > 0 && (
<AssignRoundParticipantControl
candidates={unassigned}
tees={tees}
onAssign={addRoundParticipant}
/>
)}
{unassigned.length > 0 && tees.length === 0 && (
<p className="text-xs font-medium text-brand-orange">
Banen «{round.course_name}» har ingen registrerte utslag ennå -- kan ikke legge til
deltakere før det er plass.
</p>
)}
</>
)}
</div>
)}
</div>
)
}
function AssignRoundParticipantControl({
candidates,
tees,
onAssign,
}: {
candidates: ApiParticipant[]
tees: ApiTee[]
onAssign: (tournamentParticipantId: string, teeId: string) => Promise<void>
}) {
const [participantId, setParticipantId] = useState(candidates[0]?.id ?? "")
const [teeId, setTeeId] = useState(tees[0]?.id ?? "")
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!candidates.some((c) => c.id === participantId)) setParticipantId(candidates[0]?.id ?? "")
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidates])
async function handleAssign() {
if (!participantId || !teeId || submitting) return
setSubmitting(true)
await onAssign(participantId, teeId)
setSubmitting(false)
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-border p-2.5 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="assign-participant" className="text-xs font-semibold text-muted-foreground">
Deltaker
</Label>
<select
id="assign-participant"
value={participantId}
onChange={(e) => setParticipantId(e.target.value)}
className="h-10 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.player_name}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="assign-tee" className="text-xs font-semibold text-muted-foreground">
Utslag
</Label>
<select
id="assign-tee"
value={teeId}
onChange={(e) => setTeeId(e.target.value)}
className="h-10 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{tees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<Button
type="button"
onClick={handleAssign}
disabled={submitting}
className="h-10 shrink-0 rounded-lg text-xs font-bold"
>
Legg til
</Button>
</div>
)
}
// =========================================================================
// Scorekort-fane
// =========================================================================
function ScoreTab({
base,
rounds,
onError,
}: {
base: string
rounds: ApiRound[]
onError: (message: string) => void
}) {
const [roundId, setRoundId] = useState(rounds[0]?.id ?? "")
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[]>([])
const [participantId, setParticipantId] = useState("")
const [holes, setHoles] = useState<ApiHole[] | null>(null)
const round = rounds.find((r) => r.id === roundId) ?? null
useEffect(() => {
if (!roundId) return
let cancelled = false
getJson<ApiRoundParticipant[]>(`${base}/rounds/${roundId}/participants`).then((data) => {
if (cancelled) return
const list = data ?? []
setRoundParticipants(list)
setParticipantId(list[0]?.id ?? "")
})
return () => {
cancelled = true
}
}, [base, roundId])
useEffect(() => {
if (!roundId || !participantId) {
setHoles(null)
return
}
let cancelled = false
getJson<ApiHole[]>(`${base}/rounds/${roundId}/participants/${participantId}/holes`).then((data) => {
if (!cancelled) setHoles(data ?? [])
})
return () => {
cancelled = true
}
}, [base, roundId, participantId])
async function updateHole(holeNumber: number, grossStrokes: number) {
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ gross_strokes: grossStrokes }),
})
if (!res.ok) {
onError("Klarte ikke å lagre slaget. Prøv igjen.")
return
}
const updated: ApiHole = await res.json()
setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev))
}
if (rounds.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-border p-6 text-center">
<p className="text-sm font-medium text-muted-foreground text-pretty">
Opprett en runde under «Oppsett» først.
</p>
</div>
)
}
return (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap gap-2">
{rounds.map((r) => (
<button
key={r.id}
type="button"
onClick={() => setRoundId(r.id)}
aria-pressed={r.id === roundId}
className={cn(
"flex min-h-11 items-center gap-2 rounded-full border px-4 text-sm font-bold transition-colors",
r.id === roundId
? "border-info bg-info/15 text-info"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
<Flag aria-hidden="true" className="size-4" />
{r.name || `Runde ${r.sequence}`}
</button>
))}
</div>
{roundParticipants.length === 0 ? (
<p className="text-sm font-medium text-muted-foreground">
Ingen deltakere i denne runden ennå -- legg til under «Oppsett».
</p>
) : (
<>
<div className="flex flex-wrap gap-2">
{roundParticipants.map((rp) => (
<button
key={rp.id}
type="button"
onClick={() => setParticipantId(rp.id)}
aria-pressed={rp.id === participantId}
className={cn(
"min-h-11 rounded-full border px-4 text-sm font-bold transition-colors",
rp.id === participantId
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50",
)}
>
{rp.player_name}
</button>
))}
</div>
{round && holes && (
<HoleGrid holes={holes} holeConfig={round.hole_config} onUpdate={updateHole} />
)}
</>
)}
</div>
)
}
type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double"
function classify(diff: number): ScoreClass {
if (diff <= -2) return "eagle"
if (diff === -1) return "birdie"
if (diff === 0) return "par"
if (diff === 1) return "bogey"
return "double"
}
// Samme "form + farge, aldri farge alene"-språk som round-scorecard.tsx sin
// ScoreMark: sirkel = under par, firkant = over par, fylt = 2+ slag fra par.
// "Par" er nøytral (ingen retning å vise), men beholder en synlig kant siden
// disse cellene -- ulikt originalens rene visning -- er trykkbare knapper.
function scoreMarkClasses(kind: ScoreClass): string {
switch (kind) {
case "eagle":
return "rounded-full border-2 border-primary bg-primary text-primary-foreground"
case "birdie":
return "rounded-full border-2 border-primary bg-primary/10 text-primary"
case "bogey":
return "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange"
case "double":
return "rounded-[4px] border-2 border-brand-orange bg-brand-orange text-brand-orange-foreground"
default:
return "rounded-[4px] border-2 border-border bg-card text-foreground"
}
}
function HoleGrid({
holes,
holeConfig,
onUpdate,
}: {
holes: ApiHole[]
holeConfig: ApiRound["hole_config"]
onUpdate: (holeNumber: number, grossStrokes: number) => Promise<void>
}) {
const [editingHole, setEditingHole] = useState<number | null>(null)
const [draft, setDraft] = useState("")
const numbers = holeNumbersFor(holeConfig)
const byNumber = new Map(holes.map((h) => [h.hole_number, h]))
const played = holes.filter((h) => h.gross_strokes !== null)
const totalGross = played.length > 0 ? played.reduce((sum, h) => sum + (h.gross_strokes as number), 0) : null
const totalPar = played.length > 0 ? played.reduce((sum, h) => sum + h.par, 0) : null
function openEditor(holeNumber: number, current: number | null) {
setEditingHole(holeNumber)
setDraft(current !== null ? String(current) : "")
}
async function commit(holeNumber: number) {
const value = Number(draft)
if (Number.isFinite(value) && value >= 1 && value <= 20) {
await onUpdate(holeNumber, value)
}
setEditingHole(null)
setDraft("")
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="overflow-x-auto">
<div className="inline-grid min-w-full gap-y-1" style={{ gridTemplateColumns: `repeat(${numbers.length}, minmax(2.5rem, 1fr))` }}>
{numbers.map((n) => (
<span key={`num-${n}`} className="text-center text-[11px] font-bold text-muted-foreground">
{n}
</span>
))}
{numbers.map((n) => (
<span key={`par-${n}`} className="text-center text-[10px] font-semibold tabular-nums text-muted-foreground">
Par {byNumber.get(n)?.par ?? ""}
</span>
))}
{numbers.map((n) => {
const hole = byNumber.get(n)
const gross = hole?.gross_strokes ?? null
const net = gross !== null && hole?.strokes_received !== null && hole?.strokes_received !== undefined
? gross - hole.strokes_received
: gross
const diff = net !== null && hole ? net - hole.par : null
const kind = diff !== null ? classify(diff) : null
const isEditing = editingHole === n
if (isEditing) {
return (
<input
key={`cell-${n}`}
autoFocus
inputMode="numeric"
value={draft}
onChange={(e) => setDraft(e.target.value.replace(/[^0-9]/g, ""))}
onBlur={() => commit(n)}
onKeyDown={(e) => {
if (e.key === "Enter") commit(n)
if (e.key === "Escape") {
setEditingHole(null)
setDraft("")
}
}}
className="mx-auto flex size-9 items-center justify-center rounded-[4px] border-2 border-info bg-background text-center text-sm font-extrabold tabular-nums text-foreground outline-none"
/>
)
}
return (
<button
key={`cell-${n}`}
type="button"
onClick={() => openEditor(n, gross)}
aria-label={
gross !== null
? `Hull ${n}, ${gross} slag, trykk for å endre`
: `Hull ${n}, ikke registrert, trykk for å registrere`
}
className={cn(
"mx-auto flex size-9 items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
kind
? scoreMarkClasses(kind)
: "rounded-[4px] border-2 border-dashed border-border text-muted-foreground hover:bg-accent/50",
)}
>
{gross ?? ""}
</button>
)
})}
</div>
</div>
{totalGross !== null && totalPar !== null && (
<div className="flex items-center justify-between border-t border-border pt-3 text-sm">
<span className="font-semibold text-muted-foreground">
{played.length} av {numbers.length} hull spilt
</span>
<span className="font-extrabold tabular-nums text-foreground">
{totalGross} slag ({totalGross - totalPar >= 0 ? "+" : ""}
{totalGross - totalPar} til par)
</span>
</div>
)}
</div>
)
}
// =========================================================================
// Leaderboard-fane
// =========================================================================
function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTournamentInfo }) {
const [entries, setEntries] = useState<ApiLeaderboardEntry[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
const res = await fetch(`${base}/individual-leaderboard`, { credentials: "include" })
if (!res.ok) {
if (!cancelled) setError("Klarte ikke å hente leaderboardet.")
return
}
const data: ApiLeaderboardEntry[] = await res.json()
if (!cancelled) setEntries(data)
}
load()
return () => {
cancelled = true
}
}, [base])
const method = tournament.scoring_method
function valueFor(entry: ApiLeaderboardEntry): { label: string; value: number | null } {
if (method === "stableford") return { label: "poeng", value: entry.stableford_total }
if (method === "stroke_net") return { label: "netto", value: entry.net_total }
return { label: "slag", value: entry.gross_total }
}
if (error) {
return <p className="text-sm font-medium text-destructive">{error}</p>
}
if (!entries) {
return (
<div className="flex justify-center py-10">
<div
aria-hidden="true"
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
if (!method) {
return (
<p className="text-sm font-medium text-muted-foreground text-pretty">
Sett en scoringsmetode under «Oppsett» først -- avgjør hvilket tall leaderboardet
skal rangere etter.
</p>
)
}
if (entries.length === 0) {
return (
<p className="text-sm font-medium text-muted-foreground text-pretty">Ingen deltakere ennå.</p>
)
}
return (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
{entries.map((entry, index) => {
const { label, value } = valueFor(entry)
const rank = index + 1
const isLeader = rank === 1 && value !== null
return (
<li
key={entry.tournament_participant_id}
className={cn("flex items-center gap-3 px-4 py-3", isLeader && "bg-gold/10")}
>
<span
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
isLeader ? "bg-gold text-gold-foreground" : "bg-muted text-muted-foreground",
)}
>
{isLeader ? <Medal aria-hidden="true" className="size-4" /> : rank}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-bold text-foreground">{entry.player_name}</span>
<span className="text-xs font-semibold text-muted-foreground">
{entry.rounds_played} {entry.rounds_played === 1 ? "runde" : "runder"} spilt
</span>
</div>
<span className="shrink-0 text-right text-lg font-extrabold tabular-nums text-foreground">
{value !== null ? (
<>
{value}
<span className="ml-1 text-xs font-semibold text-muted-foreground">{label}</span>
</>
) : (
<span className="text-sm font-medium text-muted-foreground"></span>
)}
</span>
</li>
)
})}
</ul>
)
}