teecup/frontend/components/individual-tournament-detail.tsx
Erol Haagenrud 00809d6817 Massimport av spillere til turneringer (CSV) + redigerbar spillertabell
Ny POST /orgs/{id}/players/bulk (samme e-post-match-dedup-mønster som
selvregistreringens ADR-017 Beslutning B, utvidet til alle felt).
CSV parses klient-side (papaparse), enkel kolonnegjetting, redigerbar
forhåndsvisning før lagring. Dekker både lagturneringer (lagtildeling)
og individuelle turneringer (klassetilknytning) i samme flyt -- begge
steg samlet (org-pool + turneringspåmelding).

Midlertidig hånd-kodet UI (player-import-panel.tsx) bygget for å bevise
hele kjeden ende-til-ende -- venter på V0-eksport for den polerte
visningen før samlet utrulling, samme rekkefølge som FlagPlantSheet.

5 nye backend-tester (111/111 totalt), tsc rent, 45/45 vitest, full
scratch-verifisert i begge turneringsformater.

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

4259 lines
156 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 type { FlagMapEntry } from "@/components/flag-map-overview"
import {
ArrowLeft,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Copy,
Crosshair,
Flag,
KeyRound,
MapPin,
Medal,
Plus,
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 {
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
status: TournamentStatus
join_code: string
format_type: string
scoring_method: string | null
// KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30).
bbb_sweep_bonus_enabled: boolean
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
flag_map_visible: boolean
}
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"
}
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
// standardutslag. Her (individuelle turneringer) brukes klasse til BÅDE
// utslag-forhåndsutfylling (AssignRoundParticipantControl) OG en egen
// resultatliste-seksjon per klasse (LeaderboardTab) -- se
// 053_tournament_classes.sql for full begrunnelse og forskjellen fra
// lagturneringer (tournament-detail.tsx), hvor klasse KUN styrer utslag.
type ApiTournamentClass = {
id: string
name: string
default_tee_id: string | null
default_tee_name: string | null
}
type ApiRound = {
id: string
tournament_id: string
sequence: number
name: string | null
hole_config: "full_18" | "front_9" | "back_9"
course_id: string
course_name: string
scheduled_at: string | null
tee_interval_minutes: number | null
start_hole: number
}
type ApiRoundParticipant = {
id: string
tournament_participant_id: string
player_name: string
tee_id: string
tee_name: string
course_handicap: number | null
playing_handicap: number | null
// 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
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 [tournament, setTournament] = useState<ApiTournamentInfo | null>(null)
const [participants, setParticipants] = useState<ApiParticipant[]>([])
const [rounds, setRounds] = useState<ApiRound[]>([])
const [courses, setCourses] = useState<ApiCourse[]>([])
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState<"setup" | "score" | "leaderboard" | "presentation">("setup")
const base = `/orgs/${organizationId}/tournaments/${tournamentId}`
useEffect(() => {
let cancelled = false
async function load() {
try {
const [tournamentsData, participantsData, roundsData, coursesData, classesData] = await Promise.all([
getJson<ApiTournamentInfo[]>(`/orgs/${organizationId}/tournaments`),
getJson<ApiParticipant[]>(`${base}/participants`),
getJson<ApiRound[]>(`${base}/rounds`),
getJson<ApiCourse[]>(`/orgs/${organizationId}/courses`),
getJson<ApiTournamentClass[]>(`${base}/classes`),
])
if (cancelled) return
const mine = tournamentsData?.find((t) => t.id === tournamentId) ?? null
if (!mine) {
setError("Fant ikke turneringen.")
return
}
setTournament(mine)
setParticipants(participantsData ?? [])
setRounds(roundsData ?? [])
setCourses(coursesData ?? [])
setClasses(classesData ?? [])
} catch {
if (!cancelled) setError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId, base])
async function updateStatus(newStatus: TournamentStatus) {
if (!tournament) return
const previous = tournament
setTournament({ ...tournament, status: newStatus })
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ status: newStatus }),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å endre turnering-status. Prøv igjen.")
}
}
async function updateScoringMethod(method: string) {
if (!tournament) return
const previous = tournament
setTournament({ ...tournament, scoring_method: method })
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ scoring_method: method }),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å endre scoringsmetode. Prøv igjen.")
}
}
async function updateBbbSweepBonus(enabled: boolean) {
if (!tournament) return
const previous = tournament
setTournament({ ...tournament, bbb_sweep_bonus_enabled: enabled })
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ bbb_sweep_bonus_enabled: enabled }),
})
if (!res.ok) throw new Error()
} catch {
setTournament(previous)
setError("Klarte ikke å endre bonuspoeng-innstillingen. Prøv igjen.")
}
}
async function 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.")
}
}
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)))
}
async function createClass(name: string, defaultTeeId: string | null) {
setError(null)
const res = await fetch(`${base}/classes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name, default_tee_id: defaultTeeId }),
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å opprette klassen."))
return
}
const created: ApiTournamentClass = await res.json()
setClasses((prev) => [...prev, created])
}
async function deleteClass(classId: string) {
setError(null)
const res = await fetch(`${base}/classes/${classId}`, { method: "DELETE", credentials: "include" })
if (res.status !== 204) {
setError(await errorMessage(res, "Klarte ikke å fjerne klassen."))
return
}
setClasses((prev) => prev.filter((c) => c.id !== classId))
setParticipants((prev) =>
prev.map((p) => (p.class_id === classId ? { ...p, class_id: null, class_name: null } : p)),
)
}
async function addRound(name: string, courseId: string, holeConfig: ApiRound["hole_config"]) {
setError(null)
const res = await fetch(`${base}/rounds`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
sequence: rounds.length + 1,
name: name.trim() || null,
course_id: courseId,
hole_config: holeConfig,
}),
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å opprette runden."))
return
}
const created: ApiRound = await res.json()
setRounds((prev) => [...prev, created].sort((a, b) => a.sequence - b.sequence))
}
async function deleteRound(roundId: string) {
setError(null)
const res = await fetch(`${base}/rounds/${roundId}`, { method: "DELETE", credentials: "include" })
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å slette runden -- har den allerede deltakere?"))
return
}
setRounds((prev) => prev.filter((r) => r.id !== roundId))
}
async function addCourse(name: string): Promise<ApiCourse | null> {
const res = await fetch(`/orgs/${organizationId}/courses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name }),
})
if (!res.ok) {
setError("Klarte ikke å opprette banen.")
return null
}
const created: ApiCourse = await res.json()
setCourses((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
return created
}
function handleCourseImported(course: ApiCourse) {
setCourses((prev) => [...prev, course].sort((a, b) => a.name.localeCompare(b.name)))
}
if (loading) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
)
}
if (error && !tournament) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-3 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">{error}</p>
<Link href="/dashboard" className="text-sm font-semibold text-primary underline underline-offset-2">
Tilbake til dashbordet
</Link>
</main>
)
}
if (!tournament) return null
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3 px-5 py-4">
<div className="flex items-center gap-3">
<Link
href="/dashboard"
aria-label="Tilbake til dashbord"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<span className="inline-flex items-center gap-1 rounded-full bg-info/15 px-2 py-0.5 text-info">
<Users aria-hidden="true" className="size-3" />
Individuell
</span>
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{tournamentName}</h1>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pl-13">
<TournamentStatusPicker status={tournament.status} onChange={updateStatus} />
<JoinCodeChip code={tournament.join_code} />
</div>
</div>
<nav
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
aria-label="Turneringsseksjoner"
>
{(
[
["setup", "Oppsett"],
["score", "Scorekort"],
["leaderboard", "Leaderboard"],
["presentation", "Presentasjon"],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => setTab(key)}
aria-current={tab === key ? "page" : undefined}
className={cn(
"min-h-11 shrink-0 rounded-full px-4 py-2 text-sm font-bold transition-colors",
tab === key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
>
{label}
</button>
))}
</nav>
</header>
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
{error && (
<div className="mb-5 rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm font-medium text-destructive">
{error}
</div>
)}
{tab === "setup" && (
<SetupTab
base={base}
organizationId={organizationId}
tournament={tournament}
participants={participants}
rounds={rounds}
courses={courses}
classes={classes}
onUpdateScoringMethod={updateScoringMethod}
onUpdateBbbSweepBonus={updateBbbSweepBonus}
onUpdateFlagMapVisible={updateFlagMapVisible}
onAddParticipant={addParticipant}
onAddNewPlayer={addNewPlayerAndParticipant}
onImportComplete={refreshParticipants}
onRemoveParticipant={removeParticipant}
onAddRound={addRound}
onDeleteRound={deleteRound}
onAddCourse={addCourse}
onCourseImported={handleCourseImported}
onCreateClass={createClass}
onDeleteClass={deleteClass}
onSetParticipantClass={setParticipantClass}
onSetParticipantStatLevel={setParticipantStatLevel}
onError={setError}
/>
)}
{tab === "score" && (
<ScoreTab base={base} rounds={rounds} scoringMethod={tournament.scoring_method} onError={setError} />
)}
{tab === "leaderboard" && (
<LeaderboardTab base={base} tournament={tournament} />
)}
{tab === "presentation" && (
<TournamentPresentationPanel organizationId={organizationId} tournamentId={tournamentId} />
)}
</main>
</div>
)
}
function JoinCodeChip({ code }: { code: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
try {
await navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// stille -- ikke kritisk
}
}
return (
<button
type="button"
onClick={handleCopy}
className="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 text-sm font-bold tabular-nums text-foreground transition-colors hover:bg-accent/50"
aria-label={`Invitasjonskode ${code}, trykk for å kopiere`}
>
{copied ? (
<Check aria-hidden="true" className="size-4 text-primary" />
) : (
<KeyRound aria-hidden="true" className="size-4 text-muted-foreground" />
)}
{code}
{!copied && <Copy aria-hidden="true" className="size-3.5 text-muted-foreground" />}
</button>
)
}
// =========================================================================
// Oppsett-fane: scoringsmetode, deltakere, runder
// =========================================================================
function SetupTab({
base,
organizationId,
tournament,
participants,
rounds,
courses,
classes,
onUpdateScoringMethod,
onUpdateBbbSweepBonus,
onUpdateFlagMapVisible,
onAddParticipant,
onAddNewPlayer,
onImportComplete,
onRemoveParticipant,
onAddRound,
onDeleteRound,
onAddCourse,
onCourseImported,
onCreateClass,
onDeleteClass,
onSetParticipantClass,
onSetParticipantStatLevel,
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
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>
onError: (message: string) => void
}) {
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])
return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<h2 className="text-base font-bold text-foreground">Scoringsmetode</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Avgjør hvordan totalen leaderboardet regnes ut, og om deltakere trenger
registrert handicap og kjønn.
</p>
<DropdownMenu>
<DropdownMenuTrigger className="flex min-h-11 w-fit items-center gap-2 rounded-xl border border-border bg-background px-4 text-sm font-bold text-foreground transition-colors hover:bg-accent/50">
{tournament.scoring_method ? SCORING_METHOD_LABELS[tournament.scoring_method] : "Ikke satt"}
<ChevronDown aria-hidden="true" className="size-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 rounded-2xl p-1.5">
{Object.entries(SCORING_METHOD_LABELS).map(([value, label]) => (
<DropdownMenuItem
key={value}
onClick={() => onUpdateScoringMethod(value)}
className="cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold"
>
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{tournament.scoring_method === "bingo_bango_bongo" && (
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
<input
type="checkbox"
checked={tournament.bbb_sweep_bonus_enabled}
onChange={(e) => onUpdateBbbSweepBonus(e.target.checked)}
className="mt-1 size-5 shrink-0 accent-primary"
/>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-bold text-foreground">Bonuspoeng ved "sveip"</span>
<span className="text-xs text-muted-foreground">
Ekstra poeng til en spiller som vinner alle tre kategoriene samme hull.
</span>
</span>
</label>
)}
{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>
<RoundsCard
base={base}
organizationId={organizationId}
rounds={rounds}
courses={courses}
participants={participants}
classes={classes}
scoringMethod={tournament.scoring_method}
onAddRound={onAddRound}
onDeleteRound={onDeleteRound}
onAddCourse={onAddCourse}
onCourseImported={onCourseImported}
onError={onError}
/>
<ClassesCard
classes={classes}
courses={courses}
organizationId={organizationId}
onCreate={onCreateClass}
onDelete={onDeleteClass}
/>
<ParticipantsCard
organizationId={organizationId}
tournamentId={tournament.id}
participants={participants}
pool={pool}
classes={classes}
onAddParticipant={onAddParticipant}
onAddNewPlayer={onAddNewPlayer}
onImportComplete={async () => {
await Promise.all([refreshPool(), onImportComplete()])
}}
onRemoveParticipant={onRemoveParticipant}
onSetClass={onSetParticipantClass}
onSetStatLevel={onSetParticipantStatLevel}
/>
</div>
)
}
// Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin
// ClassesCard for lagturnering-varianten (samme mønster, men uten
// leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten).
function ClassesCard({
classes,
courses,
organizationId,
onCreate,
onDelete,
}: {
classes: ApiTournamentClass[]
courses: ApiCourse[]
organizationId: string
onCreate: (name: string, defaultTeeId: string | null) => Promise<void>
onDelete: (classId: string) => Promise<void>
}) {
const [name, setName] = useState("")
const [courseId, setCourseId] = useState("")
const [teeId, setTeeId] = useState("")
const [tees, setTees] = useState<ApiTee[]>([])
const [adding, setAdding] = useState(false)
useEffect(() => {
if (!courseId) {
setTees([])
setTeeId("")
return
}
let cancelled = false
getJson<ApiTee[]>(`/orgs/${organizationId}/courses/${courseId}/tees`).then((data) => {
if (!cancelled) {
setTees(data ?? [])
setTeeId(data?.[0]?.id ?? "")
}
})
return () => {
cancelled = true
}
}, [organizationId, courseId])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (name.trim().length === 0 || adding) return
setAdding(true)
await onCreate(name.trim(), teeId || null)
setAdding(false)
setName("")
setCourseId("")
setTeeId("")
}
return (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-bold text-foreground">Klasser</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
{classes.length}
</span>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Foreslår riktig utslag ved oppsett av en runde, og gir hver klasse sin egen seksjon
leaderboardet.
</p>
{classes.length > 0 && (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{classes.map((c) => (
<li key={c.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-bold text-foreground">{c.name}</span>
{c.default_tee_name && (
<span className="text-xs text-muted-foreground">
Standardutslag: {c.default_tee_name}
</span>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDelete(c.id)}
aria-label={`Fjern klassen ${c.name}`}
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="ind-class-name" className="text-xs font-semibold text-muted-foreground">
Klassenavn
</Label>
<Input
id="ind-class-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="F.eks. Damer"
className="h-11"
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="ind-class-course" className="text-xs font-semibold text-muted-foreground">
Bane (for standardutslag)
</Label>
<select
id="ind-class-course"
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
>
<option value="">Ingen standardutslag</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
{tees.length > 0 && (
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="ind-class-tee" className="text-xs font-semibold text-muted-foreground">
Standardutslag
</Label>
<select
id="ind-class-tee"
value={teeId}
onChange={(e) => setTeeId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
>
{tees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
)}
<Button
type="submit"
variant="secondary"
disabled={adding || name.trim().length === 0}
className="h-11 shrink-0 rounded-xl font-semibold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til
</Button>
</form>
</section>
)
}
const STAT_LEVEL_LABELS: Record<string, string> = {
strokes_only: "Kun slag",
strokes_and_putts: "Slag og putter",
full: "All statistikk",
}
function ParticipantsCard({
organizationId,
tournamentId,
participants,
pool,
classes,
onAddParticipant,
onAddNewPlayer,
onImportComplete,
onRemoveParticipant,
onSetClass,
onSetStatLevel,
}: {
organizationId: string
tournamentId: string
participants: ApiParticipant[]
pool: ApiPlayer[]
classes: ApiTournamentClass[]
onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
onImportComplete: () => Promise<void>
onRemoveParticipant: (id: string) => Promise<void>
onSetClass: (participantId: string, classId: string | null) => Promise<void>
onSetStatLevel: (
participantId: string,
statLevel: "strokes_only" | "strokes_and_putts" | "full",
) => Promise<void>
}) {
const [showImport, setShowImport] = 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">Deltakere</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
{participants.length}
</span>
</div>
<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={tournamentId}
mode={{ kind: "individual", classes: classes.map((c) => ({ id: c.id, name: c.name })) }}
onClose={() => setShowImport(false)}
onImportComplete={() => {
void onImportComplete()
}}
/>
)}
{participants.length === 0 && (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller.
</p>
)}
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{participants.map((p) => (
<li key={p.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-bold text-foreground">{p.player_name}</span>
<span className="text-xs font-semibold text-muted-foreground">
{p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"}
</span>
</div>
{classes.length > 0 && (
<select
value={p.class_id ?? ""}
onChange={(e) => onSetClass(p.id, e.target.value || null)}
aria-label={`Klasse for ${p.player_name}`}
className="h-9 shrink-0 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none"
>
<option value="">Ingen klasse</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
{/* Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye
HoleGrid/HoleStatsSheet ber om ved scoring for DENNE
deltakeren, tournament-bredt (ikke per runde). */}
<select
value={p.stat_level}
onChange={(e) =>
onSetStatLevel(p.id, e.target.value as "strokes_only" | "strokes_and_putts" | "full")
}
aria-label={`Statistikknivå for ${p.player_name}`}
className="h-9 shrink-0 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none"
>
{Object.entries(STAT_LEVEL_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onRemoveParticipant(p.id)}
aria-label={`Fjern ${p.player_name}`}
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
<AddParticipantControl
pool={pool}
alreadyIn={new Set(participants.map((p) => p.player_id))}
onAddExisting={onAddParticipant}
onAddNew={onAddNewPlayer}
/>
</section>
)
}
// 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,
participants,
classes,
scoringMethod,
onAddRound,
onDeleteRound,
onAddCourse,
onCourseImported,
onError,
}: {
base: string
organizationId: string
rounds: ApiRound[]
courses: ApiCourse[]
participants: ApiParticipant[]
classes: ApiTournamentClass[]
scoringMethod: string | null
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onDeleteRound: (id: string) => Promise<void>
onAddCourse: (name: string) => Promise<ApiCourse | null>
onCourseImported: (course: ApiCourse) => void
onError: (message: string) => void
}) {
const [creating, setCreating] = useState(false)
return (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-bold text-foreground">Runder</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
{rounds.length}
</span>
</div>
{rounds.length === 0 && (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen runder ennå -- en enkeltdags turnering trenger kun én, en flerdagers
turnering kan ha flere.
</p>
)}
<div className="flex flex-col gap-3">
{rounds.map((r) => (
<RoundCard
key={r.id}
base={base}
organizationId={organizationId}
round={r}
participants={participants}
classes={classes}
scoringMethod={scoringMethod}
onDelete={() => onDeleteRound(r.id)}
onError={onError}
/>
))}
</div>
{!creating ? (
<Button
type="button"
variant="secondary"
onClick={() => setCreating(true)}
className="h-11 w-full rounded-2xl text-sm font-bold"
>
<Plus aria-hidden="true" className="size-4" />
Ny runde
</Button>
) : (
<NewRoundForm
organizationId={organizationId}
courses={courses}
onCourseImported={onCourseImported}
onCreate={async (name, courseId, holeConfig) => {
await onAddRound(name, courseId, holeConfig)
setCreating(false)
}}
onCreateCourse={onAddCourse}
onCancel={() => setCreating(false)}
/>
)}
</section>
)
}
function NewRoundForm({
organizationId,
courses,
onCreate,
onCreateCourse,
onCourseImported,
onCancel,
}: {
organizationId: string
courses: ApiCourse[]
onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onCreateCourse: (name: string) => Promise<ApiCourse | null>
onCourseImported: (course: ApiCourse) => void
onCancel: () => void
}) {
const [name, setName] = useState("")
const [courseId, setCourseId] = useState(courses[0]?.id ?? "")
const [holeConfig, setHoleConfig] = useState<ApiRound["hole_config"]>("full_18")
const [newCourseName, setNewCourseName] = useState("")
const [addingCourse, setAddingCourse] = useState(false)
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
const [submitting, setSubmitting] = useState(false)
async function handleCreateCourse() {
const trimmed = newCourseName.trim()
if (trimmed.length < 2) return
const created = await onCreateCourse(trimmed)
if (created) {
setCourseId(created.id)
setAddingCourse(false)
setNewCourseName("")
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!courseId || submitting) return
setSubmitting(true)
await onCreate(name, courseId, holeConfig)
setSubmitting(false)
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-xl border border-border bg-background p-3">
<div className="flex flex-col gap-1">
<Label htmlFor="round-name" className="text-xs font-semibold">
Navn (valgfritt)
</Label>
<Input
id="round-name"
autoFocus
placeholder="F.eks. Runde 1 -- fredag"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="round-course" className="text-xs font-semibold">
Bane
</Label>
{courses.length > 0 && !addingCourse ? (
<div className="flex gap-2">
<select
id="round-course"
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
className="h-10 flex-1 rounded-xl border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<Button
type="button"
variant="ghost"
onClick={() => setAddingCourse(true)}
className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold text-muted-foreground"
>
+ Ny bane
</Button>
</div>
) : (
<div className="flex gap-2">
<Input
placeholder="Banens navn"
value={newCourseName}
onChange={(e) => setNewCourseName(e.target.value)}
className="h-10 flex-1 rounded-xl text-sm"
/>
<Button
type="button"
onClick={handleCreateCourse}
className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold"
>
Opprett
</Button>
{courses.length > 0 && (
<Button
type="button"
variant="ghost"
onClick={() => setAddingCourse(false)}
className="h-10 shrink-0 rounded-xl px-2 text-xs font-bold text-muted-foreground"
>
Avbryt
</Button>
)}
</div>
)}
<p className="text-xs text-muted-foreground">
Banen ha registrert hull (par/hcp-indeks) og utslag før scoring kan
begynne -- sett opp dette under organisasjonens baner om det mangler.
</p>
{!officialSearchOpen && !templatePickerOpen ? (
<div className="flex flex-wrap gap-x-4 gap-y-1">
<button
type="button"
onClick={() => setOfficialSearchOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
</button>
<button
type="button"
onClick={() => setTemplatePickerOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Opprett bane med hull/utslag i stedet
</button>
</div>
) : officialSearchOpen ? (
<OfficialCourseSearch
organizationId={organizationId}
onClose={() => setOfficialSearchOpen(false)}
onImported={(course) => {
onCourseImported(course)
setCourseId(course.id)
setAddingCourse(false)
setOfficialSearchOpen(false)
}}
/>
) : (
<CourseTemplatePicker
organizationId={organizationId}
onCreated={(created) => {
onCourseImported({ id: created.id, name: created.name, source: "custom" })
setCourseId(created.id)
setAddingCourse(false)
setTemplatePickerOpen(false)
}}
onCancel={() => setTemplatePickerOpen(false)}
/>
)}
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="round-hole-config" className="text-xs font-semibold">
Hullomfang
</Label>
<select
id="round-hole-config"
value={holeConfig}
onChange={(e) => setHoleConfig(e.target.value as ApiRound["hole_config"])}
className="h-10 rounded-xl border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{Object.entries(HOLE_CONFIG_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={!courseId || submitting} className="h-10 flex-1 rounded-xl text-sm font-bold">
{submitting ? "Oppretter…" : "Opprett runde"}
</Button>
<Button type="button" variant="ghost" onClick={onCancel} className="h-10 rounded-xl text-sm font-bold text-muted-foreground">
Avbryt
</Button>
</div>
</form>
)
}
function RoundCard({
base,
organizationId,
round,
participants,
classes,
scoringMethod,
onDelete,
onError,
}: {
base: string
organizationId: string
round: ApiRound
participants: ApiParticipant[]
classes: ApiTournamentClass[]
scoringMethod: string | null
onDelete: () => Promise<void>
onError: (message: string) => void
}) {
const [expanded, setExpanded] = useState(false)
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null)
const [tees, setTees] = useState<ApiTee[]>([])
const [confirmingDelete, setConfirmingDelete] = useState(false)
async function loadRoundParticipants() {
const [rpData, teeData] = await Promise.all([
getJson<ApiRoundParticipant[]>(`${base}/rounds/${round.id}/participants`),
getJson<ApiTee[]>(`/orgs/${organizationId}/courses/${round.course_id}/tees`),
])
setRoundParticipants(rpData ?? [])
setTees(teeData ?? [])
}
useEffect(() => {
if (expanded && roundParticipants === null) {
loadRoundParticipants()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [expanded])
async function addRoundParticipant(tournamentParticipantId: string, teeId: string) {
onError("")
const res = await fetch(`${base}/rounds/${round.id}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ tournament_participant_id: tournamentParticipantId, tee_id: teeId }),
})
if (!res.ok) {
onError(await errorMessage(res, "Klarte ikke å legge til deltakeren i runden."))
return
}
const created: ApiRoundParticipant = await res.json()
setRoundParticipants((prev) => [...(prev ?? []), created])
}
async function removeRoundParticipant(id: string) {
const res = await fetch(`${base}/rounds/${round.id}/participants/${id}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok) {
onError("Klarte ikke å fjerne deltakeren fra runden.")
return
}
setRoundParticipants((prev) => (prev ?? []).filter((rp) => rp.id !== id))
}
const assignedIds = new Set((roundParticipants ?? []).map((rp) => rp.tournament_participant_id))
const unassigned = participants.filter((p) => !assignedIds.has(p.id))
return (
<div className="overflow-hidden rounded-xl border border-border">
<div className="flex items-center gap-3 bg-background px-4 py-3">
<button
type="button"
onClick={() => setExpanded((v) => !v)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
aria-expanded={expanded}
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-info/15 text-sm font-extrabold tabular-nums text-info">
{round.sequence}
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-bold text-foreground">
{round.name || `Runde ${round.sequence}`}
</span>
<span className="truncate text-xs font-semibold text-muted-foreground">
{round.course_name} · {HOLE_CONFIG_LABELS[round.hole_config]}
</span>
</span>
<ChevronDown
aria-hidden="true"
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")}
/>
</button>
{!confirmingDelete ? (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setConfirmingDelete(true)}
aria-label={`Slett ${round.name || `runde ${round.sequence}`}`}
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
) : (
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="destructive"
size="sm"
onClick={onDelete}
className="h-9 rounded-lg text-xs font-bold"
>
Slett
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setConfirmingDelete(false)}
className="h-9 rounded-lg text-xs font-bold text-muted-foreground"
>
Avbryt
</Button>
</div>
)}
</div>
{expanded && (
<div className="flex flex-col gap-2 border-t border-border p-3">
{roundParticipants === null ? (
<p className="text-xs text-muted-foreground">Laster</p>
) : (
<>
{roundParticipants.length === 0 && (
<p className="text-xs text-muted-foreground">Ingen deltakere i denne runden ennå.</p>
)}
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
{roundParticipants.map((rp) => (
<li key={rp.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-bold text-foreground">{rp.player_name}</span>
<span className="truncate text-xs font-semibold text-muted-foreground">
{rp.tee_name}
{rp.playing_handicap !== null ? ` · HCP ${rp.playing_handicap}` : ""}
</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeRoundParticipant(rp.id)}
aria-label={`Fjern ${rp.player_name} fra runden`}
className="size-8 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
>
<X aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
{unassigned.length > 0 && tees.length > 0 && (
<AssignRoundParticipantControl
candidates={unassigned}
tees={tees}
classes={classes}
onAssign={addRoundParticipant}
/>
)}
{unassigned.length > 0 && tees.length === 0 && (
<p className="text-xs font-medium text-brand-orange">
Banen «{round.course_name}» har ingen registrerte utslag ennå -- kan ikke legge til
deltakere før det er plass.
</p>
)}
</>
)}
</div>
)}
</div>
)
}
function AssignRoundParticipantControl({
candidates,
tees,
classes,
onAssign,
}: {
candidates: ApiParticipant[]
tees: ApiTee[]
classes: ApiTournamentClass[]
onAssign: (tournamentParticipantId: string, teeId: string) => Promise<void>
}) {
const [participantId, setParticipantId] = useState(candidates[0]?.id ?? "")
const [teeId, setTeeId] = useState(tees[0]?.id ?? "")
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!candidates.some((c) => c.id === participantId)) setParticipantId(candidates[0]?.id ?? "")
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidates])
// Konkurranseklasser (2026-08-03) -- forhåndsvelg deltakerens klasses
// standardutslag, kun hvis det utslaget faktisk finnes på DENNE rundens
// bane (klassens standardutslag kan tilhøre en annen bane). Fritt
// overstyrbart under.
useEffect(() => {
if (!participantId) return
const candidate = candidates.find((c) => c.id === participantId)
const cls = candidate?.class_id ? classes.find((c) => c.id === candidate.class_id) : null
const defaultTee = cls?.default_tee_id ? tees.find((t) => t.id === cls.default_tee_id) : null
if (defaultTee) setTeeId(defaultTee.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [participantId])
async function handleAssign() {
if (!participantId || !teeId || submitting) return
setSubmitting(true)
await onAssign(participantId, teeId)
setSubmitting(false)
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-border p-2.5 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="assign-participant" className="text-xs font-semibold text-muted-foreground">
Deltaker
</Label>
<select
id="assign-participant"
value={participantId}
onChange={(e) => setParticipantId(e.target.value)}
className="h-10 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{candidates.map((c) => (
<option key={c.id} value={c.id}>
{c.player_name}
</option>
))}
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="assign-tee" className="text-xs font-semibold text-muted-foreground">
Utslag
</Label>
<select
id="assign-tee"
value={teeId}
onChange={(e) => setTeeId(e.target.value)}
className="h-10 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{tees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
<Button
type="button"
onClick={handleAssign}
disabled={submitting}
className="h-10 shrink-0 rounded-lg text-xs font-bold"
>
Legg til
</Button>
</div>
)
}
// =========================================================================
// Scorekort-fane
// =========================================================================
function ScoreTab({
base,
rounds,
scoringMethod,
onError,
}: {
base: string
rounds: ApiRound[]
scoringMethod: string | null
onError: (message: string) => void
}) {
const [roundId, setRoundId] = useState(rounds[0]?.id ?? "")
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[]>([])
const [participantId, setParticipantId] = useState("")
const [holes, setHoles] = useState<ApiHole[] | null>(null)
const round = rounds.find((r) => r.id === roundId) ?? null
// Bumpes ved hver hull-registrering, slik at BBB-/Flag-panelene (egen
// fetch) vet de skal hente på nytt.
const [refreshTick, setRefreshTick] = useState(0)
useEffect(() => {
if (!roundId) return
let cancelled = false
getJson<ApiRoundParticipant[]>(`${base}/rounds/${roundId}/participants`).then((data) => {
if (cancelled) return
const list = data ?? []
setRoundParticipants(list)
setParticipantId(list[0]?.id ?? "")
})
return () => {
cancelled = true
}
}, [base, roundId])
useEffect(() => {
if (!roundId || !participantId) {
setHoles(null)
return
}
let cancelled = false
getJson<ApiHole[]>(`${base}/rounds/${roundId}/participants/${participantId}/holes`).then((data) => {
if (!cancelled) setHoles(data ?? [])
})
return () => {
cancelled = true
}
}, [base, roundId, participantId])
async function updateHole(holeNumber: number, grossStrokes: number) {
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ gross_strokes: grossStrokes }),
})
if (!res.ok) {
onError("Klarte ikke å lagre slaget. Prøv igjen.")
return
}
const updated: ApiHole = await res.json()
setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev))
setRefreshTick((n) => n + 1)
}
// 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 ?? "-",
totalLabel: entry.total_label ?? "E",
totalIsUnderPar: false,
isLeader: entry.is_leader,
rounds: entry.rounds.map((r) => ({ roundNumber: r.round_number, label: r.label, tone: r.tone })),
}
}
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>
)
}
return (
<div className="flex flex-col gap-4">
{groups.map((group) => (
<div key={group.key} className="flex flex-col gap-2">
{showSectionHeaders && (
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
{group.label}
</h3>
)}
{isStrokePlay ? (
<StrokePlayLeaderboard
rows={group.entries.map(toStrokePlayRow)}
caption={showSectionHeaders ? `Resultattavle ${group.label}` : "Resultattavle"}
/>
) : (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
{group.entries.map((entry, index) => renderRow(entry, index + 1))}
</ul>
)}
</div>
))}
</div>
)
}
// 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>
)
}