teecup/frontend/components/individual-tournament-detail.tsx
Erol Haagenrud aee1662de2 Fiks turneringsoppsett-rekkefølge, koble til TeeOff-import, og bygg delt bane-mal-bibliotek
Klasser vises nå etter bane-/rundeoppsett (var meningsløst å velge
standardutslag før banen fantes). TeeOff-import koblet inn i
individuelle turneringer. Ny mal-basert baneoppretting i
turneringsmodulen (fra bunnen av / TeeOff / offentlig custom-bane),
med personal_course gjenbrukt som et delt, plattform-omfattende
bane-bibliotek på tvers av org- og single-runde-modulen (ADR-042,
migrasjon 054).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 11:34:28 +02:00

2560 lines
89 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 { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
import { CourseTemplatePicker } from "@/components/course-template-editor"
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
class_id: string | null
class_name: string | null
}
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
// standardutslag. Her (individuelle turneringer) brukes klasse til BÅDE
// utslag-forhåndsutfylling (AssignRoundParticipantControl) OG en egen
// resultatliste-seksjon per klasse (LeaderboardTab) -- se
// 053_tournament_classes.sql for full begrunnelse og forskjellen fra
// lagturneringer (tournament-detail.tsx), hvor klasse KUN styrer utslag.
type ApiTournamentClass = {
id: string
name: string
default_tee_id: string | null
default_tee_name: string | 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 ApiRoundCell = {
round_number: number
label: string
tone: "under" | "even" | "over" | 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
class_id: string | null
class_name: string | null
// Augusta-stil resultattavle (2026-08-04) -- kun populert for
// scoring_method brutto/netto/stableford, se stroke-play-leaderboard.tsx.
position: string | null
is_leader: boolean
today_label: string | null
thru_label: string | null
total_label: string | null
rounds: ApiRoundCell[]
}
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 [classes, setClasses] = useState<ApiTournamentClass[]>([])
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, classesData] = await Promise.all([
getJson<ApiTournamentInfo[]>(`/orgs/${organizationId}/tournaments`),
getJson<ApiParticipant[]>(`${base}/participants`),
getJson<ApiRound[]>(`${base}/rounds`),
getJson<ApiCourse[]>(`/orgs/${organizationId}/courses`),
getJson<ApiTournamentClass[]>(`${base}/classes`),
])
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 ?? [])
setClasses(classesData ?? [])
} 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 setParticipantClass(participantId: string, classId: string | null) {
setError(null)
const res = await fetch(`${base}/participants/${participantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ class_id: classId }),
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å endre klasse."))
return
}
const updated: ApiParticipant = await res.json()
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
}
async function createClass(name: string, defaultTeeId: string | null) {
setError(null)
const res = await fetch(`${base}/classes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name, default_tee_id: defaultTeeId }),
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å opprette klassen."))
return
}
const created: ApiTournamentClass = await res.json()
setClasses((prev) => [...prev, created])
}
async function deleteClass(classId: string) {
setError(null)
const res = await fetch(`${base}/classes/${classId}`, { method: "DELETE", credentials: "include" })
if (res.status !== 204) {
setError(await errorMessage(res, "Klarte ikke å fjerne klassen."))
return
}
setClasses((prev) => prev.filter((c) => c.id !== classId))
setParticipants((prev) =>
prev.map((p) => (p.class_id === classId ? { ...p, class_id: null, class_name: null } : p)),
)
}
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
}
function handleCourseImported(course: ApiCourse) {
setCourses((prev) => [...prev, course].sort((a, b) => a.name.localeCompare(b.name)))
}
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}
classes={classes}
onUpdateScoringMethod={updateScoringMethod}
onUpdateBbbSweepBonus={updateBbbSweepBonus}
onAddParticipant={addParticipant}
onAddNewPlayer={addNewPlayerAndParticipant}
onRemoveParticipant={removeParticipant}
onAddRound={addRound}
onDeleteRound={deleteRound}
onAddCourse={addCourse}
onCourseImported={handleCourseImported}
onCreateClass={createClass}
onDeleteClass={deleteClass}
onSetParticipantClass={setParticipantClass}
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,
classes,
onUpdateScoringMethod,
onUpdateBbbSweepBonus,
onAddParticipant,
onAddNewPlayer,
onRemoveParticipant,
onAddRound,
onDeleteRound,
onAddCourse,
onCourseImported,
onCreateClass,
onDeleteClass,
onSetParticipantClass,
onError,
}: {
base: string
organizationId: string
tournament: ApiTournamentInfo
participants: ApiParticipant[]
rounds: ApiRound[]
courses: ApiCourse[]
classes: ApiTournamentClass[]
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>
onCourseImported: (course: ApiCourse) => void
onCreateClass: (name: string, defaultTeeId: string | null) => Promise<void>
onDeleteClass: (id: string) => Promise<void>
onSetParticipantClass: (participantId: string, classId: string | null) => Promise<void>
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>
<RoundsCard
base={base}
organizationId={organizationId}
rounds={rounds}
courses={courses}
participants={participants}
classes={classes}
scoringMethod={tournament.scoring_method}
onAddRound={onAddRound}
onDeleteRound={onDeleteRound}
onAddCourse={onAddCourse}
onCourseImported={onCourseImported}
onError={onError}
/>
<ClassesCard
classes={classes}
courses={courses}
organizationId={organizationId}
onCreate={onCreateClass}
onDelete={onDeleteClass}
/>
<ParticipantsCard
participants={participants}
pool={pool}
classes={classes}
onAddParticipant={onAddParticipant}
onAddNewPlayer={onAddNewPlayer}
onRemoveParticipant={onRemoveParticipant}
onSetClass={onSetParticipantClass}
/>
</div>
)
}
// Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin
// ClassesCard for lagturnering-varianten (samme mønster, men uten
// leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten).
function ClassesCard({
classes,
courses,
organizationId,
onCreate,
onDelete,
}: {
classes: ApiTournamentClass[]
courses: ApiCourse[]
organizationId: string
onCreate: (name: string, defaultTeeId: string | null) => Promise<void>
onDelete: (classId: string) => Promise<void>
}) {
const [name, setName] = useState("")
const [courseId, setCourseId] = useState("")
const [teeId, setTeeId] = useState("")
const [tees, setTees] = useState<ApiTee[]>([])
const [adding, setAdding] = useState(false)
useEffect(() => {
if (!courseId) {
setTees([])
setTeeId("")
return
}
let cancelled = false
getJson<ApiTee[]>(`/orgs/${organizationId}/courses/${courseId}/tees`).then((data) => {
if (!cancelled) {
setTees(data ?? [])
setTeeId(data?.[0]?.id ?? "")
}
})
return () => {
cancelled = true
}
}, [organizationId, courseId])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (name.trim().length === 0 || adding) return
setAdding(true)
await onCreate(name.trim(), teeId || null)
setAdding(false)
setName("")
setCourseId("")
setTeeId("")
}
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">Klasser</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
{classes.length}
</span>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Foreslår riktig utslag ved oppsett av en runde, og gir hver klasse sin egen seksjon
leaderboardet.
</p>
{classes.length > 0 && (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{classes.map((c) => (
<li key={c.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">{c.name}</span>
{c.default_tee_name && (
<span className="text-xs text-muted-foreground">
Standardutslag: {c.default_tee_name}
</span>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDelete(c.id)}
aria-label={`Fjern klassen ${c.name}`}
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="ind-class-name" className="text-xs font-semibold text-muted-foreground">
Klassenavn
</Label>
<Input
id="ind-class-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="F.eks. Damer"
className="h-11"
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="ind-class-course" className="text-xs font-semibold text-muted-foreground">
Bane (for standardutslag)
</Label>
<select
id="ind-class-course"
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
>
<option value="">Ingen standardutslag</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
{tees.length > 0 && (
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="ind-class-tee" className="text-xs font-semibold text-muted-foreground">
Standardutslag
</Label>
<select
id="ind-class-tee"
value={teeId}
onChange={(e) => setTeeId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 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="submit"
variant="secondary"
disabled={adding || name.trim().length === 0}
className="h-11 shrink-0 rounded-xl font-semibold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til
</Button>
</form>
</section>
)
}
function ParticipantsCard({
participants,
pool,
classes,
onAddParticipant,
onAddNewPlayer,
onRemoveParticipant,
onSetClass,
}: {
participants: ApiParticipant[]
pool: ApiPlayer[]
classes: ApiTournamentClass[]
onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
onRemoveParticipant: (id: string) => Promise<void>
onSetClass: (participantId: string, classId: string | null) => 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 flex-wrap 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>
{classes.length > 0 && (
<select
value={p.class_id ?? ""}
onChange={(e) => onSetClass(p.id, e.target.value || null)}
aria-label={`Klasse for ${p.player_name}`}
className="h-9 shrink-0 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none"
>
<option value="">Ingen klasse</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
<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,
classes,
scoringMethod,
onAddRound,
onDeleteRound,
onAddCourse,
onCourseImported,
onError,
}: {
base: string
organizationId: string
rounds: ApiRound[]
courses: ApiCourse[]
participants: ApiParticipant[]
classes: ApiTournamentClass[]
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>
onCourseImported: (course: ApiCourse) => void
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}
classes={classes}
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
organizationId={organizationId}
courses={courses}
onCourseImported={onCourseImported}
onCreate={async (name, courseId, holeConfig) => {
await onAddRound(name, courseId, holeConfig)
setCreating(false)
}}
onCreateCourse={onAddCourse}
onCancel={() => setCreating(false)}
/>
)}
</section>
)
}
function NewRoundForm({
organizationId,
courses,
onCreate,
onCreateCourse,
onCourseImported,
onCancel,
}: {
organizationId: string
courses: ApiCourse[]
onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onCreateCourse: (name: string) => Promise<ApiCourse | null>
onCourseImported: (course: ApiCourse) => void
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 [officialSearchOpen, setOfficialSearchOpen] = useState(false)
const [templatePickerOpen, setTemplatePickerOpen] = 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>
{!officialSearchOpen && !templatePickerOpen ? (
<div className="flex flex-wrap gap-x-4 gap-y-1">
<button
type="button"
onClick={() => setOfficialSearchOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
</button>
<button
type="button"
onClick={() => setTemplatePickerOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Opprett bane med hull/utslag i stedet
</button>
</div>
) : officialSearchOpen ? (
<OfficialCourseSearch
organizationId={organizationId}
onClose={() => setOfficialSearchOpen(false)}
onImported={(course) => {
onCourseImported(course)
setCourseId(course.id)
setAddingCourse(false)
setOfficialSearchOpen(false)
}}
/>
) : (
<CourseTemplatePicker
organizationId={organizationId}
onCreated={(created) => {
onCourseImported({ id: created.id, name: created.name, source: "custom" })
setCourseId(created.id)
setAddingCourse(false)
setTemplatePickerOpen(false)
}}
onCancel={() => setTemplatePickerOpen(false)}
/>
)}
</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,
classes,
scoringMethod,
onDelete,
onError,
}: {
base: string
organizationId: string
round: ApiRound
participants: ApiParticipant[]
classes: ApiTournamentClass[]
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}
classes={classes}
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,
classes,
onAssign,
}: {
candidates: ApiParticipant[]
tees: ApiTee[]
classes: ApiTournamentClass[]
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])
// Konkurranseklasser (2026-08-03) -- forhåndsvelg deltakerens klasses
// standardutslag, kun hvis det utslaget faktisk finnes på DENNE rundens
// bane (klassens standardutslag kan tilhøre en annen bane). Fritt
// overstyrbart under.
useEffect(() => {
if (!participantId) return
const candidate = candidates.find((c) => c.id === participantId)
const cls = candidate?.class_id ? classes.find((c) => c.id === candidate.class_id) : null
const defaultTee = cls?.default_tee_id ? tees.find((t) => t.id === cls.default_tee_id) : null
if (defaultTee) setTeeId(defaultTee.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [participantId])
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>
)
}
// Konkurranseklasser (2026-08-03) -- den mottatte listen er ALLEREDE
// sortert riktig av backend (individual_leaderboard, uendret
// sorteringslogikk); her grupperes den samme rekkefølgen visuelt på
// class_id (stabil gruppering bevarer riktig rangering INNAD i hver
// klasse). Ingen klasser opprettet ennå => alle entries havner i samme
// "Ingen klasse"-gruppe => identisk med tidligere flat visning,
// bakoverkompatibelt uten migrasjonsflagg.
const groups: { key: string; label: string; entries: ApiLeaderboardEntry[] }[] = []
const indexByKey = new Map<string, number>()
for (const entry of entries) {
const key = entry.class_id ?? "__none__"
let idx = indexByKey.get(key)
if (idx === undefined) {
idx = groups.length
indexByKey.set(key, idx)
groups.push({ key, label: entry.class_name ?? "Ingen klasse", entries: [] })
}
groups[idx].entries.push(entry)
}
// "Ingen klasse" sist, uansett hvor den først dukket opp i sorteringen.
groups.sort((a, b) => (a.key === "__none__" ? 1 : b.key === "__none__" ? -1 : 0))
const showSectionHeaders = groups.length > 1 || groups[0]?.key !== "__none__"
// Augusta-stil resultattavle (2026-08-04) -- kun for brutto/netto/
// stableford, backend har allerede regnet ut alt (POS med uavgjort,
// TODAY/THRU for gjeldende runde, TOTAL, R1-Rn). Denne funksjonen er ren
// formvending, ingen domenelogikk.
const isStrokePlay = method === "stroke_gross" || method === "stroke_net" || method === "stableford"
function toStrokePlayRow(entry: ApiLeaderboardEntry): StrokePlayRow {
return {
position: entry.position ?? "-",
playerName: entry.player_name,
todayLabel: entry.today_label,
todayIsUnderPar: null,
thruLabel: entry.thru_label ?? "-",
totalLabel: entry.total_label ?? "E",
totalIsUnderPar: false,
isLeader: entry.is_leader,
rounds: entry.rounds.map((r) => ({ roundNumber: r.round_number, label: r.label, tone: r.tone })),
}
}
function renderRow(entry: ApiLeaderboardEntry, rank: number) {
const { label, value } = valueFor(entry)
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>
)
}
return (
<div className="flex flex-col gap-4">
{groups.map((group) => (
<div key={group.key} className="flex flex-col gap-2">
{showSectionHeaders && (
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
{group.label}
</h3>
)}
{isStrokePlay ? (
<StrokePlayLeaderboard
rows={group.entries.map(toStrokePlayRow)}
caption={showSectionHeaders ? `Resultattavle ${group.label}` : "Resultattavle"}
/>
) : (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
{group.entries.map((entry, index) => renderRow(entry, index + 1))}
</ul>
)}
</div>
))}
</div>
)
}
// --- TeeOff-baneimport (2026-08-04) -----------------------------------------
// Samme mønster/endepunkter som tournament-program.tsx sin OfficialCourse
// Search (ADR-019) -- kopiert hit i stedet for delt via import, samme
// duplisering-mellom-de-to-turneringstype-filene-konvensjon som ClassesCard
// allerede fulgte. Var tidligere KUN tilgjengelig for lagturneringer; denne
// runden fikset gapet der individuelle turneringer manglet TeeOff-import helt.
type ApiOfficialFacility = {
slug: string
name: string
city: string | null
county: string | null
}
type ApiOfficialCourseOption = {
teeoff_course_id: number
name: string
is_main_course: boolean
}
function OfficialCourseSearch({
organizationId,
onClose,
onImported,
}: {
organizationId: string
onClose: () => void
onImported: (course: ApiCourse) => void
}) {
const [query, setQuery] = useState("")
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
const [selectedFacility, setSelectedFacility] = useState<ApiOfficialFacility | null>(null)
const [courseOptions, setCourseOptions] = useState<ApiOfficialCourseOption[] | null>(null)
const [searching, setSearching] = useState(false)
const [importing, setImporting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function runSearch() {
setSearching(true)
setError(null)
try {
const res = await fetch(
`/orgs/${organizationId}/courses/official-search?q=${encodeURIComponent(query.trim())}`,
{ credentials: "include" },
)
if (!res.ok) throw new Error(`search: ${res.status}`)
setFacilities(await res.json())
} catch {
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
setFacilities([])
} finally {
setSearching(false)
}
}
async function pickFacility(facility: ApiOfficialFacility) {
setSelectedFacility(facility)
setError(null)
setCourseOptions(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, {
credentials: "include",
})
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
setCourseOptions(detail.courses)
} catch {
setError("Klarte ikke å hente baner for dette anlegget.")
setCourseOptions([])
}
}
async function importCourse(course: ApiOfficialCourseOption) {
if (!selectedFacility) return
setImporting(true)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
facility_slug: selectedFacility.slug,
teeoff_course_id: course.teeoff_course_id,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const code = body?.detail?.code
if (code === "EXTERNAL_DATA_INCOMPLETE") {
setError("Denne banen mangler nok data i teeoff til å importeres ennå.")
} else if (code === "DUPLICATE") {
setError("Denne banen er allerede importert til organisasjonen.")
} else {
setError("Klarte ikke å importere banen. Prøv igjen.")
}
return
}
onImported(await res.json())
} catch {
setError("Klarte ikke å importere banen. Prøv igjen.")
} finally {
setImporting(false)
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">Hent bane fra teeoff</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="size-8 rounded-lg text-muted-foreground"
aria-label="Lukk"
>
<X aria-hidden="true" className="size-4" />
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{!selectedFacility ? (
<>
{/* Bevisst IKKE et eget <form> her -- denne komponenten rendres inne
i NewRoundForm sitt <form onSubmit={handleSubmit}>, og nestede
<form>-elementer er ugyldig HTML (samme feilrapport-funn som i
tournament-program.tsx sin variant -- "Søk"-knappen ville ellers
i praksis submitte det YTRE runde-skjemaet). */}
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="Søk anleggsnavn…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
runSearch()
}
}}
className="h-11 flex-1 rounded-xl text-base"
/>
<Button
type="button"
onClick={() => runSearch()}
disabled={searching}
className="h-11 shrink-0 rounded-xl font-bold"
>
{searching ? "Søker…" : "Søk"}
</Button>
</div>
{facilities && (
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
{facilities.map((f) => (
<li key={f.slug} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => pickFacility(f)}
className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
>
<span className="text-sm font-semibold text-foreground">{f.name}</span>
{(f.city || f.county) && (
<span className="text-xs text-muted-foreground">
{[f.city, f.county].filter(Boolean).join(", ")}
</span>
)}
</button>
</li>
))}
{facilities.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
)}
</ul>
)}
</>
) : (
<>
<button
type="button"
onClick={() => {
setSelectedFacility(null)
setCourseOptions(null)
}}
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
>
{selectedFacility.name}
</button>
{courseOptions === null ? (
<p className="text-sm text-muted-foreground">Laster baner</p>
) : (
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
{courseOptions.map((c) => (
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
<button
type="button"
disabled={importing}
onClick={() => importCourse(c)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
>
{c.name}
{importing && <span className="text-xs font-normal text-muted-foreground">Importerer</span>}
</button>
</li>
))}
{courseOptions.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
Ingen 18-hulls baner å importere hos dette anlegget ennå.
</li>
)}
</ul>
)}
</>
)}
</div>
)
}