2026-07-30 07:55:35 +02:00
|
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
|
|
// Individuell/flerrunde-turnering (ADR-037) -- flatt felt av spillere over
|
|
|
|
|
|
// én eller flere runder, ingen lag. Egen skjerm, adskilt fra
|
|
|
|
|
|
// tournament-detail.tsx (lagformat, ADR-011) -- rutet dit av
|
|
|
|
|
|
// tournament-router.tsx basert på tournament.format_type.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Håndkodet (ikke V0) på brukerens eksplisitte instruks. Bruker
|
|
|
|
|
|
// --info (blå) for "Individuell"-merket og runde-sammenheng, og --gold
|
|
|
|
|
|
// for lederen på leaderboardet -- samme etablerte, validerte tokens som
|
|
|
|
|
|
// round-card.tsx allerede bruker (HCP-spilt-til/personlig rekord), ikke
|
|
|
|
|
|
// nye, ukalibrerte farger.
|
|
|
|
|
|
|
|
|
|
|
|
import type React from "react"
|
|
|
|
|
|
import { useEffect, useMemo, useState } from "react"
|
|
|
|
|
|
import Link from "next/link"
|
|
|
|
|
|
import {
|
|
|
|
|
|
ArrowLeft,
|
|
|
|
|
|
Check,
|
|
|
|
|
|
ChevronDown,
|
|
|
|
|
|
Copy,
|
|
|
|
|
|
Flag,
|
|
|
|
|
|
KeyRound,
|
|
|
|
|
|
Medal,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
Trash2,
|
|
|
|
|
|
Trophy,
|
|
|
|
|
|
UserPlus,
|
|
|
|
|
|
Users,
|
|
|
|
|
|
X,
|
|
|
|
|
|
} from "lucide-react"
|
|
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
|
|
|
|
import { Input } from "@/components/ui/input"
|
|
|
|
|
|
import { Label } from "@/components/ui/label"
|
|
|
|
|
|
import {
|
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
|
} from "@/components/ui/dropdown-menu"
|
|
|
|
|
|
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
|
|
|
|
|
|
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
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
// KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30).
|
|
|
|
|
|
bbb_sweep_bonus_enabled: boolean
|
2026-07-30 07:55:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiParticipant = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
player_id: string
|
|
|
|
|
|
player_name: string
|
|
|
|
|
|
handicap_index_snapshot: number | 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 ApiLeaderboardEntry = {
|
|
|
|
|
|
tournament_participant_id: string
|
|
|
|
|
|
player_name: string
|
|
|
|
|
|
rounds_played: number
|
|
|
|
|
|
gross_total: number | null
|
|
|
|
|
|
net_total: number | null
|
|
|
|
|
|
stableford_total: number | null
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
copenhagen_total: number | null
|
|
|
|
|
|
bbb_total: number | null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiFlagResult = {
|
|
|
|
|
|
tournament_participant_id: string
|
|
|
|
|
|
player_name: string
|
|
|
|
|
|
holes_completed: number
|
|
|
|
|
|
ran_out: boolean
|
|
|
|
|
|
strokes_remaining: number
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ApiBBBHole = {
|
|
|
|
|
|
hole_number: number
|
|
|
|
|
|
bingo_participant_id: string | null
|
|
|
|
|
|
bango_participant_id: string | null
|
|
|
|
|
|
bongo_participant_id: string | null
|
2026-07-30 07:55:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
copenhagen: "Københavner",
|
|
|
|
|
|
bingo_bango_bongo: "Bingo Bango Bongo",
|
|
|
|
|
|
flag: "Flaggturnering",
|
2026-07-30 07:55:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 [loading, setLoading] = useState(true)
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
const [tab, setTab] = useState<"setup" | "score" | "leaderboard">("setup")
|
|
|
|
|
|
|
|
|
|
|
|
const base = `/orgs/${organizationId}/tournaments/${tournamentId}`
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [tournamentsData, participantsData, roundsData, coursesData] = await Promise.all([
|
|
|
|
|
|
getJson<ApiTournamentInfo[]>(`/orgs/${organizationId}/tournaments`),
|
|
|
|
|
|
getJson<ApiParticipant[]>(`${base}/participants`),
|
|
|
|
|
|
getJson<ApiRound[]>(`${base}/rounds`),
|
|
|
|
|
|
getJson<ApiCourse[]>(`/orgs/${organizationId}/courses`),
|
|
|
|
|
|
])
|
|
|
|
|
|
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 ?? [])
|
|
|
|
|
|
} 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.")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
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.")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 07:55:35 +02:00
|
|
|
|
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 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 px-5 pb-3" aria-label="Turneringsseksjoner">
|
|
|
|
|
|
{(
|
|
|
|
|
|
[
|
|
|
|
|
|
["setup", "Oppsett"],
|
|
|
|
|
|
["score", "Scorekort"],
|
|
|
|
|
|
["leaderboard", "Leaderboard"],
|
|
|
|
|
|
] as const
|
|
|
|
|
|
).map(([key, label]) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={key}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setTab(key)}
|
|
|
|
|
|
aria-current={tab === key ? "page" : undefined}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"min-h-11 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}
|
|
|
|
|
|
onUpdateScoringMethod={updateScoringMethod}
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
onUpdateBbbSweepBonus={updateBbbSweepBonus}
|
2026-07-30 07:55:35 +02:00
|
|
|
|
onAddParticipant={addParticipant}
|
|
|
|
|
|
onAddNewPlayer={addNewPlayerAndParticipant}
|
|
|
|
|
|
onRemoveParticipant={removeParticipant}
|
|
|
|
|
|
onAddRound={addRound}
|
|
|
|
|
|
onDeleteRound={deleteRound}
|
|
|
|
|
|
onAddCourse={addCourse}
|
|
|
|
|
|
onError={setError}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{tab === "score" && (
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
<ScoreTab base={base} rounds={rounds} scoringMethod={tournament.scoring_method} onError={setError} />
|
2026-07-30 07:55:35 +02:00
|
|
|
|
)}
|
|
|
|
|
|
{tab === "leaderboard" && (
|
|
|
|
|
|
<LeaderboardTab base={base} tournament={tournament} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</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,
|
|
|
|
|
|
onUpdateScoringMethod,
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
onUpdateBbbSweepBonus,
|
2026-07-30 07:55:35 +02:00
|
|
|
|
onAddParticipant,
|
|
|
|
|
|
onAddNewPlayer,
|
|
|
|
|
|
onRemoveParticipant,
|
|
|
|
|
|
onAddRound,
|
|
|
|
|
|
onDeleteRound,
|
|
|
|
|
|
onAddCourse,
|
|
|
|
|
|
onError,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
base: string
|
|
|
|
|
|
organizationId: string
|
|
|
|
|
|
tournament: ApiTournamentInfo
|
|
|
|
|
|
participants: ApiParticipant[]
|
|
|
|
|
|
rounds: ApiRound[]
|
|
|
|
|
|
courses: ApiCourse[]
|
|
|
|
|
|
onUpdateScoringMethod: (method: string) => void
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
onUpdateBbbSweepBonus: (enabled: boolean) => void
|
2026-07-30 07:55:35 +02:00
|
|
|
|
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>
|
|
|
|
|
|
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>
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-30 07:55:35 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<ParticipantsCard
|
|
|
|
|
|
participants={participants}
|
|
|
|
|
|
pool={pool}
|
|
|
|
|
|
onAddParticipant={onAddParticipant}
|
|
|
|
|
|
onAddNewPlayer={onAddNewPlayer}
|
|
|
|
|
|
onRemoveParticipant={onRemoveParticipant}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<RoundsCard
|
|
|
|
|
|
base={base}
|
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
|
rounds={rounds}
|
|
|
|
|
|
courses={courses}
|
|
|
|
|
|
participants={participants}
|
|
|
|
|
|
scoringMethod={tournament.scoring_method}
|
|
|
|
|
|
onAddRound={onAddRound}
|
|
|
|
|
|
onDeleteRound={onDeleteRound}
|
|
|
|
|
|
onAddCourse={onAddCourse}
|
|
|
|
|
|
onError={onError}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ParticipantsCard({
|
|
|
|
|
|
participants,
|
|
|
|
|
|
pool,
|
|
|
|
|
|
onAddParticipant,
|
|
|
|
|
|
onAddNewPlayer,
|
|
|
|
|
|
onRemoveParticipant,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
participants: ApiParticipant[]
|
|
|
|
|
|
pool: ApiPlayer[]
|
|
|
|
|
|
onAddParticipant: (playerId: string) => Promise<void>
|
|
|
|
|
|
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
|
|
|
|
|
|
onRemoveParticipant: (id: string) => 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 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>
|
|
|
|
|
|
<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,
|
|
|
|
|
|
scoringMethod,
|
|
|
|
|
|
onAddRound,
|
|
|
|
|
|
onDeleteRound,
|
|
|
|
|
|
onAddCourse,
|
|
|
|
|
|
onError,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
base: string
|
|
|
|
|
|
organizationId: string
|
|
|
|
|
|
rounds: ApiRound[]
|
|
|
|
|
|
courses: ApiCourse[]
|
|
|
|
|
|
participants: ApiParticipant[]
|
|
|
|
|
|
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>
|
|
|
|
|
|
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}
|
|
|
|
|
|
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
|
|
|
|
|
|
courses={courses}
|
|
|
|
|
|
onCreate={async (name, courseId, holeConfig) => {
|
|
|
|
|
|
await onAddRound(name, courseId, holeConfig)
|
|
|
|
|
|
setCreating(false)
|
|
|
|
|
|
}}
|
|
|
|
|
|
onCreateCourse={onAddCourse}
|
|
|
|
|
|
onCancel={() => setCreating(false)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function NewRoundForm({
|
|
|
|
|
|
courses,
|
|
|
|
|
|
onCreate,
|
|
|
|
|
|
onCreateCourse,
|
|
|
|
|
|
onCancel,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
courses: ApiCourse[]
|
|
|
|
|
|
onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
|
|
|
|
|
onCreateCourse: (name: string) => Promise<ApiCourse | null>
|
|
|
|
|
|
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 [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>
|
|
|
|
|
|
</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,
|
|
|
|
|
|
scoringMethod,
|
|
|
|
|
|
onDelete,
|
|
|
|
|
|
onError,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
base: string
|
|
|
|
|
|
organizationId: string
|
|
|
|
|
|
round: ApiRound
|
|
|
|
|
|
participants: ApiParticipant[]
|
|
|
|
|
|
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}
|
|
|
|
|
|
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,
|
|
|
|
|
|
onAssign,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
candidates: ApiParticipant[]
|
|
|
|
|
|
tees: ApiTee[]
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
|
|
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,
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
scoringMethod,
|
2026-07-30 07:55:35 +02:00
|
|
|
|
onError,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
base: string
|
|
|
|
|
|
rounds: ApiRound[]
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
scoringMethod: string | null
|
2026-07-30 07:55:35 +02:00
|
|
|
|
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
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
// Bumpes ved hver hull-registrering, slik at BBB-/Flag-panelene (egen
|
|
|
|
|
|
// fetch) vet de skal hente på nytt.
|
|
|
|
|
|
const [refreshTick, setRefreshTick] = useState(0)
|
2026-07-30 07:55:35 +02:00
|
|
|
|
|
|
|
|
|
|
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))
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
setRefreshTick((n) => n + 1)
|
2026-07-30 07:55:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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} />
|
|
|
|
|
|
)}
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
|
|
|
|
|
|
{/* 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} />
|
|
|
|
|
|
)}
|
2026-07-30 07:55:35 +02:00
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
// --- 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// --- 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 07:55:35 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
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 }
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
if (method === "copenhagen") return { label: "poeng", value: entry.copenhagen_total }
|
|
|
|
|
|
if (method === "bingo_bango_bongo") return { label: "poeng", value: entry.bbb_total }
|
2026-07-30 07:55:35 +02:00
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
// 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 07:55:35 +02:00
|
|
|
|
if (entries.length === 0) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground text-pretty">Ingen deltakere ennå.</p>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
|
|
|
|
|
|
{entries.map((entry, index) => {
|
|
|
|
|
|
const { label, value } = valueFor(entry)
|
|
|
|
|
|
const rank = index + 1
|
|
|
|
|
|
const isLeader = rank === 1 && value !== null
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li
|
|
|
|
|
|
key={entry.tournament_participant_id}
|
|
|
|
|
|
className={cn("flex items-center gap-3 px-4 py-3", isLeader && "bg-gold/10")}
|
|
|
|
|
|
>
|
|
|
|
|
|
<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
|
|
|
|
|
|
</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>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|