teecup/frontend/components/individual-tournament-detail.tsx
Erol Haagenrud 546900c2d6
Some checks failed
Backend-tester / test (push) Successful in 1m8s
Frontend-tester / test (push) Failing after 20s
Før endringer i turneringsoppsettet
2026-08-21 13:54:01 +02:00

5051 lines
194 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, useRef, useState } from "react"
import Link from "next/link"
import dynamic from "next/dynamic"
import { useRouter } from "next/navigation"
import type { FlagMapEntry } from "@/components/flag-map-overview"
import {
ArrowLeft,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ExternalLink,
Clock,
Copy,
Crosshair,
Flag,
KeyRound,
Layers,
MapPin,
Medal,
Plus,
Settings2,
Trash2,
Trophy,
Upload,
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 { PlayerImportPanel } from "@/components/player-import-panel"
import { RoundGroupsPanel } from "@/components/round-groups-panel"
import { RoundGroupsStepList, type RoundSummary } from "@/components/round-groups-step-list"
import { TournamentPlayersTable } from "@/components/tournament-players-table"
import { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card"
import { TournamentSetupNav, type SetupStep } from "@/components/tournament-setup-nav"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
ChoiceRow,
DirectionCross,
NumberPicker,
Stepper,
WizardSection,
} from "@/components/hole-stat-inputs"
import { HoleHistoryDetail } from "@/components/hole-history-detail"
import { HoleTargetDistance } from "@/components/hole-target-distance"
import { ClubPicker } from "@/components/teecup/club-picker"
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
// Utvidet registreringsskjema (2026-08-14, migrasjon 074).
first_name: string | null
last_name: string | null
birth_date: string | null // ISO yyyy-mm-dd
email: string | null
club_member_number: string | null
club: string | null
country: string | null
paid: boolean
comment: string | null
}
// Feltene sendt inn ved "Opprett ny spiller" (AddParticipantControl) --
// egen type fremfor å gjenbruke ApiPlayer direkte, siden dette er et
// SKJEMA (før spilleren finnes), ikke en allerede opprettet rad.
type NewPlayerInput = {
firstName: string
lastName: string
gender?: "m" | "f" | "x"
birthDate?: string
email?: string
clubMemberNumber?: string
club?: string
country?: string
handicapIndex?: number
paid: boolean
comment?: string
}
type ApiTournamentInfo = {
id: string
name: string
status: TournamentStatus
join_code: string
format_type: string
scoring_method: string | null
// Turneringsinnstillinger (2026-08-18) -- eksisterte alle på backend
// (fullt fungerende, ekte selvregistrerings-logikk i registration.py)
// uten noen UI noe sted til å sette dem, før TournamentSettingsCard.
start_date: string | null
end_date: string | null
visibility: string
description: string | null
registration_deadline: string | null
registration_capacity: number | null
registration_overflow_policy: string
registration_requires_approval: boolean
// KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30).
bbb_sweep_bonus_enabled: boolean
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
flag_map_visible: boolean
// Cut (migrasjon 083, 2026-08-18) -- kun meningsfullt for brutto/netto/
// stableford. cut_applied_at er null inntil organisator faktisk trykker
// "Anvend cut" -- cut_after_round/cut_size alene er bare konfigurasjon.
cut_after_round: number | null
cut_size: number | null
cut_applied_at: string | null
// Tiebreak (migrasjon 085, 2026-08-18) -- kun meningsfullt for brutto/
// netto/stableford. To UAVHENGIGE innstillinger (vinner/felt kan ha
// ulik metode). Påvirker ALDRI Cut-grensen, kun sluttresultatets
// plasseringsvisning. "none" = delt plassering (uendret oppførsel).
tiebreak_winner_method: string
tiebreak_field_method: string
// Omspill (migrasjon 086, 2026-08-18) -- "playoff" er kun gyldig for
// tiebreak_winner_method. Fritekst-regel vist proaktivt i Oppsett OG
// på leaderboardet, ikke bare når en uavgjort faktisk skjer.
tiebreak_playoff_rule: string | null
}
type ApiParticipant = {
id: string
player_id: string
player_name: string
handicap_index_snapshot: number | null
class_id: string | null
class_name: string | null
// Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye HoleGrid/
// HoleStatsSheet ber om ved scoring, tournament-bredt (ikke per runde).
stat_level: "strokes_only" | "strokes_and_putts" | "full"
// Manuell status (migrasjon 087, 2026-08-18) -- "active" (uendret) eller
// dsq/rtd/dnf/dns. Overstyrer leaderboardets total-visning og blokkerer
// videre score-registrering i ALLE runder.
status: "active" | "dsq" | "rtd" | "dnf" | "dns"
}
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
// standardutslag. Her (individuelle turneringer) brukes klasse til BÅDE
// utslag-forhåndsutfylling (rundedeltakelse-tabellen, ADR-093 -- flyttet
// dit fra den nå fjernede AssignRoundParticipantControl 2026-08-19) 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
// Statistikknivå (migrasjon 075, ADR-071) -- "strokes_only" /
// "strokes_and_putts" / "full", styrer om HoleGrid sin celle åpner det
// enkle inline-tallfeltet (uendret oppførsel) eller HoleStatsSheet.
stat_level: "strokes_only" | "strokes_and_putts" | "full"
}
type ApiHole = {
hole_number: number
par: number
stroke_index: number
gross_strokes: number | null
// Full statistikkdybde (migrasjon 075, ADR-071) -- speiler round_hole
// (frittstående runder), styrt av deltakerens stat_level. Alltid til
// stede i responsen (null når ikke registrert), uansett stat_level --
// frontend avgjør selv hva som faktisk VISES/redigeres.
putts: number | null
club_off_tee: string | null
tee_shot_result: string | null
approach_result: string | null
chip_count: number | null
bunker_shot_count: number | null
penalty_strokes: number | null
first_putt_distance_bucket: string | null
anyway_strokes: number | null
version: number
strokes_received: number | null
}
type ApiRoundCell = {
round_number: number
label: string
tone: "under" | "even" | "over" | null
}
type ApiEclecticHoleCell = {
hole_number: number
par: number
value: number
round_number: number
}
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
// Nøytral "ikke startet"-info (2026-08-18) -- kun satt når deltakeren
// ikke har startet gjeldende/neste runde (thru_label "-"), se
// stroke-play-leaderboard.tsx.
next_tee_time: string | null
next_start_hole: number | null
rounds: ApiRoundCell[]
// Eclectic (ADR-067-tillegget "Del C", 2026-08-14) -- kun populert for
// scoring_method eclectic_gross/eclectic_net/eclectic_stableford.
eclectic_total: number | null
eclectic_holes: ApiEclecticHoleCell[]
}
type ApiFlagResult = {
tournament_participant_id: string
player_name: string
holes_completed: number
ran_out: boolean
strokes_remaining: number
}
// GPS-flaggplanting + runde 2+ (migrasjon 070, 2026-08-14) -- speiler
// rounds.py sin frittstående-variant (round-detail.tsx), se der for full
// begrunnelse. Duplisert her (ikke importert på tvers av de to store
// sidefilene) -- samme selvstendighets-konvensjon som round-scorecard.tsx
// allerede følger for sine egne typer/hjelpefunksjoner.
type ApiFlagPlant = {
id: string
lap: number
hole_number: number
lat: number
lng: number
on_green: boolean
distance_to_pin_cm: number | null
planted_by_user_id: string
planted_at: string
}
type ApiFlagOverflowHole = {
lap: number
hole_number: number
gross_strokes: number | null
played: boolean
}
function playedHoleNumbersOrg(holeConfig: "full_18" | "front_9" | "back_9"): number[] {
if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
return Array.from({ length: 18 }, (_, i) => i + 1)
}
function computeFlagCurrentPositionOrg(
playOrder: number[],
holes: ApiHole[] | null,
overflowHoles: ApiFlagOverflowHole[],
): { lap: number; holeNumber: number } {
const lap1Played = new Set((holes ?? []).filter((h) => h.gross_strokes !== null).map((h) => h.hole_number))
for (const h of playOrder) {
if (!lap1Played.has(h)) return { lap: 1, holeNumber: h }
}
let lap = 2
while (true) {
const lapPlayed = new Set(overflowHoles.filter((h) => h.lap === lap && h.played).map((h) => h.hole_number))
for (const h of playOrder) {
if (!lapPlayed.has(h)) return { lap, holeNumber: h }
}
lap += 1
}
}
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",
eclectic_gross: "Eclectic (brutto)",
eclectic_net: "Eclectic (netto)",
eclectic_stableford: "Eclectic (Stableford)",
}
const ECLECTIC_SCORING_METHODS = new Set(["eclectic_gross", "eclectic_net", "eclectic_stableford"])
function holeNumbersFor(config: ApiRound["hole_config"]): number[] {
if (config === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1)
if (config === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10)
return Array.from({ length: 18 }, (_, i) => i + 1)
}
async function getJson<T>(url: string): Promise<T | null> {
const res = await fetch(url, { credentials: "include" })
if (!res.ok) return null
return (await res.json()) as T
}
async function errorMessage(res: Response, fallback: string): Promise<string> {
try {
const body = await res.json()
return body?.detail?.message ?? fallback
} catch {
return fallback
}
}
// --- Component ---------------------------------------------------------
export function IndividualTournamentDetail({
organizationId,
tournamentId,
tournamentName,
}: {
organizationId: string
tournamentId: string
tournamentName: string
}) {
const router = useRouter()
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")
// Kun org-eier/admin får se/bruke "Slett turnering" (ADR-086) -- samme
// sperre/begrunnelse som tournament-detail.tsx (lagformatet).
const [myRole, setMyRole] = useState<"owner" | "admin" | "member" | null>(null)
const [deleting, setDeleting] = useState(false)
const base = `/orgs/${organizationId}/tournaments/${tournamentId}`
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((me: { organizations: { organization_id: string; role: "owner" | "admin" | "member" }[] } | null) => {
if (cancelled || !me) return
setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [organizationId])
const isOrgAdmin = myRole === "owner" || myRole === "admin"
async function deleteTournament() {
if (
!confirm(
`Slette "${tournamentName}" permanent? Dette fjerner ALT -- deltakere, alle runder og all scoring. Kan ikke angres.`,
)
) {
return
}
setDeleting(true)
setError(null)
try {
const res = await fetch(base, { method: "DELETE", credentials: "include" })
if (!res.ok && res.status !== 204) {
const body = await res.json().catch(() => null)
throw new Error(body?.detail?.message ?? "Klarte ikke å slette turneringen.")
}
router.replace("/dashboard")
} catch (err) {
setError(err instanceof Error ? err.message : "Klarte ikke å slette turneringen.")
setDeleting(false)
}
}
// Løftet ut av useEffect (2026-08-19) -- brukes nå fra to steder: den
// vanlige innlastingen ved mount, OG en "frisk opp når fanen blir synlig
// igjen"-lytter under. Bruker: "Jeg kan ikke se at deltagertabellen blir
// oppdatert når det er lagret endringer i spillerpool-tabellen" -- rot-
// årsak: "Rediger i spillerpoolen" er en full sidenavigasjon (ekte
// <a href>, ikke Next-Link), og nettleserens bfcache gjenoppretter siden
// i EKSAKT samme (utdaterte) tilstand ved tilbake-navigasjon, uten å
// kjøre noen effekter på nytt -- `mountedRef` brukes i stedet for en
// per-kall `cancelled`-variabel siden funksjonen nå kalles fra flere
// uavhengige steder.
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
async function loadTournamentData() {
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 (!mountedRef.current) 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 (mountedRef.current) setError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.")
} finally {
if (mountedRef.current) setLoading(false)
}
}
useEffect(() => {
loadTournamentData()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId, tournamentId, base])
// Stille bakgrunns-refresh (ingen loading-spinner -- setLoading(false)
// er allerede satt) når fanen/vinduet blir synlig igjen, ELLER siden
// gjenopprettes fra bfcache (pageshow med persisted=true er det
// spesifikke signalet for akkurat det tilfellet).
useEffect(() => {
function handleVisible() {
if (document.visibilityState === "visible") loadTournamentData()
}
function handlePageShow(e: PageTransitionEvent) {
if (e.persisted) loadTournamentData()
}
document.addEventListener("visibilitychange", handleVisible)
window.addEventListener("pageshow", handlePageShow)
return () => {
document.removeEventListener("visibilitychange", handleVisible)
window.removeEventListener("pageshow", handlePageShow)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 updateFlagMapVisible(visible: boolean) {
if (!tournament) return
const previous = tournament
setTournament({ ...tournament, flag_map_visible: visible })
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ flag_map_visible: visible }),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å endre kartoversikt-innstillingen. Prøv igjen.")
}
}
// Cut (migrasjon 083, 2026-08-18) -- ren KONFIGURASJON, fritt endre/
// nullstille helt til "Anvend cut" faktisk trykkes (applyCut under).
async function updateCutConfig(afterRound: number | null, size: number | null) {
if (!tournament) return
const previous = tournament
setTournament({ ...tournament, cut_after_round: afterRound, cut_size: size })
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ cut_after_round: afterRound, cut_size: size }),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å lagre cut-innstillingene. Prøv igjen.")
}
}
// Tiebreak (migrasjon 085, 2026-08-18) -- ren KONFIGURASJON, samme
// optimistiske PATCH-mønster som scoringsmetode/cut over. Påvirker
// ALDRI Cut-grensen, kun sluttresultatets plasseringsvisning.
async function updateTiebreakConfig(winner: string, field: string, playoffRule: string | null) {
if (!tournament) return
const previous = tournament
setTournament({
...tournament,
tiebreak_winner_method: winner,
tiebreak_field_method: field,
tiebreak_playoff_rule: playoffRule,
})
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
tiebreak_winner_method: winner,
tiebreak_field_method: field,
tiebreak_playoff_rule: playoffRule,
}),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å lagre tiebreak-innstillingene. Prøv igjen.")
}
}
// Turneringsinnstillinger (2026-08-18, TournamentSettingsCard --
// V0-eksportert, se ADR/CHANGELOG #118). Komponenten viser sin EGEN
// lagre-/feil-tilstand rundt onSave-løftet -- IKKE fang feilen her
// (til forskjell fra de andre optimistiske PATCH-hjelperne over), la
// den kaste videre slik komponenten forventer.
async function updateSettings(next: TournamentSettingsValues) {
if (!tournament) return
const registrationDeadlineIso = fromDatetimeLocalValue(next.registrationDeadline)
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name: next.name,
start_date: next.startDate,
end_date: next.endDate,
visibility: next.visibility,
description: next.description || null,
registration_deadline: registrationDeadlineIso,
registration_capacity: next.registrationCapacity,
registration_overflow_policy: next.registrationOverflowPolicy,
registration_requires_approval: next.registrationRequiresApproval,
}),
})
if (!res.ok) throw new Error("Klarte ikke å lagre innstillingene.")
setTournament({
...tournament,
name: next.name,
start_date: next.startDate,
end_date: next.endDate,
visibility: next.visibility,
description: next.description || null,
registration_deadline: registrationDeadlineIso,
registration_capacity: next.registrationCapacity,
registration_overflow_policy: next.registrationOverflowPolicy,
registration_requires_approval: next.registrationRequiresApproval,
})
}
// MANUELL anvendelse (bruker bekreftet eksplisitt 2026-08-18: IKKE
// automatisk ved fullspilt runde) -- idempotent på serveren, men likevel
// en reell, konsekvensfull handling for ekte spillere (blokkerer dem fra
// videre scoring), derfor samme confirm()-mønster som andre irreversible
// handlinger i appen (slett turnering osv.).
async function applyCut() {
if (!tournament) return
if (
!confirm(
"Anvende cut nå? Spillere utenfor grensen blokkeres fra å registrere score i senere runder. Kan kjøres på nytt (regner alt på nytt), men bør ikke trykkes før alle er ferdige med runden.",
)
) {
return
}
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/apply-cut`, {
method: "POST",
credentials: "include",
})
if (!res.ok) throw new Error()
const result: { survivors: number; cut_count: number; added_to_later_rounds: number } = await res.json()
setTournament({ ...tournament, cut_applied_at: new Date().toISOString() })
await refreshParticipants()
setError(null)
const extra =
result.added_to_later_rounds > 0
? ` ${result.added_to_later_rounds} rundedeltakelser lagt til automatisk i senere runder (kuttede spillere er samtidig fjernet der de ikke allerede har scorer).`
: ""
alert(`Cut anvendt: ${result.survivors} går videre, ${result.cut_count} er kuttet.${extra}`)
} catch {
setError("Klarte ikke å anvende cutten. 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)))
}
// Etter massimport (PlayerImportPanel) -- panelet gjør sine egne
// POST-kall direkte (bulk + participants), enklest å hente
// deltakerlisten på nytt etterpå fremfor å slå sammen delvis ukjent
// respons-tilstand manuelt.
async function refreshParticipants() {
const data = await getJson<ApiParticipant[]>(`${base}/participants`)
if (data) setParticipants(data)
}
async function addNewPlayerAndParticipant(input: NewPlayerInput) {
setError(null)
const displayName = `${input.firstName} ${input.lastName}`.trim()
const playerRes = await fetch(`/orgs/${organizationId}/players`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
display_name: displayName,
first_name: input.firstName.trim() || null,
last_name: input.lastName.trim() || null,
handicap_index: input.handicapIndex ?? null,
gender: input.gender ?? null,
birth_date: input.birthDate || null,
email: input.email?.trim() || null,
club_member_number: input.clubMemberNumber?.trim() || null,
club: input.club?.trim() || null,
country: input.country?.trim() || null,
paid: input.paid,
comment: input.comment?.trim() || null,
}),
})
if (!playerRes.ok) {
setError(await errorMessage(playerRes, "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 setParticipantStatLevel(
participantId: string,
statLevel: "strokes_only" | "strokes_and_putts" | "full",
) {
setError(null)
const res = await fetch(`${base}/participants/${participantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ stat_level: statLevel }),
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å endre statistikknivå."))
return
}
const updated: ApiParticipant = await res.json()
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
}
// Retting av EN deltakers HCP direkte i turneringen (2026-08-18) --
// spillerpoolen (ADR-085) endrer kun grunnlaget for FREMTIDIGE
// rundepåmeldinger, aldri en allerede satt handicap_index_snapshot her.
// Rører ALDRI allerede opprettede tournament_round_participant.course_
// handicap for runder deltakeren allerede er satt opp i (samme "frosset
// snapshot"-prinsipp som ADR-085 sin bulk-import allerede har).
async function setParticipantHandicap(participantId: string, handicap: number | null) {
setError(null)
const res = await fetch(`${base}/participants/${participantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ handicap_index_snapshot: handicap }),
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å endre handicap."))
return
}
const updated: ApiParticipant = await res.json()
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
}
// Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne
// sette Dsq, Rtd, Dnf, og Dns." Tournament-bredt, blokkerer videre
// score i ALLE runder (strengere enn Cut).
async function setParticipantStatus(
participantId: string,
status: "active" | "dsq" | "rtd" | "dnf" | "dns",
) {
setError(null)
const res = await fetch(`${base}/participants/${participantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ status }),
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å endre status."))
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} />
{isOrgAdmin && (
<button
type="button"
onClick={() => void deleteTournament()}
disabled={deleting}
aria-label="Slett turnering"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-50"
>
<Trash2 aria-hidden="true" className="size-5" />
</button>
)}
</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>
{/* Leaderboard-fanen er BEVISST unntatt fra den delte 4xl-bredden
(2026-08-18, brukerrapport: "fyller ikke hele bredden på skjermen")
-- StrokePlayLeaderboard er selv bygget for å skalere opp og fylle
en stor skjerm (egne `lg:`-brytningspunkt-klasser, se komponentens
egen kommentar om "storskjerm uten skrolling"), men satt inni denne
delte 4xl-wrapperen (896px, samme wrapper som skjema-tunge Oppsett/
Presentasjon-fanene bruker) fikk den aldri sjansen til det -- felles
med resten av siden siden komponenten ble bygget/testet isolert.
Oppsett-fanen fikk samme problem 2026-08-18 (ADR-092): 6 steg i
TournamentSetupNav får ikke plass side ved side i 896px, selv på en
bred skjerm -- akkurat den "stor skjerm først"-regelen wizarden
skulle innfri. Egen, litt smalere unntaksbredde enn leaderboardets
110rem (dette er skjemainnhold, ikke en datatabell). */}
<main
className={cn(
"mx-auto w-full flex-1 px-5 py-6 sm:py-8",
tab === "leaderboard" ? "max-w-[110rem]" : tab === "setup" ? "max-w-6xl" : "max-w-4xl",
)}
>
{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}
onUpdateFlagMapVisible={updateFlagMapVisible}
onUpdateCutConfig={updateCutConfig}
onUpdateTiebreakConfig={updateTiebreakConfig}
onUpdateSettings={updateSettings}
onApplyCut={applyCut}
onAddParticipant={addParticipant}
onAddNewPlayer={addNewPlayerAndParticipant}
onImportComplete={refreshParticipants}
onRemoveParticipant={removeParticipant}
onAddRound={addRound}
onDeleteRound={deleteRound}
onAddCourse={addCourse}
onCourseImported={handleCourseImported}
onCreateClass={createClass}
onDeleteClass={deleteClass}
onSetParticipantClass={setParticipantClass}
onSetParticipantStatLevel={setParticipantStatLevel}
onSetParticipantHandicap={setParticipantHandicap}
onSetParticipantStatus={setParticipantStatus}
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
// =========================================================================
// <input type="datetime-local"> krever "YYYY-MM-DDTHH:mm" (ingen sekunder/
// tidssone) -- backend (Pydantic datetime) serialiserer/forventer full ISO
// med tidssone. Samme konverteringsbehov -- og samme tidssone-bug hvis det
// hoppes over -- som allerede funnet og fikset i round-groups-panel.tsx
// (ADR-088): new Date(...).toISOString() FØR sending, nettleserens egen
// lokal-tolkning ved visning.
function toDatetimeLocalValue(iso: string | null): string | null {
if (!iso) return null
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return null
const pad = (n: number) => String(n).padStart(2, "0")
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function fromDatetimeLocalValue(value: string | null): string | null {
if (!value) return null
const date = new Date(value)
return Number.isNaN(date.getTime()) ? null : date.toISOString()
}
// Wizard-steg (ADR-092) -- GameBook-skjermdumper var strukturelt forbilde
// (steg i stedet for én lang stablet side), IKKE visuelt forbilde.
// Fri navigasjon -- ingen låsing/fullført-haker, se ADR-092: GameBooks
// egne skjermdumper viser haker på alle steg selv med tomt navnefelt,
// altså ikke ekte validering, og TeeCup er allerede fleksibelt på
// oppsett-rekkefølge.
const SETUP_STEPS: SetupStep[] = [
{ key: "basics", label: "Turneringsoppsett", icon: Settings2 },
{ key: "rounds", label: "Runder og baner", icon: Flag },
{ key: "classes", label: "Klasser", icon: Layers },
{ key: "players", label: "Spillere", icon: Users },
{ key: "groups", label: "Grupper og startliste", icon: Clock },
{ key: "results", label: "Resultat og opsjoner", icon: Trophy },
]
function SetupTab({
base,
organizationId,
tournament,
participants,
rounds,
courses,
classes,
onUpdateScoringMethod,
onUpdateBbbSweepBonus,
onUpdateFlagMapVisible,
onUpdateCutConfig,
onApplyCut,
onUpdateTiebreakConfig,
onUpdateSettings,
onAddParticipant,
onAddNewPlayer,
onImportComplete,
onRemoveParticipant,
onAddRound,
onDeleteRound,
onAddCourse,
onCourseImported,
onCreateClass,
onDeleteClass,
onSetParticipantClass,
onSetParticipantStatLevel,
onSetParticipantHandicap,
onSetParticipantStatus,
onError,
}: {
base: string
organizationId: string
tournament: ApiTournamentInfo
participants: ApiParticipant[]
rounds: ApiRound[]
courses: ApiCourse[]
classes: ApiTournamentClass[]
onUpdateScoringMethod: (method: string) => void
onUpdateBbbSweepBonus: (enabled: boolean) => void
onUpdateFlagMapVisible: (visible: boolean) => void
onUpdateCutConfig: (afterRound: number | null, size: number | null) => Promise<void>
onApplyCut: () => Promise<void>
onUpdateTiebreakConfig: (winner: string, field: string, playoffRule: string | null) => Promise<void>
onUpdateSettings: (next: TournamentSettingsValues) => Promise<void>
onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
onImportComplete: () => 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>
onSetParticipantStatLevel: (
participantId: string,
statLevel: "strokes_only" | "strokes_and_putts" | "full",
) => Promise<void>
onSetParticipantHandicap: (participantId: string, handicap: number | null) => Promise<void>
onSetParticipantStatus: (
participantId: string,
status: "active" | "dsq" | "rtd" | "dnf" | "dns",
) => Promise<void>
onError: (message: string) => void
}) {
const [step, setStep] = useState<string>("basics")
const [groupsOpenFor, setGroupsOpenFor] = useState<string | null>(null)
const [showImport, setShowImport] = useState(false)
const [pool, setPool] = useState<ApiPlayer[]>([])
async function refreshPool() {
const data = await getJson<ApiPlayer[]>(`/orgs/${organizationId}/players`)
setPool(data ?? [])
}
useEffect(() => {
let cancelled = false
getJson<ApiPlayer[]>(`/orgs/${organizationId}/players`).then((data) => {
if (!cancelled) setPool(data ?? [])
})
return () => {
cancelled = true
}
}, [organizationId])
// Samme "frisk opp ved synlig igjen"-behov som gjelder hele siden
// (se IndividualTournamentDetail sin tilsvarende lytter) -- "Rediger i
// spillerpoolen" er en full sidenavigasjon, og bfcache kan gjenopprette
// denne komponenten i utdatert tilstand ved tilbake-navigasjon.
useEffect(() => {
function handleVisible() {
if (document.visibilityState === "visible") refreshPool()
}
function handlePageShow(e: PageTransitionEvent) {
if (e.persisted) refreshPool()
}
document.addEventListener("visibilitychange", handleVisible)
window.addEventListener("pageshow", handlePageShow)
return () => {
document.removeEventListener("visibilitychange", handleVisible)
window.removeEventListener("pageshow", handlePageShow)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId])
// Rundedeltakelse-tabellen (ADR-093, "Spillere"-steget) -- lat innlasting,
// KUN når steget faktisk vises (ikke eagerly ved sideinnlasting), av alle
// rundenes rundedeltakere OG alle unike baners utslag samtidig. Flere
// runder kan dele bane -- dedupliserer på course_id for å ikke hente
// samme banes utslag flere ganger.
const [roundParticipantsByRound, setRoundParticipantsByRound] = useState<Record<
string,
ApiRoundParticipant[]
> | null>(null)
const [teesByCourse, setTeesByCourse] = useState<Record<string, ApiTee[]>>({})
const [busyRoundIds, setBusyRoundIds] = useState<string[]>([])
const [participationErrors, setParticipationErrors] = useState<string[]>([])
useEffect(() => {
if (step !== "players" || roundParticipantsByRound !== null || rounds.length === 0) return
let cancelled = false
async function load() {
const uniqueCourseIds = Array.from(new Set(rounds.map((r) => r.course_id)))
const [rpLists, teeLists] = await Promise.all([
Promise.all(rounds.map((r) => getJson<ApiRoundParticipant[]>(`${base}/rounds/${r.id}/participants`))),
Promise.all(
uniqueCourseIds.map((cid) => getJson<ApiTee[]>(`/orgs/${organizationId}/courses/${cid}/tees`)),
),
])
if (cancelled) return
const byRound: Record<string, ApiRoundParticipant[]> = {}
rounds.forEach((r, i) => {
byRound[r.id] = rpLists[i] ?? []
})
const byCourse: Record<string, ApiTee[]> = {}
uniqueCourseIds.forEach((cid, i) => {
byCourse[cid] = teeLists[i] ?? []
})
setRoundParticipantsByRound(byRound)
setTeesByCourse(byCourse)
}
load()
return () => {
cancelled = true
}
}, [step, roundParticipantsByRound, rounds, base, organizationId])
async function refreshRoundParticipants(roundId: string) {
const data = await getJson<ApiRoundParticipant[]>(`${base}/rounds/${roundId}/participants`)
setRoundParticipantsByRound((prev) => ({ ...(prev ?? {}), [roundId]: data ?? [] }))
}
// "Anvend cut" (ADR-095) kaskader nå til rundedeltakelse i alle senere
// runder på backend -- tvinger derfor et fullt reload av
// roundParticipantsByRound (i stedet for kun ett enkelt refreshRoundParticipants-
// kall) siden FLERE runder kan ha fått rader lagt til/fjernet samtidig.
async function applyCutAndRefreshRounds() {
await onApplyCut()
setRoundParticipantsByRound(null)
}
async function toggleRoundParticipant(
roundId: string,
tournamentParticipantId: string,
checked: boolean,
teeId: string,
) {
setParticipationErrors([])
if (checked) {
const res = await fetch(`${base}/rounds/${roundId}/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()
setRoundParticipantsByRound((prev) => ({
...(prev ?? {}),
[roundId]: [...(prev?.[roundId] ?? []), created],
}))
} else {
const existing = (roundParticipantsByRound?.[roundId] ?? []).find(
(rp) => rp.tournament_participant_id === tournamentParticipantId,
)
if (!existing) return
const res = await fetch(`${base}/rounds/${roundId}/participants/${existing.id}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok) {
onError("Klarte ikke å fjerne deltakeren fra runden.")
return
}
setRoundParticipantsByRound((prev) => ({
...(prev ?? {}),
[roundId]: (prev?.[roundId] ?? []).filter((rp) => rp.id !== existing.id),
}))
}
}
async function bulkSetRoundParticipation(roundId: string, checked: boolean, defaultTeeId: string) {
setBusyRoundIds((prev) => [...prev, roundId])
setParticipationErrors([])
const currentlyAssigned = roundParticipantsByRound?.[roundId] ?? []
const assignedByParticipantId = new Map(currentlyAssigned.map((rp) => [rp.tournament_participant_id, rp]))
const failures: string[] = []
if (checked) {
// "Velg alle" + et valgt Standardutslag betyr "ALLE spiller dette
// utslaget" -- IKKE bare "fyll inn de som mangler". Rettet
// 2026-08-19 (bruker: "Dersom jeg velger at ALLE skal gå fra 55 så
// kan jeg ikke individuelt endre enkeltspillere") -- den gamle
// versjonen hoppet over alle som allerede var tilordnet runden
// (uansett hvilket utslag de hadde), så et valgt Standardutslag slo
// aldri inn for dem. PATCHer utslaget på allerede tilordnede i
// stedet for å hoppe over dem, POSTer nye for resten.
await Promise.all(
participants.map(async (p) => {
const teeId = resolveDefaultTeeId(p, classes, teesByCourse[rounds.find((r) => r.id === roundId)?.course_id ?? ""] ?? [], defaultTeeId)
if (!teeId) {
failures.push(`${p.player_name}: fant ikke noe gyldig utslag.`)
return
}
const existing = assignedByParticipantId.get(p.id)
if (existing) {
if (existing.tee_id === teeId) return // allerede riktig, ingenting å gjøre
const res = await fetch(`${base}/rounds/${roundId}/participants/${existing.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ tee_id: teeId }),
})
if (!res.ok) failures.push(`${p.player_name}: ${await errorMessage(res, "ukjent feil")}`)
} else {
const res = await fetch(`${base}/rounds/${roundId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ tournament_participant_id: p.id, tee_id: teeId }),
})
if (!res.ok) failures.push(`${p.player_name}: ${await errorMessage(res, "ukjent feil")}`)
}
}),
)
} else {
await Promise.all(
currentlyAssigned.map(async (rp) => {
const res = await fetch(`${base}/rounds/${roundId}/participants/${rp.id}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok) failures.push(`${rp.player_name}: klarte ikke å fjerne.`)
}),
)
}
await refreshRoundParticipants(roundId)
setBusyRoundIds((prev) => prev.filter((id) => id !== roundId))
if (failures.length > 0) {
setParticipationErrors([`${failures.length} feilet:`, ...failures])
}
}
async function changeRoundParticipantTee(roundId: string, tournamentParticipantId: string, teeId: string) {
// Rettet 2026-08-19 (bruker: "kan jeg ikke individuelt endre
// enkeltspillere") -- TournamentPlayersTable sin cellemodell kjenner
// kun tournament_participant-IDen (nøkkelen i cells[roundId][...]),
// ALDRI selve tournament_round_participant-raden sin egen ID. Denne
// funksjonen kalte tidligere PATCH-URL-en direkte med den ID-en den
// fikk inn, som stille traff feil (ikke-eksisterende) rad -- 404,
// ingen synlig endring. Slår nå opp riktig round-participant-ID
// FØRST, samme oppslagsmønster som toggleRoundParticipant sin
// DELETE-gren allerede bruker.
const existing = (roundParticipantsByRound?.[roundId] ?? []).find(
(rp) => rp.tournament_participant_id === tournamentParticipantId,
)
if (!existing) return
const res = await fetch(`${base}/rounds/${roundId}/participants/${existing.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ tee_id: teeId }),
})
if (!res.ok) {
onError(await errorMessage(res, "Klarte ikke å endre utslagssted."))
return
}
const updated: ApiRoundParticipant = await res.json()
setRoundParticipantsByRound((prev) => ({
...(prev ?? {}),
[roundId]: (prev?.[roundId] ?? []).map((rp) => (rp.id === updated.id ? updated : rp)),
}))
}
async function changePlayerGender(playerId: string, gender: "m" | "f" | "x") {
const res = await fetch(`/orgs/${organizationId}/players/${playerId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ gender }),
})
if (!res.ok) {
onError(await errorMessage(res, "Klarte ikke å endre kjønn."))
return
}
setPool((prev) => prev.map((p) => (p.id === playerId ? { ...p, gender } : p)))
}
async function changePlayerBirthDate(playerId: string, birthDate: string) {
const res = await fetch(`/orgs/${organizationId}/players/${playerId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ birth_date: birthDate }),
})
if (!res.ok) {
onError(await errorMessage(res, "Klarte ikke å endre fødselsdato."))
return
}
setPool((prev) => prev.map((p) => (p.id === playerId ? { ...p, birth_date: birthDate } : p)))
}
const settingsValues: TournamentSettingsValues = {
name: tournament.name,
startDate: tournament.start_date,
endDate: tournament.end_date,
visibility: (tournament.visibility as TournamentSettingsValues["visibility"]) ?? "org",
description: tournament.description ?? "",
registrationDeadline: toDatetimeLocalValue(tournament.registration_deadline),
registrationCapacity: tournament.registration_capacity,
registrationOverflowPolicy:
(tournament.registration_overflow_policy as TournamentSettingsValues["registrationOverflowPolicy"]) ??
"waitlist",
registrationRequiresApproval: tournament.registration_requires_approval,
}
const roundSummaries: RoundSummary[] = rounds.map((r) => ({
id: r.id,
name: r.name || `Runde ${r.sequence}`,
sequence: r.sequence,
courseName: r.course_name || null,
dateLabel: r.scheduled_at
? new Date(r.scheduled_at).toLocaleDateString("nb-NO", { day: "numeric", month: "short", year: "numeric" })
: null,
}))
const groupsOpenRound = rounds.find((r) => r.id === groupsOpenFor) ?? null
return (
<TournamentSetupNav steps={SETUP_STEPS} activeStep={step} onChange={setStep}>
{step === "basics" && <TournamentSettingsCard values={settingsValues} onSave={onUpdateSettings} />}
{step === "rounds" && (
<RoundsCard
base={base}
organizationId={organizationId}
rounds={rounds}
courses={courses}
scoringMethod={tournament.scoring_method}
onAddRound={onAddRound}
onDeleteRound={onDeleteRound}
onAddCourse={onAddCourse}
onCourseImported={onCourseImported}
onError={onError}
/>
)}
{step === "classes" && (
<ClassesCard
classes={classes}
courses={courses}
organizationId={organizationId}
onCreate={onCreateClass}
onDelete={onDeleteClass}
/>
)}
{step === "players" && (
<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">Spillere</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>
<Button type="button" variant="outline" onClick={() => setShowImport(true)} className="min-h-11 self-start">
<Upload aria-hidden="true" className="mr-2 size-4" />
Importer fra CSV
</Button>
{showImport && (
<PlayerImportPanel
organizationId={organizationId}
tournamentId={tournament.id}
mode={{ kind: "individual", classes: classes.map((c) => ({ id: c.id, name: c.name })) }}
onClose={() => setShowImport(false)}
onImportComplete={() => {
void (async () => {
await Promise.all([refreshPool(), onImportComplete()])
})()
}}
/>
)}
{participants.length > 0 && (
// HCP-en her er en FROSSET kopi, satt når deltakeren legges til
// (ADR-090) -- bevisst, ikke en bug (spurt eksplisitt om av
// bruker 2026-08-19, som ba om at dette kommuniseres tydeligere
// her og i /hjelp fremfor å reversere designet). Endringer i
// spillerpoolen når som helst ETTER dette synker ALDRI inn av
// seg selv -- HCP-en må rettes her, direkte.
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
HCP-en under er en egen kopi for turneringen, satt da deltakeren ble lagt til --
en endring i spillerpoolen oppdaterer den IKKE automatisk. Rett HCP-en direkte i
kolonnen under om den er feil.
</p>
)}
{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>
) : rounds.length > 0 && roundParticipantsByRound === null ? (
<p className="text-sm text-muted-foreground">Laster</p>
) : (
<TournamentPlayersTable
organizationId={organizationId}
classes={classes.map((c) => ({ id: c.id, name: c.name }))}
players={participants.map((p) => {
const player = pool.find((pl) => pl.id === p.player_id)
return {
participantId: p.id,
playerId: p.player_id,
playerName: p.player_name,
handicapSnapshot: p.handicap_index_snapshot,
classId: p.class_id,
statLevel: p.stat_level,
status: p.status,
gender: player?.gender ?? null,
birthDate: player?.birth_date ?? null,
}
})}
rounds={rounds.map((r) => ({
id: r.id,
label: r.name || `Runde ${r.sequence}`,
tees: teesByCourse[r.course_id] ?? [],
}))}
cells={Object.fromEntries(
rounds.map((r) => [
r.id,
Object.fromEntries(
participants.map((p) => {
const rp = (roundParticipantsByRound?.[r.id] ?? []).find(
(x) => x.tournament_participant_id === p.id,
)
return [
p.id,
rp
? { assigned: true, teeId: rp.tee_id, teeName: rp.tee_name }
: { assigned: false, teeId: null, teeName: null },
]
}),
),
]),
)}
onSetHandicap={onSetParticipantHandicap}
onSetClass={onSetParticipantClass}
onSetStatLevel={onSetParticipantStatLevel}
onSetStatus={onSetParticipantStatus}
onRemoveParticipant={onRemoveParticipant}
onChangeGender={changePlayerGender}
onChangeBirthDate={changePlayerBirthDate}
onToggleRound={toggleRoundParticipant}
onBulkToggleRound={bulkSetRoundParticipation}
onChangeRoundTee={changeRoundParticipantTee}
busyRoundIds={busyRoundIds}
errors={participationErrors}
/>
)}
<AddParticipantControl
pool={pool}
alreadyIn={new Set(participants.map((p) => p.player_id))}
onAddExisting={onAddParticipant}
onAddNew={onAddNewPlayer}
/>
</section>
)}
{step === "groups" && (
<div className="flex flex-col gap-6">
<RoundGroupsStepList rounds={roundSummaries} onManageGroups={setGroupsOpenFor} />
{groupsOpenRound && (
<RoundGroupsPanel
base={base}
organizationId={organizationId}
tournamentId={tournament.id}
roundId={groupsOpenRound.id}
roundLabel={groupsOpenRound.name || `Runde ${groupsOpenRound.sequence}`}
onClose={() => setGroupsOpenFor(null)}
/>
)}
</div>
)}
{step === "results" && (
<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>
)}
{tournament.scoring_method === "flag" && (
<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.flag_map_visible}
onChange={(e) => onUpdateFlagMapVisible(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">Kartoversikt over flagg</span>
<span className="text-xs text-muted-foreground">
Når dette er , kan alle deltakere se hvor alle andre har plantet flagget. Av som standard.
</span>
</span>
</label>
)}
</section>
{tournament.scoring_method != null &&
["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && (
<>
<CutCard tournament={tournament} rounds={rounds} onUpdateConfig={onUpdateCutConfig} onApplyCut={applyCutAndRefreshRounds} />
<TiebreakCard tournament={tournament} onUpdateConfig={onUpdateTiebreakConfig} />
</>
)}
</div>
)}
</TournamentSetupNav>
)
}
// Cut (migrasjon 083, 2026-08-18) -- topp N og delt plass, etter et
// organisator-valgt rundenummer, MANUELT anvendt (bruker bekreftet
// eksplisitt: ikke automatisk ved fullspilt runde). Samme "vanlig
// innstillingskort"-mønster som Scoringsmetode-seksjonen over -- ingen
// V0-runde nødvendig, dette er en liten tilføyelse til et allerede
// håndkodet oppsett-skjema, ikke en ny selvstendig UI-flate.
function CutCard({
tournament,
rounds,
onUpdateConfig,
onApplyCut,
}: {
tournament: ApiTournamentInfo
rounds: ApiRound[]
onUpdateConfig: (afterRound: number | null, size: number | null) => Promise<void>
onApplyCut: () => Promise<void>
}) {
const [afterRound, setAfterRound] = useState(tournament.cut_after_round?.toString() ?? "")
const [size, setSize] = useState(tournament.cut_size?.toString() ?? "")
const [saving, setSaving] = useState(false)
const [applying, setApplying] = useState(false)
const sortedRounds = [...rounds].sort((a, b) => a.sequence - b.sequence)
const dirty = afterRound !== (tournament.cut_after_round?.toString() ?? "") || size !== (tournament.cut_size?.toString() ?? "")
const configComplete = afterRound.trim() !== "" && size.trim() !== ""
async function handleSave() {
setSaving(true)
await onUpdateConfig(afterRound.trim() ? Number(afterRound) : null, size.trim() ? Number(size) : null)
setSaving(false)
}
async function handleApply() {
setApplying(true)
await onApplyCut()
setApplying(false)
}
return (
<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">Cut</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Topp N og delt plass slipper videre etter valgt runde. Kuttede spillere vises i egen
seksjon leaderboardet og blokkeres fra å registrere score i senere runder.
{sortedRounds.length > 0 && (
<> Turneringen har {sortedRounds.length} {sortedRounds.length === 1 ? "runde" : "runder"} satt opp.</>
)}
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4">
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Etter runde nr.</span>
<input
type="number"
min={1}
value={afterRound}
onChange={(e) => setAfterRound(e.target.value)}
placeholder="f.eks. 2"
className="h-11 rounded-xl border border-border bg-background px-4 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</label>
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Antall som går videre</span>
<input
type="number"
min={1}
value={size}
onChange={(e) => setSize(e.target.value)}
placeholder="f.eks. 20"
className="h-11 rounded-xl border border-border bg-background px-4 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<span className="text-xs text-muted-foreground">
Plassen dette tallet havner blir grensen -- ikke et fast tak. Er noen uavgjort
nøyaktig der, slipper alle de gjennom (kan altså bli litt flere enn {size || "N"}).
</span>
</label>
<button
type="button"
onClick={() => void handleSave()}
disabled={!dirty || saving}
className="inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Lagrer..." : "Lagre innstilling"}
</button>
</div>
<div className="flex flex-col items-start gap-2 border-t border-border pt-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-medium text-muted-foreground">
{tournament.cut_applied_at
? `Cut anvendt ${new Date(tournament.cut_applied_at).toLocaleString("no-NO", { dateStyle: "medium", timeStyle: "short" })}. Kan anvendes på nytt hvis noe rettes.`
: "Cut er ikke anvendt ennå -- ingen spillere er blokkert."}
</p>
<button
type="button"
onClick={() => void handleApply()}
disabled={!configComplete || applying}
className="inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-xl bg-primary px-5 text-sm font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{applying ? "Anvender..." : tournament.cut_applied_at ? "Anvend cut på nytt" : "Anvend cut"}
</button>
</div>
</section>
)
}
// Tiebreak (migrasjon 085, 2026-08-18) -- "Ved likt resultat ... Skal
// laveste hcp plasseres foran, beste siste 18/9/6/3/siste hull plasseres
// foran, eller deles plasseringene. Her kan svaret være forskjellig for
// vinner og resten av feltet." Bruker: metoden skal defineres av
// organisator ved oppsett, IKKE fastlåst i koden -- derfor to
// uavhengige nedtrekk. Samme "vanlig innstillingskort"-mønster som
// CutCard rett over.
// "Omspill" (migrasjon 086, 2026-08-18) -- KUN et gyldig valg for
// vinneren (bruker sin egen formulering: "ved lik score for LEDER"),
// ikke for resten av feltet -- appen kan ikke selv avgjøre et omspill,
// det skjer på banen. tiebreak_playoff_rule er fritekst vist proaktivt
// her OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
const TIEBREAK_OPTIONS_WINNER: { value: string; label: string; hint: string }[] = [
{ value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T1»." },
{ value: "countback", label: "Countback", hint: "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" },
{ value: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" },
{ value: "playoff", label: "Omspill", hint: "Avgjøres på banen -- beskriv formatet under, vises til alle før turneringen" },
]
const TIEBREAK_OPTIONS_FIELD: { value: string; label: string; hint: string }[] = [
{ value: "none", label: "Delt plassering", hint: "Ingen automatisk avgjørelse -- vises som «T5» osv." },
{ value: "countback", label: "Countback", hint: "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" },
{ value: "lowest_hcp", label: "Laveste HCP", hint: "Spilleren med lavest handicap-indeks vinner uavgjortheten" },
]
function TiebreakCard({
tournament,
onUpdateConfig,
}: {
tournament: ApiTournamentInfo
onUpdateConfig: (winner: string, field: string, playoffRule: string | null) => Promise<void>
}) {
const [winner, setWinner] = useState(tournament.tiebreak_winner_method)
const [field, setField] = useState(tournament.tiebreak_field_method)
const [playoffRule, setPlayoffRule] = useState(tournament.tiebreak_playoff_rule ?? "")
const [saving, setSaving] = useState(false)
const dirty =
winner !== tournament.tiebreak_winner_method ||
field !== tournament.tiebreak_field_method ||
playoffRule !== (tournament.tiebreak_playoff_rule ?? "")
async function handleSave() {
setSaving(true)
await onUpdateConfig(winner, field, winner === "playoff" ? playoffRule.trim() || null : null)
setSaving(false)
}
return (
<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">Tiebreak ved likt resultat</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Velges uavhengig for vinneren og resten av feltet. Påvirker kun sluttresultatets
plassering -- Cut-grensen (over) bruker alltid full delt plass, uansett denne innstillingen.
</p>
<div className="flex flex-col gap-4 sm:flex-row">
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Ved likt resultat for vinneren</span>
<select
value={winner}
onChange={(e) => setWinner(e.target.value)}
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{TIEBREAK_OPTIONS_WINNER.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">
{TIEBREAK_OPTIONS_WINNER.find((o) => o.value === winner)?.hint}
</span>
</label>
<label className="flex flex-1 flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Ved likt resultat i resten av feltet</span>
<select
value={field}
onChange={(e) => setField(e.target.value)}
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{TIEBREAK_OPTIONS_FIELD.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">
{TIEBREAK_OPTIONS_FIELD.find((o) => o.value === field)?.hint}
</span>
</label>
</div>
{winner === "playoff" && (
<label className="flex flex-col gap-1.5">
<span className="text-sm font-bold text-foreground">Omspill-format</span>
<input
type="text"
value={playoffRule}
onChange={(e) => setPlayoffRule(e.target.value)}
placeholder="F.eks. Sudden death fra hull 18, til vinner er kåret."
className="h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<span className="text-xs text-muted-foreground">
Vises leaderboardet slik at deltakerne kjenner formatet før turneringen.
</span>
</label>
)}
<button
type="button"
onClick={() => void handleSave()}
disabled={!dirty || saving}
className="inline-flex h-11 w-fit items-center justify-center gap-2 self-end rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Lagrer..." : "Lagre innstilling"}
</button>
</section>
)
}
// 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>
)
}
// Splitter et fritekst-navn i for-/etternavn -- samme "første ord =
// fornavn, resten = etternavn"-heuristikk som migrasjon 052/074 sin
// backfill, brukt her til å FORHÅNDSUTFYLLE opprett-skjemaet fra
// søkefeltet (fortsatt redigerbart -- kun et fornuftig utgangspunkt).
function splitName(fullName: string): { first: string; last: string } {
const trimmed = fullName.trim()
const spaceIndex = trimmed.indexOf(" ")
if (spaceIndex === -1) return { first: trimmed, last: "" }
return { first: trimmed.slice(0, spaceIndex), last: trimmed.slice(spaceIndex + 1).trim() }
}
const MAX_VISIBLE_MATCHES = 8
function AddParticipantControl({
pool,
alreadyIn,
onAddExisting,
onAddNew,
}: {
pool: ApiPlayer[]
alreadyIn: Set<string>
onAddExisting: (playerId: string) => Promise<void>
onAddNew: (input: NewPlayerInput) => Promise<void>
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [creating, setCreating] = useState(false)
const [firstName, setFirstName] = useState("")
const [lastName, setLastName] = useState("")
const [newHandicap, setNewHandicap] = useState("")
const [newGender, setNewGender] = useState<"m" | "f" | "x" | "">("")
const [showMoreDetails, setShowMoreDetails] = useState(false)
const [birthDate, setBirthDate] = useState("")
const [email, setEmail] = useState("")
const [clubMemberNumber, setClubMemberNumber] = useState("")
const [club, setClub] = useState("")
const [country, setCountry] = useState("")
const [paid, setPaid] = useState(false)
const [comment, setComment] = useState("")
const trimmed = query.trim()
const available = useMemo(() => pool.filter((p) => !alreadyIn.has(p.id)), [pool, alreadyIn])
const matches = useMemo(() => {
if (!trimmed) return available
const q = trimmed.toLowerCase()
return available.filter((p) => p.display_name.toLowerCase().includes(q))
}, [available, trimmed])
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)
setFirstName("")
setLastName("")
setNewHandicap("")
setNewGender("")
setShowMoreDetails(false)
setBirthDate("")
setEmail("")
setClubMemberNumber("")
setClub("")
setCountry("")
setPaid(false)
setComment("")
}
async function handleAddExisting(playerId: string) {
await onAddExisting(playerId)
reset()
setOpen(false)
}
function startCreating() {
const { first, last } = splitName(trimmed)
setFirstName(first)
setLastName(last)
setCreating(true)
}
async function handleCreate() {
if (firstName.trim().length === 0) return
const hcpValue = newHandicap.trim() === "" ? undefined : Number(newHandicap.replace(",", "."))
const hcp = hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined
await onAddNew({
firstName,
lastName,
gender: newGender || undefined,
handicapIndex: hcp,
birthDate: birthDate || undefined,
email: email || undefined,
clubMemberNumber: clubMemberNumber || undefined,
club: club || undefined,
country: country || undefined,
paid,
comment: comment || 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 blant eksisterende, eller skriv navnet på en ny…"
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>
{!creating && (
<>
{matches.length === 0 && available.length === 0 && (
<p className="px-1 text-sm text-muted-foreground">
{pool.length === 0
? "Ingen eksisterende spillere i organisasjonen ennå -- opprett den første under."
: "Alle eksisterende spillere er allerede lagt til -- opprett en ny under."}
</p>
)}
{matches.length === 0 && available.length > 0 && trimmed.length > 0 && (
<p className="px-1 text-sm text-muted-foreground">Ingen treff blant eksisterende spillere.</p>
)}
{matches.length > 0 && (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{matches.slice(0, MAX_VISIBLE_MATCHES).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>
)}
{matches.length > MAX_VISIBLE_MATCHES && (
<p className="px-1 text-xs text-muted-foreground">
+{matches.length - MAX_VISIBLE_MATCHES} flere -- fortsett å skrive for å begrense listen.
</p>
)}
</>
)}
{showCreate && !creating && (
<button
type="button"
onClick={startCreating}
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-3 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-first-name" className="text-xs font-semibold">
Fornavn
</Label>
<Input
id="new-participant-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-last-name" className="text-xs font-semibold">
Etternavn
</Label>
<Input
id="new-participant-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
</div>
<div className="flex gap-2">
<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 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>
{!showMoreDetails ? (
<button
type="button"
onClick={() => setShowMoreDetails(true)}
className="min-h-9 text-left text-xs font-bold text-primary"
>
+ Flere detaljer (fødselsdato, e-post, klubb, betalt m.m.)
</button>
) : (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex gap-2">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-birth-date" className="text-xs font-semibold">
Fødselsdato
</Label>
<Input
id="new-participant-birth-date"
type="date"
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-email" className="text-xs font-semibold">
E-postadresse
</Label>
<Input
id="new-participant-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
</div>
<div className="flex gap-2">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-club" className="text-xs font-semibold">
Hjemmeklubb
</Label>
<Input
id="new-participant-club"
value={club}
onChange={(e) => setClub(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-club-member-number" className="text-xs font-semibold">
Medlemsnummer i hjemmeklubb
</Label>
<Input
id="new-participant-club-member-number"
value={clubMemberNumber}
onChange={(e) => setClubMemberNumber(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="new-participant-country" className="text-xs font-semibold">
Land
</Label>
<Input
id="new-participant-country"
value={country}
onChange={(e) => setCountry(e.target.value)}
className="h-10 w-full rounded-xl text-sm sm:w-1/2"
/>
</div>
<label className="flex min-h-9 items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
checked={paid}
onChange={(e) => setPaid(e.target.checked)}
className="size-5 shrink-0 accent-primary"
/>
Betalt
</label>
<div className="flex flex-col gap-1">
<Label htmlFor="new-participant-comment" className="text-xs font-semibold">
Kommentar
</Label>
<textarea
id="new-participant-comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={2}
className="w-full resize-y rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
/>
</div>
</div>
)}
<Button
type="button"
onClick={handleCreate}
disabled={firstName.trim().length === 0}
className="h-10 rounded-xl text-sm font-bold"
>
Opprett og legg til «{`${firstName} ${lastName}`.trim()}»
</Button>
</div>
)}
</div>
)
}
function RoundsCard({
base,
organizationId,
rounds,
courses,
scoringMethod,
onAddRound,
onDeleteRound,
onAddCourse,
onCourseImported,
onError,
}: {
base: string
organizationId: string
rounds: ApiRound[]
courses: ApiCourse[]
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}
round={r}
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,
round,
scoringMethod,
onDelete,
onError,
}: {
base: string
round: ApiRound
scoringMethod: string | null
onDelete: () => Promise<void>
onError: (message: string) => void
}) {
const [expanded, setExpanded] = useState(false)
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null)
const [confirmingDelete, setConfirmingDelete] = useState(false)
async function loadRoundParticipants() {
const rpData = await getJson<ApiRoundParticipant[]>(`${base}/rounds/${round.id}/participants`)
setRoundParticipants(rpData ?? [])
}
useEffect(() => {
if (expanded && roundParticipants === null) {
loadRoundParticipants()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expanded])
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))
}
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>
<p className="text-xs text-muted-foreground">
Legg til eller fjern deltakere fra runden under «Spillere».
</p>
</>
)}
</div>
)}
</div>
)
}
// Klassens standardutslag brukes som forhåndsvalg ved bulk-tilordning i
// den sammenslåtte spillertabellen (ADR-093/094, tournament-players-
// table.tsx) -- samme oppløsning som den nå fjernede
// AssignRoundParticipantControl brukte for ett-om-gangen-tilordning:
// deltakerens klasses standardutslag, KUN hvis det faktisk finnes på
// den aktuelle rundens bane (klassens standardutslag kan tilhøre en
// annen bane). Faller tilbake til rundens eget valgte "Standardutslag"
// (lokal UI-state inni tournament-players-table.tsx), og til slutt til
// det første utslaget i lista.
function resolveDefaultTeeId(
participant: ApiParticipant,
classes: ApiTournamentClass[],
tees: ApiTee[],
roundDefaultTeeId: string | null,
): string | null {
const cls = participant.class_id ? classes.find((c) => c.id === participant.class_id) : null
const classTee = cls?.default_tee_id ? tees.find((t) => t.id === cls.default_tee_id) : null
if (classTee) return classTee.id
if (roundDefaultTeeId && tees.some((t) => t.id === roundDefaultTeeId)) return roundDefaultTeeId
return tees[0]?.id ?? null
}
// =========================================================================
// 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)
}
// Full statistikkdybde (migrasjon 075, ADR-071) -- samme endepunkt som
// updateHole over, men med hele HolePatch-kroppen fra HoleStatsSheet i
// stedet for kun gross_strokes. Kaster videre ved feil (409 STALE_VERSION
// inkludert) -- HoleStatsSheet fanger og viser en generisk feilmelding,
// beholder brukerens utfylte skjema i stedet for å lukke sheet-et blindt.
async function updateHoleFull(holeNumber: number, patch: HolePatch) {
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(patch),
})
if (!res.ok) {
throw new Error("Klarte ikke å lagre.")
}
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
base={base}
roundId={roundId}
participantId={participantId}
holes={holes}
holeConfig={round.hole_config}
statLevel={roundParticipants.find((rp) => rp.id === participantId)?.stat_level ?? "strokes_only"}
playerName={roundParticipants.find((rp) => rp.id === participantId)?.player_name ?? ""}
strokesReceived={(n) => holes.find((h) => h.hole_number === n)?.strokes_received ?? null}
ownBagClubs={[]}
onUpdate={updateHole}
onUpdateFull={updateHoleFull}
/>
)}
{/* 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} />
)}
{/* GPS-flaggplanting + runde 2+ (2026-08-14) -- for DEN VALGTE
deltakeren (samme selv-only autorisasjon som selve
scoreføringen over, ingen vits å vise for andre enn den man
faktisk fører for). */}
{round && scoringMethod === "flag" && roundParticipants.find((rp) => rp.id === participantId) && (
<FlagPlantPanel
base={base}
round={round}
participant={roundParticipants.find((rp) => rp.id === participantId)!}
refreshTick={refreshTick}
onChanged={() => setRefreshTick((n) => n + 1)}
/>
)}
{/* Kartoversikt over flagg (migrasjon 071, "Del B", ADR-067) --
kun for org-medlem/deltaker (ingen offentlig tilskuer-gren
for individuelle turneringer, se ADR-067). */}
{round && scoringMethod === "flag" && (
<FlagMapSectionOrg 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>
)
}
// --- GPS-flaggplanting + runde 2+ (2026-08-14) -------------------------------
// V0-eksport (zip 11), samme komponent som round-detail.tsx sin
// FlagPlantSheet -- egen kopi her (fil-lokalt konvensjon, se
// FlagPlantSheet i round-detail.tsx for samme innmat/kommentar).
function FlagPlantSheetOrg({
open,
onClose,
expectedHoleNumber,
expectedLap,
onConfirm,
}: {
open: boolean
onClose: () => void
expectedHoleNumber: number
expectedLap: number
onConfirm: (result: {
lat: number
lng: number
onGreen: boolean
distanceToPinCm: number | null
}) => Promise<{ ok: true } | { ok: false; message: string }>
}) {
const [step, setStep] = useState<"gps" | "holedOut" | "green" | "done">("gps")
const [geoStatus, setGeoStatus] = useState<"loading" | "error">("loading")
const [position, setPosition] = useState<{ lat: number; lng: number } | null>(null)
// Step 2 "Ja" reveals a dead-end guidance card (no submission).
const [holedOutYes, setHoledOutYes] = useState(false)
// Step 3 answer + distance inputs (kept as strings for clean input UX).
const [onGreen, setOnGreen] = useState<boolean | null>(null)
const [meters, setMeters] = useState("")
const [centimeters, setCentimeters] = useState("")
const [submitting, setSubmitting] = useState(false)
const [serverError, setServerError] = useState<string | null>(null)
const [confirmedSummary, setConfirmedSummary] = useState<{
onGreen: boolean
distanceToPinCm: number | null
} | null>(null)
// Reset everything each time the sheet is (re)opened.
useEffect(() => {
if (!open) return
setStep("gps")
setGeoStatus("loading")
setPosition(null)
setHoledOutYes(false)
setOnGreen(null)
setMeters("")
setCentimeters("")
setSubmitting(false)
setServerError(null)
setConfirmedSummary(null)
}, [open])
// Step 1 — automatic GPS acquisition. Runs on entering the gps step.
const geoRequested = useRef(false)
useEffect(() => {
if (!open || step !== "gps") {
geoRequested.current = false
return
}
if (geoRequested.current) return
geoRequested.current = true
if (typeof navigator === "undefined" || !navigator.geolocation) {
setGeoStatus("error")
return
}
setGeoStatus("loading")
navigator.geolocation.getCurrentPosition(
(pos) => {
setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
setStep("holedOut")
},
() => setGeoStatus("error"),
{ enableHighAccuracy: true, timeout: 10000 },
)
}, [open, step])
function retryGeo() {
geoRequested.current = false
setGeoStatus("loading")
if (typeof navigator === "undefined" || !navigator.geolocation) {
setGeoStatus("error")
return
}
geoRequested.current = true
navigator.geolocation.getCurrentPosition(
(pos) => {
setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude })
setStep("holedOut")
},
() => setGeoStatus("error"),
{ enableHighAccuracy: true, timeout: 10000 },
)
}
if (!open) return null
// ---- validation for the on-green distance --------------------------------
const metersNum = meters === "" ? null : Number(meters)
const cmNum = centimeters === "" ? null : Number(centimeters)
const metersValid = metersNum !== null && Number.isInteger(metersNum) && metersNum >= 0
const cmValid = cmNum !== null && Number.isInteger(cmNum) && cmNum >= 0 && cmNum <= 99
const greenInputValid = metersValid && cmValid
const canConfirm = onGreen === false || (onGreen === true && greenInputValid)
async function handleConfirm() {
if (!position || onGreen === null || submitting) return
const distanceToPinCm =
onGreen && metersNum !== null && cmNum !== null ? metersNum * 100 + cmNum : null
setSubmitting(true)
setServerError(null)
try {
const res = await onConfirm({
lat: position.lat,
lng: position.lng,
onGreen,
distanceToPinCm,
})
if (res.ok) {
setConfirmedSummary({ onGreen, distanceToPinCm })
setStep("done")
} else {
setServerError(res.message)
}
} catch {
setServerError("Noe gikk galt. Sjekk nettforbindelsen og prøv igjen.")
} finally {
setSubmitting(false)
}
}
function goBack() {
setServerError(null)
if (step === "green") {
setOnGreen(null)
setMeters("")
setCentimeters("")
setStep("holedOut")
}
}
const showBack = step === "green"
return (
<div
className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
role="dialog"
aria-modal="true"
aria-label={`Plant flagget, hull ${expectedHoleNumber}`}
>
{/* Header */}
<header className="flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 pb-4 pt-[max(1rem,env(safe-area-inset-top))]">
<div className="flex items-start gap-2">
{showBack ? (
<button
type="button"
onClick={goBack}
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-2 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronLeft className="size-5 shrink-0" aria-hidden="true" />
Tilbake
</button>
) : null}
<div className="flex items-start gap-2 pt-1.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-orange/15 text-brand-orange">
<Flag className="size-5" aria-hidden="true" />
</span>
<div>
<h2 className="text-lg font-bold leading-tight text-balance">Plant flagget</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
{`Hull ${expectedHoleNumber} · runde ${expectedLap}`}
</p>
</div>
</div>
</div>
<button
type="button"
onClick={onClose}
className="inline-flex min-h-11 items-center gap-1 rounded-xl px-3 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-5 shrink-0" aria-hidden="true" />
Lukk
</button>
</header>
{/* Main */}
<main className="flex-1 overflow-y-auto p-4">
{/* Step 1 — GPS */}
{step === "gps" ? (
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-12 text-center">
{geoStatus === "loading" ? (
<>
<div className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
<p className="text-lg font-semibold">Finner posisjonen din </p>
<p className="text-base text-muted-foreground text-pretty">
Stå der du gikk tom for slag mens vi henter GPS-posisjonen.
</p>
</>
) : (
<div className="flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-5 text-left">
<p role="alert" className="text-base text-destructive text-pretty">
Fant ikke posisjonen din. Sjekk at posisjonstjenester er for
TeeCup, og prøv igjen.
</p>
<button
type="button"
onClick={retryGeo}
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Crosshair className="size-5 shrink-0" aria-hidden="true" />
Prøv igjen
</button>
</div>
)}
</div>
) : null}
{/* Step 2 — Holed out on this hole? */}
{step === "holedOut" ? (
<div className="mx-auto flex max-w-md flex-col gap-5 pt-2">
<h3 className="text-2xl font-bold text-balance">
{`Hullet du ut på hull ${expectedHoleNumber}?`}
</h3>
<p className="text-base text-muted-foreground text-pretty">
Er hullet fullført, scoren føres inn før du kan plante flagget.
</p>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setHoledOutYes(true)}
aria-pressed={holedOutYes}
className={cn(
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
holedOutYes
? "border-brand-orange bg-brand-orange text-brand-orange-foreground"
: "border-border bg-card text-foreground hover:bg-muted",
)}
>
Ja
</button>
<button
type="button"
onClick={() => {
setHoledOutYes(false)
setStep("green")
}}
className="inline-flex min-h-14 items-center justify-center rounded-xl border-2 border-border bg-card px-6 text-lg font-bold text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Nei
</button>
</div>
{/* "Ja" is a friendly dead end — guidance + Lukk, no submission. */}
{holedOutYes ? (
<div className="flex flex-col gap-4 rounded-2xl border border-brand-orange/40 bg-brand-orange/5 p-4">
<p role="alert" className="text-base text-foreground text-pretty">
{`Da må du først føre inn scoren for hull ${expectedHoleNumber}. Trykk «Plant flagget» på nytt når du er ved neste utslag.`}
</p>
<button
type="button"
onClick={onClose}
className="inline-flex min-h-14 items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Lukk
</button>
</div>
) : null}
</div>
) : null}
{/* Step 3 — On the green? + distance */}
{step === "green" ? (
<div className="mx-auto flex max-w-md flex-col gap-5 pt-2">
<h3 className="text-2xl font-bold text-balance">Landet du green?</h3>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setOnGreen(true)}
aria-pressed={onGreen === true}
className={cn(
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
onGreen === true
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-muted",
)}
>
Ja
</button>
<button
type="button"
onClick={() => {
setOnGreen(false)
setMeters("")
setCentimeters("")
}}
aria-pressed={onGreen === false}
className={cn(
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
onGreen === false
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-muted",
)}
>
Nei
</button>
</div>
{onGreen === true ? (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
<p className="text-sm text-muted-foreground text-pretty">
Dette avgjør rekkefølgen mot andre spillere som gikk tom
samme hull.
</p>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<label htmlFor="fp-org-meters" className="text-base font-semibold">
Meter
</label>
<input
id="fp-org-meters"
inputMode="numeric"
pattern="[0-9]*"
value={meters}
onChange={(e) => setMeters(e.target.value.replace(/[^0-9]/g, ""))}
aria-invalid={meters !== "" && !metersValid}
className="h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="0"
/>
</div>
<div className="flex flex-col gap-1.5">
<label htmlFor="fp-org-cm" className="text-base font-semibold">
Centimeter
</label>
<input
id="fp-org-cm"
inputMode="numeric"
pattern="[0-9]*"
value={centimeters}
onChange={(e) => setCentimeters(e.target.value.replace(/[^0-9]/g, ""))}
aria-invalid={centimeters !== "" && !cmValid}
className="h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="0"
/>
</div>
</div>
{centimeters !== "" && !cmValid ? (
<p role="alert" className="text-sm font-medium text-destructive">
Centimeter være mellom 0 og 99.
</p>
) : null}
</div>
) : null}
{serverError ? (
<p
role="alert"
className="rounded-xl border border-destructive/40 bg-destructive/10 p-3 text-base font-medium text-destructive text-pretty"
>
{serverError}
</p>
) : null}
</div>
) : null}
{/* Step 4 — Success */}
{step === "done" && confirmedSummary ? (
<div className="mx-auto flex max-w-md flex-col items-center gap-4 pt-12 text-center">
<span className="flex size-20 items-center justify-center rounded-full bg-primary/10 text-primary">
<Check className="size-11" strokeWidth={2.5} aria-hidden="true" />
</span>
<h3 className="text-2xl font-bold">Flagget er plantet!</h3>
<div className="flex flex-col items-center gap-1">
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-base font-semibold">
<MapPin className="size-4 shrink-0" aria-hidden="true" />
{`Hull ${expectedHoleNumber}`}
</span>
{confirmedSummary.onGreen && confirmedSummary.distanceToPinCm !== null ? (
<span className="text-base text-muted-foreground tabular-nums">
{`${Math.floor(confirmedSummary.distanceToPinCm / 100)} m ${
confirmedSummary.distanceToPinCm % 100
} cm fra hullet`}
</span>
) : null}
</div>
</div>
) : null}
</main>
{/* Footer — contextual primary action */}
{step === "green" ? (
<footer className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled={!canConfirm || submitting}
onClick={handleConfirm}
className="inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{submitting ? (
<>
<span className="size-5 animate-spin rounded-full border-2 border-primary-foreground/40 border-t-primary-foreground" />
Sender
</>
) : (
<>
<Flag className="size-5 shrink-0" aria-hidden="true" />
Bekreft plassering
</>
)}
</button>
</footer>
) : null}
{step === "done" ? (
<footer className="shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<button
type="button"
onClick={onClose}
className="inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Ferdig
</button>
</footer>
) : null}
</div>
)
}
function FlagPlantPanel({
base,
round,
participant,
refreshTick,
onChanged,
}: {
base: string
round: ApiRound
participant: ApiRoundParticipant
refreshTick: number
onChanged: () => void
}) {
const playOrder = useMemo(() => playedHoleNumbersOrg(round.hole_config), [round.hole_config])
const [holes, setHoles] = useState<ApiHole[] | null>(null)
const [plant, setPlant] = useState<ApiFlagPlant | null>(null)
const [overflow, setOverflow] = useState<ApiFlagOverflowHole[]>([])
const [sheetOpen, setSheetOpen] = useState(false)
const [draft, setDraft] = useState<Record<number, string>>({})
const [overflowError, setOverflowError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => {
let cancelled = false
Promise.all([
getJson<ApiHole[]>(`${base}/rounds/${round.id}/participants/${participant.id}/holes`),
getJson<ApiFlagPlant | null>(`${base}/rounds/${round.id}/participants/${participant.id}/flag-plant`),
getJson<ApiFlagOverflowHole[]>(`${base}/rounds/${round.id}/participants/${participant.id}/flag-overflow`),
]).then(([h, p, o]) => {
if (cancelled) return
setHoles(h)
setPlant(p)
setOverflow(o ?? [])
})
return () => {
cancelled = true
}
}, [base, round.id, participant.id, refreshTick])
const currentPosition = computeFlagCurrentPositionOrg(playOrder, holes, overflow)
async function handleUnplant() {
setBusy(true)
await fetch(`${base}/rounds/${round.id}/participants/${participant.id}/flag-plant`, {
method: "DELETE",
credentials: "include",
})
setBusy(false)
setPlant(null)
onChanged()
}
async function submitOverflowHole(holeNumber: number) {
const raw = draft[holeNumber]
const score = parseInt(raw ?? "", 10)
if (!raw || Number.isNaN(score) || score <= 0) return
setOverflowError(null)
const res = await fetch(
`${base}/rounds/${round.id}/participants/${participant.id}/flag-overflow/${currentPosition.lap}/holes/${holeNumber}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ gross_strokes: score }),
},
)
if (!res.ok) {
const body = await res.json().catch(() => null)
setOverflowError(body?.detail?.message ?? "Klarte ikke å registrere score.")
return
}
const updated: ApiFlagOverflowHole = await res.json()
setOverflow((prev) => [...prev.filter((h) => !(h.lap === updated.lap && h.hole_number === updated.hole_number)), updated])
setDraft((prev) => ({ ...prev, [holeNumber]: "" }))
onChanged()
}
if (!holes) return null
const byOverflowHole = new Map(overflow.filter((h) => h.lap === currentPosition.lap).map((h) => [h.hole_number, h]))
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">GPS-flaggplanting -- {participant.player_name}</span>
<div className="flex flex-wrap items-center gap-2">
{plant ? (
<>
<span className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1.5 text-sm font-bold text-primary">
<Flag aria-hidden="true" className="size-4" />
Flagg plantet · hull {plant.hole_number}
{plant.lap > 1 ? ` (runde ${plant.lap})` : ""}
{plant.on_green && plant.distance_to_pin_cm !== null
? ` · ${Math.floor(plant.distance_to_pin_cm / 100)}m ${plant.distance_to_pin_cm % 100}cm fra hullet`
: ""}
</span>
<button
type="button"
disabled={busy}
onClick={handleUnplant}
className="flex min-h-11 items-center justify-center rounded-xl px-2 text-sm font-semibold text-muted-foreground underline underline-offset-2 hover:text-foreground disabled:opacity-60"
>
Angre
</button>
</>
) : (
<button
type="button"
onClick={() => setSheetOpen(true)}
className="flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
<Flag aria-hidden="true" className="size-4" />
Plant flagget
</button>
)}
</div>
{currentPosition.lap >= 2 && (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<span className="text-sm font-bold text-foreground">Runde {currentPosition.lap} -- før inn slag per hull</span>
<div className="flex flex-wrap gap-2">
{playOrder.map((h) => {
const existing = byOverflowHole.get(h)
return (
<div key={h} className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">Hull {h}</span>
{existing?.played ? (
<span className="flex size-9 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary tabular-nums">
{existing.gross_strokes}
</span>
) : (
<Input
type="number"
inputMode="numeric"
min={1}
max={20}
value={draft[h] ?? ""}
onChange={(e) => setDraft((prev) => ({ ...prev, [h]: e.target.value }))}
onBlur={() => submitOverflowHole(h)}
className="h-9 w-14 text-center"
/>
)}
</div>
)
})}
</div>
{overflowError && <p role="alert" className="text-sm font-medium text-destructive">{overflowError}</p>}
</div>
)}
<FlagPlantSheetOrg
open={sheetOpen}
onClose={() => setSheetOpen(false)}
expectedHoleNumber={currentPosition.holeNumber}
expectedLap={currentPosition.lap}
onConfirm={async (result) => {
const res = await fetch(`${base}/rounds/${round.id}/participants/${participant.id}/flag-plant`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
lat: result.lat, lng: result.lng,
hole_number: currentPosition.holeNumber, lap: currentPosition.lap,
on_green: result.onGreen, distance_to_pin_cm: result.distanceToPinCm,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
return { ok: false, message: body?.detail?.message ?? "Klarte ikke å plante flagget." }
}
const updated: ApiFlagPlant = await res.json()
setPlant(updated)
onChanged()
return { ok: true }
}}
/>
</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>
)
}
// --- Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) --------
// Samme "egen fetch + refreshTick"-mønster som FlagRoundPanel over. Ingen
// offentlig tilskuer-gren her (org-medlem/deltaker-only, se ADR-067 --
// individuelle turneringer har ingen offentlig spectator-side ennå).
const FlagMapOverviewLazy = dynamic(() => import("@/components/flag-map-overview"), { ssr: false })
type ApiFlagMapOrg = { visible_to_all: boolean; flags: FlagMapEntry[] }
function FlagMapSectionOrg({
base,
round,
refreshTick,
}: {
base: string
round: ApiRound
refreshTick: number
}) {
const [data, setData] = useState<ApiFlagMapOrg | null>(null)
const [expanded, setExpanded] = useState(false)
useEffect(() => {
let cancelled = false
getJson<{ visible_to_all: boolean; flags: Array<Record<string, unknown>> }>(
`${base}/rounds/${round.id}/flag-map`,
).then((json) => {
if (cancelled || !json) return
setData({
visible_to_all: json.visible_to_all,
flags: json.flags.map((f) => ({
participantId: f.participant_id as string,
displayName: f.display_name as string,
lap: f.lap as number,
holeNumber: f.hole_number as number,
lat: f.lat as number,
lng: f.lng as number,
onGreen: f.on_green as boolean,
distanceToPinCm: f.distance_to_pin_cm as number | null,
})),
})
})
return () => {
cancelled = true
}
}, [base, round.id, refreshTick])
if (!data) return null
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex min-h-11 items-center justify-between gap-2 text-left"
aria-expanded={expanded}
>
<span className="text-sm font-bold text-foreground">Kartoversikt over flagg</span>
<span className="text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis kart"}</span>
</button>
{expanded && (
<div className="h-80 overflow-hidden rounded-xl border border-border">
<FlagMapOverviewLazy flags={data.flags} visibleToAll={data.visible_to_all} />
</div>
)}
</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"
}
}
// --- Full statistikkregistrering per hull (migrasjon 075, ADR-071) --------
// Enkelt-skjerm (IKKE en flerstegs-veiviser som ScoringWizard i round-
// detail.tsx -- HoleGrid åpner ETT hull om gangen for ÉN allerede valgt
// deltaker, ikke en hel spillerekkefølge, så et enkelt skjermbilde med
// alle aktuelle felt gruppert er tilstrekkelig her). Viser kun feltene
// deltakerens stat_level faktisk ber om.
type HolePatch = {
gross_strokes: number
putts?: number | null
club_off_tee?: string | null
tee_shot_result?: string | null
approach_result?: string | null
chip_count?: number | null
bunker_shot_count?: number | null
penalty_strokes?: number | null
first_putt_distance_bucket?: string | null
anyway_strokes?: number | null
expected_version?: number | null
}
// ADR-078 (2026-08-16): HoleStatsSheet bygget om fra ÉN lang scrollbar
// skjerm til en liten intern steg-flyt -- samme visuelle språk (fulskjerm,
// header/footer) og samme stegrekkefølge som ScoringWizard (frittstående
// runder), etter eksplisitt ønske om at "opplevelsen for brukeren skal
// være lik uansett". IKKE en gjenbruk av ScoringWizard-koden -- lagringen
// er bevisst UENDRET (ÉN batched save() ved siste steg, ikke PATCH per
// felt slik runde-siden gjør) for å unngå å røre en fungerende, annerledes
// arkitektur mekanikk som ikke var en del av forespørselen. Se
// wizardStepsFor()/WizardStep i round-detail.tsx for det speilede
// mønsteret på runde-siden.
// ADR-079 (2026-08-16): "overview" er et nytt FØRSTE steg -- personlig
// hull-historikk flyttet hit ut av selve "strokes"-steget, samme
// begrunnelse som round-siden (se PlayerHoleCards i round-detail.tsx).
// Turnering-siden har ingen ekstern "skjerm før" å legge den kompakte
// oppsummeringen i slik runde-siden har (HoleGrid er et tett rutenett,
// ikke en kortliste) -- blir derfor et eget steg i stedet. For å unngå å
// legge en ekstra obligatorisk trykk-runde til for HVER hull-registrering
// (18 hull × N spillere), hopper steget automatisk videre av seg selv når
// spilleren ikke har noen historikk å vise her (samme selv-skjulende
// oppførsel som HoleHistoryPanel selv har) -- kun synlig når det faktisk
// er noe å vise.
type HoleHistorySummary = {
times_played: number
times_personal: number
times_tournament: number
average_score: number
average_score_vs_par: number
gir_percent: number | null
average_putts: number | null
}
type SheetStep = "overview" | "strokes" | "putts" | "puttDistance" | "direction" | "holeDetails"
function sheetStepsFor(statLevel: "strokes_and_putts" | "full"): SheetStep[] {
if (statLevel === "full") return ["overview", "strokes", "putts", "puttDistance", "direction", "holeDetails"]
return ["overview", "strokes", "putts", "puttDistance"]
}
function HoleStatsSheet({
hole,
playerName,
statLevel,
strokesReceived,
ownBagClubs,
historyUrl,
targetPointsBaseUrl,
onSave,
onClose,
}: {
hole: ApiHole
playerName: string
statLevel: "strokes_and_putts" | "full"
strokesReceived: number | null
ownBagClubs: string[]
historyUrl: string
targetPointsBaseUrl: string
onSave: (patch: HolePatch) => Promise<void>
onClose: () => void
}) {
const [strokes, setStrokes] = useState<number | null>(hole.gross_strokes)
const [putts, setPutts] = useState<number | null>(hole.putts)
const [puttBucket, setPuttBucket] = useState<string | null>(hole.first_putt_distance_bucket)
const [club, setClub] = useState<string | null>(hole.club_off_tee)
const [teeShot, setTeeShot] = useState<string | null>(hole.tee_shot_result)
const [approach, setApproach] = useState<string | null>(hole.approach_result)
const [chip, setChip] = useState(hole.chip_count ?? 0)
const [bunker, setBunker] = useState(hole.bunker_shot_count ?? 0)
const [penalty, setPenalty] = useState(hole.penalty_strokes ?? 0)
const [anywayStrokes, setAnywayStrokes] = useState(hole.anyway_strokes ?? 0)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const steps = sheetStepsFor(statLevel)
const [stepIndex, setStepIndex] = useState(0)
const step = steps[Math.min(stepIndex, steps.length - 1)]
const isFirstStep = stepIndex === 0
const isLastStep = stepIndex === steps.length - 1
const canProceed = step === "strokes" ? strokes !== null : true
// "overview"-steget (ADR-079): hentet én gang ved åpning, ikke re-hentet
// per steg-bytte. undefined = laster ennå, null = ingen historikk.
const [historySummary, setHistorySummary] = useState<HoleHistorySummary | null | undefined>(undefined)
const [showFullHistory, setShowFullHistory] = useState(false)
useEffect(() => {
let cancelled = false
fetch(historyUrl, { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (!cancelled) setHistorySummary(data)
})
.catch(() => {
if (!cancelled) setHistorySummary(null)
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Hopp automatisk forbi "overview" når det ikke er noe å vise -- unngår
// et ekstra obligatorisk trykk for HVER hull-registrering (se
// begrunnelsen på SheetStep over).
useEffect(() => {
if (step === "overview" && historySummary === null) forward()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step, historySummary])
// Auto-hopp, samme mønster som ScoringWizard (round-detail.tsx) --
// hopper videre av seg selv når steget sitt eget felt får en verdi.
const autoField = step === "strokes" ? "strokes" : step === "putts" ? "putts" : step === "puttDistance" ? "puttBucket" : null
const enteredWithValueRef = useRef(false)
const firedRef = useRef(false)
useEffect(() => {
firedRef.current = false
enteredWithValueRef.current =
autoField === "strokes" ? strokes !== null : autoField === "putts" ? putts !== null : autoField === "puttBucket" ? puttBucket !== null : false
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step])
useEffect(() => {
if (!autoField || firedRef.current || enteredWithValueRef.current) return
const value = autoField === "strokes" ? strokes : autoField === "putts" ? putts : puttBucket
if (value !== null) {
firedRef.current = true
forward()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [strokes, putts, puttBucket])
// GIR-auto-inferens, samme formel/begrunnelse som ScoringWizard.
useEffect(() => {
if (step !== "direction") return
if (approach !== null) return
if (strokes === null || putts === null) return
if (strokes - putts <= hole.par - 2) setApproach("hit")
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step, strokes, putts, approach, hole.par])
async function save() {
if (strokes === null) {
setError("Registrer antall slag først.")
return
}
setSaving(true)
setError(null)
try {
await onSave({
gross_strokes: strokes,
putts,
first_putt_distance_bucket: puttBucket,
club_off_tee: club,
tee_shot_result: teeShot,
approach_result: approach,
chip_count: chip,
bunker_shot_count: bunker,
penalty_strokes: penalty,
anyway_strokes: anywayStrokes,
expected_version: hole.version,
})
onClose()
} catch {
setError("Klarte ikke å lagre. Prøv igjen.")
setSaving(false)
}
}
function back() {
if (isFirstStep) {
onClose()
return
}
setStepIndex((i) => i - 1)
}
function forward() {
if (!isLastStep) {
setStepIndex((i) => i + 1)
return
}
void save()
}
return (
<div className="fixed inset-0 z-50 flex flex-col bg-background">
<header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3">
<button
type="button"
onClick={onClose}
aria-label="Lukk"
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-foreground transition-colors hover:bg-accent"
>
<X aria-hidden="true" className="size-5" />
</button>
<div className="flex min-w-0 flex-1 flex-col items-center text-center">
<span className="truncate text-base font-extrabold text-foreground">
Hull {hole.hole_number} · Par {hole.par}
</span>
<span className="truncate text-sm font-semibold text-muted-foreground">{playerName}</span>
</div>
<div className="size-11 shrink-0" aria-hidden="true" />
</header>
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto p-5 sm:p-6">
<div className="mx-auto flex w-full max-w-sm flex-col gap-6">
{/* Rangefinder (migrasjon 079) -- samme selvskjulende komponent
som frittstående runder, persistent uansett hvilket steg
brukeren er på (samme plassering/prinsipp som round-detail.tsx). */}
<HoleTargetDistance baseUrl={targetPointsBaseUrl} holeNumber={hole.hole_number} size="full" />
{/* ADR-079: personlig historikk, flyttet hit ut av "strokes"-
steget -- se begrunnelsen for auto-hopp ved SheetStep over.
Samme kompakte oppsummering + klikk-for-full-historikk-
mønster som HoleHistoryPanel (hole-stat-inputs.tsx), men
lokal her siden foreldren allerede har hentet dataene (for
auto-hopp-avgjørelsen) -- unngår et redundant andre kall. */}
{step === "overview" && historySummary && (
<button
type="button"
onClick={() => setShowFullHistory(true)}
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent/50"
>
<span className="flex flex-col gap-1">
<span className="text-sm font-bold text-foreground">Din historikk dette hullet</span>
<span className="text-xs leading-relaxed text-muted-foreground text-pretty">
Spilt {historySummary.times_played} {historySummary.times_played === 1 ? "gang" : "ganger"} før
{" "}({historySummary.times_personal} frittstående, {historySummary.times_tournament} turnering) --
{" "}snitt {historySummary.average_score.toFixed(1)} slag (
{historySummary.average_score_vs_par >= 0 ? "+" : ""}
{historySummary.average_score_vs_par.toFixed(1)} til par)
{historySummary.gir_percent !== null && `, GIR ${Math.round(historySummary.gir_percent)}%`}
{historySummary.average_putts !== null && `, ${historySummary.average_putts.toFixed(1)} putter i snitt`}
</span>
</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
</button>
)}
{step === "strokes" && (
<NumberPicker
label="Slag"
value={strokes}
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
expandLabel="10+"
parValue={hole.par}
showGolfTerms
strokesReceived={strokesReceived}
onChange={setStrokes}
readOnly={false}
/>
)}
{step === "putts" && (
<NumberPicker
label="Putter"
value={putts}
directValues={[0, 1, 2, 3, 4, 5, 6]}
expandValues={[7, 8, 9, 10]}
expandLabel="7+"
maxValue={strokes ?? undefined}
accent="orange"
onChange={setPutts}
readOnly={false}
/>
)}
{step === "puttDistance" && (
<ChoiceRow
label="Avstand første putt"
options={[
{ value: "<1m", label: "0-1m" },
{ value: "<2m", label: "1-2m" },
{ value: "<3m", label: "2-3m" },
{ value: "<5m", label: "3-5m" },
{ value: "<8m", label: "5-8m" },
{ value: "8m+", label: "8m+" },
]}
value={puttBucket}
onChange={setPuttBucket}
readOnly={false}
/>
)}
{step === "direction" && (
<>
<ClubPicker
label="Kølle brukt ved utslaget"
value={club ?? ""}
onChange={setClub}
ownBagClubs={ownBagClubs}
readOnly={false}
/>
<WizardSection title="Retning">
{hole.par !== 3 && (
<DirectionCross
label="Utslag"
variant="horizontal"
centerLabel="Fairway"
value={teeShot}
onChange={setTeeShot}
readOnly={false}
/>
)}
<DirectionCross
label="Innspill"
variant="full"
centerLabel="Traff"
value={approach}
onChange={setApproach}
readOnly={false}
/>
</WizardSection>
</>
)}
{/* Teller-flis-rutenett (V0-designet, ADR-078, 2026-08-16) --
samme 2x2-mønster som round-detail.tsx sitt speilede
holeDetails-steg, se kommentaren der for begrunnelsen. */}
{step === "holeDetails" && (
<div className="grid grid-cols-2 gap-3">
<Stepper label="Chip" value={chip} onChange={setChip} readOnly={false} max={strokes ?? undefined} />
<Stepper label="Bunker" value={bunker} onChange={setBunker} readOnly={false} max={strokes ?? undefined} />
<Stepper label="Straffeslag" value={penalty} onChange={setPenalty} readOnly={false} max={strokes ?? undefined} />
<Stepper label="Anywayslag" value={anywayStrokes} onChange={setAnywayStrokes} readOnly={false} max={strokes ?? undefined} />
</div>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</div>
</main>
<footer className="grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4">
<Button type="button" variant="outline" onClick={back} className="h-14 rounded-2xl text-base font-bold">
{isFirstStep ? "Avbryt" : "Forrige"}
</Button>
<Button
type="button"
disabled={!canProceed || saving}
onClick={forward}
className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
>
{isLastStep ? (saving ? "Lagrer…" : "Lagre") : "Neste"}
</Button>
</footer>
{showFullHistory && (
<HoleHistoryDetail url={historyUrl} holeNumber={hole.hole_number} onClose={() => setShowFullHistory(false)} />
)}
</div>
)
}
function HoleGrid({
base,
roundId,
participantId,
holes,
holeConfig,
statLevel,
playerName,
strokesReceived,
ownBagClubs,
onUpdate,
onUpdateFull,
}: {
base: string
roundId: string
participantId: string
holes: ApiHole[]
holeConfig: ApiRound["hole_config"]
statLevel: "strokes_only" | "strokes_and_putts" | "full"
playerName: string
strokesReceived: (holeNumber: number) => number | null
ownBagClubs: string[]
onUpdate: (holeNumber: number, grossStrokes: number) => Promise<void>
onUpdateFull: (holeNumber: number, patch: HolePatch) => Promise<void>
}) {
const [editingHole, setEditingHole] = useState<number | null>(null)
const [draft, setDraft] = useState("")
// Full statistikkdybde (migrasjon 075, ADR-071): en annen deltaker-
// celle enn den enkle inline-tekstboksen under -- kun for statLevel !=
// "strokes_only". HoleGrid sin egen strokes_only-vei (editingHole/
// draft/openEditor/commit over) er BEVISST uendret, ingen regresjon.
const [sheetHole, setSheetHole] = useState<number | null>(null)
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) {
if (statLevel !== "strokes_only") {
setSheetHole(holeNumber)
return
}
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>
)}
{sheetHole !== null && statLevel !== "strokes_only" && byNumber.get(sheetHole) && (
<HoleStatsSheet
hole={byNumber.get(sheetHole)!}
playerName={playerName}
statLevel={statLevel}
strokesReceived={strokesReceived(sheetHole)}
ownBagClubs={ownBagClubs}
historyUrl={`${base}/rounds/${roundId}/participants/${participantId}/holes/${sheetHole}/history`}
targetPointsBaseUrl={`${base}/rounds/${roundId}`}
onSave={(patch) => onUpdateFull(sheetHole, patch)}
onClose={() => setSheetHole(null)}
/>
)}
</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)
// Eclectic (ADR-067-tillegget "Del C") -- "per-hull beste-kilde"-visning,
// én deltaker utvidet om gangen (samme "trykk for detaljer"-mønster som
// resten av appen, ikke alle utvidet samtidig).
const [expandedEclecticId, setExpandedEclecticId] = 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 }
if (method === "eclectic_gross") return { label: "slag", value: entry.eclectic_total }
if (method === "eclectic_net") return { label: "netto", value: entry.eclectic_total }
if (method === "eclectic_stableford") return { label: "poeng", value: entry.eclectic_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 ?? "-",
// total_label er null når deltakeren ikke har spilt ett eneste hull
// ennå -- nøytral tilstand, IKKE til forveksling med "E" (jevnt med
// par, en ekte score). stroke-play-leaderboard.tsx viser tee-tid/
// starthull i stedet når totalLabel er null.
totalLabel: entry.total_label,
totalIsUnderPar: false,
isLeader: entry.is_leader,
nextTeeTime: entry.next_tee_time,
nextStartHole: entry.next_start_hole,
rounds: entry.rounds.map((r) => ({ roundNumber: r.round_number, label: r.label, tone: r.tone })),
}
}
const isEclectic = method !== null && ECLECTIC_SCORING_METHODS.has(method)
function renderRow(entry: ApiLeaderboardEntry, rank: number) {
const { label, value } = valueFor(entry)
const isLeader = rank === 1 && value !== null
const canExpand = isEclectic && entry.eclectic_holes.length > 0
const expanded = expandedEclecticId === entry.tournament_participant_id
const row = (
<div className="flex items-center gap-3 px-4 py-3">
<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
{canExpand ? (expanded ? " · skjul hull-for-hull" : " · vis hull-for-hull") : ""}
</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>
</div>
)
return (
<li key={entry.tournament_participant_id} className={cn(isLeader && "bg-gold/10")}>
{canExpand ? (
<button
type="button"
onClick={() => setExpandedEclecticId(expanded ? null : entry.tournament_participant_id)}
aria-expanded={expanded}
className="min-h-11 w-full text-left transition-colors hover:bg-accent/40"
>
{row}
</button>
) : (
row
)}
{expanded ? <EclecticHoleTable holes={entry.eclectic_holes} valueLabel={label} /> : null}
</li>
)
}
// Omspill-varsel (migrasjon 086, 2026-08-18) -- vist proaktivt her,
// FØR en eventuell uavgjort faktisk oppstår ("Dette bør informeres om
// før turneringen"), ikke bare når leaderboardet viser en delt
// førsteplass. Én global innstilling, vises én gang over gruppene
// (ikke per klasse-seksjon).
const playoffNote =
isStrokePlay && tournament.tiebreak_winner_method === "playoff" && tournament.tiebreak_playoff_rule
? tournament.tiebreak_playoff_rule
: null
// base = /orgs/{organizationId}/tournaments/{tournamentId} -- utledet i
// stedet for å utvide LeaderboardTab sitt propsett kun for utskrifts-lenken.
const [, , printOrgId, , printTournamentId] = base.split("/")
return (
<div className="flex flex-col gap-4">
<div className="flex justify-end">
<Link
href={`/tournaments/${printTournamentId}/print/resultlist?org=${printOrgId}`}
className="inline-flex min-h-9 items-center gap-1.5 rounded-full border border-border px-3 text-xs font-bold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
Skriv ut resultatliste
</Link>
</div>
{playoffNote && (
<div className="flex items-start gap-3 rounded-2xl border border-gold bg-gold/10 px-4 py-3">
<Trophy aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-gold-foreground" />
<p className="text-sm font-semibold leading-relaxed text-foreground text-pretty">
Ved likt resultat for 1. plass: {playoffNote}
</p>
</div>
)}
{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>
)
}
// Eclectic (ADR-067-tillegget "Del C") -- "per-hull beste-kilde"-visning:
// hvilket hull, hva slaget/poenget ble, og HVILKEN runde det kom fra.
// Beviser for spilleren at "drømmerunden" faktisk er satt sammen av det
// beste fra flere runder, ikke bare den beste hele runden.
function EclecticHoleTable({ holes, valueLabel }: { holes: ApiEclecticHoleCell[]; valueLabel: string }) {
const sorted = [...holes].sort((a, b) => a.hole_number - b.hole_number)
return (
<div className="overflow-x-auto border-t border-border bg-muted/30 px-4 py-3">
<table className="w-full min-w-[420px] border-collapse text-sm">
<thead>
<tr className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<th scope="col" className="px-2 py-1 text-left">
Hull
</th>
<th scope="col" className="px-2 py-1 text-right">
Par
</th>
<th scope="col" className="px-2 py-1 text-right">
{valueLabel === "poeng" ? "Poeng" : valueLabel === "netto" ? "Netto" : "Slag"}
</th>
<th scope="col" className="px-2 py-1 text-right">
Fra runde
</th>
</tr>
</thead>
<tbody>
{sorted.map((h) => (
<tr key={h.hole_number} className="border-t border-border/60">
<td className="px-2 py-1 font-semibold text-foreground">{h.hole_number}</td>
<td className="px-2 py-1 text-right tabular-nums text-muted-foreground">{h.par}</td>
<td className="px-2 py-1 text-right font-bold tabular-nums text-foreground">{h.value}</td>
<td className="px-2 py-1 text-right tabular-nums text-muted-foreground">Runde {h.round_number}</td>
</tr>
))}
</tbody>
</table>
</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>
)
}