"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, ArrowRight, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, 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" // Flight-scoping i ScoreTab (ADR-099). tournament_round_group_id: string | null is_self: boolean } 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 = { full_18: "18 hull", front_9: "Hull 1–9", back_9: "Hull 10–18", } const SCORING_METHOD_LABELS: Record = { 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(url: string): Promise { 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 { 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(null) const [participants, setParticipants] = useState([]) const [rounds, setRounds] = useState([]) const [courses, setCourses] = useState([]) const [classes, setClasses] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(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 // , 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(`/orgs/${organizationId}/tournaments`), getJson(`${base}/participants`), getJson(`${base}/rounds`), getJson(`/orgs/${organizationId}/courses`), getJson(`${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(`${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 { 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 (
) } if (error && !tournament) { return (

{error}

Tilbake til dashbordet
) } if (!tournament) return null return (
{isOrgAdmin && ( )}
{/* 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). */}
{error && (
{error}
)} {tab === "setup" && ( )} {tab === "score" && ( )} {tab === "leaderboard" && ( )} {tab === "presentation" && ( )}
) } 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 ( ) } // ========================================================================= // Oppsett-fane: scoringsmetode, deltakere, runder // ========================================================================= // 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 onApplyCut: () => Promise onUpdateTiebreakConfig: (winner: string, field: string, playoffRule: string | null) => Promise onUpdateSettings: (next: TournamentSettingsValues) => Promise onAddParticipant: (playerId: string) => Promise onAddNewPlayer: (input: NewPlayerInput) => Promise onImportComplete: () => Promise onRemoveParticipant: (id: string) => Promise onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise onDeleteRound: (id: string) => Promise onAddCourse: (name: string) => Promise onCourseImported: (course: ApiCourse) => void onCreateClass: (name: string, defaultTeeId: string | null) => Promise onDeleteClass: (id: string) => Promise onSetParticipantClass: (participantId: string, classId: string | null) => Promise onSetParticipantStatLevel: ( participantId: string, statLevel: "strokes_only" | "strokes_and_putts" | "full", ) => Promise onSetParticipantHandicap: (participantId: string, handicap: number | null) => Promise onSetParticipantStatus: ( participantId: string, status: "active" | "dsq" | "rtd" | "dnf" | "dns", ) => Promise onError: (message: string) => void }) { const [step, setStep] = useState("basics") const [groupsOpenFor, setGroupsOpenFor] = useState(null) const [showImport, setShowImport] = useState(false) const [pool, setPool] = useState([]) async function refreshPool() { const data = await getJson(`/orgs/${organizationId}/players`) setPool(data ?? []) } useEffect(() => { let cancelled = false getJson(`/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 | null>(null) const [teesByCourse, setTeesByCourse] = useState>({}) const [busyRoundIds, setBusyRoundIds] = useState([]) const [participationErrors, setParticipationErrors] = useState([]) 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(`${base}/rounds/${r.id}/participants`))), Promise.all( uniqueCourseIds.map((cid) => getJson(`/orgs/${organizationId}/courses/${cid}/tees`)), ), ]) if (cancelled) return const byRound: Record = {} rounds.forEach((r, i) => { byRound[r.id] = rpLists[i] ?? [] }) const byCourse: Record = {} 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(`${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 ( {step === "basics" && } {step === "rounds" && ( )} {step === "classes" && ( )} {step === "players" && (

Spillere

{participants.length}
{showImport && ( ({ 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.

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.

)} {participants.length === 0 ? (

Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller.

) : rounds.length > 0 && roundParticipantsByRound === null ? (

Laster…

) : ( ({ 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} /> )} p.player_id))} onAddExisting={onAddParticipant} onAddNew={onAddNewPlayer} />
)} {step === "groups" && (
{groupsOpenRound && ( setGroupsOpenFor(null)} /> )}
)} {step === "results" && (

Scoringsmetode

Avgjør hvordan totalen på leaderboardet regnes ut, og om deltakere trenger registrert handicap og kjønn.

{tournament.scoring_method ? SCORING_METHOD_LABELS[tournament.scoring_method] : "Ikke satt"} {Object.entries(SCORING_METHOD_LABELS).map(([value, label]) => ( onUpdateScoringMethod(value)} className="cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold" > {label} ))} {tournament.scoring_method === "bingo_bango_bongo" && ( )} {tournament.scoring_method === "flag" && ( )}
{tournament.scoring_method != null && ["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && ( <> )}
)}
) } // 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 onApplyCut: () => Promise }) { 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 (

Cut

Topp N og delt plass slipper videre etter valgt runde. Kuttede spillere vises i egen seksjon på leaderboardet og blokkeres fra å registrere score i senere runder. {sortedRounds.length > 0 && ( <> Turneringen har {sortedRounds.length} {sortedRounds.length === 1 ? "runde" : "runder"} satt opp. )}

{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."}

) } // 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 }) { 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 (

Tiebreak ved likt resultat

Velges uavhengig for vinneren og resten av feltet. Påvirker kun sluttresultatets plassering -- Cut-grensen (over) bruker alltid full delt plass, uansett denne innstillingen.

{winner === "playoff" && ( )}
) } // 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 onDelete: (classId: string) => Promise }) { const [name, setName] = useState("") const [courseId, setCourseId] = useState("") const [teeId, setTeeId] = useState("") const [tees, setTees] = useState([]) const [adding, setAdding] = useState(false) useEffect(() => { if (!courseId) { setTees([]) setTeeId("") return } let cancelled = false getJson(`/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 (

Klasser

{classes.length}

Foreslår riktig utslag ved oppsett av en runde, og gir hver klasse sin egen seksjon på leaderboardet.

{classes.length > 0 && (
    {classes.map((c) => (
  • {c.name} {c.default_tee_name && ( Standardutslag: {c.default_tee_name} )}
  • ))}
)}
setName(e.target.value)} placeholder="F.eks. Damer" className="h-11" />
{tees.length > 0 && (
)}
) } // 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 onAddExisting: (playerId: string) => Promise onAddNew: (input: NewPlayerInput) => Promise }) { 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 ( ) } return (
{ setQuery(e.target.value) setCreating(false) }} onKeyDown={(e) => { if (e.key === "Escape") { setOpen(false) reset() } }} className="h-11 flex-1 rounded-2xl text-base" />
{!creating && ( <> {matches.length === 0 && available.length === 0 && (

{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."}

)} {matches.length === 0 && available.length > 0 && trimmed.length > 0 && (

Ingen treff blant eksisterende spillere.

)} {matches.length > 0 && (
    {matches.slice(0, MAX_VISIBLE_MATCHES).map((p) => (
  • ))}
)} {matches.length > MAX_VISIBLE_MATCHES && (

+{matches.length - MAX_VISIBLE_MATCHES} flere -- fortsett å skrive for å begrense listen.

)} )} {showCreate && !creating && ( )} {showCreate && creating && (
setFirstName(e.target.value)} className="h-10 rounded-xl text-sm" />
setLastName(e.target.value)} className="h-10 rounded-xl text-sm" />
setNewHandicap(e.target.value)} className="h-10 rounded-xl text-sm" />
{!showMoreDetails ? ( ) : (
setBirthDate(e.target.value)} className="h-10 rounded-xl text-sm" />
setEmail(e.target.value)} className="h-10 rounded-xl text-sm" />
setClub(e.target.value)} className="h-10 rounded-xl text-sm" />
setClubMemberNumber(e.target.value)} className="h-10 rounded-xl text-sm" />
setCountry(e.target.value)} className="h-10 w-full rounded-xl text-sm sm:w-1/2" />