teecup/frontend/components/individual-tournament-detail.tsx
Erol Haagenrud fa6a1fa78d Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.

To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).

Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00

1921 lines
65 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 { TournamentPresentationPanel } from "@/components/tournament-presentation"
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
// KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30).
bbb_sweep_bonus_enabled: boolean
}
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
copenhagen_total: number | null
bbb_total: number | null
}
type ApiFlagResult = {
tournament_participant_id: string
player_name: string
holes_completed: number
ran_out: boolean
strokes_remaining: number
}
type ApiBBBHole = {
hole_number: number
bingo_participant_id: string | null
bango_participant_id: string | null
bongo_participant_id: string | 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",
copenhagen: "Københavner",
bingo_bango_bongo: "Bingo Bango Bongo",
flag: "Flaggturnering",
}
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" | "presentation">("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 updateBbbSweepBonus(enabled: boolean) {
if (!tournament) return
const previous = tournament
setTournament({ ...tournament, bbb_sweep_bonus_enabled: enabled })
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ bbb_sweep_bonus_enabled: enabled }),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å endre bonuspoeng-innstillingen. 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 overflow-x-auto px-5 pb-3"
aria-label="Turneringsseksjoner"
>
{(
[
["setup", "Oppsett"],
["score", "Scorekort"],
["leaderboard", "Leaderboard"],
["presentation", "Presentasjon"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => setTab(key)}
aria-current={tab === key ? "page" : undefined}
className={cn(
"min-h-11 shrink-0 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}
onUpdateBbbSweepBonus={updateBbbSweepBonus}
onAddParticipant={addParticipant}
onAddNewPlayer={addNewPlayerAndParticipant}
onRemoveParticipant={removeParticipant}
onAddRound={addRound}
onDeleteRound={deleteRound}
onAddCourse={addCourse}
onError={setError}
/>
)}
{tab === "score" && (
<ScoreTab base={base} rounds={rounds} scoringMethod={tournament.scoring_method} onError={setError} />
)}
{tab === "leaderboard" && (
<LeaderboardTab base={base} tournament={tournament} />
)}
{tab === "presentation" && (
<TournamentPresentationPanel organizationId={organizationId} tournamentId={tournamentId} />
)}
</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,
onUpdateBbbSweepBonus,
onAddParticipant,
onAddNewPlayer,
onRemoveParticipant,
onAddRound,
onDeleteRound,
onAddCourse,
onError,
}: {
base: string
organizationId: string
tournament: ApiTournamentInfo
participants: ApiParticipant[]
rounds: ApiRound[]
courses: ApiCourse[]
onUpdateScoringMethod: (method: string) => void
onUpdateBbbSweepBonus: (enabled: boolean) => 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>
{tournament.scoring_method === "bingo_bango_bongo" && (
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
<input
type="checkbox"
checked={tournament.bbb_sweep_bonus_enabled}
onChange={(e) => onUpdateBbbSweepBonus(e.target.checked)}
className="mt-1 size-5 shrink-0 accent-primary"
/>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-bold text-foreground">Bonuspoeng ved "sveip"</span>
<span className="text-xs text-muted-foreground">
Ekstra poeng til en spiller som vinner alle tre kategoriene samme hull.
</span>
</span>
</label>
)}
</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,
scoringMethod,
onError,
}: {
base: string
rounds: ApiRound[]
scoringMethod: string | null
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
// Bumpes ved hver hull-registrering, slik at BBB-/Flag-panelene (egen
// fetch) vet de skal hente på nytt.
const [refreshTick, setRefreshTick] = useState(0)
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))
setRefreshTick((n) => n + 1)
}
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} />
)}
{/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon,
uavhengig av gross_strokes-registreringen over. */}
{round && scoringMethod === "bingo_bango_bongo" && (
<BBBRoundPanel base={base} round={round} participants={roundParticipants} refreshTick={refreshTick} />
)}
{/* Flaggturnering (2026-07-30) -- per-runde-resultat (ikke
akkumulerbart på tvers av runder, se LeaderboardTab). */}
{round && scoringMethod === "flag" && (
<FlagRoundPanel base={base} round={round} refreshTick={refreshTick} />
)}
</>
)}
</div>
)
}
// --- Bingo Bango Bongo -- per-hull-observasjon (2026-07-30) -----------------
function BBBCategoryRow({
label,
participants,
selectedId,
onSelect,
}: {
label: string
participants: ApiRoundParticipant[]
selectedId: string | null
onSelect: (id: string | null) => void
}) {
return (
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold text-foreground">{label}</span>
<div className="flex flex-wrap gap-1.5">
{participants.map((p) => {
const active = selectedId === p.tournament_participant_id
return (
<button
key={p.tournament_participant_id}
type="button"
onClick={() => onSelect(active ? null : p.tournament_participant_id)}
aria-pressed={active}
className={cn(
"min-h-9 rounded-lg border px-2.5 text-xs font-semibold transition-colors",
active
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-accent/50",
)}
>
{p.player_name}
</button>
)
})}
</div>
</div>
)
}
function BBBRoundPanel({
base,
round,
participants,
refreshTick,
}: {
base: string
round: ApiRound
participants: ApiRoundParticipant[]
refreshTick: number
}) {
const numbers = holeNumbersFor(round.hole_config)
const [selectedHole, setSelectedHole] = useState(numbers[0] ?? 1)
const [bbbHoles, setBbbHoles] = useState<Record<number, ApiBBBHole>>({})
useEffect(() => {
let cancelled = false
getJson<ApiBBBHole[]>(`${base}/rounds/${round.id}/bbb`).then((data) => {
if (!cancelled) setBbbHoles(Object.fromEntries((data ?? []).map((h) => [h.hole_number, h])))
})
return () => {
cancelled = true
}
}, [base, round.id, refreshTick])
async function set(field: "bingo_participant_id" | "bango_participant_id" | "bongo_participant_id", id: string | null) {
const res = await fetch(`${base}/rounds/${round.id}/bbb/${selectedHole}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ [field]: id }),
})
if (!res.ok) return
const updated: ApiBBBHole = await res.json()
setBbbHoles((prev) => ({ ...prev, [updated.hole_number]: updated }))
}
const selection = bbbHoles[selectedHole] ?? null
return (
<div 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">
<span className="text-sm font-bold text-foreground">Bingo Bango Bongo</span>
<select
value={selectedHole}
onChange={(e) => setSelectedHole(Number(e.target.value))}
className="h-9 rounded-lg border border-border bg-background px-2 text-sm font-semibold text-foreground outline-none"
>
{numbers.map((n) => (
<option key={n} value={n}>
Hull {n}
</option>
))}
</select>
</div>
<BBBCategoryRow
label="Bingo (først på green)"
participants={participants}
selectedId={selection?.bingo_participant_id ?? null}
onSelect={(id) => void set("bingo_participant_id", id)}
/>
<BBBCategoryRow
label="Bango (nærmest hull, når alle er på green)"
participants={participants}
selectedId={selection?.bango_participant_id ?? null}
onSelect={(id) => void set("bango_participant_id", id)}
/>
<BBBCategoryRow
label="Bongo (først i hull)"
participants={participants}
selectedId={selection?.bongo_participant_id ?? null}
onSelect={(id) => void set("bongo_participant_id", id)}
/>
</div>
)
}
// --- Flaggturnering -- per-runde-resultat (2026-07-30) ----------------------
function FlagRoundPanel({
base,
round,
refreshTick,
}: {
base: string
round: ApiRound
refreshTick: number
}) {
const [results, setResults] = useState<ApiFlagResult[] | null>(null)
useEffect(() => {
let cancelled = false
getJson<ApiFlagResult[]>(`${base}/rounds/${round.id}/flag-result`).then((data) => {
if (!cancelled) setResults(data)
})
return () => {
cancelled = true
}
}, [base, round.id, refreshTick])
if (!results) return null
if (results.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Venter at handicap beregnes for minst én spiller.
</p>
)
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<span className="text-sm font-bold text-foreground">Flaggturnering</span>
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{results.map((r) => (
<li key={r.tournament_participant_id} className="flex items-center justify-between gap-3 px-4 py-2.5">
<span className="text-sm font-semibold text-foreground">{r.player_name}</span>
<span className="flex flex-col items-end">
<span className="text-sm font-extrabold tabular-nums text-foreground">{r.holes_completed} hull</span>
<span className="text-xs tabular-nums text-muted-foreground">
{r.ran_out ? "gikk tom for slag" : `${r.strokes_remaining} slag igjen`}
</span>
</span>
</li>
))}
</ul>
</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 }
if (method === "copenhagen") return { label: "poeng", value: entry.copenhagen_total }
if (method === "bingo_bango_bongo") return { label: "poeng", value: entry.bbb_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>
)
}
// Flaggturnering er en PER-RUNDE-konkurranse ("hvem kommer lengst DENNE
// runden"), ikke et akkumulerbart tall på tvers av flere runder -- se
// egen resultatvisning per runde under "Scorekort" i stedet.
if (method === "flag") {
return (
<p className="text-sm font-medium text-muted-foreground text-pretty">
Flaggturnering vises per runde -- se resultatet under «Scorekort» for hver runde.
</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>
)
}