"use client" // Individuell/flerrunde-turnering (ADR-037) -- flatt felt av spillere over // én eller flere runder, ingen lag. Egen skjerm, adskilt fra // tournament-detail.tsx (lagformat, ADR-011) -- rutet dit av // tournament-router.tsx basert på tournament.format_type. // // Håndkodet (ikke V0) på brukerens eksplisitte instruks. Bruker // --info (blå) for "Individuell"-merket og runde-sammenheng, og --gold // for lederen på leaderboardet -- samme etablerte, validerte tokens som // round-card.tsx allerede bruker (HCP-spilt-til/personlig rekord), ikke // nye, ukalibrerte farger. import type React from "react" import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { ArrowLeft, Check, ChevronDown, Copy, Flag, KeyRound, Medal, Plus, Trash2, Trophy, UserPlus, Users, X, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { TournamentPresentationPanel } from "@/components/tournament-presentation" import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard" import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge" import { CourseTemplatePicker } from "@/components/course-template-editor" import { cn } from "@/lib/utils" // --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) -- type ApiPlayer = { id: string display_name: string handicap_index: number | null gender: "m" | "f" | "x" | null } type ApiTournamentInfo = { id: string status: TournamentStatus join_code: string format_type: string scoring_method: string | null // KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30). bbb_sweep_bonus_enabled: boolean } type ApiParticipant = { id: string player_id: string player_name: string handicap_index_snapshot: number | null class_id: string | null class_name: string | null } // Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt // standardutslag. Her (individuelle turneringer) brukes klasse til BÅDE // utslag-forhåndsutfylling (AssignRoundParticipantControl) OG en egen // resultatliste-seksjon per klasse (LeaderboardTab) -- se // 053_tournament_classes.sql for full begrunnelse og forskjellen fra // lagturneringer (tournament-detail.tsx), hvor klasse KUN styrer utslag. type ApiTournamentClass = { id: string name: string default_tee_id: string | null default_tee_name: string | null } type ApiRound = { id: string tournament_id: string sequence: number name: string | null hole_config: "full_18" | "front_9" | "back_9" course_id: string course_name: string scheduled_at: string | null tee_interval_minutes: number | null start_hole: number } type ApiRoundParticipant = { id: string tournament_participant_id: string player_name: string tee_id: string tee_name: string course_handicap: number | null playing_handicap: number | null } type ApiHole = { hole_number: number par: number stroke_index: number gross_strokes: number | null strokes_received: number | null } type ApiRoundCell = { round_number: number label: string tone: "under" | "even" | "over" | null } type ApiLeaderboardEntry = { tournament_participant_id: string player_name: string rounds_played: number gross_total: number | null net_total: number | null stableford_total: number | null copenhagen_total: number | null bbb_total: number | null class_id: string | null class_name: string | null // Augusta-stil resultattavle (2026-08-04) -- kun populert for // scoring_method brutto/netto/stableford, se stroke-play-leaderboard.tsx. position: string | null is_leader: boolean today_label: string | null thru_label: string | null total_label: string | null rounds: ApiRoundCell[] } type ApiFlagResult = { tournament_participant_id: string player_name: string holes_completed: number ran_out: boolean strokes_remaining: number } type ApiBBBHole = { hole_number: number bingo_participant_id: string | null bango_participant_id: string | null bongo_participant_id: string | null } type ApiCourse = { id: string; name: string; source: string } type ApiTeeRating = { gender: string; course_rating: number; slope_rating: number; par: number } type ApiTee = { id: string; name: string; ratings: ApiTeeRating[] } const HOLE_CONFIG_LABELS: Record = { full_18: "18 hull", front_9: "Hull 1–9", back_9: "Hull 10–18", } const SCORING_METHOD_LABELS: Record = { stroke_gross: "Bruttoslagspill", stroke_net: "Nettoslagspill", stableford: "Stableford", copenhagen: "Københavner", bingo_bango_bongo: "Bingo Bango Bongo", flag: "Flaggturnering", } function holeNumbersFor(config: ApiRound["hole_config"]): number[] { if (config === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1) if (config === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10) return Array.from({ length: 18 }, (_, i) => i + 1) } async function getJson(url: string): Promise { const res = await fetch(url, { credentials: "include" }) if (!res.ok) return null return (await res.json()) as T } async function errorMessage(res: Response, fallback: string): Promise { try { const body = await res.json() return body?.detail?.message ?? fallback } catch { return fallback } } // --- Component --------------------------------------------------------- export function IndividualTournamentDetail({ organizationId, tournamentId, tournamentName, }: { organizationId: string tournamentId: string tournamentName: string }) { const [tournament, setTournament] = useState(null) const [participants, setParticipants] = useState([]) const [rounds, setRounds] = useState([]) const [courses, setCourses] = useState([]) const [classes, setClasses] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [tab, setTab] = useState<"setup" | "score" | "leaderboard" | "presentation">("setup") const base = `/orgs/${organizationId}/tournaments/${tournamentId}` useEffect(() => { let cancelled = false async function load() { try { const [tournamentsData, participantsData, roundsData, coursesData, classesData] = await Promise.all([ getJson(`/orgs/${organizationId}/tournaments`), getJson(`${base}/participants`), getJson(`${base}/rounds`), getJson(`/orgs/${organizationId}/courses`), getJson(`${base}/classes`), ]) if (cancelled) return const mine = tournamentsData?.find((t) => t.id === tournamentId) ?? null if (!mine) { setError("Fant ikke turneringen.") return } setTournament(mine) setParticipants(participantsData ?? []) setRounds(roundsData ?? []) setCourses(coursesData ?? []) setClasses(classesData ?? []) } catch { if (!cancelled) setError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.") } finally { if (!cancelled) setLoading(false) } } load() return () => { cancelled = true } }, [organizationId, tournamentId, base]) async function updateStatus(newStatus: TournamentStatus) { if (!tournament) return const previous = tournament setTournament({ ...tournament, status: newStatus }) try { const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ status: newStatus }), }) if (!res.ok) throw new Error() } catch { setTournament(previous) setError("Klarte ikke å endre turnering-status. Prøv igjen.") } } async function updateScoringMethod(method: string) { if (!tournament) return const previous = tournament setTournament({ ...tournament, scoring_method: method }) try { const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ scoring_method: method }), }) if (!res.ok) throw new Error() } catch { setTournament(previous) setError("Klarte ikke å endre scoringsmetode. Prøv igjen.") } } async function updateBbbSweepBonus(enabled: boolean) { if (!tournament) return const previous = tournament setTournament({ ...tournament, bbb_sweep_bonus_enabled: enabled }) try { const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ bbb_sweep_bonus_enabled: enabled }), }) if (!res.ok) throw new Error() } catch { setTournament(previous) setError("Klarte ikke å endre bonuspoeng-innstillingen. Prøv igjen.") } } async function addParticipant(playerId: string) { setError(null) const res = await fetch(`${base}/participants`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ player_id: playerId }), }) if (!res.ok) { setError(await errorMessage(res, "Klarte ikke å legge til deltakeren.")) return } const created: ApiParticipant = await res.json() setParticipants((prev) => [...prev, created].sort((a, b) => a.player_name.localeCompare(b.player_name))) } async function addNewPlayerAndParticipant(name: string, handicap?: number, gender?: "m" | "f" | "x") { setError(null) const playerRes = await fetch(`/orgs/${organizationId}/players`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ display_name: name, handicap_index: handicap ?? null, gender: gender ?? null }), }) if (!playerRes.ok) { setError("Klarte ikke å opprette spilleren.") return } const player: ApiPlayer = await playerRes.json() await addParticipant(player.id) } async function removeParticipant(participantId: string) { setError(null) const res = await fetch(`${base}/participants/${participantId}`, { method: "DELETE", credentials: "include", }) if (!res.ok) { setError(await errorMessage(res, "Klarte ikke å fjerne deltakeren -- kanskje fortsatt lagt til i en runde?")) return } setParticipants((prev) => prev.filter((p) => p.id !== participantId)) } async function setParticipantClass(participantId: string, classId: string | null) { setError(null) const res = await fetch(`${base}/participants/${participantId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ class_id: classId }), }) if (!res.ok) { setError(await errorMessage(res, "Klarte ikke å endre klasse.")) return } const updated: ApiParticipant = await res.json() setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p))) } async function createClass(name: string, defaultTeeId: string | null) { setError(null) const res = await fetch(`${base}/classes`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ name, default_tee_id: defaultTeeId }), }) if (!res.ok) { setError(await errorMessage(res, "Klarte ikke å opprette klassen.")) return } const created: ApiTournamentClass = await res.json() setClasses((prev) => [...prev, created]) } async function deleteClass(classId: string) { setError(null) const res = await fetch(`${base}/classes/${classId}`, { method: "DELETE", credentials: "include" }) if (res.status !== 204) { setError(await errorMessage(res, "Klarte ikke å fjerne klassen.")) return } setClasses((prev) => prev.filter((c) => c.id !== classId)) setParticipants((prev) => prev.map((p) => (p.class_id === classId ? { ...p, class_id: null, class_name: null } : p)), ) } async function addRound(name: string, courseId: string, holeConfig: ApiRound["hole_config"]) { setError(null) const res = await fetch(`${base}/rounds`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ sequence: rounds.length + 1, name: name.trim() || null, course_id: courseId, hole_config: holeConfig, }), }) if (!res.ok) { setError(await errorMessage(res, "Klarte ikke å opprette runden.")) return } const created: ApiRound = await res.json() setRounds((prev) => [...prev, created].sort((a, b) => a.sequence - b.sequence)) } async function deleteRound(roundId: string) { setError(null) const res = await fetch(`${base}/rounds/${roundId}`, { method: "DELETE", credentials: "include" }) if (!res.ok) { setError(await errorMessage(res, "Klarte ikke å slette runden -- har den allerede deltakere?")) return } setRounds((prev) => prev.filter((r) => r.id !== roundId)) } async function addCourse(name: string): Promise { const res = await fetch(`/orgs/${organizationId}/courses`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ name }), }) if (!res.ok) { setError("Klarte ikke å opprette banen.") return null } const created: ApiCourse = await res.json() setCourses((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name))) return created } function handleCourseImported(course: ApiCourse) { setCourses((prev) => [...prev, course].sort((a, b) => a.name.localeCompare(b.name))) } if (loading) { return (
) } if (error && !tournament) { return (

{error}

Tilbake til dashbordet
) } if (!tournament) return null return (
{error && (
{error}
)} {tab === "setup" && ( )} {tab === "score" && ( )} {tab === "leaderboard" && ( )} {tab === "presentation" && ( )}
) } function JoinCodeChip({ code }: { code: string }) { const [copied, setCopied] = useState(false) async function handleCopy() { try { await navigator.clipboard.writeText(code) setCopied(true) setTimeout(() => setCopied(false), 1500) } catch { // stille -- ikke kritisk } } return ( ) } // ========================================================================= // Oppsett-fane: scoringsmetode, deltakere, runder // ========================================================================= function SetupTab({ base, organizationId, tournament, participants, rounds, courses, classes, onUpdateScoringMethod, onUpdateBbbSweepBonus, onAddParticipant, onAddNewPlayer, onRemoveParticipant, onAddRound, onDeleteRound, onAddCourse, onCourseImported, onCreateClass, onDeleteClass, onSetParticipantClass, onError, }: { base: string organizationId: string tournament: ApiTournamentInfo participants: ApiParticipant[] rounds: ApiRound[] courses: ApiCourse[] classes: ApiTournamentClass[] onUpdateScoringMethod: (method: string) => void onUpdateBbbSweepBonus: (enabled: boolean) => void onAddParticipant: (playerId: string) => Promise onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise onRemoveParticipant: (id: string) => Promise onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise onDeleteRound: (id: string) => Promise onAddCourse: (name: string) => Promise onCourseImported: (course: ApiCourse) => void onCreateClass: (name: string, defaultTeeId: string | null) => Promise onDeleteClass: (id: string) => Promise onSetParticipantClass: (participantId: string, classId: string | null) => Promise onError: (message: string) => void }) { const [pool, setPool] = useState([]) useEffect(() => { let cancelled = false getJson(`/orgs/${organizationId}/players`).then((data) => { if (!cancelled) setPool(data ?? []) }) return () => { cancelled = true } }, [organizationId]) return (

Scoringsmetode

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

{tournament.scoring_method ? SCORING_METHOD_LABELS[tournament.scoring_method] : "Ikke satt"} {Object.entries(SCORING_METHOD_LABELS).map(([value, label]) => ( onUpdateScoringMethod(value)} className="cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold" > {label} ))} {tournament.scoring_method === "bingo_bango_bongo" && ( )}
) } // Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin // ClassesCard for lagturnering-varianten (samme mønster, men uten // leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten). function ClassesCard({ classes, courses, organizationId, onCreate, onDelete, }: { classes: ApiTournamentClass[] courses: ApiCourse[] organizationId: string onCreate: (name: string, defaultTeeId: string | null) => Promise onDelete: (classId: string) => Promise }) { const [name, setName] = useState("") const [courseId, setCourseId] = useState("") const [teeId, setTeeId] = useState("") const [tees, setTees] = useState([]) const [adding, setAdding] = useState(false) useEffect(() => { if (!courseId) { setTees([]) setTeeId("") return } let cancelled = false getJson(`/orgs/${organizationId}/courses/${courseId}/tees`).then((data) => { if (!cancelled) { setTees(data ?? []) setTeeId(data?.[0]?.id ?? "") } }) return () => { cancelled = true } }, [organizationId, courseId]) async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (name.trim().length === 0 || adding) return setAdding(true) await onCreate(name.trim(), teeId || null) setAdding(false) setName("") setCourseId("") setTeeId("") } return (

Klasser

{classes.length}

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

{classes.length > 0 && (
    {classes.map((c) => (
  • {c.name} {c.default_tee_name && ( Standardutslag: {c.default_tee_name} )}
  • ))}
)}
setName(e.target.value)} placeholder="F.eks. Damer" className="h-11" />
{tees.length > 0 && (
)}
) } function ParticipantsCard({ participants, pool, classes, onAddParticipant, onAddNewPlayer, onRemoveParticipant, onSetClass, }: { participants: ApiParticipant[] pool: ApiPlayer[] classes: ApiTournamentClass[] onAddParticipant: (playerId: string) => Promise onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise onRemoveParticipant: (id: string) => Promise onSetClass: (participantId: string, classId: string | null) => Promise }) { return (

Deltakere

{participants.length}
{participants.length === 0 && (

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

)}
    {participants.map((p) => (
  • {p.player_name} {p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"}
    {classes.length > 0 && ( )}
  • ))}
p.player_id))} onAddExisting={onAddParticipant} onAddNew={onAddNewPlayer} />
) } function AddParticipantControl({ pool, alreadyIn, onAddExisting, onAddNew, }: { pool: ApiPlayer[] alreadyIn: Set onAddExisting: (playerId: string) => Promise onAddNew: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise }) { const [open, setOpen] = useState(false) const [query, setQuery] = useState("") const [creating, setCreating] = useState(false) const [newHandicap, setNewHandicap] = useState("") const [newGender, setNewGender] = useState<"m" | "f" | "x" | "">("") const trimmed = query.trim() const matches = useMemo(() => { if (!trimmed) return [] const q = trimmed.toLowerCase() return pool.filter((p) => !alreadyIn.has(p.id) && p.display_name.toLowerCase().includes(q)) }, [pool, trimmed, alreadyIn]) const exactMatch = useMemo( () => pool.some((p) => p.display_name.toLowerCase() === trimmed.toLowerCase()), [pool, trimmed], ) const showCreate = trimmed.length >= 2 && !exactMatch function reset() { setQuery("") setCreating(false) setNewHandicap("") setNewGender("") } async function handleAddExisting(playerId: string) { await onAddExisting(playerId) reset() setOpen(false) } async function handleCreate() { if (trimmed.length < 2) return const hcpValue = newHandicap.trim() === "" ? undefined : Number(newHandicap.replace(",", ".")) const hcp = hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined await onAddNew(trimmed, hcp, newGender || undefined) reset() setOpen(false) } if (!open) { return ( ) } return (
{ setQuery(e.target.value) setCreating(false) }} onKeyDown={(e) => { if (e.key === "Escape") { setOpen(false) reset() } }} className="h-11 flex-1 rounded-2xl text-base" />
{matches.length > 0 && (
    {matches.slice(0, 6).map((p) => (
  • ))}
)} {showCreate && !creating && ( )} {showCreate && creating && (
setNewHandicap(e.target.value)} className="h-10 rounded-xl text-sm" />
)}
) } 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 onDeleteRound: (id: string) => Promise onAddCourse: (name: string) => Promise onCourseImported: (course: ApiCourse) => void onError: (message: string) => void }) { const [creating, setCreating] = useState(false) return (

Runder

{rounds.length}
{rounds.length === 0 && (

Ingen runder ennå -- en enkeltdags turnering trenger kun én, en flerdagers turnering kan ha flere.

)}
{rounds.map((r) => ( onDeleteRound(r.id)} onError={onError} /> ))}
{!creating ? ( ) : ( { await onAddRound(name, courseId, holeConfig) setCreating(false) }} onCreateCourse={onAddCourse} onCancel={() => setCreating(false)} /> )}
) } function NewRoundForm({ organizationId, courses, onCreate, onCreateCourse, onCourseImported, onCancel, }: { organizationId: string courses: ApiCourse[] onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise onCreateCourse: (name: string) => Promise onCourseImported: (course: ApiCourse) => void onCancel: () => void }) { const [name, setName] = useState("") const [courseId, setCourseId] = useState(courses[0]?.id ?? "") const [holeConfig, setHoleConfig] = useState("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 (
setName(e.target.value)} className="h-10 rounded-xl text-sm" />
{courses.length > 0 && !addingCourse ? (
) : (
setNewCourseName(e.target.value)} className="h-10 flex-1 rounded-xl text-sm" /> {courses.length > 0 && ( )}
)}

Banen må ha registrert hull (par/hcp-indeks) og utslag før scoring kan begynne -- sett opp dette under organisasjonens baner om det mangler.

{!officialSearchOpen && !templatePickerOpen ? (
) : officialSearchOpen ? ( setOfficialSearchOpen(false)} onImported={(course) => { onCourseImported(course) setCourseId(course.id) setAddingCourse(false) setOfficialSearchOpen(false) }} /> ) : ( { onCourseImported({ id: created.id, name: created.name, source: "custom" }) setCourseId(created.id) setAddingCourse(false) setTemplatePickerOpen(false) }} onCancel={() => setTemplatePickerOpen(false)} /> )}
) } 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 onError: (message: string) => void }) { const [expanded, setExpanded] = useState(false) const [roundParticipants, setRoundParticipants] = useState(null) const [tees, setTees] = useState([]) const [confirmingDelete, setConfirmingDelete] = useState(false) async function loadRoundParticipants() { const [rpData, teeData] = await Promise.all([ getJson(`${base}/rounds/${round.id}/participants`), getJson(`/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 (
{!confirmingDelete ? ( ) : (
)}
{expanded && (
{roundParticipants === null ? (

Laster…

) : ( <> {roundParticipants.length === 0 && (

Ingen deltakere i denne runden ennå.

)}
    {roundParticipants.map((rp) => (
  • {rp.player_name} {rp.tee_name} {rp.playing_handicap !== null ? ` · HCP ${rp.playing_handicap}` : ""}
  • ))}
{unassigned.length > 0 && tees.length > 0 && ( )} {unassigned.length > 0 && tees.length === 0 && (

Banen «{round.course_name}» har ingen registrerte utslag ennå -- kan ikke legge til deltakere før det er på plass.

)} )}
)}
) } function AssignRoundParticipantControl({ candidates, tees, classes, onAssign, }: { candidates: ApiParticipant[] tees: ApiTee[] classes: ApiTournamentClass[] onAssign: (tournamentParticipantId: string, teeId: string) => Promise }) { 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 (
) } // ========================================================================= // 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([]) const [participantId, setParticipantId] = useState("") const [holes, setHoles] = useState(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(`${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(`${base}/rounds/${roundId}/participants/${participantId}/holes`).then((data) => { if (!cancelled) setHoles(data ?? []) }) return () => { cancelled = true } }, [base, roundId, participantId]) async function updateHole(holeNumber: number, grossStrokes: number) { const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ gross_strokes: grossStrokes }), }) if (!res.ok) { onError("Klarte ikke å lagre slaget. Prøv igjen.") return } const updated: ApiHole = await res.json() setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev)) setRefreshTick((n) => n + 1) } if (rounds.length === 0) { return (

Opprett en runde under «Oppsett» først.

) } return (
{rounds.map((r) => ( ))}
{roundParticipants.length === 0 ? (

Ingen deltakere i denne runden ennå -- legg til under «Oppsett».

) : ( <>
{roundParticipants.map((rp) => ( ))}
{round && holes && ( )} {/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon, uavhengig av gross_strokes-registreringen over. */} {round && scoringMethod === "bingo_bango_bongo" && ( )} {/* Flaggturnering (2026-07-30) -- per-runde-resultat (ikke akkumulerbart på tvers av runder, se LeaderboardTab). */} {round && scoringMethod === "flag" && ( )} )}
) } // --- 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 (
{label}
{participants.map((p) => { const active = selectedId === p.tournament_participant_id return ( ) })}
) } 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>({}) useEffect(() => { let cancelled = false getJson(`${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 (
Bingo Bango Bongo
void set("bingo_participant_id", id)} /> void set("bango_participant_id", id)} /> void set("bongo_participant_id", id)} />
) } // --- Flaggturnering -- per-runde-resultat (2026-07-30) ---------------------- function FlagRoundPanel({ base, round, refreshTick, }: { base: string round: ApiRound refreshTick: number }) { const [results, setResults] = useState(null) useEffect(() => { let cancelled = false getJson(`${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 (

Venter på at handicap beregnes for minst én spiller.

) } return (
Flaggturnering
    {results.map((r) => (
  • {r.player_name} {r.holes_completed} hull {r.ran_out ? "gikk tom for slag" : `${r.strokes_remaining} slag igjen`}
  • ))}
) } type ScoreClass = "eagle" | "birdie" | "par" | "bogey" | "double" function classify(diff: number): ScoreClass { if (diff <= -2) return "eagle" if (diff === -1) return "birdie" if (diff === 0) return "par" if (diff === 1) return "bogey" return "double" } // Samme "form + farge, aldri farge alene"-språk som round-scorecard.tsx sin // ScoreMark: sirkel = under par, firkant = over par, fylt = 2+ slag fra par. // "Par" er nøytral (ingen retning å vise), men beholder en synlig kant siden // disse cellene -- ulikt originalens rene visning -- er trykkbare knapper. function scoreMarkClasses(kind: ScoreClass): string { switch (kind) { case "eagle": return "rounded-full border-2 border-primary bg-primary text-primary-foreground" case "birdie": return "rounded-full border-2 border-primary bg-primary/10 text-primary" case "bogey": return "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange" case "double": return "rounded-[4px] border-2 border-brand-orange bg-brand-orange text-brand-orange-foreground" default: return "rounded-[4px] border-2 border-border bg-card text-foreground" } } function HoleGrid({ holes, holeConfig, onUpdate, }: { holes: ApiHole[] holeConfig: ApiRound["hole_config"] onUpdate: (holeNumber: number, grossStrokes: number) => Promise }) { const [editingHole, setEditingHole] = useState(null) const [draft, setDraft] = useState("") const numbers = holeNumbersFor(holeConfig) const byNumber = new Map(holes.map((h) => [h.hole_number, h])) const played = holes.filter((h) => h.gross_strokes !== null) const totalGross = played.length > 0 ? played.reduce((sum, h) => sum + (h.gross_strokes as number), 0) : null const totalPar = played.length > 0 ? played.reduce((sum, h) => sum + h.par, 0) : null function openEditor(holeNumber: number, current: number | null) { setEditingHole(holeNumber) setDraft(current !== null ? String(current) : "") } async function commit(holeNumber: number) { const value = Number(draft) if (Number.isFinite(value) && value >= 1 && value <= 20) { await onUpdate(holeNumber, value) } setEditingHole(null) setDraft("") } return (
{numbers.map((n) => ( {n} ))} {numbers.map((n) => ( Par {byNumber.get(n)?.par ?? "–"} ))} {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 ( 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 ( ) })}
{totalGross !== null && totalPar !== null && (
{played.length} av {numbers.length} hull spilt {totalGross} slag ({totalGross - totalPar >= 0 ? "+" : ""} {totalGross - totalPar} til par)
)}
) } // ========================================================================= // Leaderboard-fane // ========================================================================= function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTournamentInfo }) { const [entries, setEntries] = useState(null) const [error, setError] = useState(null) useEffect(() => { let cancelled = false async function load() { const res = await fetch(`${base}/individual-leaderboard`, { credentials: "include" }) if (!res.ok) { if (!cancelled) setError("Klarte ikke å hente leaderboardet.") return } const data: ApiLeaderboardEntry[] = await res.json() if (!cancelled) setEntries(data) } load() return () => { cancelled = true } }, [base]) const method = tournament.scoring_method function valueFor(entry: ApiLeaderboardEntry): { label: string; value: number | null } { if (method === "stableford") return { label: "poeng", value: entry.stableford_total } if (method === "stroke_net") return { label: "netto", value: entry.net_total } if (method === "copenhagen") return { label: "poeng", value: entry.copenhagen_total } if (method === "bingo_bango_bongo") return { label: "poeng", value: entry.bbb_total } return { label: "slag", value: entry.gross_total } } if (error) { return

{error}

} if (!entries) { return (
) } if (!method) { return (

Sett en scoringsmetode under «Oppsett» først -- avgjør hvilket tall leaderboardet skal rangere etter.

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

Flaggturnering vises per runde -- se resultatet under «Scorekort» for hver runde.

) } if (entries.length === 0) { return (

Ingen deltakere ennå.

) } // 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() for (const entry of entries) { const key = entry.class_id ?? "__none__" let idx = indexByKey.get(key) if (idx === undefined) { idx = groups.length indexByKey.set(key, idx) groups.push({ key, label: entry.class_name ?? "Ingen klasse", entries: [] }) } groups[idx].entries.push(entry) } // "Ingen klasse" sist, uansett hvor den først dukket opp i sorteringen. groups.sort((a, b) => (a.key === "__none__" ? 1 : b.key === "__none__" ? -1 : 0)) const showSectionHeaders = groups.length > 1 || groups[0]?.key !== "__none__" // Augusta-stil resultattavle (2026-08-04) -- kun for brutto/netto/ // stableford, backend har allerede regnet ut alt (POS med uavgjort, // TODAY/THRU for gjeldende runde, TOTAL, R1-Rn). Denne funksjonen er ren // formvending, ingen domenelogikk. const isStrokePlay = method === "stroke_gross" || method === "stroke_net" || method === "stableford" function toStrokePlayRow(entry: ApiLeaderboardEntry): StrokePlayRow { return { position: entry.position ?? "-", playerName: entry.player_name, todayLabel: entry.today_label, todayIsUnderPar: null, thruLabel: entry.thru_label ?? "-", totalLabel: entry.total_label ?? "E", totalIsUnderPar: false, isLeader: entry.is_leader, rounds: entry.rounds.map((r) => ({ roundNumber: r.round_number, label: r.label, tone: r.tone })), } } function renderRow(entry: ApiLeaderboardEntry, rank: number) { const { label, value } = valueFor(entry) const isLeader = rank === 1 && value !== null return (
  • {isLeader ?
    {entry.player_name} {entry.rounds_played} {entry.rounds_played === 1 ? "runde" : "runder"} spilt
    {value !== null ? ( <> {value} {label} ) : ( )}
  • ) } return (
    {groups.map((group) => (
    {showSectionHeaders && (

    {group.label}

    )} {isStrokePlay ? ( ) : (
      {group.entries.map((entry, index) => renderRow(entry, index + 1))}
    )}
    ))}
    ) } // --- 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(null) const [selectedFacility, setSelectedFacility] = useState(null) const [courseOptions, setCourseOptions] = useState(null) const [searching, setSearching] = useState(false) const [importing, setImporting] = useState(false) const [error, setError] = useState(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 (
    Hent bane fra teeoff
    {error &&

    {error}

    } {!selectedFacility ? ( <> {/* Bevisst IKKE et eget
    her -- denne komponenten rendres inne i NewRoundForm sitt , og nestede -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). */}
    setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() runSearch() } }} className="h-11 flex-1 rounded-xl text-base" />
    {facilities && (
      {facilities.map((f) => (
    • ))} {facilities.length === 0 && (
    • Ingen treff.
    • )}
    )} ) : ( <> {courseOptions === null ? (

    Laster baner…

    ) : (
      {courseOptions.map((c) => (
    • ))} {courseOptions.length === 0 && (
    • Ingen 18-hulls baner å importere hos dette anlegget ennå.
    • )}
    )} )}
    ) }