Del C (ADR-068, migrasjon 072, siste del av den tredelte utvidelsen som startet med Flaggturnering GPS/kart, se ADR-066/067): nytt format eclectic_gross/eclectic_net/eclectic_stableford -- beste resultat per hull på tvers av en turnerings egne runder, krever samme bane (avvist tydelig ved rundeopprettelse ellers). Regnes ut ved lesing, ingen nye tabeller. Bevisst avvik fra opprinnelig plan: integrert som en ny gren i eksisterende individual-leaderboard-endepunkt fremfor et nytt eget endepunkt -- se ADR-068 for begrunnelsen. Tre ikke-relaterte, brukerrapporterte UI-rettelser tatt med i samme runde: avstandsindikatoren brukte "grønn"/"Midt" i stedet for riktige golf-uttrykk "green"/"senter", og "Oppdateres live"-badgen fjernet. "Antall hull"-bryteren i Ny runde-veiviseren fikk samme grønne aksent-valgt-stil som resten av samme skjerm (delt Segmented-primitiv). Se ARCHITECTURE_DECISIONS.md (ADR-068) og CHANGELOG.md (punkt 84) for full begrunnelse og verifiseringslogg. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3439 lines
124 KiB
TypeScript
3439 lines
124 KiB
TypeScript
"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,
|
||
Copy,
|
||
Crosshair,
|
||
Flag,
|
||
KeyRound,
|
||
MapPin,
|
||
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
|
||
// 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
|
||
}
|
||
|
||
// 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 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 1–9",
|
||
back_9: "Hull 10–18",
|
||
}
|
||
|
||
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)))
|
||
}
|
||
|
||
async function addNewPlayerAndParticipant(name: string, handicap?: number, gender?: "m" | "f" | "x") {
|
||
setError(null)
|
||
const playerRes = await fetch(`/orgs/${organizationId}/players`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ display_name: name, handicap_index: handicap ?? null, gender: gender ?? null }),
|
||
})
|
||
if (!playerRes.ok) {
|
||
setError("Klarte ikke å opprette spilleren.")
|
||
return
|
||
}
|
||
const player: ApiPlayer = await playerRes.json()
|
||
await addParticipant(player.id)
|
||
}
|
||
|
||
async function removeParticipant(participantId: string) {
|
||
setError(null)
|
||
const res = await fetch(`${base}/participants/${participantId}`, {
|
||
method: "DELETE",
|
||
credentials: "include",
|
||
})
|
||
if (!res.ok) {
|
||
setError(await errorMessage(res, "Klarte ikke å fjerne deltakeren -- kanskje fortsatt lagt til i en runde?"))
|
||
return
|
||
}
|
||
setParticipants((prev) => prev.filter((p) => p.id !== participantId))
|
||
}
|
||
|
||
async function setParticipantClass(participantId: string, classId: string | null) {
|
||
setError(null)
|
||
const res = await fetch(`${base}/participants/${participantId}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ class_id: classId }),
|
||
})
|
||
if (!res.ok) {
|
||
setError(await errorMessage(res, "Klarte ikke å endre klasse."))
|
||
return
|
||
}
|
||
const updated: ApiParticipant = await res.json()
|
||
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
|
||
}
|
||
|
||
async function createClass(name: string, defaultTeeId: string | null) {
|
||
setError(null)
|
||
const res = await fetch(`${base}/classes`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ name, default_tee_id: defaultTeeId }),
|
||
})
|
||
if (!res.ok) {
|
||
setError(await errorMessage(res, "Klarte ikke å opprette klassen."))
|
||
return
|
||
}
|
||
const created: ApiTournamentClass = await res.json()
|
||
setClasses((prev) => [...prev, created])
|
||
}
|
||
|
||
async function deleteClass(classId: string) {
|
||
setError(null)
|
||
const res = await fetch(`${base}/classes/${classId}`, { method: "DELETE", credentials: "include" })
|
||
if (res.status !== 204) {
|
||
setError(await errorMessage(res, "Klarte ikke å fjerne klassen."))
|
||
return
|
||
}
|
||
setClasses((prev) => prev.filter((c) => c.id !== classId))
|
||
setParticipants((prev) =>
|
||
prev.map((p) => (p.class_id === classId ? { ...p, class_id: null, class_name: null } : p)),
|
||
)
|
||
}
|
||
|
||
async function addRound(name: string, courseId: string, holeConfig: ApiRound["hole_config"]) {
|
||
setError(null)
|
||
const res = await fetch(`${base}/rounds`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({
|
||
sequence: rounds.length + 1,
|
||
name: name.trim() || null,
|
||
course_id: courseId,
|
||
hole_config: holeConfig,
|
||
}),
|
||
})
|
||
if (!res.ok) {
|
||
setError(await errorMessage(res, "Klarte ikke å opprette runden."))
|
||
return
|
||
}
|
||
const created: ApiRound = await res.json()
|
||
setRounds((prev) => [...prev, created].sort((a, b) => a.sequence - b.sequence))
|
||
}
|
||
|
||
async function deleteRound(roundId: string) {
|
||
setError(null)
|
||
const res = await fetch(`${base}/rounds/${roundId}`, { method: "DELETE", credentials: "include" })
|
||
if (!res.ok) {
|
||
setError(await errorMessage(res, "Klarte ikke å slette runden -- har den allerede deltakere?"))
|
||
return
|
||
}
|
||
setRounds((prev) => prev.filter((r) => r.id !== roundId))
|
||
}
|
||
|
||
async function addCourse(name: string): Promise<ApiCourse | null> {
|
||
const res = await fetch(`/orgs/${organizationId}/courses`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ name }),
|
||
})
|
||
if (!res.ok) {
|
||
setError("Klarte ikke å opprette banen.")
|
||
return null
|
||
}
|
||
const created: ApiCourse = await res.json()
|
||
setCourses((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
|
||
return created
|
||
}
|
||
|
||
function handleCourseImported(course: ApiCourse) {
|
||
setCourses((prev) => [...prev, course].sort((a, b) => a.name.localeCompare(b.name)))
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
|
||
<div
|
||
aria-hidden="true"
|
||
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error && !tournament) {
|
||
return (
|
||
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-3 bg-background px-5 text-center">
|
||
<p className="text-sm font-medium text-destructive">{error}</p>
|
||
<Link href="/dashboard" className="text-sm font-semibold text-primary underline underline-offset-2">
|
||
Tilbake til dashbordet
|
||
</Link>
|
||
</main>
|
||
)
|
||
}
|
||
|
||
if (!tournament) return null
|
||
|
||
return (
|
||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3 px-5 py-4">
|
||
<div className="flex items-center gap-3">
|
||
<Link
|
||
href="/dashboard"
|
||
aria-label="Tilbake til dashbord"
|
||
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||
>
|
||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||
</Link>
|
||
<div className="flex min-w-0 flex-1 flex-col">
|
||
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||
<span className="inline-flex items-center gap-1 rounded-full bg-info/15 px-2 py-0.5 text-info">
|
||
<Users aria-hidden="true" className="size-3" />
|
||
Individuell
|
||
</span>
|
||
Turnering
|
||
</span>
|
||
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{tournamentName}</h1>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2 pl-13">
|
||
<TournamentStatusPicker status={tournament.status} onChange={updateStatus} />
|
||
<JoinCodeChip code={tournament.join_code} />
|
||
</div>
|
||
</div>
|
||
|
||
<nav
|
||
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
||
aria-label="Turneringsseksjoner"
|
||
>
|
||
{(
|
||
[
|
||
["setup", "Oppsett"],
|
||
["score", "Scorekort"],
|
||
["leaderboard", "Leaderboard"],
|
||
["presentation", "Presentasjon"],
|
||
] as const
|
||
).map(([key, label]) => (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() => setTab(key)}
|
||
aria-current={tab === key ? "page" : undefined}
|
||
className={cn(
|
||
"min-h-11 shrink-0 rounded-full px-4 py-2 text-sm font-bold transition-colors",
|
||
tab === key
|
||
? "bg-primary text-primary-foreground"
|
||
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
</header>
|
||
|
||
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
|
||
{error && (
|
||
<div className="mb-5 rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm font-medium text-destructive">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{tab === "setup" && (
|
||
<SetupTab
|
||
base={base}
|
||
organizationId={organizationId}
|
||
tournament={tournament}
|
||
participants={participants}
|
||
rounds={rounds}
|
||
courses={courses}
|
||
classes={classes}
|
||
onUpdateScoringMethod={updateScoringMethod}
|
||
onUpdateBbbSweepBonus={updateBbbSweepBonus}
|
||
onUpdateFlagMapVisible={updateFlagMapVisible}
|
||
onAddParticipant={addParticipant}
|
||
onAddNewPlayer={addNewPlayerAndParticipant}
|
||
onRemoveParticipant={removeParticipant}
|
||
onAddRound={addRound}
|
||
onDeleteRound={deleteRound}
|
||
onAddCourse={addCourse}
|
||
onCourseImported={handleCourseImported}
|
||
onCreateClass={createClass}
|
||
onDeleteClass={deleteClass}
|
||
onSetParticipantClass={setParticipantClass}
|
||
onError={setError}
|
||
/>
|
||
)}
|
||
{tab === "score" && (
|
||
<ScoreTab base={base} rounds={rounds} scoringMethod={tournament.scoring_method} onError={setError} />
|
||
)}
|
||
{tab === "leaderboard" && (
|
||
<LeaderboardTab base={base} tournament={tournament} />
|
||
)}
|
||
{tab === "presentation" && (
|
||
<TournamentPresentationPanel organizationId={organizationId} tournamentId={tournamentId} />
|
||
)}
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function JoinCodeChip({ code }: { code: string }) {
|
||
const [copied, setCopied] = useState(false)
|
||
async function handleCopy() {
|
||
try {
|
||
await navigator.clipboard.writeText(code)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 1500)
|
||
} catch {
|
||
// stille -- ikke kritisk
|
||
}
|
||
}
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={handleCopy}
|
||
className="inline-flex min-h-11 items-center gap-1.5 rounded-full border border-border bg-card px-3 py-2 text-sm font-bold tabular-nums text-foreground transition-colors hover:bg-accent/50"
|
||
aria-label={`Invitasjonskode ${code}, trykk for å kopiere`}
|
||
>
|
||
{copied ? (
|
||
<Check aria-hidden="true" className="size-4 text-primary" />
|
||
) : (
|
||
<KeyRound aria-hidden="true" className="size-4 text-muted-foreground" />
|
||
)}
|
||
{code}
|
||
{!copied && <Copy aria-hidden="true" className="size-3.5 text-muted-foreground" />}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// =========================================================================
|
||
// Oppsett-fane: scoringsmetode, deltakere, runder
|
||
// =========================================================================
|
||
|
||
function SetupTab({
|
||
base,
|
||
organizationId,
|
||
tournament,
|
||
participants,
|
||
rounds,
|
||
courses,
|
||
classes,
|
||
onUpdateScoringMethod,
|
||
onUpdateBbbSweepBonus,
|
||
onUpdateFlagMapVisible,
|
||
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
|
||
onUpdateFlagMapVisible: (visible: boolean) => void
|
||
onAddParticipant: (playerId: string) => Promise<void>
|
||
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
|
||
onRemoveParticipant: (id: string) => Promise<void>
|
||
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
||
onDeleteRound: (id: string) => Promise<void>
|
||
onAddCourse: (name: string) => Promise<ApiCourse | null>
|
||
onCourseImported: (course: ApiCourse) => void
|
||
onCreateClass: (name: string, defaultTeeId: string | null) => Promise<void>
|
||
onDeleteClass: (id: string) => Promise<void>
|
||
onSetParticipantClass: (participantId: string, classId: string | null) => Promise<void>
|
||
onError: (message: string) => void
|
||
}) {
|
||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
getJson<ApiPlayer[]>(`/orgs/${organizationId}/players`).then((data) => {
|
||
if (!cancelled) setPool(data ?? [])
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [organizationId])
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||
<h2 className="text-base font-bold text-foreground">Scoringsmetode</h2>
|
||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||
Avgjør hvordan totalen på 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 på 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 PÅ, 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
|
||
participants={participants}
|
||
pool={pool}
|
||
classes={classes}
|
||
onAddParticipant={onAddParticipant}
|
||
onAddNewPlayer={onAddNewPlayer}
|
||
onRemoveParticipant={onRemoveParticipant}
|
||
onSetClass={onSetParticipantClass}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin
|
||
// ClassesCard for lagturnering-varianten (samme mønster, men uten
|
||
// leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten).
|
||
function ClassesCard({
|
||
classes,
|
||
courses,
|
||
organizationId,
|
||
onCreate,
|
||
onDelete,
|
||
}: {
|
||
classes: ApiTournamentClass[]
|
||
courses: ApiCourse[]
|
||
organizationId: string
|
||
onCreate: (name: string, defaultTeeId: string | null) => Promise<void>
|
||
onDelete: (classId: string) => Promise<void>
|
||
}) {
|
||
const [name, setName] = useState("")
|
||
const [courseId, setCourseId] = useState("")
|
||
const [teeId, setTeeId] = useState("")
|
||
const [tees, setTees] = useState<ApiTee[]>([])
|
||
const [adding, setAdding] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (!courseId) {
|
||
setTees([])
|
||
setTeeId("")
|
||
return
|
||
}
|
||
let cancelled = false
|
||
getJson<ApiTee[]>(`/orgs/${organizationId}/courses/${courseId}/tees`).then((data) => {
|
||
if (!cancelled) {
|
||
setTees(data ?? [])
|
||
setTeeId(data?.[0]?.id ?? "")
|
||
}
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [organizationId, courseId])
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
if (name.trim().length === 0 || adding) return
|
||
setAdding(true)
|
||
await onCreate(name.trim(), teeId || null)
|
||
setAdding(false)
|
||
setName("")
|
||
setCourseId("")
|
||
setTeeId("")
|
||
}
|
||
|
||
return (
|
||
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<h2 className="text-base font-bold text-foreground">Klasser</h2>
|
||
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
|
||
{classes.length}
|
||
</span>
|
||
</div>
|
||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||
Foreslår riktig utslag ved oppsett av en runde, og gir hver klasse sin egen seksjon på
|
||
leaderboardet.
|
||
</p>
|
||
|
||
{classes.length > 0 && (
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{classes.map((c) => (
|
||
<li key={c.id} className="flex items-center justify-between gap-3 px-4 py-3">
|
||
<div className="flex min-w-0 flex-col">
|
||
<span className="truncate text-sm font-bold text-foreground">{c.name}</span>
|
||
{c.default_tee_name && (
|
||
<span className="text-xs text-muted-foreground">
|
||
Standardutslag: {c.default_tee_name}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => onDelete(c.id)}
|
||
aria-label={`Fjern klassen ${c.name}`}
|
||
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
|
||
>
|
||
<Trash2 aria-hidden="true" className="size-4" />
|
||
</Button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||
<div className="flex flex-1 flex-col gap-1">
|
||
<Label htmlFor="ind-class-name" className="text-xs font-semibold text-muted-foreground">
|
||
Klassenavn
|
||
</Label>
|
||
<Input
|
||
id="ind-class-name"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="F.eks. Damer"
|
||
className="h-11"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-1 flex-col gap-1">
|
||
<Label htmlFor="ind-class-course" className="text-xs font-semibold text-muted-foreground">
|
||
Bane (for standardutslag)
|
||
</Label>
|
||
<select
|
||
id="ind-class-course"
|
||
value={courseId}
|
||
onChange={(e) => setCourseId(e.target.value)}
|
||
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
|
||
>
|
||
<option value="">Ingen standardutslag</option>
|
||
{courses.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
{tees.length > 0 && (
|
||
<div className="flex flex-1 flex-col gap-1">
|
||
<Label htmlFor="ind-class-tee" className="text-xs font-semibold text-muted-foreground">
|
||
Standardutslag
|
||
</Label>
|
||
<select
|
||
id="ind-class-tee"
|
||
value={teeId}
|
||
onChange={(e) => setTeeId(e.target.value)}
|
||
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
|
||
>
|
||
{tees.map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<Button
|
||
type="submit"
|
||
variant="secondary"
|
||
disabled={adding || name.trim().length === 0}
|
||
className="h-11 shrink-0 rounded-xl font-semibold"
|
||
>
|
||
<Plus aria-hidden="true" className="size-4" />
|
||
Legg til
|
||
</Button>
|
||
</form>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function ParticipantsCard({
|
||
participants,
|
||
pool,
|
||
classes,
|
||
onAddParticipant,
|
||
onAddNewPlayer,
|
||
onRemoveParticipant,
|
||
onSetClass,
|
||
}: {
|
||
participants: ApiParticipant[]
|
||
pool: ApiPlayer[]
|
||
classes: ApiTournamentClass[]
|
||
onAddParticipant: (playerId: string) => Promise<void>
|
||
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
|
||
onRemoveParticipant: (id: string) => Promise<void>
|
||
onSetClass: (participantId: string, classId: string | null) => Promise<void>
|
||
}) {
|
||
return (
|
||
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<h2 className="text-base font-bold text-foreground">Deltakere</h2>
|
||
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
|
||
{participants.length}
|
||
</span>
|
||
</div>
|
||
|
||
{participants.length === 0 && (
|
||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||
Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller.
|
||
</p>
|
||
)}
|
||
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{participants.map((p) => (
|
||
<li key={p.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
||
<div className="flex min-w-0 flex-col">
|
||
<span className="truncate text-sm font-bold text-foreground">{p.player_name}</span>
|
||
<span className="text-xs font-semibold text-muted-foreground">
|
||
{p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"}
|
||
</span>
|
||
</div>
|
||
{classes.length > 0 && (
|
||
<select
|
||
value={p.class_id ?? ""}
|
||
onChange={(e) => onSetClass(p.id, e.target.value || null)}
|
||
aria-label={`Klasse for ${p.player_name}`}
|
||
className="h-9 shrink-0 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none"
|
||
>
|
||
<option value="">Ingen klasse</option>
|
||
{classes.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => onRemoveParticipant(p.id)}
|
||
aria-label={`Fjern ${p.player_name}`}
|
||
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
|
||
>
|
||
<Trash2 aria-hidden="true" className="size-4" />
|
||
</Button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<AddParticipantControl
|
||
pool={pool}
|
||
alreadyIn={new Set(participants.map((p) => p.player_id))}
|
||
onAddExisting={onAddParticipant}
|
||
onAddNew={onAddNewPlayer}
|
||
/>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function AddParticipantControl({
|
||
pool,
|
||
alreadyIn,
|
||
onAddExisting,
|
||
onAddNew,
|
||
}: {
|
||
pool: ApiPlayer[]
|
||
alreadyIn: Set<string>
|
||
onAddExisting: (playerId: string) => Promise<void>
|
||
onAddNew: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const [query, setQuery] = useState("")
|
||
const [creating, setCreating] = useState(false)
|
||
const [newHandicap, setNewHandicap] = useState("")
|
||
const [newGender, setNewGender] = useState<"m" | "f" | "x" | "">("")
|
||
|
||
const trimmed = query.trim()
|
||
const matches = useMemo(() => {
|
||
if (!trimmed) return []
|
||
const q = trimmed.toLowerCase()
|
||
return pool.filter((p) => !alreadyIn.has(p.id) && p.display_name.toLowerCase().includes(q))
|
||
}, [pool, trimmed, alreadyIn])
|
||
const exactMatch = useMemo(
|
||
() => pool.some((p) => p.display_name.toLowerCase() === trimmed.toLowerCase()),
|
||
[pool, trimmed],
|
||
)
|
||
const showCreate = trimmed.length >= 2 && !exactMatch
|
||
|
||
function reset() {
|
||
setQuery("")
|
||
setCreating(false)
|
||
setNewHandicap("")
|
||
setNewGender("")
|
||
}
|
||
|
||
async function handleAddExisting(playerId: string) {
|
||
await onAddExisting(playerId)
|
||
reset()
|
||
setOpen(false)
|
||
}
|
||
|
||
async function handleCreate() {
|
||
if (trimmed.length < 2) return
|
||
const hcpValue = newHandicap.trim() === "" ? undefined : Number(newHandicap.replace(",", "."))
|
||
const hcp = hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined
|
||
await onAddNew(trimmed, hcp, newGender || undefined)
|
||
reset()
|
||
setOpen(false)
|
||
}
|
||
|
||
if (!open) {
|
||
return (
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={() => setOpen(true)}
|
||
className="h-11 w-full rounded-2xl text-sm font-bold"
|
||
>
|
||
<UserPlus aria-hidden="true" className="size-4" />
|
||
Legg til deltaker
|
||
</Button>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<Input
|
||
autoFocus
|
||
placeholder="Søk eller skriv navn…"
|
||
value={query}
|
||
onChange={(e) => {
|
||
setQuery(e.target.value)
|
||
setCreating(false)
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Escape") {
|
||
setOpen(false)
|
||
reset()
|
||
}
|
||
}}
|
||
className="h-11 flex-1 rounded-2xl text-base"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
onClick={() => {
|
||
setOpen(false)
|
||
reset()
|
||
}}
|
||
aria-label="Lukk"
|
||
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
|
||
>
|
||
<X aria-hidden="true" className="size-5" />
|
||
</Button>
|
||
</div>
|
||
|
||
{matches.length > 0 && (
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{matches.slice(0, 6).map((p) => (
|
||
<li key={p.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleAddExisting(p.id)}
|
||
className="flex min-h-11 w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
{p.display_name}
|
||
<Plus aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{showCreate && !creating && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setCreating(true)}
|
||
className="flex min-h-11 items-center gap-2 rounded-xl border border-dashed border-border px-3 py-2.5 text-left text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||
>
|
||
<UserPlus aria-hidden="true" className="size-4" />
|
||
Opprett ny spiller: «{trimmed}»
|
||
</button>
|
||
)}
|
||
|
||
{showCreate && creating && (
|
||
<div className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3">
|
||
<div className="flex gap-2">
|
||
<div className="flex flex-1 flex-col gap-1">
|
||
<Label htmlFor="new-participant-hcp" className="text-xs font-semibold">
|
||
HCP (valgfritt)
|
||
</Label>
|
||
<Input
|
||
id="new-participant-hcp"
|
||
inputMode="decimal"
|
||
placeholder="F.eks. 18,4"
|
||
value={newHandicap}
|
||
onChange={(e) => setNewHandicap(e.target.value)}
|
||
className="h-10 rounded-xl text-sm"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-1 flex-col gap-1">
|
||
<Label htmlFor="new-participant-gender" className="text-xs font-semibold">
|
||
Kjønn (for HCP-beregning)
|
||
</Label>
|
||
<select
|
||
id="new-participant-gender"
|
||
value={newGender}
|
||
onChange={(e) => setNewGender(e.target.value as "m" | "f" | "x" | "")}
|
||
className="h-10 rounded-xl border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
|
||
>
|
||
<option value="">Ikke satt</option>
|
||
<option value="f">Kvinne</option>
|
||
<option value="m">Mann</option>
|
||
<option value="x">Annet</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<Button type="button" onClick={handleCreate} className="h-10 rounded-xl text-sm font-bold">
|
||
Opprett og legg til «{trimmed}»
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RoundsCard({
|
||
base,
|
||
organizationId,
|
||
rounds,
|
||
courses,
|
||
participants,
|
||
classes,
|
||
scoringMethod,
|
||
onAddRound,
|
||
onDeleteRound,
|
||
onAddCourse,
|
||
onCourseImported,
|
||
onError,
|
||
}: {
|
||
base: string
|
||
organizationId: string
|
||
rounds: ApiRound[]
|
||
courses: ApiCourse[]
|
||
participants: ApiParticipant[]
|
||
classes: ApiTournamentClass[]
|
||
scoringMethod: string | null
|
||
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
||
onDeleteRound: (id: string) => Promise<void>
|
||
onAddCourse: (name: string) => Promise<ApiCourse | null>
|
||
onCourseImported: (course: ApiCourse) => void
|
||
onError: (message: string) => void
|
||
}) {
|
||
const [creating, setCreating] = useState(false)
|
||
|
||
return (
|
||
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<h2 className="text-base font-bold text-foreground">Runder</h2>
|
||
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
|
||
{rounds.length}
|
||
</span>
|
||
</div>
|
||
|
||
{rounds.length === 0 && (
|
||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||
Ingen runder ennå -- en enkeltdags turnering trenger kun én, en flerdagers
|
||
turnering kan ha flere.
|
||
</p>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-3">
|
||
{rounds.map((r) => (
|
||
<RoundCard
|
||
key={r.id}
|
||
base={base}
|
||
organizationId={organizationId}
|
||
round={r}
|
||
participants={participants}
|
||
classes={classes}
|
||
scoringMethod={scoringMethod}
|
||
onDelete={() => onDeleteRound(r.id)}
|
||
onError={onError}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{!creating ? (
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={() => setCreating(true)}
|
||
className="h-11 w-full rounded-2xl text-sm font-bold"
|
||
>
|
||
<Plus aria-hidden="true" className="size-4" />
|
||
Ny runde
|
||
</Button>
|
||
) : (
|
||
<NewRoundForm
|
||
organizationId={organizationId}
|
||
courses={courses}
|
||
onCourseImported={onCourseImported}
|
||
onCreate={async (name, courseId, holeConfig) => {
|
||
await onAddRound(name, courseId, holeConfig)
|
||
setCreating(false)
|
||
}}
|
||
onCreateCourse={onAddCourse}
|
||
onCancel={() => setCreating(false)}
|
||
/>
|
||
)}
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function NewRoundForm({
|
||
organizationId,
|
||
courses,
|
||
onCreate,
|
||
onCreateCourse,
|
||
onCourseImported,
|
||
onCancel,
|
||
}: {
|
||
organizationId: string
|
||
courses: ApiCourse[]
|
||
onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
||
onCreateCourse: (name: string) => Promise<ApiCourse | null>
|
||
onCourseImported: (course: ApiCourse) => void
|
||
onCancel: () => void
|
||
}) {
|
||
const [name, setName] = useState("")
|
||
const [courseId, setCourseId] = useState(courses[0]?.id ?? "")
|
||
const [holeConfig, setHoleConfig] = useState<ApiRound["hole_config"]>("full_18")
|
||
const [newCourseName, setNewCourseName] = useState("")
|
||
const [addingCourse, setAddingCourse] = useState(false)
|
||
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
async function handleCreateCourse() {
|
||
const trimmed = newCourseName.trim()
|
||
if (trimmed.length < 2) return
|
||
const created = await onCreateCourse(trimmed)
|
||
if (created) {
|
||
setCourseId(created.id)
|
||
setAddingCourse(false)
|
||
setNewCourseName("")
|
||
}
|
||
}
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
if (!courseId || submitting) return
|
||
setSubmitting(true)
|
||
await onCreate(name, courseId, holeConfig)
|
||
setSubmitting(false)
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 rounded-xl border border-border bg-background p-3">
|
||
<div className="flex flex-col gap-1">
|
||
<Label htmlFor="round-name" className="text-xs font-semibold">
|
||
Navn (valgfritt)
|
||
</Label>
|
||
<Input
|
||
id="round-name"
|
||
autoFocus
|
||
placeholder="F.eks. Runde 1 -- fredag"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
className="h-10 rounded-xl text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-1">
|
||
<Label htmlFor="round-course" className="text-xs font-semibold">
|
||
Bane
|
||
</Label>
|
||
{courses.length > 0 && !addingCourse ? (
|
||
<div className="flex gap-2">
|
||
<select
|
||
id="round-course"
|
||
value={courseId}
|
||
onChange={(e) => setCourseId(e.target.value)}
|
||
className="h-10 flex-1 rounded-xl border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
|
||
>
|
||
{courses.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
onClick={() => setAddingCourse(true)}
|
||
className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold text-muted-foreground"
|
||
>
|
||
+ Ny bane
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="flex gap-2">
|
||
<Input
|
||
placeholder="Banens navn"
|
||
value={newCourseName}
|
||
onChange={(e) => setNewCourseName(e.target.value)}
|
||
className="h-10 flex-1 rounded-xl text-sm"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
onClick={handleCreateCourse}
|
||
className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold"
|
||
>
|
||
Opprett
|
||
</Button>
|
||
{courses.length > 0 && (
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
onClick={() => setAddingCourse(false)}
|
||
className="h-10 shrink-0 rounded-xl px-2 text-xs font-bold text-muted-foreground"
|
||
>
|
||
Avbryt
|
||
</Button>
|
||
)}
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-muted-foreground">
|
||
Banen må 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 på plass.
|
||
</p>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function AssignRoundParticipantControl({
|
||
candidates,
|
||
tees,
|
||
classes,
|
||
onAssign,
|
||
}: {
|
||
candidates: ApiParticipant[]
|
||
tees: ApiTee[]
|
||
classes: ApiTournamentClass[]
|
||
onAssign: (tournamentParticipantId: string, teeId: string) => Promise<void>
|
||
}) {
|
||
const [participantId, setParticipantId] = useState(candidates[0]?.id ?? "")
|
||
const [teeId, setTeeId] = useState(tees[0]?.id ?? "")
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
useEffect(() => {
|
||
if (!candidates.some((c) => c.id === participantId)) setParticipantId(candidates[0]?.id ?? "")
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [candidates])
|
||
|
||
// Konkurranseklasser (2026-08-03) -- forhåndsvelg deltakerens klasses
|
||
// standardutslag, kun hvis det utslaget faktisk finnes på DENNE rundens
|
||
// bane (klassens standardutslag kan tilhøre en annen bane). Fritt
|
||
// overstyrbart under.
|
||
useEffect(() => {
|
||
if (!participantId) return
|
||
const candidate = candidates.find((c) => c.id === participantId)
|
||
const cls = candidate?.class_id ? classes.find((c) => c.id === candidate.class_id) : null
|
||
const defaultTee = cls?.default_tee_id ? tees.find((t) => t.id === cls.default_tee_id) : null
|
||
if (defaultTee) setTeeId(defaultTee.id)
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [participantId])
|
||
|
||
async function handleAssign() {
|
||
if (!participantId || !teeId || submitting) return
|
||
setSubmitting(true)
|
||
await onAssign(participantId, teeId)
|
||
setSubmitting(false)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-border p-2.5 sm:flex-row sm:items-end">
|
||
<div className="flex flex-1 flex-col gap-1">
|
||
<Label htmlFor="assign-participant" className="text-xs font-semibold text-muted-foreground">
|
||
Deltaker
|
||
</Label>
|
||
<select
|
||
id="assign-participant"
|
||
value={participantId}
|
||
onChange={(e) => setParticipantId(e.target.value)}
|
||
className="h-10 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
|
||
>
|
||
{candidates.map((c) => (
|
||
<option key={c.id} value={c.id}>
|
||
{c.player_name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="flex flex-1 flex-col gap-1">
|
||
<Label htmlFor="assign-tee" className="text-xs font-semibold text-muted-foreground">
|
||
Utslag
|
||
</Label>
|
||
<select
|
||
id="assign-tee"
|
||
value={teeId}
|
||
onChange={(e) => setTeeId(e.target.value)}
|
||
className="h-10 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
|
||
>
|
||
{tees.map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
onClick={handleAssign}
|
||
disabled={submitting}
|
||
className="h-10 shrink-0 rounded-lg text-xs font-bold"
|
||
>
|
||
Legg til
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// =========================================================================
|
||
// Scorekort-fane
|
||
// =========================================================================
|
||
|
||
function ScoreTab({
|
||
base,
|
||
rounds,
|
||
scoringMethod,
|
||
onError,
|
||
}: {
|
||
base: string
|
||
rounds: ApiRound[]
|
||
scoringMethod: string | null
|
||
onError: (message: string) => void
|
||
}) {
|
||
const [roundId, setRoundId] = useState(rounds[0]?.id ?? "")
|
||
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[]>([])
|
||
const [participantId, setParticipantId] = useState("")
|
||
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
||
const round = rounds.find((r) => r.id === roundId) ?? null
|
||
// Bumpes ved hver hull-registrering, slik at BBB-/Flag-panelene (egen
|
||
// fetch) vet de skal hente på nytt.
|
||
const [refreshTick, setRefreshTick] = useState(0)
|
||
|
||
useEffect(() => {
|
||
if (!roundId) return
|
||
let cancelled = false
|
||
getJson<ApiRoundParticipant[]>(`${base}/rounds/${roundId}/participants`).then((data) => {
|
||
if (cancelled) return
|
||
const list = data ?? []
|
||
setRoundParticipants(list)
|
||
setParticipantId(list[0]?.id ?? "")
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [base, roundId])
|
||
|
||
useEffect(() => {
|
||
if (!roundId || !participantId) {
|
||
setHoles(null)
|
||
return
|
||
}
|
||
let cancelled = false
|
||
getJson<ApiHole[]>(`${base}/rounds/${roundId}/participants/${participantId}/holes`).then((data) => {
|
||
if (!cancelled) setHoles(data ?? [])
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [base, roundId, participantId])
|
||
|
||
async function updateHole(holeNumber: number, grossStrokes: number) {
|
||
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ gross_strokes: grossStrokes }),
|
||
})
|
||
if (!res.ok) {
|
||
onError("Klarte ikke å lagre slaget. Prøv igjen.")
|
||
return
|
||
}
|
||
const updated: ApiHole = await res.json()
|
||
setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev))
|
||
setRefreshTick((n) => n + 1)
|
||
}
|
||
|
||
if (rounds.length === 0) {
|
||
return (
|
||
<div className="rounded-2xl border border-dashed border-border p-6 text-center">
|
||
<p className="text-sm font-medium text-muted-foreground text-pretty">
|
||
Opprett en runde under «Oppsett» først.
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-5">
|
||
<div className="flex flex-wrap gap-2">
|
||
{rounds.map((r) => (
|
||
<button
|
||
key={r.id}
|
||
type="button"
|
||
onClick={() => setRoundId(r.id)}
|
||
aria-pressed={r.id === roundId}
|
||
className={cn(
|
||
"flex min-h-11 items-center gap-2 rounded-full border px-4 text-sm font-bold transition-colors",
|
||
r.id === roundId
|
||
? "border-info bg-info/15 text-info"
|
||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
<Flag aria-hidden="true" className="size-4" />
|
||
{r.name || `Runde ${r.sequence}`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{roundParticipants.length === 0 ? (
|
||
<p className="text-sm font-medium text-muted-foreground">
|
||
Ingen deltakere i denne runden ennå -- legg til under «Oppsett».
|
||
</p>
|
||
) : (
|
||
<>
|
||
<div className="flex flex-wrap gap-2">
|
||
{roundParticipants.map((rp) => (
|
||
<button
|
||
key={rp.id}
|
||
type="button"
|
||
onClick={() => setParticipantId(rp.id)}
|
||
aria-pressed={rp.id === participantId}
|
||
className={cn(
|
||
"min-h-11 rounded-full border px-4 text-sm font-bold transition-colors",
|
||
rp.id === participantId
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{rp.player_name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{round && holes && (
|
||
<HoleGrid holes={holes} holeConfig={round.hole_config} onUpdate={updateHole} />
|
||
)}
|
||
|
||
{/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon,
|
||
uavhengig av gross_strokes-registreringen over. */}
|
||
{round && scoringMethod === "bingo_bango_bongo" && (
|
||
<BBBRoundPanel base={base} round={round} participants={roundParticipants} refreshTick={refreshTick} />
|
||
)}
|
||
|
||
{/* Flaggturnering (2026-07-30) -- per-runde-resultat (ikke
|
||
akkumulerbart på tvers av runder, se LeaderboardTab). */}
|
||
{round && scoringMethod === "flag" && (
|
||
<FlagRoundPanel base={base} round={round} refreshTick={refreshTick} />
|
||
)}
|
||
|
||
{/* 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 på 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, må 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 på 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 på
|
||
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 må 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 på 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"
|
||
}
|
||
}
|
||
|
||
function HoleGrid({
|
||
holes,
|
||
holeConfig,
|
||
onUpdate,
|
||
}: {
|
||
holes: ApiHole[]
|
||
holeConfig: ApiRound["hole_config"]
|
||
onUpdate: (holeNumber: number, grossStrokes: number) => Promise<void>
|
||
}) {
|
||
const [editingHole, setEditingHole] = useState<number | null>(null)
|
||
const [draft, setDraft] = useState("")
|
||
const numbers = holeNumbersFor(holeConfig)
|
||
const byNumber = new Map(holes.map((h) => [h.hole_number, h]))
|
||
|
||
const played = holes.filter((h) => h.gross_strokes !== null)
|
||
const totalGross = played.length > 0 ? played.reduce((sum, h) => sum + (h.gross_strokes as number), 0) : null
|
||
const totalPar = played.length > 0 ? played.reduce((sum, h) => sum + h.par, 0) : null
|
||
|
||
function openEditor(holeNumber: number, current: number | null) {
|
||
setEditingHole(holeNumber)
|
||
setDraft(current !== null ? String(current) : "")
|
||
}
|
||
|
||
async function commit(holeNumber: number) {
|
||
const value = Number(draft)
|
||
if (Number.isFinite(value) && value >= 1 && value <= 20) {
|
||
await onUpdate(holeNumber, value)
|
||
}
|
||
setEditingHole(null)
|
||
setDraft("")
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||
<div className="overflow-x-auto">
|
||
<div className="inline-grid min-w-full gap-y-1" style={{ gridTemplateColumns: `repeat(${numbers.length}, minmax(2.5rem, 1fr))` }}>
|
||
{numbers.map((n) => (
|
||
<span key={`num-${n}`} className="text-center text-[11px] font-bold text-muted-foreground">
|
||
{n}
|
||
</span>
|
||
))}
|
||
{numbers.map((n) => (
|
||
<span key={`par-${n}`} className="text-center text-[10px] font-semibold tabular-nums text-muted-foreground">
|
||
Par {byNumber.get(n)?.par ?? "–"}
|
||
</span>
|
||
))}
|
||
{numbers.map((n) => {
|
||
const hole = byNumber.get(n)
|
||
const gross = hole?.gross_strokes ?? null
|
||
const net = gross !== null && hole?.strokes_received !== null && hole?.strokes_received !== undefined
|
||
? gross - hole.strokes_received
|
||
: gross
|
||
const diff = net !== null && hole ? net - hole.par : null
|
||
const kind = diff !== null ? classify(diff) : null
|
||
const isEditing = editingHole === n
|
||
|
||
if (isEditing) {
|
||
return (
|
||
<input
|
||
key={`cell-${n}`}
|
||
autoFocus
|
||
inputMode="numeric"
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value.replace(/[^0-9]/g, ""))}
|
||
onBlur={() => commit(n)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") commit(n)
|
||
if (e.key === "Escape") {
|
||
setEditingHole(null)
|
||
setDraft("")
|
||
}
|
||
}}
|
||
className="mx-auto flex size-9 items-center justify-center rounded-[4px] border-2 border-info bg-background text-center text-sm font-extrabold tabular-nums text-foreground outline-none"
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<button
|
||
key={`cell-${n}`}
|
||
type="button"
|
||
onClick={() => openEditor(n, gross)}
|
||
aria-label={
|
||
gross !== null
|
||
? `Hull ${n}, ${gross} slag, trykk for å endre`
|
||
: `Hull ${n}, ikke registrert, trykk for å registrere`
|
||
}
|
||
className={cn(
|
||
"mx-auto flex size-9 items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
|
||
kind
|
||
? scoreMarkClasses(kind)
|
||
: "rounded-[4px] border-2 border-dashed border-border text-muted-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{gross ?? "–"}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{totalGross !== null && totalPar !== null && (
|
||
<div className="flex items-center justify-between border-t border-border pt-3 text-sm">
|
||
<span className="font-semibold text-muted-foreground">
|
||
{played.length} av {numbers.length} hull spilt
|
||
</span>
|
||
<span className="font-extrabold tabular-nums text-foreground">
|
||
{totalGross} slag ({totalGross - totalPar >= 0 ? "+" : ""}
|
||
{totalGross - totalPar} til par)
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// =========================================================================
|
||
// Leaderboard-fane
|
||
// =========================================================================
|
||
|
||
function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTournamentInfo }) {
|
||
const [entries, setEntries] = useState<ApiLeaderboardEntry[] | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
// 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>
|
||
)
|
||
}
|