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"
2026-08-14 10:38:54 +02:00
import { useEffect , useMemo , useRef , useState } from "react"
2026-07-30 07:55:35 +02:00
import Link from "next/link"
2026-08-14 10:38:54 +02:00
import dynamic from "next/dynamic"
2026-08-18 12:59:37 +02:00
import { useRouter } from "next/navigation"
2026-08-14 10:38:54 +02:00
import type { FlagMapEntry } from "@/components/flag-map-overview"
2026-07-30 07:55:35 +02:00
import {
ArrowLeft ,
2026-08-21 23:00:22 +02:00
ArrowRight ,
2026-07-30 07:55:35 +02:00
Check ,
ChevronDown ,
2026-08-14 10:38:54 +02:00
ChevronLeft ,
2026-08-16 10:21:48 +02:00
ChevronRight ,
2026-08-21 23:00:22 +02:00
ChevronsUpDown ,
2026-08-18 18:06:49 +02:00
ExternalLink ,
2026-08-18 15:43:39 +02:00
Clock ,
2026-07-30 07:55:35 +02:00
Copy ,
2026-08-14 10:38:54 +02:00
Crosshair ,
2026-07-30 07:55:35 +02:00
Flag ,
KeyRound ,
2026-08-18 20:18:47 +02:00
Layers ,
2026-08-14 10:38:54 +02:00
MapPin ,
2026-07-30 07:55:35 +02:00
Medal ,
Plus ,
2026-08-18 20:18:47 +02:00
Settings2 ,
2026-07-30 07:55:35 +02:00
Trash2 ,
Trophy ,
2026-08-17 14:53:55 +02:00
Upload ,
2026-07-30 07:55:35 +02:00
UserPlus ,
Users ,
X ,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
2026-08-17 14:53:55 +02:00
import { PlayerImportPanel } from "@/components/player-import-panel"
2026-08-18 15:43:39 +02:00
import { RoundGroupsPanel } from "@/components/round-groups-panel"
2026-08-18 20:18:47 +02:00
import { RoundGroupsStepList , type RoundSummary } from "@/components/round-groups-step-list"
2026-08-19 07:54:07 +02:00
import { TournamentPlayersTable } from "@/components/tournament-players-table"
2026-08-18 17:00:44 +02:00
import { TournamentSettingsCard , type TournamentSettingsValues } from "@/components/tournament-settings-card"
2026-08-18 20:18:47 +02:00
import { TournamentSetupNav , type SetupStep } from "@/components/tournament-setup-nav"
2026-07-30 07:55:35 +02:00
import {
DropdownMenu ,
DropdownMenuContent ,
DropdownMenuItem ,
DropdownMenuTrigger ,
} from "@/components/ui/dropdown-menu"
2026-08-15 06:32:17 +02:00
import {
ChoiceRow ,
DirectionCross ,
NumberPicker ,
Stepper ,
WizardSection ,
} from "@/components/hole-stat-inputs"
2026-08-16 10:21:48 +02:00
import { HoleHistoryDetail } from "@/components/hole-history-detail"
2026-08-17 12:06:47 +02:00
import { HoleTargetDistance } from "@/components/hole-target-distance"
2026-08-15 06:32:17 +02:00
import { ClubPicker } from "@/components/teecup/club-picker"
Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.
To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).
Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00
import { TournamentPresentationPanel } from "@/components/tournament-presentation"
2026-08-04 04:47:27 +02:00
import { StrokePlayLeaderboard , type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
2026-07-30 07:55:35 +02:00
import { TournamentStatusPicker , type TournamentStatus } from "@/components/tournament-status-badge"
2026-08-04 11:34:28 +02:00
import { CourseTemplatePicker } from "@/components/course-template-editor"
2026-08-22 06:39:44 +02:00
import { TournamentRoundMessages } from "@/components/tournament-round-messages"
2026-07-30 07:55:35 +02:00
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
2026-08-14 15:08:45 +02:00
// Utvidet registreringsskjema (2026-08-14, migrasjon 074).
first_name : string | null
last_name : string | null
birth_date : string | null // ISO yyyy-mm-dd
email : string | null
club_member_number : string | null
club : string | null
country : string | null
paid : boolean
comment : string | null
}
// Feltene sendt inn ved "Opprett ny spiller" (AddParticipantControl) --
// egen type fremfor å gjenbruke ApiPlayer direkte, siden dette er et
// SKJEMA (før spilleren finnes), ikke en allerede opprettet rad.
type NewPlayerInput = {
firstName : string
lastName : string
gender ? : "m" | "f" | "x"
birthDate? : string
email? : string
clubMemberNumber? : string
club? : string
country? : string
handicapIndex? : number
paid : boolean
comment? : string
2026-07-30 07:55:35 +02:00
}
type ApiTournamentInfo = {
id : string
2026-08-18 17:00:44 +02:00
name : string
2026-07-30 07:55:35 +02:00
status : TournamentStatus
join_code : string
format_type : string
scoring_method : string | null
2026-08-18 17:00:44 +02:00
// Turneringsinnstillinger (2026-08-18) -- eksisterte alle på backend
// (fullt fungerende, ekte selvregistrerings-logikk i registration.py)
// uten noen UI noe sted til å sette dem, før TournamentSettingsCard.
start_date : string | null
end_date : string | null
visibility : string
description : string | null
registration_deadline : string | null
registration_capacity : number | null
registration_overflow_policy : string
registration_requires_approval : boolean
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-08-14 10:38:54 +02:00
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
flag_map_visible : boolean
2026-08-18 15:07:21 +02:00
// Cut (migrasjon 083, 2026-08-18) -- kun meningsfullt for brutto/netto/
// stableford. cut_applied_at er null inntil organisator faktisk trykker
// "Anvend cut" -- cut_after_round/cut_size alene er bare konfigurasjon.
cut_after_round : number | null
cut_size : number | null
cut_applied_at : string | null
2026-08-18 16:29:10 +02:00
// Tiebreak (migrasjon 085, 2026-08-18) -- kun meningsfullt for brutto/
// netto/stableford. To UAVHENGIGE innstillinger (vinner/felt kan ha
// ulik metode). Påvirker ALDRI Cut-grensen, kun sluttresultatets
// plasseringsvisning. "none" = delt plassering (uendret oppførsel).
tiebreak_winner_method : string
tiebreak_field_method : string
2026-08-18 17:29:59 +02:00
// Omspill (migrasjon 086, 2026-08-18) -- "playoff" er kun gyldig for
// tiebreak_winner_method. Fritekst-regel vist proaktivt i Oppsett OG
// på leaderboardet, ikke bare når en uavgjort faktisk skjer.
tiebreak_playoff_rule : string | null
2026-07-30 07:55:35 +02:00
}
type ApiParticipant = {
id : string
player_id : string
player_name : string
handicap_index_snapshot : number | null
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
class_id : string | null
class_name : string | null
2026-08-15 06:32:17 +02:00
// Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye HoleGrid/
// HoleStatsSheet ber om ved scoring, tournament-bredt (ikke per runde).
stat_level : "strokes_only" | "strokes_and_putts" | "full"
2026-08-18 18:06:49 +02:00
// Manuell status (migrasjon 087, 2026-08-18) -- "active" (uendret) eller
// dsq/rtd/dnf/dns. Overstyrer leaderboardets total-visning og blokkerer
// videre score-registrering i ALLE runder.
status : "active" | "dsq" | "rtd" | "dnf" | "dns"
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
}
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
// standardutslag. Her (individuelle turneringer) brukes klasse til BÅDE
2026-08-19 06:21:58 +02:00
// utslag-forhåndsutfylling (rundedeltakelse-tabellen, ADR-093 -- flyttet
// dit fra den nå fjernede AssignRoundParticipantControl 2026-08-19) OG en
// egen resultatliste-seksjon per klasse (LeaderboardTab) -- se
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
// 053_tournament_classes.sql for full begrunnelse og forskjellen fra
// lagturneringer (tournament-detail.tsx), hvor klasse KUN styrer utslag.
type ApiTournamentClass = {
id : string
name : string
default_tee_id : string | null
default_tee_name : string | null
2026-07-30 07:55:35 +02:00
}
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
2026-08-15 06:32:17 +02:00
// Statistikknivå (migrasjon 075, ADR-071) -- "strokes_only" /
// "strokes_and_putts" / "full", styrer om HoleGrid sin celle åpner det
// enkle inline-tallfeltet (uendret oppførsel) eller HoleStatsSheet.
stat_level : "strokes_only" | "strokes_and_putts" | "full"
2026-08-21 21:46:38 +02:00
// Flight-scoping i ScoreTab (ADR-099).
tournament_round_group_id : string | null
is_self : boolean
2026-07-30 07:55:35 +02:00
}
type ApiHole = {
hole_number : number
par : number
stroke_index : number
gross_strokes : number | null
2026-08-15 06:32:17 +02:00
// Full statistikkdybde (migrasjon 075, ADR-071) -- speiler round_hole
// (frittstående runder), styrt av deltakerens stat_level. Alltid til
// stede i responsen (null når ikke registrert), uansett stat_level --
// frontend avgjør selv hva som faktisk VISES/redigeres.
putts : number | null
club_off_tee : string | null
tee_shot_result : string | null
approach_result : string | null
chip_count : number | null
bunker_shot_count : number | null
penalty_strokes : number | null
first_putt_distance_bucket : string | null
anyway_strokes : number | null
version : number
2026-07-30 07:55:35 +02:00
strokes_received : number | null
}
2026-08-04 04:47:27 +02:00
type ApiRoundCell = {
round_number : number
label : string
tone : "under" | "even" | "over" | null
2026-08-22 05:26:46 +02:00
// Hull-for-hull-utvidelse i leaderboardet (ADR-101).
tournament_round_id : string
2026-08-04 04:47:27 +02:00
}
2026-08-14 11:12:25 +02:00
type ApiEclecticHoleCell = {
hole_number : number
par : number
value : number
round_number : number
}
2026-07-30 07:55:35 +02:00
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
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
class_id : string | null
class_name : string | null
2026-08-04 04:47:27 +02:00
// Augusta-stil resultattavle (2026-08-04) -- kun populert for
// scoring_method brutto/netto/stableford, se stroke-play-leaderboard.tsx.
position : string | null
is_leader : boolean
today_label : string | null
thru_label : string | null
total_label : string | null
2026-08-18 14:23:03 +02:00
// Nøytral "ikke startet"-info (2026-08-18) -- kun satt når deltakeren
// ikke har startet gjeldende/neste runde (thru_label "-"), se
// stroke-play-leaderboard.tsx.
next_tee_time : string | null
next_start_hole : number | null
2026-08-04 04:47:27 +02:00
rounds : ApiRoundCell [ ]
2026-08-14 11:12:25 +02:00
// Eclectic (ADR-067-tillegget "Del C", 2026-08-14) -- kun populert for
// scoring_method eclectic_gross/eclectic_net/eclectic_stableford.
eclectic_total : number | null
eclectic_holes : ApiEclecticHoleCell [ ]
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
}
type ApiFlagResult = {
tournament_participant_id : string
player_name : string
holes_completed : number
ran_out : boolean
strokes_remaining : number
}
2026-08-14 10:38:54 +02:00
// GPS-flaggplanting + runde 2+ (migrasjon 070, 2026-08-14) -- speiler
// rounds.py sin frittstående-variant (round-detail.tsx), se der for full
// begrunnelse. Duplisert her (ikke importert på tvers av de to store
// sidefilene) -- samme selvstendighets-konvensjon som round-scorecard.tsx
// allerede følger for sine egne typer/hjelpefunksjoner.
type ApiFlagPlant = {
id : string
lap : number
hole_number : number
lat : number
lng : number
on_green : boolean
distance_to_pin_cm : number | null
planted_by_user_id : string
planted_at : string
}
type ApiFlagOverflowHole = {
lap : number
hole_number : number
gross_strokes : number | null
played : boolean
}
function playedHoleNumbersOrg ( holeConfig : "full_18" | "front_9" | "back_9" ) : number [ ] {
if ( holeConfig === "front_9" ) return Array . from ( { length : 9 } , ( _ , i ) = > i + 1 )
if ( holeConfig === "back_9" ) return Array . from ( { length : 9 } , ( _ , i ) = > i + 10 )
return Array . from ( { length : 18 } , ( _ , i ) = > i + 1 )
}
function computeFlagCurrentPositionOrg (
playOrder : number [ ] ,
holes : ApiHole [ ] | null ,
overflowHoles : ApiFlagOverflowHole [ ] ,
) : { lap : number ; holeNumber : number } {
const lap1Played = new Set ( ( holes ? ? [ ] ) . filter ( ( h ) = > h . gross_strokes !== null ) . map ( ( h ) = > h . hole_number ) )
for ( const h of playOrder ) {
if ( ! lap1Played . has ( h ) ) return { lap : 1 , holeNumber : h }
}
let lap = 2
while ( true ) {
const lapPlayed = new Set ( overflowHoles . filter ( ( h ) = > h . lap === lap && h . played ) . map ( ( h ) = > h . hole_number ) )
for ( const h of playOrder ) {
if ( ! lapPlayed . has ( h ) ) return { lap , holeNumber : h }
}
lap += 1
}
}
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
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-08-14 11:12:25 +02:00
eclectic_gross : "Eclectic (brutto)" ,
eclectic_net : "Eclectic (netto)" ,
eclectic_stableford : "Eclectic (Stableford)" ,
2026-07-30 07:55:35 +02:00
}
2026-08-14 11:12:25 +02:00
const ECLECTIC_SCORING_METHODS = new Set ( [ "eclectic_gross" , "eclectic_net" , "eclectic_stableford" ] )
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
} ) {
2026-08-18 12:59:37 +02:00
const router = useRouter ( )
2026-07-30 07:55:35 +02:00
const [ tournament , setTournament ] = useState < ApiTournamentInfo | null > ( null )
const [ participants , setParticipants ] = useState < ApiParticipant [ ] > ( [ ] )
const [ rounds , setRounds ] = useState < ApiRound [ ] > ( [ ] )
const [ courses , setCourses ] = useState < ApiCourse [ ] > ( [ ] )
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
const [ classes , setClasses ] = useState < ApiTournamentClass [ ] > ( [ ] )
2026-07-30 07:55:35 +02:00
const [ loading , setLoading ] = useState ( true )
const [ error , setError ] = useState < string | null > ( null )
Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.
To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).
Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00
const [ tab , setTab ] = useState < "setup" | "score" | "leaderboard" | "presentation" > ( "setup" )
2026-08-18 12:59:37 +02:00
// Kun org-eier/admin får se/bruke "Slett turnering" (ADR-086) -- samme
// sperre/begrunnelse som tournament-detail.tsx (lagformatet).
const [ myRole , setMyRole ] = useState < "owner" | "admin" | "member" | null > ( null )
const [ deleting , setDeleting ] = useState ( false )
2026-08-22 06:39:44 +02:00
// Rundespesifikk kommentartråd (ADR-102) -- forfatter-/moderasjons-
// sjekk trenger viewerens egen bruker-id, ikke bare rollen.
const [ currentUserId , setCurrentUserId ] = useState < string | null > ( null )
2026-07-30 07:55:35 +02:00
const base = ` /orgs/ ${ organizationId } /tournaments/ ${ tournamentId } `
2026-08-18 12:59:37 +02:00
useEffect ( ( ) = > {
let cancelled = false
fetch ( "/auth/me" , { credentials : "include" } )
. then ( ( res ) = > ( res . ok ? res . json ( ) : null ) )
2026-08-22 06:39:44 +02:00
. then (
(
me : {
id : string
organizations : { organization_id : string ; role : "owner" | "admin" | "member" } [ ]
} | null ,
) = > {
if ( cancelled || ! me ) return
setMyRole ( me . organizations . find ( ( o ) = > o . organization_id === organizationId ) ? . role ? ? null )
setCurrentUserId ( me . id )
} ,
)
2026-08-18 12:59:37 +02:00
. catch ( ( ) = > { } )
return ( ) = > {
cancelled = true
}
} , [ organizationId ] )
const isOrgAdmin = myRole === "owner" || myRole === "admin"
async function deleteTournament() {
if (
! confirm (
` Slette " ${ tournamentName } " permanent? Dette fjerner ALT -- deltakere, alle runder og all scoring. Kan ikke angres. ` ,
)
) {
return
}
setDeleting ( true )
setError ( null )
try {
const res = await fetch ( base , { method : "DELETE" , credentials : "include" } )
if ( ! res . ok && res . status !== 204 ) {
const body = await res . json ( ) . catch ( ( ) = > null )
throw new Error ( body ? . detail ? . message ? ? "Klarte ikke å slette turneringen." )
}
router . replace ( "/dashboard" )
} catch ( err ) {
setError ( err instanceof Error ? err . message : "Klarte ikke å slette turneringen." )
setDeleting ( false )
}
}
2026-08-19 09:50:56 +02:00
// Løftet ut av useEffect (2026-08-19) -- brukes nå fra to steder: den
// vanlige innlastingen ved mount, OG en "frisk opp når fanen blir synlig
// igjen"-lytter under. Bruker: "Jeg kan ikke se at deltagertabellen blir
// oppdatert når det er lagret endringer i spillerpool-tabellen" -- rot-
// årsak: "Rediger i spillerpoolen" er en full sidenavigasjon (ekte
// <a href>, ikke Next-Link), og nettleserens bfcache gjenoppretter siden
// i EKSAKT samme (utdaterte) tilstand ved tilbake-navigasjon, uten å
// kjøre noen effekter på nytt -- `mountedRef` brukes i stedet for en
// per-kall `cancelled`-variabel siden funksjonen nå kalles fra flere
// uavhengige steder.
const mountedRef = useRef ( true )
2026-07-30 07:55:35 +02:00
useEffect ( ( ) = > {
2026-08-19 09:50:56 +02:00
mountedRef . current = true
return ( ) = > {
mountedRef . current = false
}
} , [ ] )
async function loadTournamentData() {
try {
const [ tournamentsData , participantsData , roundsData , coursesData , classesData ] = await Promise . all ( [
getJson < ApiTournamentInfo [ ] > ( ` /orgs/ ${ organizationId } /tournaments ` ) ,
getJson < ApiParticipant [ ] > ( ` ${ base } /participants ` ) ,
getJson < ApiRound [ ] > ( ` ${ base } /rounds ` ) ,
getJson < ApiCourse [ ] > ( ` /orgs/ ${ organizationId } /courses ` ) ,
getJson < ApiTournamentClass [ ] > ( ` ${ base } /classes ` ) ,
] )
if ( ! mountedRef . current ) return
const mine = tournamentsData ? . find ( ( t ) = > t . id === tournamentId ) ? ? null
if ( ! mine ) {
setError ( "Fant ikke turneringen." )
return
2026-07-30 07:55:35 +02:00
}
2026-08-19 09:50:56 +02:00
setTournament ( mine )
setParticipants ( participantsData ? ? [ ] )
setRounds ( roundsData ? ? [ ] )
setCourses ( coursesData ? ? [ ] )
setClasses ( classesData ? ? [ ] )
} catch {
if ( mountedRef . current ) setError ( "Klarte ikke å laste turneringen. Prøv å laste siden på nytt." )
} finally {
if ( mountedRef . current ) setLoading ( false )
}
}
useEffect ( ( ) = > {
loadTournamentData ( )
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ organizationId , tournamentId , base ] )
// Stille bakgrunns-refresh (ingen loading-spinner -- setLoading(false)
// er allerede satt) når fanen/vinduet blir synlig igjen, ELLER siden
// gjenopprettes fra bfcache (pageshow med persisted=true er det
// spesifikke signalet for akkurat det tilfellet).
useEffect ( ( ) = > {
function handleVisible() {
if ( document . visibilityState === "visible" ) loadTournamentData ( )
}
function handlePageShow ( e : PageTransitionEvent ) {
if ( e . persisted ) loadTournamentData ( )
2026-07-30 07:55:35 +02:00
}
2026-08-19 09:50:56 +02:00
document . addEventListener ( "visibilitychange" , handleVisible )
window . addEventListener ( "pageshow" , handlePageShow )
2026-07-30 07:55:35 +02:00
return ( ) = > {
2026-08-19 09:50:56 +02:00
document . removeEventListener ( "visibilitychange" , handleVisible )
window . removeEventListener ( "pageshow" , handlePageShow )
2026-07-30 07:55:35 +02:00
}
2026-08-19 09:50:56 +02:00
// eslint-disable-next-line react-hooks/exhaustive-deps
2026-07-30 07:55:35 +02:00
} , [ 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-08-14 10:38:54 +02:00
async function updateFlagMapVisible ( visible : boolean ) {
if ( ! tournament ) return
const previous = tournament
setTournament ( { . . . tournament , flag_map_visible : visible } )
try {
const res = await fetch ( ` /orgs/ ${ organizationId } /tournaments/ ${ tournamentId } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { flag_map_visible : visible } ) ,
} )
if ( ! res . ok ) throw new Error ( )
} catch {
setTournament ( previous )
setError ( "Klarte ikke å endre kartoversikt-innstillingen. Prøv igjen." )
}
}
2026-08-18 15:07:21 +02:00
// Cut (migrasjon 083, 2026-08-18) -- ren KONFIGURASJON, fritt endre/
// nullstille helt til "Anvend cut" faktisk trykkes (applyCut under).
async function updateCutConfig ( afterRound : number | null , size : number | null ) {
if ( ! tournament ) return
const previous = tournament
setTournament ( { . . . tournament , cut_after_round : afterRound , cut_size : size } )
try {
const res = await fetch ( ` /orgs/ ${ organizationId } /tournaments/ ${ tournamentId } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { cut_after_round : afterRound , cut_size : size } ) ,
} )
if ( ! res . ok ) throw new Error ( )
} catch {
setTournament ( previous )
setError ( "Klarte ikke å lagre cut-innstillingene. Prøv igjen." )
}
}
2026-08-18 16:29:10 +02:00
// Tiebreak (migrasjon 085, 2026-08-18) -- ren KONFIGURASJON, samme
// optimistiske PATCH-mønster som scoringsmetode/cut over. Påvirker
// ALDRI Cut-grensen, kun sluttresultatets plasseringsvisning.
2026-08-18 17:29:59 +02:00
async function updateTiebreakConfig ( winner : string , field : string , playoffRule : string | null ) {
2026-08-18 16:29:10 +02:00
if ( ! tournament ) return
const previous = tournament
2026-08-18 17:29:59 +02:00
setTournament ( {
. . . tournament ,
tiebreak_winner_method : winner ,
tiebreak_field_method : field ,
tiebreak_playoff_rule : playoffRule ,
} )
2026-08-18 16:29:10 +02:00
try {
const res = await fetch ( ` /orgs/ ${ organizationId } /tournaments/ ${ tournamentId } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
2026-08-18 17:29:59 +02:00
body : JSON.stringify ( {
tiebreak_winner_method : winner ,
tiebreak_field_method : field ,
tiebreak_playoff_rule : playoffRule ,
} ) ,
2026-08-18 16:29:10 +02:00
} )
if ( ! res . ok ) throw new Error ( )
} catch {
setTournament ( previous )
setError ( "Klarte ikke å lagre tiebreak-innstillingene. Prøv igjen." )
}
}
2026-08-18 17:00:44 +02:00
// Turneringsinnstillinger (2026-08-18, TournamentSettingsCard --
// V0-eksportert, se ADR/CHANGELOG #118). Komponenten viser sin EGEN
// lagre-/feil-tilstand rundt onSave-løftet -- IKKE fang feilen her
// (til forskjell fra de andre optimistiske PATCH-hjelperne over), la
// den kaste videre slik komponenten forventer.
async function updateSettings ( next : TournamentSettingsValues ) {
if ( ! tournament ) return
const registrationDeadlineIso = fromDatetimeLocalValue ( next . registrationDeadline )
const res = await fetch ( ` /orgs/ ${ organizationId } /tournaments/ ${ tournamentId } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( {
name : next.name ,
start_date : next.startDate ,
end_date : next.endDate ,
visibility : next.visibility ,
description : next.description || null ,
registration_deadline : registrationDeadlineIso ,
registration_capacity : next.registrationCapacity ,
registration_overflow_policy : next.registrationOverflowPolicy ,
registration_requires_approval : next.registrationRequiresApproval ,
} ) ,
} )
if ( ! res . ok ) throw new Error ( "Klarte ikke å lagre innstillingene." )
setTournament ( {
. . . tournament ,
name : next.name ,
start_date : next.startDate ,
end_date : next.endDate ,
visibility : next.visibility ,
description : next.description || null ,
registration_deadline : registrationDeadlineIso ,
registration_capacity : next.registrationCapacity ,
registration_overflow_policy : next.registrationOverflowPolicy ,
registration_requires_approval : next.registrationRequiresApproval ,
} )
}
2026-08-18 15:07:21 +02:00
// MANUELL anvendelse (bruker bekreftet eksplisitt 2026-08-18: IKKE
// automatisk ved fullspilt runde) -- idempotent på serveren, men likevel
// en reell, konsekvensfull handling for ekte spillere (blokkerer dem fra
// videre scoring), derfor samme confirm()-mønster som andre irreversible
// handlinger i appen (slett turnering osv.).
async function applyCut() {
if ( ! tournament ) return
if (
! confirm (
"Anvende cut nå? Spillere utenfor grensen blokkeres fra å registrere score i senere runder. Kan kjøres på nytt (regner alt på nytt), men bør ikke trykkes før alle er ferdige med runden." ,
)
) {
return
}
try {
const res = await fetch ( ` /orgs/ ${ organizationId } /tournaments/ ${ tournamentId } /apply-cut ` , {
method : "POST" ,
credentials : "include" ,
} )
if ( ! res . ok ) throw new Error ( )
2026-08-21 08:04:53 +02:00
const result : { survivors : number ; cut_count : number ; added_to_later_rounds : number } = await res . json ( )
2026-08-18 15:07:21 +02:00
setTournament ( { . . . tournament , cut_applied_at : new Date ( ) . toISOString ( ) } )
await refreshParticipants ( )
setError ( null )
2026-08-21 08:04:53 +02:00
const extra =
result . added_to_later_rounds > 0
? ` ${ result . added_to_later_rounds } rundedeltakelser lagt til automatisk i senere runder (kuttede spillere er samtidig fjernet der de ikke allerede har scorer). `
: ""
alert ( ` Cut anvendt: ${ result . survivors } går videre, ${ result . cut_count } er kuttet. ${ extra } ` )
2026-08-18 15:07:21 +02:00
} catch {
setError ( "Klarte ikke å anvende cutten. 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 ) ) )
}
2026-08-17 14:53:55 +02:00
// Etter massimport (PlayerImportPanel) -- panelet gjør sine egne
// POST-kall direkte (bulk + participants), enklest å hente
// deltakerlisten på nytt etterpå fremfor å slå sammen delvis ukjent
// respons-tilstand manuelt.
async function refreshParticipants() {
const data = await getJson < ApiParticipant [ ] > ( ` ${ base } /participants ` )
if ( data ) setParticipants ( data )
}
2026-08-14 15:08:45 +02:00
async function addNewPlayerAndParticipant ( input : NewPlayerInput ) {
2026-07-30 07:55:35 +02:00
setError ( null )
2026-08-14 15:08:45 +02:00
const displayName = ` ${ input . firstName } ${ input . lastName } ` . trim ( )
2026-07-30 07:55:35 +02:00
const playerRes = await fetch ( ` /orgs/ ${ organizationId } /players ` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
2026-08-14 15:08:45 +02:00
body : JSON.stringify ( {
display_name : displayName ,
first_name : input.firstName.trim ( ) || null ,
last_name : input.lastName.trim ( ) || null ,
handicap_index : input.handicapIndex ? ? null ,
gender : input.gender ? ? null ,
birth_date : input.birthDate || null ,
email : input.email?.trim ( ) || null ,
club_member_number : input.clubMemberNumber?.trim ( ) || null ,
club : input.club?.trim ( ) || null ,
country : input.country?.trim ( ) || null ,
paid : input.paid ,
comment : input.comment?.trim ( ) || null ,
} ) ,
2026-07-30 07:55:35 +02:00
} )
if ( ! playerRes . ok ) {
2026-08-14 15:08:45 +02:00
setError ( await errorMessage ( playerRes , "Klarte ikke å opprette spilleren." ) )
2026-07-30 07:55:35 +02:00
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 ) )
}
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
async function setParticipantClass ( participantId : string , classId : string | null ) {
setError ( null )
const res = await fetch ( ` ${ base } /participants/ ${ participantId } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { class_id : classId } ) ,
} )
if ( ! res . ok ) {
setError ( await errorMessage ( res , "Klarte ikke å endre klasse." ) )
return
}
const updated : ApiParticipant = await res . json ( )
setParticipants ( ( prev ) = > prev . map ( ( p ) = > ( p . id === updated . id ? updated : p ) ) )
}
2026-08-15 06:32:17 +02:00
async function setParticipantStatLevel (
participantId : string ,
statLevel : "strokes_only" | "strokes_and_putts" | "full" ,
) {
setError ( null )
const res = await fetch ( ` ${ base } /participants/ ${ participantId } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { stat_level : statLevel } ) ,
} )
if ( ! res . ok ) {
setError ( await errorMessage ( res , "Klarte ikke å endre statistikknivå." ) )
return
}
const updated : ApiParticipant = await res . json ( )
setParticipants ( ( prev ) = > prev . map ( ( p ) = > ( p . id === updated . id ? updated : p ) ) )
}
2026-08-18 17:29:59 +02:00
// Retting av EN deltakers HCP direkte i turneringen (2026-08-18) --
// spillerpoolen (ADR-085) endrer kun grunnlaget for FREMTIDIGE
// rundepåmeldinger, aldri en allerede satt handicap_index_snapshot her.
// Rører ALDRI allerede opprettede tournament_round_participant.course_
// handicap for runder deltakeren allerede er satt opp i (samme "frosset
// snapshot"-prinsipp som ADR-085 sin bulk-import allerede har).
async function setParticipantHandicap ( participantId : string , handicap : number | null ) {
setError ( null )
const res = await fetch ( ` ${ base } /participants/ ${ participantId } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { handicap_index_snapshot : handicap } ) ,
} )
if ( ! res . ok ) {
setError ( await errorMessage ( res , "Klarte ikke å endre handicap." ) )
return
}
const updated : ApiParticipant = await res . json ( )
setParticipants ( ( prev ) = > prev . map ( ( p ) = > ( p . id === updated . id ? updated : p ) ) )
}
2026-08-18 18:06:49 +02:00
// Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne
// sette Dsq, Rtd, Dnf, og Dns." Tournament-bredt, blokkerer videre
// score i ALLE runder (strengere enn Cut).
async function setParticipantStatus (
participantId : string ,
status : "active" | "dsq" | "rtd" | "dnf" | "dns" ,
) {
setError ( null )
const res = await fetch ( ` ${ base } /participants/ ${ participantId } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { status } ) ,
} )
if ( ! res . ok ) {
setError ( await errorMessage ( res , "Klarte ikke å endre status." ) )
return
}
const updated : ApiParticipant = await res . json ( )
setParticipants ( ( prev ) = > prev . map ( ( p ) = > ( p . id === updated . id ? updated : p ) ) )
}
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
async function createClass ( name : string , defaultTeeId : string | null ) {
setError ( null )
const res = await fetch ( ` ${ base } /classes ` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { name , default_tee_id : defaultTeeId } ) ,
} )
if ( ! res . ok ) {
setError ( await errorMessage ( res , "Klarte ikke å opprette klassen." ) )
return
}
const created : ApiTournamentClass = await res . json ( )
setClasses ( ( prev ) = > [ . . . prev , created ] )
}
async function deleteClass ( classId : string ) {
setError ( null )
const res = await fetch ( ` ${ base } /classes/ ${ classId } ` , { method : "DELETE" , credentials : "include" } )
if ( res . status !== 204 ) {
setError ( await errorMessage ( res , "Klarte ikke å fjerne klassen." ) )
return
}
setClasses ( ( prev ) = > prev . filter ( ( c ) = > c . id !== classId ) )
setParticipants ( ( prev ) = >
prev . map ( ( p ) = > ( p . class_id === classId ? { . . . p , class_id : null , class_name : null } : p ) ) ,
)
}
2026-07-30 07:55:35 +02:00
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
}
2026-08-04 11:34:28 +02:00
function handleCourseImported ( course : ApiCourse ) {
setCourses ( ( prev ) = > [ . . . prev , course ] . sort ( ( a , b ) = > a . name . localeCompare ( b . name ) ) )
}
2026-07-30 07:55:35 +02:00
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 } / >
2026-08-18 12:59:37 +02:00
{ isOrgAdmin && (
< button
type = "button"
onClick = { ( ) = > void deleteTournament ( ) }
disabled = { deleting }
aria - label = "Slett turnering"
className = "flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-50"
>
< Trash2 aria-hidden = "true" className = "size-5" / >
< / button >
) }
2026-07-30 07:55:35 +02:00
< / div >
< / div >
Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.
To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).
Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00
< nav
className = "mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
aria - label = "Turneringsseksjoner"
>
2026-07-30 07:55:35 +02:00
{ (
[
[ "setup" , "Oppsett" ] ,
[ "score" , "Scorekort" ] ,
[ "leaderboard" , "Leaderboard" ] ,
Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.
To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).
Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00
[ "presentation" , "Presentasjon" ] ,
2026-07-30 07:55:35 +02:00
] as const
) . map ( ( [ key , label ] ) = > (
< button
key = { key }
type = "button"
onClick = { ( ) = > setTab ( key ) }
aria - current = { tab === key ? "page" : undefined }
className = { cn (
Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.
To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).
Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00
"min-h-11 shrink-0 rounded-full px-4 py-2 text-sm font-bold transition-colors" ,
2026-07-30 07:55:35 +02:00
tab === key
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent/60 hover:text-foreground" ,
) }
>
{ label }
< / button >
) ) }
< / nav >
< / header >
2026-08-18 13:18:04 +02:00
{ / * L e a d e r b o a r d - f a n e n e r B E V I S S T u n n t a t t f r a d e n d e l t e 4 x l - b r e d d e n
( 2026 - 08 - 18 , brukerrapport : "fyller ikke hele bredden på skjermen" )
-- StrokePlayLeaderboard er selv bygget for å skalere opp og fylle
en stor skjerm ( egne ` lg: ` - brytningspunkt - klasser , se komponentens
egen kommentar om "storskjerm uten skrolling" ) , men satt inni denne
delte 4 xl - wrapperen ( 896 px , samme wrapper som skjema - tunge Oppsett /
Presentasjon - fanene bruker ) fikk den aldri sjansen til det -- felles
2026-08-18 20:18:47 +02:00
med resten av siden siden komponenten ble bygget / testet isolert .
Oppsett - fanen fikk samme problem 2026 - 08 - 18 ( ADR - 092 ) : 6 steg i
TournamentSetupNav får ikke plass side ved side i 896 px , selv på en
bred skjerm -- akkurat den "stor skjerm først" - regelen wizarden
skulle innfri . Egen , litt smalere unntaksbredde enn leaderboardets
110 rem ( dette er skjemainnhold , ikke en datatabell ) . * / }
2026-08-18 13:18:04 +02:00
< main
className = { cn (
"mx-auto w-full flex-1 px-5 py-6 sm:py-8" ,
2026-08-18 20:18:47 +02:00
tab === "leaderboard" ? "max-w-[110rem]" : tab === "setup" ? "max-w-6xl" : "max-w-4xl" ,
2026-08-18 13:18:04 +02:00
) }
>
2026-07-30 07:55:35 +02:00
{ 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 }
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
classes = { classes }
2026-07-30 07:55:35 +02:00
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-08-14 10:38:54 +02:00
onUpdateFlagMapVisible = { updateFlagMapVisible }
2026-08-18 15:07:21 +02:00
onUpdateCutConfig = { updateCutConfig }
2026-08-18 16:29:10 +02:00
onUpdateTiebreakConfig = { updateTiebreakConfig }
2026-08-18 17:00:44 +02:00
onUpdateSettings = { updateSettings }
2026-08-18 15:07:21 +02:00
onApplyCut = { applyCut }
2026-07-30 07:55:35 +02:00
onAddParticipant = { addParticipant }
onAddNewPlayer = { addNewPlayerAndParticipant }
2026-08-17 14:53:55 +02:00
onImportComplete = { refreshParticipants }
2026-07-30 07:55:35 +02:00
onRemoveParticipant = { removeParticipant }
onAddRound = { addRound }
onDeleteRound = { deleteRound }
onAddCourse = { addCourse }
2026-08-04 11:34:28 +02:00
onCourseImported = { handleCourseImported }
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
onCreateClass = { createClass }
onDeleteClass = { deleteClass }
onSetParticipantClass = { setParticipantClass }
2026-08-15 06:32:17 +02:00
onSetParticipantStatLevel = { setParticipantStatLevel }
2026-08-18 17:29:59 +02:00
onSetParticipantHandicap = { setParticipantHandicap }
2026-08-18 18:06:49 +02:00
onSetParticipantStatus = { setParticipantStatus }
2026-07-30 07:55:35 +02:00
onError = { setError }
/ >
) }
{ tab === "score" && (
2026-08-21 23:00:22 +02:00
< ScoreTab
base = { base }
rounds = { rounds }
scoringMethod = { tournament . scoring_method }
participants = { participants }
2026-08-22 06:39:44 +02:00
currentUserId = { currentUserId }
isOrgAdmin = { isOrgAdmin }
2026-08-21 23:00:22 +02:00
onError = { setError }
/ >
2026-07-30 07:55:35 +02:00
) }
{ tab === "leaderboard" && (
< LeaderboardTab base = { base } tournament = { tournament } / >
) }
Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.
To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).
Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00
{ tab === "presentation" && (
< TournamentPresentationPanel organizationId = { organizationId } tournamentId = { tournamentId } / >
) }
2026-07-30 07:55:35 +02:00
< / 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
// =========================================================================
2026-08-18 17:00:44 +02:00
// <input type="datetime-local"> krever "YYYY-MM-DDTHH:mm" (ingen sekunder/
// tidssone) -- backend (Pydantic datetime) serialiserer/forventer full ISO
// med tidssone. Samme konverteringsbehov -- og samme tidssone-bug hvis det
// hoppes over -- som allerede funnet og fikset i round-groups-panel.tsx
// (ADR-088): new Date(...).toISOString() FØR sending, nettleserens egen
// lokal-tolkning ved visning.
function toDatetimeLocalValue ( iso : string | null ) : string | null {
if ( ! iso ) return null
const date = new Date ( iso )
if ( Number . isNaN ( date . getTime ( ) ) ) return null
const pad = ( n : number ) = > String ( n ) . padStart ( 2 , "0" )
return ` ${ date . getFullYear ( ) } - ${ pad ( date . getMonth ( ) + 1 ) } - ${ pad ( date . getDate ( ) ) } T ${ pad ( date . getHours ( ) ) } : ${ pad ( date . getMinutes ( ) ) } `
}
function fromDatetimeLocalValue ( value : string | null ) : string | null {
if ( ! value ) return null
const date = new Date ( value )
return Number . isNaN ( date . getTime ( ) ) ? null : date . toISOString ( )
}
2026-08-18 20:18:47 +02:00
// Wizard-steg (ADR-092) -- GameBook-skjermdumper var strukturelt forbilde
// (steg i stedet for én lang stablet side), IKKE visuelt forbilde.
// Fri navigasjon -- ingen låsing/fullført-haker, se ADR-092: GameBooks
// egne skjermdumper viser haker på alle steg selv med tomt navnefelt,
// altså ikke ekte validering, og TeeCup er allerede fleksibelt på
// oppsett-rekkefølge.
const SETUP_STEPS : SetupStep [ ] = [
{ key : "basics" , label : "Turneringsoppsett" , icon : Settings2 } ,
{ key : "rounds" , label : "Runder og baner" , icon : Flag } ,
{ key : "classes" , label : "Klasser" , icon : Layers } ,
{ key : "players" , label : "Spillere" , icon : Users } ,
{ key : "groups" , label : "Grupper og startliste" , icon : Clock } ,
{ key : "results" , label : "Resultat og opsjoner" , icon : Trophy } ,
]
2026-07-30 07:55:35 +02:00
function SetupTab ( {
base ,
organizationId ,
tournament ,
participants ,
rounds ,
courses ,
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
classes ,
2026-07-30 07:55:35 +02:00
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-08-14 10:38:54 +02:00
onUpdateFlagMapVisible ,
2026-08-18 15:07:21 +02:00
onUpdateCutConfig ,
onApplyCut ,
2026-08-18 16:29:10 +02:00
onUpdateTiebreakConfig ,
2026-08-18 17:00:44 +02:00
onUpdateSettings ,
2026-07-30 07:55:35 +02:00
onAddParticipant ,
onAddNewPlayer ,
2026-08-17 14:53:55 +02:00
onImportComplete ,
2026-07-30 07:55:35 +02:00
onRemoveParticipant ,
onAddRound ,
onDeleteRound ,
onAddCourse ,
2026-08-04 11:34:28 +02:00
onCourseImported ,
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
onCreateClass ,
onDeleteClass ,
onSetParticipantClass ,
2026-08-15 06:32:17 +02:00
onSetParticipantStatLevel ,
2026-08-18 17:29:59 +02:00
onSetParticipantHandicap ,
2026-08-18 18:06:49 +02:00
onSetParticipantStatus ,
2026-07-30 07:55:35 +02:00
onError ,
} : {
base : string
organizationId : string
tournament : ApiTournamentInfo
participants : ApiParticipant [ ]
rounds : ApiRound [ ]
courses : ApiCourse [ ]
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
classes : ApiTournamentClass [ ]
2026-07-30 07:55:35 +02:00
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-08-14 10:38:54 +02:00
onUpdateFlagMapVisible : ( visible : boolean ) = > void
2026-08-18 15:07:21 +02:00
onUpdateCutConfig : ( afterRound : number | null , size : number | null ) = > Promise < void >
onApplyCut : ( ) = > Promise < void >
2026-08-18 17:29:59 +02:00
onUpdateTiebreakConfig : ( winner : string , field : string , playoffRule : string | null ) = > Promise < void >
2026-08-18 17:00:44 +02:00
onUpdateSettings : ( next : TournamentSettingsValues ) = > Promise < void >
2026-07-30 07:55:35 +02:00
onAddParticipant : ( playerId : string ) = > Promise < void >
2026-08-14 15:08:45 +02:00
onAddNewPlayer : ( input : NewPlayerInput ) = > Promise < void >
2026-08-17 14:53:55 +02:00
onImportComplete : ( ) = > Promise < void >
2026-07-30 07:55:35 +02:00
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 >
2026-08-04 11:34:28 +02:00
onCourseImported : ( course : ApiCourse ) = > void
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
onCreateClass : ( name : string , defaultTeeId : string | null ) = > Promise < void >
onDeleteClass : ( id : string ) = > Promise < void >
onSetParticipantClass : ( participantId : string , classId : string | null ) = > Promise < void >
2026-08-15 06:32:17 +02:00
onSetParticipantStatLevel : (
participantId : string ,
statLevel : "strokes_only" | "strokes_and_putts" | "full" ,
) = > Promise < void >
2026-08-18 17:29:59 +02:00
onSetParticipantHandicap : ( participantId : string , handicap : number | null ) = > Promise < void >
2026-08-18 18:06:49 +02:00
onSetParticipantStatus : (
participantId : string ,
status : "active" | "dsq" | "rtd" | "dnf" | "dns" ,
) = > Promise < void >
2026-07-30 07:55:35 +02:00
onError : ( message : string ) = > void
} ) {
2026-08-18 20:18:47 +02:00
const [ step , setStep ] = useState < string > ( "basics" )
const [ groupsOpenFor , setGroupsOpenFor ] = useState < string | null > ( null )
2026-08-19 07:54:07 +02:00
const [ showImport , setShowImport ] = useState ( false )
2026-07-30 07:55:35 +02:00
const [ pool , setPool ] = useState < ApiPlayer [ ] > ( [ ] )
2026-08-17 14:53:55 +02:00
async function refreshPool() {
const data = await getJson < ApiPlayer [ ] > ( ` /orgs/ ${ organizationId } /players ` )
setPool ( data ? ? [ ] )
}
2026-07-30 07:55:35 +02:00
useEffect ( ( ) = > {
let cancelled = false
getJson < ApiPlayer [ ] > ( ` /orgs/ ${ organizationId } /players ` ) . then ( ( data ) = > {
if ( ! cancelled ) setPool ( data ? ? [ ] )
} )
return ( ) = > {
cancelled = true
}
} , [ organizationId ] )
2026-08-19 09:50:56 +02:00
// Samme "frisk opp ved synlig igjen"-behov som gjelder hele siden
// (se IndividualTournamentDetail sin tilsvarende lytter) -- "Rediger i
// spillerpoolen" er en full sidenavigasjon, og bfcache kan gjenopprette
// denne komponenten i utdatert tilstand ved tilbake-navigasjon.
useEffect ( ( ) = > {
function handleVisible() {
if ( document . visibilityState === "visible" ) refreshPool ( )
}
function handlePageShow ( e : PageTransitionEvent ) {
if ( e . persisted ) refreshPool ( )
}
document . addEventListener ( "visibilitychange" , handleVisible )
window . addEventListener ( "pageshow" , handlePageShow )
return ( ) = > {
document . removeEventListener ( "visibilitychange" , handleVisible )
window . removeEventListener ( "pageshow" , handlePageShow )
}
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ organizationId ] )
2026-07-30 07:55:35 +02:00
2026-08-19 06:21:58 +02:00
// Rundedeltakelse-tabellen (ADR-093, "Spillere"-steget) -- lat innlasting,
// KUN når steget faktisk vises (ikke eagerly ved sideinnlasting), av alle
// rundenes rundedeltakere OG alle unike baners utslag samtidig. Flere
// runder kan dele bane -- dedupliserer på course_id for å ikke hente
// samme banes utslag flere ganger.
const [ roundParticipantsByRound , setRoundParticipantsByRound ] = useState < Record <
string ,
ApiRoundParticipant [ ]
> | null > ( null )
const [ teesByCourse , setTeesByCourse ] = useState < Record < string , ApiTee [ ] > > ( { } )
const [ busyRoundIds , setBusyRoundIds ] = useState < string [ ] > ( [ ] )
const [ participationErrors , setParticipationErrors ] = useState < string [ ] > ( [ ] )
useEffect ( ( ) = > {
if ( step !== "players" || roundParticipantsByRound !== null || rounds . length === 0 ) return
let cancelled = false
async function load() {
const uniqueCourseIds = Array . from ( new Set ( rounds . map ( ( r ) = > r . course_id ) ) )
const [ rpLists , teeLists ] = await Promise . all ( [
Promise . all ( rounds . map ( ( r ) = > getJson < ApiRoundParticipant [ ] > ( ` ${ base } /rounds/ ${ r . id } /participants ` ) ) ) ,
Promise . all (
uniqueCourseIds . map ( ( cid ) = > getJson < ApiTee [ ] > ( ` /orgs/ ${ organizationId } /courses/ ${ cid } /tees ` ) ) ,
) ,
] )
if ( cancelled ) return
const byRound : Record < string , ApiRoundParticipant [ ] > = { }
rounds . forEach ( ( r , i ) = > {
byRound [ r . id ] = rpLists [ i ] ? ? [ ]
} )
const byCourse : Record < string , ApiTee [ ] > = { }
uniqueCourseIds . forEach ( ( cid , i ) = > {
byCourse [ cid ] = teeLists [ i ] ? ? [ ]
} )
setRoundParticipantsByRound ( byRound )
setTeesByCourse ( byCourse )
}
load ( )
return ( ) = > {
cancelled = true
}
} , [ step , roundParticipantsByRound , rounds , base , organizationId ] )
async function refreshRoundParticipants ( roundId : string ) {
const data = await getJson < ApiRoundParticipant [ ] > ( ` ${ base } /rounds/ ${ roundId } /participants ` )
setRoundParticipantsByRound ( ( prev ) = > ( { . . . ( prev ? ? { } ) , [ roundId ] : data ? ? [ ] } ) )
}
2026-08-21 08:04:53 +02:00
// "Anvend cut" (ADR-095) kaskader nå til rundedeltakelse i alle senere
// runder på backend -- tvinger derfor et fullt reload av
// roundParticipantsByRound (i stedet for kun ett enkelt refreshRoundParticipants-
// kall) siden FLERE runder kan ha fått rader lagt til/fjernet samtidig.
async function applyCutAndRefreshRounds() {
await onApplyCut ( )
setRoundParticipantsByRound ( null )
}
2026-08-19 06:21:58 +02:00
async function toggleRoundParticipant (
roundId : string ,
tournamentParticipantId : string ,
checked : boolean ,
teeId : string ,
) {
setParticipationErrors ( [ ] )
if ( checked ) {
const res = await fetch ( ` ${ base } /rounds/ ${ roundId } /participants ` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { tournament_participant_id : tournamentParticipantId , tee_id : teeId } ) ,
} )
if ( ! res . ok ) {
onError ( await errorMessage ( res , "Klarte ikke å legge til deltakeren i runden." ) )
return
}
const created : ApiRoundParticipant = await res . json ( )
setRoundParticipantsByRound ( ( prev ) = > ( {
. . . ( prev ? ? { } ) ,
[ roundId ] : [ . . . ( prev ? . [ roundId ] ? ? [ ] ) , created ] ,
} ) )
} else {
const existing = ( roundParticipantsByRound ? . [ roundId ] ? ? [ ] ) . find (
( rp ) = > rp . tournament_participant_id === tournamentParticipantId ,
)
if ( ! existing ) return
const res = await fetch ( ` ${ base } /rounds/ ${ roundId } /participants/ ${ existing . id } ` , {
method : "DELETE" ,
credentials : "include" ,
} )
if ( ! res . ok ) {
onError ( "Klarte ikke å fjerne deltakeren fra runden." )
return
}
setRoundParticipantsByRound ( ( prev ) = > ( {
. . . ( prev ? ? { } ) ,
[ roundId ] : ( prev ? . [ roundId ] ? ? [ ] ) . filter ( ( rp ) = > rp . id !== existing . id ) ,
} ) )
}
}
async function bulkSetRoundParticipation ( roundId : string , checked : boolean , defaultTeeId : string ) {
setBusyRoundIds ( ( prev ) = > [ . . . prev , roundId ] )
setParticipationErrors ( [ ] )
2026-08-19 10:27:25 +02:00
const currentlyAssigned = roundParticipantsByRound ? . [ roundId ] ? ? [ ]
const assignedByParticipantId = new Map ( currentlyAssigned . map ( ( rp ) = > [ rp . tournament_participant_id , rp ] ) )
2026-08-19 06:21:58 +02:00
const failures : string [ ] = [ ]
if ( checked ) {
2026-08-19 10:27:25 +02:00
// "Velg alle" + et valgt Standardutslag betyr "ALLE spiller dette
// utslaget" -- IKKE bare "fyll inn de som mangler". Rettet
// 2026-08-19 (bruker: "Dersom jeg velger at ALLE skal gå fra 55 så
// kan jeg ikke individuelt endre enkeltspillere") -- den gamle
// versjonen hoppet over alle som allerede var tilordnet runden
// (uansett hvilket utslag de hadde), så et valgt Standardutslag slo
// aldri inn for dem. PATCHer utslaget på allerede tilordnede i
// stedet for å hoppe over dem, POSTer nye for resten.
2026-08-19 06:21:58 +02:00
await Promise . all (
2026-08-19 10:27:25 +02:00
participants . map ( async ( p ) = > {
2026-08-19 06:21:58 +02:00
const teeId = resolveDefaultTeeId ( p , classes , teesByCourse [ rounds . find ( ( r ) = > r . id === roundId ) ? . course_id ? ? "" ] ? ? [ ] , defaultTeeId )
if ( ! teeId ) {
failures . push ( ` ${ p . player_name } : fant ikke noe gyldig utslag. ` )
return
}
2026-08-19 10:27:25 +02:00
const existing = assignedByParticipantId . get ( p . id )
if ( existing ) {
if ( existing . tee_id === teeId ) return // allerede riktig, ingenting å gjøre
const res = await fetch ( ` ${ base } /rounds/ ${ roundId } /participants/ ${ existing . id } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { tee_id : teeId } ) ,
} )
if ( ! res . ok ) failures . push ( ` ${ p . player_name } : ${ await errorMessage ( res , "ukjent feil" ) } ` )
} else {
const res = await fetch ( ` ${ base } /rounds/ ${ roundId } /participants ` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { tournament_participant_id : p.id , tee_id : teeId } ) ,
} )
if ( ! res . ok ) failures . push ( ` ${ p . player_name } : ${ await errorMessage ( res , "ukjent feil" ) } ` )
2026-08-19 06:21:58 +02:00
}
} ) ,
)
} else {
await Promise . all (
2026-08-19 10:27:25 +02:00
currentlyAssigned . map ( async ( rp ) = > {
2026-08-19 06:21:58 +02:00
const res = await fetch ( ` ${ base } /rounds/ ${ roundId } /participants/ ${ rp . id } ` , {
method : "DELETE" ,
credentials : "include" ,
} )
if ( ! res . ok ) failures . push ( ` ${ rp . player_name } : klarte ikke å fjerne. ` )
} ) ,
)
}
await refreshRoundParticipants ( roundId )
setBusyRoundIds ( ( prev ) = > prev . filter ( ( id ) = > id !== roundId ) )
if ( failures . length > 0 ) {
2026-08-19 10:27:25 +02:00
setParticipationErrors ( [ ` ${ failures . length } feilet: ` , . . . failures ] )
2026-08-19 06:21:58 +02:00
}
}
2026-08-19 10:27:25 +02:00
async function changeRoundParticipantTee ( roundId : string , tournamentParticipantId : string , teeId : string ) {
// Rettet 2026-08-19 (bruker: "kan jeg ikke individuelt endre
// enkeltspillere") -- TournamentPlayersTable sin cellemodell kjenner
// kun tournament_participant-IDen (nøkkelen i cells[roundId][...]),
// ALDRI selve tournament_round_participant-raden sin egen ID. Denne
// funksjonen kalte tidligere PATCH-URL-en direkte med den ID-en den
// fikk inn, som stille traff feil (ikke-eksisterende) rad -- 404,
// ingen synlig endring. Slår nå opp riktig round-participant-ID
// FØRST, samme oppslagsmønster som toggleRoundParticipant sin
// DELETE-gren allerede bruker.
const existing = ( roundParticipantsByRound ? . [ roundId ] ? ? [ ] ) . find (
( rp ) = > rp . tournament_participant_id === tournamentParticipantId ,
)
if ( ! existing ) return
const res = await fetch ( ` ${ base } /rounds/ ${ roundId } /participants/ ${ existing . id } ` , {
2026-08-19 06:21:58 +02:00
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { tee_id : teeId } ) ,
} )
if ( ! res . ok ) {
onError ( await errorMessage ( res , "Klarte ikke å endre utslagssted." ) )
return
}
const updated : ApiRoundParticipant = await res . json ( )
setRoundParticipantsByRound ( ( prev ) = > ( {
. . . ( prev ? ? { } ) ,
[ roundId ] : ( prev ? . [ roundId ] ? ? [ ] ) . map ( ( rp ) = > ( rp . id === updated . id ? updated : rp ) ) ,
} ) )
}
async function changePlayerGender ( playerId : string , gender : "m" | "f" | "x" ) {
const res = await fetch ( ` /orgs/ ${ organizationId } /players/ ${ playerId } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { gender } ) ,
} )
if ( ! res . ok ) {
onError ( await errorMessage ( res , "Klarte ikke å endre kjønn." ) )
return
}
setPool ( ( prev ) = > prev . map ( ( p ) = > ( p . id === playerId ? { . . . p , gender } : p ) ) )
}
async function changePlayerBirthDate ( playerId : string , birthDate : string ) {
const res = await fetch ( ` /orgs/ ${ organizationId } /players/ ${ playerId } ` , {
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { birth_date : birthDate } ) ,
} )
if ( ! res . ok ) {
onError ( await errorMessage ( res , "Klarte ikke å endre fødselsdato." ) )
return
}
setPool ( ( prev ) = > prev . map ( ( p ) = > ( p . id === playerId ? { . . . p , birth_date : birthDate } : p ) ) )
}
2026-08-18 17:00:44 +02:00
const settingsValues : TournamentSettingsValues = {
name : tournament.name ,
startDate : tournament.start_date ,
endDate : tournament.end_date ,
visibility : ( tournament . visibility as TournamentSettingsValues [ "visibility" ] ) ? ? "org" ,
description : tournament.description ? ? "" ,
registrationDeadline : toDatetimeLocalValue ( tournament . registration_deadline ) ,
registrationCapacity : tournament.registration_capacity ,
registrationOverflowPolicy :
( tournament . registration_overflow_policy as TournamentSettingsValues [ "registrationOverflowPolicy" ] ) ? ?
"waitlist" ,
registrationRequiresApproval : tournament.registration_requires_approval ,
}
2026-08-18 20:18:47 +02:00
const roundSummaries : RoundSummary [ ] = rounds . map ( ( r ) = > ( {
id : r.id ,
name : r.name || ` Runde ${ r . sequence } ` ,
sequence : r.sequence ,
courseName : r.course_name || null ,
dateLabel : r.scheduled_at
? new Date ( r . scheduled_at ) . toLocaleDateString ( "nb-NO" , { day : "numeric" , month : "short" , year : "numeric" } )
: null ,
} ) )
const groupsOpenRound = rounds . find ( ( r ) = > r . id === groupsOpenFor ) ? ? null
2026-07-30 07:55:35 +02:00
return (
2026-08-18 20:18:47 +02:00
< TournamentSetupNav steps = { SETUP_STEPS } activeStep = { step } onChange = { setStep } >
{ step === "basics" && < TournamentSettingsCard values = { settingsValues } onSave = { onUpdateSettings } / > }
2026-08-18 17:00:44 +02:00
2026-08-18 20:18:47 +02:00
{ step === "rounds" && (
< RoundsCard
base = { base }
organizationId = { organizationId }
rounds = { rounds }
courses = { courses }
scoringMethod = { tournament . scoring_method }
onAddRound = { onAddRound }
onDeleteRound = { onDeleteRound }
onAddCourse = { onAddCourse }
onCourseImported = { onCourseImported }
onError = { onError }
/ >
) }
2026-08-04 11:34:28 +02:00
2026-08-18 20:18:47 +02:00
{ step === "classes" && (
< ClassesCard
classes = { classes }
courses = { courses }
organizationId = { organizationId }
onCreate = { onCreateClass }
onDelete = { onDeleteClass }
/ >
) }
2026-08-18 15:07:21 +02:00
2026-08-18 20:18:47 +02:00
{ step === "players" && (
2026-08-19 07:54:07 +02:00
< 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" > Spillere < / h2 >
< span className = "rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground" >
{ participants . length }
< / span >
< / div >
< Button type = "button" variant = "outline" onClick = { ( ) = > setShowImport ( true ) } className = "min-h-11 self-start" >
< Upload aria-hidden = "true" className = "mr-2 size-4" / >
Importer fra CSV
< / Button >
{ showImport && (
< PlayerImportPanel
organizationId = { organizationId }
tournamentId = { tournament . id }
mode = { { kind : "individual" , classes : classes.map ( ( c ) = > ( { id : c.id , name : c.name } ) ) } }
onClose = { ( ) = > setShowImport ( false ) }
onImportComplete = { ( ) = > {
void ( async ( ) = > {
await Promise . all ( [ refreshPool ( ) , onImportComplete ( ) ] )
} ) ( )
} }
/ >
) }
2026-08-19 10:27:25 +02:00
{ participants . length > 0 && (
// HCP-en her er en FROSSET kopi, satt når deltakeren legges til
// (ADR-090) -- bevisst, ikke en bug (spurt eksplisitt om av
// bruker 2026-08-19, som ba om at dette kommuniseres tydeligere
// her og i /hjelp fremfor å reversere designet). Endringer i
// spillerpoolen når som helst ETTER dette synker ALDRI inn av
// seg selv -- HCP-en må rettes her, direkte.
< p className = "text-xs leading-relaxed text-muted-foreground text-pretty" >
HCP - en under er en egen kopi for turneringen , satt da deltakeren ble lagt til --
en endring i spillerpoolen oppdaterer den IKKE automatisk . Rett HCP - en direkte i
kolonnen under om den er feil .
< / p >
) }
2026-08-19 07:54:07 +02:00
{ 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 >
) : rounds . length > 0 && roundParticipantsByRound === null ? (
< p className = "text-sm text-muted-foreground" > Laster … < / p >
) : (
< TournamentPlayersTable
organizationId = { organizationId }
classes = { classes . map ( ( c ) = > ( { id : c.id , name : c.name } ) ) }
players = { participants . map ( ( p ) = > {
const player = pool . find ( ( pl ) = > pl . id === p . player_id )
return {
participantId : p.id ,
playerId : p.player_id ,
playerName : p.player_name ,
handicapSnapshot : p.handicap_index_snapshot ,
classId : p.class_id ,
statLevel : p.stat_level ,
status : p.status ,
gender : player?.gender ? ? null ,
birthDate : player?.birth_date ? ? null ,
}
} ) }
rounds = { rounds . map ( ( r ) = > ( {
id : r.id ,
label : r.name || ` Runde ${ r . sequence } ` ,
tees : teesByCourse [ r . course_id ] ? ? [ ] ,
} ) ) }
cells = { Object . fromEntries (
rounds . map ( ( r ) = > [
r . id ,
Object . fromEntries (
participants . map ( ( p ) = > {
const rp = ( roundParticipantsByRound ? . [ r . id ] ? ? [ ] ) . find (
( x ) = > x . tournament_participant_id === p . id ,
)
return [
p . id ,
rp
? { assigned : true , teeId : rp.tee_id , teeName : rp.tee_name }
: { assigned : false , teeId : null , teeName : null } ,
]
} ) ,
) ,
] ) ,
) }
onSetHandicap = { onSetParticipantHandicap }
onSetClass = { onSetParticipantClass }
onSetStatLevel = { onSetParticipantStatLevel }
onSetStatus = { onSetParticipantStatus }
onRemoveParticipant = { onRemoveParticipant }
onChangeGender = { changePlayerGender }
onChangeBirthDate = { changePlayerBirthDate }
onToggleRound = { toggleRoundParticipant }
onBulkToggleRound = { bulkSetRoundParticipation }
onChangeRoundTee = { changeRoundParticipantTee }
busyRoundIds = { busyRoundIds }
errors = { participationErrors }
/ >
) }
< AddParticipantControl
2026-08-19 06:21:58 +02:00
pool = { pool }
2026-08-19 07:54:07 +02:00
alreadyIn = { new Set ( participants . map ( ( p ) = > p . player_id ) ) }
onAddExisting = { onAddParticipant }
onAddNew = { onAddNewPlayer }
2026-08-19 06:21:58 +02:00
/ >
2026-08-19 07:54:07 +02:00
< / section >
2026-08-18 20:18:47 +02:00
) }
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
2026-08-18 20:18:47 +02:00
{ step === "groups" && (
< div className = "flex flex-col gap-6" >
< RoundGroupsStepList rounds = { roundSummaries } onManageGroups = { setGroupsOpenFor } / >
{ groupsOpenRound && (
< RoundGroupsPanel
base = { base }
2026-08-21 13:54:01 +02:00
organizationId = { organizationId }
tournamentId = { tournament . id }
2026-08-18 20:18:47 +02:00
roundId = { groupsOpenRound . id }
roundLabel = { groupsOpenRound . name || ` Runde ${ groupsOpenRound . sequence } ` }
onClose = { ( ) = > setGroupsOpenFor ( null ) }
/ >
) }
< / div >
) }
{ step === "results" && (
< div className = "flex flex-col gap-6" >
< section className = "flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8" >
< h2 className = "text-base font-bold text-foreground" > Scoringsmetode < / h2 >
< p className = "text-sm leading-relaxed text-muted-foreground text-pretty" >
Avgjør hvordan totalen på leaderboardet regnes ut , og om deltakere trenger
registrert handicap og kjønn .
< / p >
< DropdownMenu >
< DropdownMenuTrigger className = "flex min-h-11 w-fit items-center gap-2 rounded-xl border border-border bg-background px-4 text-sm font-bold text-foreground transition-colors hover:bg-accent/50" >
{ tournament . scoring_method ? SCORING_METHOD_LABELS [ tournament . scoring_method ] : "Ikke satt" }
< ChevronDown aria-hidden = "true" className = "size-4 text-muted-foreground" / >
< / DropdownMenuTrigger >
< DropdownMenuContent align = "start" className = "w-56 rounded-2xl p-1.5" >
{ Object . entries ( SCORING_METHOD_LABELS ) . map ( ( [ value , label ] ) = > (
< DropdownMenuItem
key = { value }
onClick = { ( ) = > onUpdateScoringMethod ( value ) }
className = "cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold"
>
{ label }
< / DropdownMenuItem >
) ) }
< / DropdownMenuContent >
< / DropdownMenu >
{ tournament . scoring_method === "bingo_bango_bongo" && (
< label className = "flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3" >
< input
type = "checkbox"
checked = { tournament . bbb_sweep_bonus_enabled }
onChange = { ( e ) = > onUpdateBbbSweepBonus ( e . target . checked ) }
className = "mt-1 size-5 shrink-0 accent-primary"
/ >
< span className = "flex flex-col gap-0.5" >
< span className = "text-sm font-bold text-foreground" > Bonuspoeng ved "sveip" < / span >
< span className = "text-xs text-muted-foreground" >
Ekstra poeng til en spiller som vinner alle tre kategoriene på samme hull .
< / span >
< / span >
< / label >
) }
{ tournament . scoring_method === "flag" && (
< label className = "flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3" >
< input
type = "checkbox"
checked = { tournament . flag_map_visible }
onChange = { ( e ) = > onUpdateFlagMapVisible ( e . target . checked ) }
className = "mt-1 size-5 shrink-0 accent-primary"
/ >
< span className = "flex flex-col gap-0.5" >
< span className = "text-sm font-bold text-foreground" > Kartoversikt over flagg < / span >
< span className = "text-xs text-muted-foreground" >
Når dette er PÅ , kan alle deltakere se hvor alle andre har plantet flagget . Av som standard .
< / span >
< / span >
< / label >
) }
< / section >
{ tournament . scoring_method != null &&
[ "stroke_gross" , "stroke_net" , "stableford" ] . includes ( tournament . scoring_method ) && (
< >
2026-08-21 08:04:53 +02:00
< CutCard tournament = { tournament } rounds = { rounds } onUpdateConfig = { onUpdateCutConfig } onApplyCut = { applyCutAndRefreshRounds } / >
2026-08-18 20:18:47 +02:00
< TiebreakCard tournament = { tournament } onUpdateConfig = { onUpdateTiebreakConfig } / >
< / >
) }
< / div >
) }
< / TournamentSetupNav >
2026-07-30 07:55:35 +02:00
)
}
2026-08-18 15:07:21 +02:00
// Cut (migrasjon 083, 2026-08-18) -- topp N og delt plass, etter et
// organisator-valgt rundenummer, MANUELT anvendt (bruker bekreftet
// eksplisitt: ikke automatisk ved fullspilt runde). Samme "vanlig
// innstillingskort"-mønster som Scoringsmetode-seksjonen over -- ingen
// V0-runde nødvendig, dette er en liten tilføyelse til et allerede
// håndkodet oppsett-skjema, ikke en ny selvstendig UI-flate.
function CutCard ( {
tournament ,
rounds ,
onUpdateConfig ,
onApplyCut ,
} : {
tournament : ApiTournamentInfo
rounds : ApiRound [ ]
onUpdateConfig : ( afterRound : number | null , size : number | null ) = > Promise < void >
onApplyCut : ( ) = > Promise < void >
} ) {
const [ afterRound , setAfterRound ] = useState ( tournament . cut_after_round ? . toString ( ) ? ? "" )
const [ size , setSize ] = useState ( tournament . cut_size ? . toString ( ) ? ? "" )
const [ saving , setSaving ] = useState ( false )
const [ applying , setApplying ] = useState ( false )
const sortedRounds = [ . . . rounds ] . sort ( ( a , b ) = > a . sequence - b . sequence )
const dirty = afterRound !== ( tournament . cut_after_round ? . toString ( ) ? ? "" ) || size !== ( tournament . cut_size ? . toString ( ) ? ? "" )
const configComplete = afterRound . trim ( ) !== "" && size . trim ( ) !== ""
async function handleSave() {
setSaving ( true )
await onUpdateConfig ( afterRound . trim ( ) ? Number ( afterRound ) : null , size . trim ( ) ? Number ( size ) : null )
setSaving ( false )
}
async function handleApply() {
setApplying ( true )
await onApplyCut ( )
setApplying ( false )
}
return (
< 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" > Cut < / h2 >
< p className = "text-sm leading-relaxed text-muted-foreground text-pretty" >
Topp N og delt plass slipper videre etter valgt runde . Kuttede spillere vises i egen
seksjon på leaderboardet og blokkeres fra å registrere score i senere runder .
{ sortedRounds . length > 0 && (
< > Turneringen har { sortedRounds . length } { sortedRounds . length === 1 ? "runde" : "runder" } satt opp . < / >
) }
< / p >
< div className = "flex flex-col gap-3 sm:flex-row sm:items-end sm:gap-4" >
< label className = "flex flex-1 flex-col gap-1.5" >
< span className = "text-sm font-bold text-foreground" > Etter runde nr . < / span >
< input
type = "number"
min = { 1 }
value = { afterRound }
onChange = { ( e ) = > setAfterRound ( e . target . value ) }
placeholder = "f.eks. 2"
className = "h-11 rounded-xl border border-border bg-background px-4 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/ >
< / label >
< label className = "flex flex-1 flex-col gap-1.5" >
< span className = "text-sm font-bold text-foreground" > Antall som går videre < / span >
< input
type = "number"
min = { 1 }
value = { size }
onChange = { ( e ) = > setSize ( e . target . value ) }
placeholder = "f.eks. 20"
className = "h-11 rounded-xl border border-border bg-background px-4 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/ >
2026-08-18 17:38:56 +02:00
< span className = "text-xs text-muted-foreground" >
Plassen dette tallet havner på blir grensen -- ikke et fast tak . Er noen uavgjort
nøyaktig der , slipper alle de gjennom ( kan altså bli litt flere enn { size || "N" } ) .
< / span >
2026-08-18 15:07:21 +02:00
< / label >
< button
type = "button"
onClick = { ( ) = > void handleSave ( ) }
disabled = { ! dirty || saving }
className = "inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{ saving ? "Lagrer..." : "Lagre innstilling" }
< / button >
< / div >
< div className = "flex flex-col items-start gap-2 border-t border-border pt-3 sm:flex-row sm:items-center sm:justify-between" >
< p className = "text-xs font-medium text-muted-foreground" >
{ tournament . cut_applied_at
? ` Cut anvendt ${ new Date ( tournament . cut_applied_at ) . toLocaleString ( "no-NO" , { dateStyle : "medium" , timeStyle : "short" } )}. Kan anvendes på nytt hvis noe rettes. `
: "Cut er ikke anvendt ennå -- ingen spillere er blokkert." }
< / p >
< button
type = "button"
onClick = { ( ) = > void handleApply ( ) }
disabled = { ! configComplete || applying }
className = "inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-xl bg-primary px-5 text-sm font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{ applying ? "Anvender..." : tournament . cut_applied_at ? "Anvend cut på nytt" : "Anvend cut" }
< / button >
< / div >
< / section >
)
}
2026-08-18 16:29:10 +02:00
// Tiebreak (migrasjon 085, 2026-08-18) -- "Ved likt resultat ... Skal
// laveste hcp plasseres foran, beste siste 18/9/6/3/siste hull plasseres
// foran, eller deles plasseringene. Her kan svaret være forskjellig for
// vinner og resten av feltet." Bruker: metoden skal defineres av
// organisator ved oppsett, IKKE fastlåst i koden -- derfor to
// uavhengige nedtrekk. Samme "vanlig innstillingskort"-mønster som
// CutCard rett over.
2026-08-18 17:29:59 +02:00
// "Omspill" (migrasjon 086, 2026-08-18) -- KUN et gyldig valg for
// vinneren (bruker sin egen formulering: "ved lik score for LEDER"),
// ikke for resten av feltet -- appen kan ikke selv avgjøre et omspill,
// det skjer på banen. tiebreak_playoff_rule er fritekst vist proaktivt
// her OG på leaderboardet, ikke bare når en uavgjort faktisk skjer.
const TIEBREAK_OPTIONS_WINNER : { value : string ; label : string ; hint : string } [ ] = [
{ value : "none" , label : "Delt plassering" , hint : "Ingen automatisk avgjørelse -- vises som «T1»." } ,
{ value : "countback" , label : "Countback" , hint : "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" } ,
{ value : "lowest_hcp" , label : "Laveste HCP" , hint : "Spilleren med lavest handicap-indeks vinner uavgjortheten" } ,
{ value : "playoff" , label : "Omspill" , hint : "Avgjøres på banen -- beskriv formatet under, vises til alle før turneringen" } ,
]
const TIEBREAK_OPTIONS_FIELD : { value : string ; label : string ; hint : string } [ ] = [
{ value : "none" , label : "Delt plassering" , hint : "Ingen automatisk avgjørelse -- vises som «T5» osv." } ,
2026-08-18 16:29:10 +02:00
{ value : "countback" , label : "Countback" , hint : "Beste siste 9 -> 6 -> 3 -> siste hull i siste runde" } ,
{ value : "lowest_hcp" , label : "Laveste HCP" , hint : "Spilleren med lavest handicap-indeks vinner uavgjortheten" } ,
]
function TiebreakCard ( {
tournament ,
onUpdateConfig ,
} : {
tournament : ApiTournamentInfo
2026-08-18 17:29:59 +02:00
onUpdateConfig : ( winner : string , field : string , playoffRule : string | null ) = > Promise < void >
2026-08-18 16:29:10 +02:00
} ) {
const [ winner , setWinner ] = useState ( tournament . tiebreak_winner_method )
const [ field , setField ] = useState ( tournament . tiebreak_field_method )
2026-08-18 17:29:59 +02:00
const [ playoffRule , setPlayoffRule ] = useState ( tournament . tiebreak_playoff_rule ? ? "" )
2026-08-18 16:29:10 +02:00
const [ saving , setSaving ] = useState ( false )
2026-08-18 17:29:59 +02:00
const dirty =
winner !== tournament . tiebreak_winner_method ||
field !== tournament . tiebreak_field_method ||
playoffRule !== ( tournament . tiebreak_playoff_rule ? ? "" )
2026-08-18 16:29:10 +02:00
async function handleSave() {
setSaving ( true )
2026-08-18 17:29:59 +02:00
await onUpdateConfig ( winner , field , winner === "playoff" ? playoffRule . trim ( ) || null : null )
2026-08-18 16:29:10 +02:00
setSaving ( false )
}
return (
< 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" > Tiebreak ved likt resultat < / h2 >
< p className = "text-sm leading-relaxed text-muted-foreground text-pretty" >
Velges uavhengig for vinneren og resten av feltet . Påvirker kun sluttresultatets
plassering -- Cut - grensen ( over ) bruker alltid full delt plass , uansett denne innstillingen .
< / p >
< div className = "flex flex-col gap-4 sm:flex-row" >
< label className = "flex flex-1 flex-col gap-1.5" >
< span className = "text-sm font-bold text-foreground" > Ved likt resultat for vinneren < / span >
< select
value = { winner }
onChange = { ( e ) = > setWinner ( e . target . value ) }
className = "h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
2026-08-18 17:29:59 +02:00
{ TIEBREAK_OPTIONS_WINNER . map ( ( o ) = > (
2026-08-18 16:29:10 +02:00
< option key = { o . value } value = { o . value } >
{ o . label }
< / option >
) ) }
< / select >
< span className = "text-xs text-muted-foreground" >
2026-08-18 17:29:59 +02:00
{ TIEBREAK_OPTIONS_WINNER . find ( ( o ) = > o . value === winner ) ? . hint }
2026-08-18 16:29:10 +02:00
< / span >
< / label >
< label className = "flex flex-1 flex-col gap-1.5" >
< span className = "text-sm font-bold text-foreground" > Ved likt resultat i resten av feltet < / span >
< select
value = { field }
onChange = { ( e ) = > setField ( e . target . value ) }
className = "h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
2026-08-18 17:29:59 +02:00
{ TIEBREAK_OPTIONS_FIELD . map ( ( o ) = > (
2026-08-18 16:29:10 +02:00
< option key = { o . value } value = { o . value } >
{ o . label }
< / option >
) ) }
< / select >
< span className = "text-xs text-muted-foreground" >
2026-08-18 17:29:59 +02:00
{ TIEBREAK_OPTIONS_FIELD . find ( ( o ) = > o . value === field ) ? . hint }
2026-08-18 16:29:10 +02:00
< / span >
< / label >
< / div >
2026-08-18 17:29:59 +02:00
{ winner === "playoff" && (
< label className = "flex flex-col gap-1.5" >
< span className = "text-sm font-bold text-foreground" > Omspill - format < / span >
< input
type = "text"
value = { playoffRule }
onChange = { ( e ) = > setPlayoffRule ( e . target . value ) }
placeholder = "F.eks. Sudden death fra hull 18, til vinner er kåret."
className = "h-11 rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/ >
< span className = "text-xs text-muted-foreground" >
Vises på leaderboardet slik at deltakerne kjenner formatet før turneringen .
< / span >
< / label >
) }
2026-08-18 16:29:10 +02:00
< button
type = "button"
onClick = { ( ) = > void handleSave ( ) }
disabled = { ! dirty || saving }
className = "inline-flex h-11 w-fit items-center justify-center gap-2 self-end rounded-xl border border-border bg-background px-5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 disabled:cursor-not-allowed disabled:opacity-50"
>
{ saving ? "Lagrer..." : "Lagre innstilling" }
< / button >
< / section >
)
}
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
// Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin
// ClassesCard for lagturnering-varianten (samme mønster, men uten
// leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten).
function ClassesCard ( {
classes ,
courses ,
organizationId ,
onCreate ,
onDelete ,
} : {
classes : ApiTournamentClass [ ]
courses : ApiCourse [ ]
organizationId : string
onCreate : ( name : string , defaultTeeId : string | null ) = > Promise < void >
onDelete : ( classId : string ) = > Promise < void >
} ) {
const [ name , setName ] = useState ( "" )
const [ courseId , setCourseId ] = useState ( "" )
const [ teeId , setTeeId ] = useState ( "" )
const [ tees , setTees ] = useState < ApiTee [ ] > ( [ ] )
const [ adding , setAdding ] = useState ( false )
useEffect ( ( ) = > {
if ( ! courseId ) {
setTees ( [ ] )
setTeeId ( "" )
return
}
let cancelled = false
getJson < ApiTee [ ] > ( ` /orgs/ ${ organizationId } /courses/ ${ courseId } /tees ` ) . then ( ( data ) = > {
if ( ! cancelled ) {
setTees ( data ? ? [ ] )
setTeeId ( data ? . [ 0 ] ? . id ? ? "" )
}
} )
return ( ) = > {
cancelled = true
}
} , [ organizationId , courseId ] )
async function handleSubmit ( e : React.FormEvent ) {
e . preventDefault ( )
if ( name . trim ( ) . length === 0 || adding ) return
setAdding ( true )
await onCreate ( name . trim ( ) , teeId || null )
setAdding ( false )
setName ( "" )
setCourseId ( "" )
setTeeId ( "" )
}
return (
< section className = "flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8" >
< div className = "flex items-center justify-between gap-2" >
< h2 className = "text-base font-bold text-foreground" > Klasser < / h2 >
< span className = "rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground" >
{ classes . length }
< / span >
< / div >
< p className = "text-sm leading-relaxed text-muted-foreground text-pretty" >
Foreslår riktig utslag ved oppsett av en runde , og gir hver klasse sin egen seksjon på
leaderboardet .
< / p >
{ classes . length > 0 && (
< ul className = "flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border" >
{ classes . map ( ( c ) = > (
< li key = { c . id } className = "flex items-center justify-between gap-3 px-4 py-3" >
< div className = "flex min-w-0 flex-col" >
< span className = "truncate text-sm font-bold text-foreground" > { c . name } < / span >
{ c . default_tee_name && (
< span className = "text-xs text-muted-foreground" >
Standardutslag : { c . default_tee_name }
< / span >
) }
< / div >
< Button
type = "button"
variant = "ghost"
size = "icon"
onClick = { ( ) = > onDelete ( c . id ) }
aria - label = { ` Fjern klassen ${ c . name } ` }
className = "size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
>
< Trash2 aria-hidden = "true" className = "size-4" / >
< / Button >
< / li >
) ) }
< / ul >
) }
< form onSubmit = { handleSubmit } className = "flex flex-col gap-2 sm:flex-row sm:items-end" >
< div className = "flex flex-1 flex-col gap-1" >
< Label htmlFor = "ind-class-name" className = "text-xs font-semibold text-muted-foreground" >
Klassenavn
< / Label >
< Input
id = "ind-class-name"
value = { name }
onChange = { ( e ) = > setName ( e . target . value ) }
placeholder = "F.eks. Damer"
className = "h-11"
/ >
< / div >
< div className = "flex flex-1 flex-col gap-1" >
< Label htmlFor = "ind-class-course" className = "text-xs font-semibold text-muted-foreground" >
Bane ( for standardutslag )
< / Label >
< select
id = "ind-class-course"
value = { courseId }
onChange = { ( e ) = > setCourseId ( e . target . value ) }
className = "h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
>
< option value = "" > Ingen standardutslag < / option >
{ courses . map ( ( c ) = > (
< option key = { c . id } value = { c . id } >
{ c . name }
< / option >
) ) }
< / select >
< / div >
{ tees . length > 0 && (
< div className = "flex flex-1 flex-col gap-1" >
< Label htmlFor = "ind-class-tee" className = "text-xs font-semibold text-muted-foreground" >
Standardutslag
< / Label >
< select
id = "ind-class-tee"
value = { teeId }
onChange = { ( e ) = > setTeeId ( e . target . value ) }
className = "h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
>
{ tees . map ( ( t ) = > (
< option key = { t . id } value = { t . id } >
{ t . name }
< / option >
) ) }
< / select >
< / div >
) }
< Button
type = "submit"
variant = "secondary"
disabled = { adding || name . trim ( ) . length === 0 }
className = "h-11 shrink-0 rounded-xl font-semibold"
>
< Plus aria-hidden = "true" className = "size-4" / >
Legg til
< / Button >
< / form >
< / section >
)
}
2026-08-14 15:08:45 +02:00
// Splitter et fritekst-navn i for-/etternavn -- samme "første ord =
// fornavn, resten = etternavn"-heuristikk som migrasjon 052/074 sin
// backfill, brukt her til å FORHÅNDSUTFYLLE opprett-skjemaet fra
// søkefeltet (fortsatt redigerbart -- kun et fornuftig utgangspunkt).
function splitName ( fullName : string ) : { first : string ; last : string } {
const trimmed = fullName . trim ( )
const spaceIndex = trimmed . indexOf ( " " )
if ( spaceIndex === - 1 ) return { first : trimmed , last : "" }
return { first : trimmed.slice ( 0 , spaceIndex ) , last : trimmed.slice ( spaceIndex + 1 ) . trim ( ) }
}
const MAX_VISIBLE_MATCHES = 8
2026-07-30 07:55:35 +02:00
function AddParticipantControl ( {
pool ,
alreadyIn ,
onAddExisting ,
onAddNew ,
} : {
pool : ApiPlayer [ ]
alreadyIn : Set < string >
onAddExisting : ( playerId : string ) = > Promise < void >
2026-08-14 15:08:45 +02:00
onAddNew : ( input : NewPlayerInput ) = > Promise < void >
2026-07-30 07:55:35 +02:00
} ) {
const [ open , setOpen ] = useState ( false )
const [ query , setQuery ] = useState ( "" )
const [ creating , setCreating ] = useState ( false )
2026-08-14 15:08:45 +02:00
const [ firstName , setFirstName ] = useState ( "" )
const [ lastName , setLastName ] = useState ( "" )
2026-07-30 07:55:35 +02:00
const [ newHandicap , setNewHandicap ] = useState ( "" )
const [ newGender , setNewGender ] = useState < "m" | "f" | "x" | "" > ( "" )
2026-08-14 15:08:45 +02:00
const [ showMoreDetails , setShowMoreDetails ] = useState ( false )
const [ birthDate , setBirthDate ] = useState ( "" )
const [ email , setEmail ] = useState ( "" )
const [ clubMemberNumber , setClubMemberNumber ] = useState ( "" )
const [ club , setClub ] = useState ( "" )
const [ country , setCountry ] = useState ( "" )
const [ paid , setPaid ] = useState ( false )
const [ comment , setComment ] = useState ( "" )
2026-07-30 07:55:35 +02:00
const trimmed = query . trim ( )
2026-08-14 15:08:45 +02:00
const available = useMemo ( ( ) = > pool . filter ( ( p ) = > ! alreadyIn . has ( p . id ) ) , [ pool , alreadyIn ] )
2026-07-30 07:55:35 +02:00
const matches = useMemo ( ( ) = > {
2026-08-14 15:08:45 +02:00
if ( ! trimmed ) return available
2026-07-30 07:55:35 +02:00
const q = trimmed . toLowerCase ( )
2026-08-14 15:08:45 +02:00
return available . filter ( ( p ) = > p . display_name . toLowerCase ( ) . includes ( q ) )
} , [ available , trimmed ] )
2026-07-30 07:55:35 +02:00
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 )
2026-08-14 15:08:45 +02:00
setFirstName ( "" )
setLastName ( "" )
2026-07-30 07:55:35 +02:00
setNewHandicap ( "" )
setNewGender ( "" )
2026-08-14 15:08:45 +02:00
setShowMoreDetails ( false )
setBirthDate ( "" )
setEmail ( "" )
setClubMemberNumber ( "" )
setClub ( "" )
setCountry ( "" )
setPaid ( false )
setComment ( "" )
2026-07-30 07:55:35 +02:00
}
async function handleAddExisting ( playerId : string ) {
await onAddExisting ( playerId )
reset ( )
setOpen ( false )
}
2026-08-14 15:08:45 +02:00
function startCreating() {
const { first , last } = splitName ( trimmed )
setFirstName ( first )
setLastName ( last )
setCreating ( true )
}
2026-07-30 07:55:35 +02:00
async function handleCreate() {
2026-08-14 15:08:45 +02:00
if ( firstName . trim ( ) . length === 0 ) return
2026-07-30 07:55:35 +02:00
const hcpValue = newHandicap . trim ( ) === "" ? undefined : Number ( newHandicap . replace ( "," , "." ) )
const hcp = hcpValue !== undefined && ! Number . isNaN ( hcpValue ) ? hcpValue : undefined
2026-08-14 15:08:45 +02:00
await onAddNew ( {
firstName ,
lastName ,
gender : newGender || undefined ,
handicapIndex : hcp ,
birthDate : birthDate || undefined ,
email : email || undefined ,
clubMemberNumber : clubMemberNumber || undefined ,
club : club || undefined ,
country : country || undefined ,
paid ,
comment : comment || undefined ,
} )
2026-07-30 07:55:35 +02:00
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
2026-08-14 15:08:45 +02:00
placeholder = "Søk blant eksisterende, eller skriv navnet på en ny…"
2026-07-30 07:55:35 +02:00
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 >
2026-08-14 15:08:45 +02:00
{ ! creating && (
< >
{ matches . length === 0 && available . length === 0 && (
< p className = "px-1 text-sm text-muted-foreground" >
{ pool . length === 0
? "Ingen eksisterende spillere i organisasjonen ennå -- opprett den første under."
: "Alle eksisterende spillere er allerede lagt til -- opprett en ny under." }
< / p >
) }
{ matches . length === 0 && available . length > 0 && trimmed . length > 0 && (
< p className = "px-1 text-sm text-muted-foreground" > Ingen treff blant eksisterende spillere . < / p >
) }
{ matches . length > 0 && (
< ul className = "flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border" >
{ matches . slice ( 0 , MAX_VISIBLE_MATCHES ) . map ( ( p ) = > (
< li key = { p . id } >
< button
type = "button"
onClick = { ( ) = > handleAddExisting ( p . id ) }
className = "flex min-h-11 w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
>
{ p . display_name }
< Plus aria-hidden = "true" className = "size-4 shrink-0 text-primary" / >
< / button >
< / li >
) ) }
< / ul >
) }
{ matches . length > MAX_VISIBLE_MATCHES && (
< p className = "px-1 text-xs text-muted-foreground" >
+ { matches . length - MAX_VISIBLE_MATCHES } flere -- fortsett å skrive for å begrense listen .
< / p >
) }
< / >
2026-07-30 07:55:35 +02:00
) }
{ showCreate && ! creating && (
< button
type = "button"
2026-08-14 15:08:45 +02:00
onClick = { startCreating }
2026-07-30 07:55:35 +02:00
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 && (
2026-08-14 15:08:45 +02:00
< div className = "flex flex-col gap-3 rounded-xl border border-border bg-background p-3" >
2026-07-30 07:55:35 +02:00
< div className = "flex gap-2" >
< div className = "flex flex-1 flex-col gap-1" >
2026-08-14 15:08:45 +02:00
< Label htmlFor = "new-participant-first-name" className = "text-xs font-semibold" >
Fornavn
2026-07-30 07:55:35 +02:00
< / Label >
< Input
2026-08-14 15:08:45 +02:00
id = "new-participant-first-name"
value = { firstName }
onChange = { ( e ) = > setFirstName ( e . target . value ) }
2026-07-30 07:55:35 +02:00
className = "h-10 rounded-xl text-sm"
/ >
< / div >
2026-08-14 15:08:45 +02:00
< div className = "flex flex-1 flex-col gap-1" >
< Label htmlFor = "new-participant-last-name" className = "text-xs font-semibold" >
Etternavn
< / Label >
< Input
id = "new-participant-last-name"
value = { lastName }
onChange = { ( e ) = > setLastName ( e . target . value ) }
className = "h-10 rounded-xl text-sm"
/ >
< / div >
< / div >
< div className = "flex gap-2" >
2026-07-30 07:55:35 +02:00
< 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 >
2026-08-14 15:08:45 +02:00
< 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 >
2026-07-30 07:55:35 +02:00
< / div >
2026-08-14 15:08:45 +02:00
{ ! showMoreDetails ? (
< button
type = "button"
onClick = { ( ) = > setShowMoreDetails ( true ) }
className = "min-h-9 text-left text-xs font-bold text-primary"
>
+ Flere detaljer ( fødselsdato , e - post , klubb , betalt m . m . )
< / button >
) : (
< div className = "flex flex-col gap-2 border-t border-border pt-3" >
< div className = "flex gap-2" >
< div className = "flex flex-1 flex-col gap-1" >
< Label htmlFor = "new-participant-birth-date" className = "text-xs font-semibold" >
Fødselsdato
< / Label >
< Input
id = "new-participant-birth-date"
type = "date"
value = { birthDate }
onChange = { ( e ) = > setBirthDate ( e . target . value ) }
className = "h-10 rounded-xl text-sm"
/ >
< / div >
< div className = "flex flex-1 flex-col gap-1" >
< Label htmlFor = "new-participant-email" className = "text-xs font-semibold" >
E - postadresse
< / Label >
< Input
id = "new-participant-email"
type = "email"
value = { email }
onChange = { ( e ) = > setEmail ( e . target . value ) }
className = "h-10 rounded-xl text-sm"
/ >
< / div >
< / div >
< div className = "flex gap-2" >
< div className = "flex flex-1 flex-col gap-1" >
< Label htmlFor = "new-participant-club" className = "text-xs font-semibold" >
Hjemmeklubb
< / Label >
< Input
id = "new-participant-club"
value = { club }
onChange = { ( e ) = > setClub ( e . target . value ) }
className = "h-10 rounded-xl text-sm"
/ >
< / div >
< div className = "flex flex-1 flex-col gap-1" >
< Label htmlFor = "new-participant-club-member-number" className = "text-xs font-semibold" >
Medlemsnummer i hjemmeklubb
< / Label >
< Input
id = "new-participant-club-member-number"
value = { clubMemberNumber }
onChange = { ( e ) = > setClubMemberNumber ( e . target . value ) }
className = "h-10 rounded-xl text-sm"
/ >
< / div >
< / div >
< div className = "flex flex-col gap-1" >
< Label htmlFor = "new-participant-country" className = "text-xs font-semibold" >
Land
< / Label >
< Input
id = "new-participant-country"
value = { country }
onChange = { ( e ) = > setCountry ( e . target . value ) }
className = "h-10 w-full rounded-xl text-sm sm:w-1/2"
/ >
< / div >
< label className = "flex min-h-9 items-center gap-2 text-sm font-medium text-foreground" >
< input
type = "checkbox"
checked = { paid }
onChange = { ( e ) = > setPaid ( e . target . checked ) }
className = "size-5 shrink-0 accent-primary"
/ >
Betalt
< / label >
< div className = "flex flex-col gap-1" >
< Label htmlFor = "new-participant-comment" className = "text-xs font-semibold" >
Kommentar
< / Label >
< textarea
id = "new-participant-comment"
value = { comment }
onChange = { ( e ) = > setComment ( e . target . value ) }
rows = { 2 }
className = "w-full resize-y rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
/ >
< / div >
< / div >
) }
< Button
type = "button"
onClick = { handleCreate }
disabled = { firstName . trim ( ) . length === 0 }
className = "h-10 rounded-xl text-sm font-bold"
>
Opprett og legg til « { ` ${ firstName } ${ lastName } ` . trim ( ) } »
2026-07-30 07:55:35 +02:00
< / Button >
< / div >
) }
< / div >
)
}
function RoundsCard ( {
base ,
organizationId ,
rounds ,
courses ,
scoringMethod ,
onAddRound ,
onDeleteRound ,
onAddCourse ,
2026-08-04 11:34:28 +02:00
onCourseImported ,
2026-07-30 07:55:35 +02:00
onError ,
} : {
base : string
organizationId : string
rounds : ApiRound [ ]
courses : ApiCourse [ ]
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 >
2026-08-04 11:34:28 +02:00
onCourseImported : ( course : ApiCourse ) = > void
2026-07-30 07:55:35 +02:00
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 }
round = { r }
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
2026-08-04 11:34:28 +02:00
organizationId = { organizationId }
2026-07-30 07:55:35 +02:00
courses = { courses }
2026-08-04 11:34:28 +02:00
onCourseImported = { onCourseImported }
2026-07-30 07:55:35 +02:00
onCreate = { async ( name , courseId , holeConfig ) = > {
await onAddRound ( name , courseId , holeConfig )
setCreating ( false )
} }
onCreateCourse = { onAddCourse }
onCancel = { ( ) = > setCreating ( false ) }
/ >
) }
< / section >
)
}
function NewRoundForm ( {
2026-08-04 11:34:28 +02:00
organizationId ,
2026-07-30 07:55:35 +02:00
courses ,
onCreate ,
onCreateCourse ,
2026-08-04 11:34:28 +02:00
onCourseImported ,
2026-07-30 07:55:35 +02:00
onCancel ,
} : {
2026-08-04 11:34:28 +02:00
organizationId : string
2026-07-30 07:55:35 +02:00
courses : ApiCourse [ ]
onCreate : ( name : string , courseId : string , holeConfig : ApiRound [ "hole_config" ] ) = > Promise < void >
onCreateCourse : ( name : string ) = > Promise < ApiCourse | null >
2026-08-04 11:34:28 +02:00
onCourseImported : ( course : ApiCourse ) = > void
2026-07-30 07:55:35 +02:00
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 )
2026-08-04 11:34:28 +02:00
const [ officialSearchOpen , setOfficialSearchOpen ] = useState ( false )
const [ templatePickerOpen , setTemplatePickerOpen ] = useState ( false )
2026-07-30 07:55:35 +02:00
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 >
2026-08-04 11:34:28 +02:00
{ ! officialSearchOpen && ! templatePickerOpen ? (
< div className = "flex flex-wrap gap-x-4 gap-y-1" >
< button
type = "button"
onClick = { ( ) = > setOfficialSearchOpen ( true ) }
className = "self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
< / button >
< button
type = "button"
onClick = { ( ) = > setTemplatePickerOpen ( true ) }
className = "self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Opprett bane med hull / utslag i stedet
< / button >
< / div >
) : officialSearchOpen ? (
< OfficialCourseSearch
organizationId = { organizationId }
onClose = { ( ) = > setOfficialSearchOpen ( false ) }
onImported = { ( course ) = > {
onCourseImported ( course )
setCourseId ( course . id )
setAddingCourse ( false )
setOfficialSearchOpen ( false )
} }
/ >
) : (
< CourseTemplatePicker
organizationId = { organizationId }
onCreated = { ( created ) = > {
onCourseImported ( { id : created.id , name : created.name , source : "custom" } )
setCourseId ( created . id )
setAddingCourse ( false )
setTemplatePickerOpen ( false )
} }
onCancel = { ( ) = > setTemplatePickerOpen ( false ) }
/ >
) }
2026-07-30 07:55:35 +02:00
< / 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 ,
round ,
scoringMethod ,
onDelete ,
onError ,
} : {
base : string
round : ApiRound
scoringMethod : string | null
onDelete : ( ) = > Promise < void >
onError : ( message : string ) = > void
} ) {
const [ expanded , setExpanded ] = useState ( false )
const [ roundParticipants , setRoundParticipants ] = useState < ApiRoundParticipant [ ] | null > ( null )
const [ confirmingDelete , setConfirmingDelete ] = useState ( false )
async function loadRoundParticipants() {
2026-08-19 06:21:58 +02:00
const rpData = await getJson < ApiRoundParticipant [ ] > ( ` ${ base } /rounds/ ${ round . id } /participants ` )
2026-07-30 07:55:35 +02:00
setRoundParticipants ( rpData ? ? [ ] )
}
useEffect ( ( ) = > {
if ( expanded && roundParticipants === null ) {
loadRoundParticipants ( )
}
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ expanded ] )
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 ) )
}
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 >
2026-08-19 06:21:58 +02:00
< p className = "text-xs text-muted-foreground" >
Legg til eller fjern deltakere fra runden under « Spillere » .
< / p >
2026-07-30 07:55:35 +02:00
< / >
) }
< / div >
) }
< / div >
)
}
2026-08-19 06:21:58 +02:00
// Klassens standardutslag brukes som forhåndsvalg ved bulk-tilordning i
2026-08-19 07:54:07 +02:00
// den sammenslåtte spillertabellen (ADR-093/094, tournament-players-
// table.tsx) -- samme oppløsning som den nå fjernede
// AssignRoundParticipantControl brukte for ett-om-gangen-tilordning:
// deltakerens klasses standardutslag, KUN hvis det faktisk finnes på
// den aktuelle rundens bane (klassens standardutslag kan tilhøre en
// annen bane). Faller tilbake til rundens eget valgte "Standardutslag"
// (lokal UI-state inni tournament-players-table.tsx), og til slutt til
// det første utslaget i lista.
2026-08-19 06:21:58 +02:00
function resolveDefaultTeeId (
participant : ApiParticipant ,
classes : ApiTournamentClass [ ] ,
tees : ApiTee [ ] ,
roundDefaultTeeId : string | null ,
) : string | null {
const cls = participant . class_id ? classes . find ( ( c ) = > c . id === participant . class_id ) : null
const classTee = cls ? . default_tee_id ? tees . find ( ( t ) = > t . id === cls . default_tee_id ) : null
if ( classTee ) return classTee . id
if ( roundDefaultTeeId && tees . some ( ( t ) = > t . id === roundDefaultTeeId ) ) return roundDefaultTeeId
return tees [ 0 ] ? . id ? ? null
2026-07-30 07:55:35 +02:00
}
// =========================================================================
// 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-08-21 23:00:22 +02:00
participants ,
2026-08-22 06:39:44 +02:00
currentUserId ,
isOrgAdmin ,
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-08-21 23:00:22 +02:00
participants : ApiParticipant [ ]
2026-08-22 06:39:44 +02:00
currentUserId : string | null
isOrgAdmin : boolean
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 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
2026-08-21 21:46:38 +02:00
// Flight-scoping (ADR-099) -- standard er å vise KUN egen
2026-08-21 23:00:22 +02:00
// utslagsgruppe, med en lenke for å se hele feltet. roundParticipants
// selv holdes ALLTID ufiltrert (BBB/Flag-panelene under bruker den
// direkte, uavhengig av dette).
2026-08-21 21:46:38 +02:00
const [ showFullField , setShowFullField ] = useState ( false )
const myParticipant = roundParticipants . find ( ( rp ) = > rp . is_self ) ? ? null
const myGroupId = myParticipant ? . tournament_round_group_id ? ? null
const visibleParticipants =
showFullField || ! myGroupId
? roundParticipants
: roundParticipants . filter ( ( rp ) = > rp . tournament_round_group_id === myGroupId )
2026-08-21 23:00:22 +02:00
// Flighten den nye hull-først-visningen under viser -- ALLTID flight-
// scoped (fallback til hele feltet uten gruppe, samme regel som over),
// UAVHENGIG av "Vis hele feltet"-bryteren (den styrer kun det gamle,
// nå sekundære spiller-først-scorekortet lenger ned).
const flightParticipants = myGroupId
? roundParticipants . filter ( ( rp ) = > rp . tournament_round_group_id === myGroupId )
: roundParticipants
2026-08-21 21:46:38 +02:00
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 )
2026-08-21 21:46:38 +02:00
setShowFullField ( false )
const self = list . find ( ( rp ) = > rp . is_self )
const selfGroupId = self ? . tournament_round_group_id ? ? null
const defaultList = selfGroupId ? list . filter ( ( rp ) = > rp . tournament_round_group_id === selfGroupId ) : list
setParticipantId ( self ? . id ? ? defaultList [ 0 ] ? . id ? ? "" )
2026-07-30 07:55:35 +02:00
} )
return ( ) = > {
cancelled = true
}
} , [ base , roundId ] )
2026-08-21 23:00:22 +02:00
// Hull-for-hull-data PER DELTAKER, ETT felles kart (ADR-099, steg 2) --
// dekker BÅDE den nye hull-først-flighten under OG den (nå sekundære)
// spiller-valgte scorekort-visningen (`holes` er en avledet variabel
// under, ikke lenger egen state). Henter kun det som faktisk trengs
// (flighten + evt. den enkeltvalgte spilleren om den ligger utenfor
// flighten, f.eks. via "Vis hele feltet") -- samme N-parallelle
// "hent det som mangler"-mønster som round-detail.tsx sin
// holesByParticipant/loadHoles.
const [ holesByParticipant , setHolesByParticipant ] = useState < Record < string , ApiHole [ ] > > ( { } )
2026-07-30 07:55:35 +02:00
useEffect ( ( ) = > {
2026-08-21 23:00:22 +02:00
if ( ! roundId ) return
2026-07-30 07:55:35 +02:00
let cancelled = false
2026-08-21 23:00:22 +02:00
const ids = new Set ( flightParticipants . map ( ( rp ) = > rp . id ) )
if ( participantId ) ids . add ( participantId )
for ( const id of ids ) {
if ( holesByParticipant [ id ] ) continue
getJson < ApiHole [ ] > ( ` ${ base } /rounds/ ${ roundId } /participants/ ${ id } /holes ` ) . then ( ( data ) = > {
if ( cancelled ) return
setHolesByParticipant ( ( prev ) = > ( { . . . prev , [ id ] : data ? ? [ ] } ) )
} )
}
2026-07-30 07:55:35 +02:00
return ( ) = > {
cancelled = true
}
2026-08-21 23:00:22 +02:00
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ base , roundId , roundParticipants , participantId , holesByParticipant ] )
const holes = participantId ? ( holesByParticipant [ participantId ] ? ? null ) : null
// Full statistikkdybde (migrasjon 075, ADR-071) -- ÉN batched save() ved
// siste steg (ikke PATCH per felt, se ADR-078). Skriver alltid inn i
// holesByParticipant (nøkkelbasert på HVEM det gjelder, ikke bare "den
// valgte spilleren" -- ADR-099 utvidet dette fra ett-spiller-om-gangen
// siden flere spilleres ark nå kan være i bruk etter hverandre uten
// remount).
async function updateHoleFull ( participantIdParam : string , holeNumber : number , patch : HolePatch ) {
const res = await fetch ( ` ${ base } /rounds/ ${ roundId } /participants/ ${ participantIdParam } /holes/ ${ holeNumber } ` , {
2026-07-30 07:55:35 +02:00
method : "PATCH" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
2026-08-21 23:00:22 +02:00
body : JSON.stringify ( patch ) ,
2026-07-30 07:55:35 +02:00
} )
if ( ! res . ok ) {
2026-08-21 23:00:22 +02:00
throw new Error ( "Klarte ikke å lagre." )
2026-07-30 07:55:35 +02:00
}
const updated : ApiHole = await res . json ( )
2026-08-21 23:00:22 +02:00
setHolesByParticipant ( ( prev ) = > ( {
. . . prev ,
[ participantIdParam ] : ( prev [ participantIdParam ] ? ? [ ] ) . map ( ( h ) = > ( h . hole_number === holeNumber ? updated : h ) ) ,
} ) )
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
}
2026-08-21 23:00:22 +02:00
// --- Hull-først flerspiller-scoring (ADR-099, steg 2) -------------------
const flightHoleNumbers = round ? holeNumbersFor ( round . hole_config ) : [ ]
function isRegistered ( participantIdParam : string , holeNumber : number ) : boolean {
return ( holesByParticipant [ participantIdParam ] ? . find ( ( h ) = > h . hole_number === holeNumber ) ? . gross_strokes ? ? null ) !== null
}
// Kjeden score-registreres i -- hopper over DSQ/RTD/DNF/DNS (samme
// begrunnelse som advanceWizardPlayer() i round-detail.tsx sitt
// "fullførte spillere hoppes over i samlebåndet"). Brukes OGSÅ for
// "er hullet ferdig"-sjekkene under (standardhull + hurtig-hopp-
// stripen) -- en diskvalifisert spiller kan aldri få registrert ny
// score (se team_authz.py), så å kreve DERES registrering for at et
// hull skal telle som "alle ferdig" ville låst standardvalget til
// samme hull for alltid (funnet i scratch-verifisering). Kortlisten
// under viser fortsatt ALLE flightParticipants, inkl. DSQ'et -- kun
// fullførthet-LOGIKKEN er kjede-scoped.
const chain = flightParticipants . filter ( ( rp ) = > {
const tp = participants . find ( ( p ) = > p . id === rp . tournament_participant_id )
return ! tp || tp . status === "active"
} )
const [ activeHole , setActiveHole ] = useState < number | null > ( null )
useEffect ( ( ) = > {
setActiveHole ( null )
} , [ roundId ] )
useEffect ( ( ) = > {
if ( activeHole !== null || flightHoleNumbers . length === 0 || chain . length === 0 ) return
// Vent til ALLE kjede-medlemmers hull-data faktisk har kommet inn --
// uten denne sjekken ville standardvalget kunne låse seg til hull 1
// (feilaktig "første åpne hull" basert på data som rett og slett ikke
// var lastet ennå), selv om senere data viser at hull 1-3 alt var
// registrert (funnet i scratch-verifisering).
const allLoaded = chain . every ( ( rp ) = > holesByParticipant [ rp . id ] !== undefined )
if ( ! allLoaded ) return
const firstOpen = flightHoleNumbers . find ( ( n ) = > ! chain . every ( ( rp ) = > isRegistered ( rp . id , n ) ) )
setActiveHole ( firstOpen ? ? flightHoleNumbers [ 0 ] )
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ activeHole , chain , holesByParticipant , flightHoleNumbers ] )
const [ wizardPlayerId , setWizardPlayerId ] = useState < string | null > ( null )
// "Vis hele scorekortet" (ADR-099, steg 2) -- kollapset som standard,
// samme fiks som round-detail.tsx sin PlayerHoleCards/ScorecardGrid-
// splitt.
const [ showFullScorecard , setShowFullScorecard ] = useState ( false )
2026-08-22 06:39:44 +02:00
// Rundespesifikk kommentartråd (ADR-102) -- kollapset som standard,
// samme mønster som "Vis hele scorekortet".
const [ showComments , setShowComments ] = useState ( false )
2026-08-21 23:00:22 +02:00
const wizardIdx = chain . findIndex ( ( rp ) = > rp . id === wizardPlayerId )
const hasNextPlayer = wizardIdx !== - 1 && wizardIdx < chain . length - 1
const nextPlayerName = hasNextPlayer ? chain [ wizardIdx + 1 ] . player_name : null
function openWizardFromButton() {
if ( activeHole === null ) return
const firstUnregistered = chain . find ( ( rp ) = > ! isRegistered ( rp . id , activeHole ) )
setWizardPlayerId ( ( firstUnregistered ? ? chain [ 0 ] ) ? . id ? ? null )
}
function advanceWizard() {
if ( wizardIdx !== - 1 && wizardIdx < chain . length - 1 ) {
setWizardPlayerId ( chain [ wizardIdx + 1 ] . id )
return
}
setWizardPlayerId ( null )
if ( activeHole !== null && flightHoleNumbers . length > 0 ) {
const i = flightHoleNumbers . indexOf ( activeHole )
setActiveHole ( flightHoleNumbers [ ( i + 1 ) % flightHoleNumbers . length ] )
2026-08-15 06:32:17 +02:00
}
}
2026-08-21 23:00:22 +02:00
function hcpFor ( rp : ApiRoundParticipant ) : number | null {
return participants . find ( ( p ) = > p . id === rp . tournament_participant_id ) ? . handicap_index_snapshot ? ? null
}
function runningSum ( participantIdParam : string ) : { strokes : number ; toPar : number } | null {
const played = ( holesByParticipant [ participantIdParam ] ? ? [ ] ) . filter ( ( h ) = > h . gross_strokes !== null )
if ( played . length === 0 ) return null
const strokes = played . reduce ( ( s , h ) = > s + ( h . gross_strokes as number ) , 0 )
const par = played . reduce ( ( s , h ) = > s + h . par , 0 )
return { strokes , toPar : strokes - par }
}
const activeHoleMeta = ( ( ) = > {
for ( const rp of flightParticipants ) {
const h = holesByParticipant [ rp . id ] ? . find ( ( x ) = > x . hole_number === activeHole )
if ( h ) return { par : h.par , strokeIndex : h.stroke_index }
}
return null
} ) ( )
const wizardHole = wizardPlayerId !== null ? ( holesByParticipant [ wizardPlayerId ] ? . find ( ( h ) = > h . hole_number === activeHole ) ? ? null ) : null
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 >
) : (
< >
2026-08-21 23:00:22 +02:00
{ ! myGroupId && (
< p className = "text-sm font-medium text-muted-foreground" >
Ingen gruppeinndeling er satt for denne runden ennå -- viser hele feltet .
< / p >
) }
2026-07-30 07:55:35 +02:00
2026-08-21 23:00:22 +02:00
{ /* Hull-navigator (ADR-099, steg 2) */ }
{ round && activeHole !== null && (
< div className = "flex flex-col gap-3 rounded-2xl border border-border bg-card p-3" >
< div className = "flex items-stretch gap-2" >
< button
type = "button"
onClick = { ( ) = > setActiveHole ( flightHoleNumbers [ ( flightHoleNumbers . indexOf ( activeHole ) - 1 + flightHoleNumbers . length ) % flightHoleNumbers . length ] ) }
className = "flex min-h-11 flex-1 items-center justify-center gap-1 rounded-xl border border-border bg-background px-2 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
< ChevronLeft aria-hidden = "true" className = "size-5" / >
Forrige
< / button >
< div className = "flex flex-col items-center justify-center px-2 text-center" >
< span className = "text-lg font-extrabold leading-none text-foreground" > Hull { activeHole } < / span >
< span className = "mt-1 text-xs font-semibold tabular-nums text-muted-foreground" >
{ activeHoleMeta ? ` Par ${ activeHoleMeta . par } · Indeks ${ activeHoleMeta . strokeIndex } ` : " " }
< / span >
< / div >
< button
type = "button"
onClick = { ( ) = > setActiveHole ( flightHoleNumbers [ ( flightHoleNumbers . indexOf ( activeHole ) + 1 ) % flightHoleNumbers . length ] ) }
className = "flex min-h-11 flex-1 items-center justify-center gap-1 rounded-xl border border-border bg-background px-2 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Neste
< ChevronRight aria-hidden = "true" className = "size-5" / >
< / button >
< / div >
< div className = "-mx-1 flex gap-1.5 overflow-x-auto px-1 pb-1" >
{ flightHoleNumbers . map ( ( n ) = > {
const active = n === activeHole
const allDone = chain . length > 0 && chain . every ( ( rp ) = > isRegistered ( rp . id , n ) )
return (
< button
key = { n }
type = "button"
onClick = { ( ) = > setActiveHole ( n ) }
aria - current = { active ? "true" : undefined }
aria - label = { ` Hull ${ n } ${ allDone ? ", alle registrert" : "" } ` }
className = { cn (
"flex size-11 shrink-0 items-center justify-center rounded-xl text-base font-bold tabular-nums transition-colors" ,
active
? "bg-primary text-primary-foreground"
: allDone
? "border border-primary/40 bg-primary/10 text-primary"
: "border border-border bg-background text-muted-foreground hover:bg-accent/50" ,
) }
>
{ n }
< / button >
)
} ) }
< / div >
< / div >
) }
{ /* Flight-kortliste (ADR-099, steg 2) */ }
{ activeHole !== null && (
< ul className = "flex flex-col gap-2.5" >
{ flightParticipants . map ( ( rp ) = > {
const holeForPlayer = holesByParticipant [ rp . id ] ? . find ( ( h ) = > h . hole_number === activeHole )
const gross = holeForPlayer ? . gross_strokes ? ? null
const net =
gross !== null && holeForPlayer ? . strokes_received !== null && holeForPlayer ? . strokes_received !== undefined
? gross - holeForPlayer . strokes_received
: gross
const diff = net !== null && holeForPlayer ? net - holeForPlayer.par : null
const kind = diff !== null ? classify ( diff ) : null
const sum = runningSum ( rp . id )
const hcp = hcpFor ( rp )
return (
< li key = { rp . id } >
< button
type = "button"
onClick = { ( ) = > setWizardPlayerId ( rp . id ) }
className = { cn (
"flex w-full items-center gap-3 rounded-2xl border border-border bg-card p-3 text-left transition-colors hover:bg-accent/50" ,
rp . is_self && "border-l-4 border-l-primary" ,
) }
>
< div className = "min-w-0 flex-1" >
< p className = "flex items-center gap-2 truncate text-base font-extrabold text-foreground" >
{ rp . player_name }
{ rp . is_self && (
< span className = "rounded-full bg-primary/15 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary" >
Deg
< / span >
) }
< / p >
< p className = "text-xs font-semibold tabular-nums text-muted-foreground" >
HCP { hcp !== null ? hcp . toFixed ( 1 ) . replace ( "." , "," ) : "– " } · Sum { " " }
{ sum ? ` ${ sum . strokes } ( ${ sum . toPar >= 0 ? "+" : "" } ${ sum . toPar } ) ` : "– " }
< / p >
< / div >
{ gross !== null ? (
< span
className = { cn (
"flex size-12 items-center justify-center text-xl font-extrabold tabular-nums transition-colors" ,
kind ? scoreMarkClasses ( kind ) : "rounded-[4px] border-2 border-border text-foreground" ,
) }
>
{ gross }
< / span >
) : (
< span
aria - hidden = "true"
className = "flex size-12 items-center justify-center rounded-[4px] border-2 border-dashed border-primary/50 text-primary"
>
< Plus className = "size-5" / >
< / span >
) }
< / button >
< / li >
)
} ) }
< / ul >
) }
{ activeHole !== null && chain . length > 0 && (
2026-08-21 21:46:38 +02:00
< button
type = "button"
2026-08-21 23:00:22 +02:00
onClick = { openWizardFromButton }
className = "flex min-h-12 w-full items-center justify-center gap-2 rounded-2xl bg-primary px-4 text-base font-extrabold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
2026-08-21 21:46:38 +02:00
>
2026-08-21 23:00:22 +02:00
Registrer score
< ArrowRight aria-hidden = "true" className = "size-5" / >
2026-08-21 21:46:38 +02:00
< / button >
) }
2026-08-21 23:00:22 +02:00
{ wizardPlayerId !== null && wizardHole && (
< TournamentScoringWizard
hole = { wizardHole }
playerName = { roundParticipants . find ( ( rp ) = > rp . id === wizardPlayerId ) ? . player_name ? ? "" }
statLevel = { roundParticipants . find ( ( rp ) = > rp . id === wizardPlayerId ) ? . stat_level ? ? "strokes_only" }
strokesReceived = { wizardHole . strokes_received }
2026-08-15 06:32:17 +02:00
ownBagClubs = { [ ] }
2026-08-21 23:00:22 +02:00
historyUrl = { ` ${ base } /rounds/ ${ roundId } /participants/ ${ wizardPlayerId } /holes/ ${ wizardHole . hole_number } /history ` }
targetPointsBaseUrl = { ` ${ base } /rounds/ ${ roundId } ` }
players = { chain . map ( ( rp ) = > ( { id : rp.id , playerName : rp.player_name , registered : isRegistered ( rp . id , wizardHole . hole_number ) } ) ) }
activePlayerId = { wizardPlayerId }
onSwitchPlayer = { setWizardPlayerId }
hasNextPlayer = { hasNextPlayer }
nextPlayerName = { nextPlayerName }
onSave = { ( patch ) = > updateHoleFull ( wizardPlayerId , wizardHole . hole_number , patch ) }
onAdvance = { advanceWizard }
onClose = { ( ) = > setWizardPlayerId ( null ) }
2026-08-15 06:32:17 +02:00
/ >
2026-07-30 07:55:35 +02:00
) }
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
2026-08-21 23:00:22 +02:00
{ / * F u l l t s c o r e k o r t , s p i l l e r - f ø r s t ( s t e g 1 ) - - s e k u n d æ r t n å , b a k e n
bryter , samme fiks som round - detail . tsx sitt " Vis hele
scorekortet " ( kommentar der : et bredt grid tar for stor plass
i bredden på mobil som standardvisning ) . Ingen ny kode for
selve gridet , kun ny plassering . * / }
< div className = "overflow-hidden rounded-2xl border border-border bg-card" >
< button
type = "button"
onClick = { ( ) = > setShowFullScorecard ( ( v ) = > ! v ) }
aria - expanded = { showFullScorecard }
className = "flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Vis hele scorekortet
< ChevronsUpDown aria-hidden = "true" className = "size-4 shrink-0 text-muted-foreground" / >
< / button >
{ showFullScorecard && (
< div className = "flex flex-col gap-3 border-t border-border p-3" >
< div className = "flex flex-wrap gap-2" >
{ visibleParticipants . 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 >
{ myGroupId && (
< button
type = "button"
onClick = { ( ) = > setShowFullField ( ( v ) = > ! v ) }
className = "self-start text-sm font-bold text-info underline-offset-4 hover:underline"
>
{ showFullField ? "Vis kun min flight" : ` Vis hele feltet ( ${ roundParticipants . length } ) ` }
< / button >
) }
{ round && holes && (
< HoleGrid
base = { base }
roundId = { roundId }
participantId = { participantId }
holes = { holes }
holeConfig = { round . hole_config }
statLevel = { roundParticipants . find ( ( rp ) = > rp . id === participantId ) ? . stat_level ? ? "strokes_only" }
playerName = { roundParticipants . find ( ( rp ) = > rp . id === participantId ) ? . player_name ? ? "" }
strokesReceived = { ( n ) = > holes . find ( ( h ) = > h . hole_number === n ) ? . strokes_received ? ? null }
ownBagClubs = { [ ] }
onUpdateFull = { ( holeNumber , patch ) = > updateHoleFull ( participantId , holeNumber , patch ) }
/ >
) }
< / div >
) }
< / div >
2026-08-22 06:39:44 +02:00
{ / * R u n d e s p e s i f i k k k o m m e n t a r t r å d ( A D R - 1 0 2 ) - - e k t e p a r a l l e l l t i l
frittstående runders RoundMessages , IKKE Banter Board
( tournament_feed , hele turneringen ) . Kollapset som standard ,
samme mønster som "Vis hele scorekortet" over . * / }
{ round && currentUserId && (
< div className = "overflow-hidden rounded-2xl border border-border bg-card" >
< button
type = "button"
onClick = { ( ) = > setShowComments ( ( v ) = > ! v ) }
aria - expanded = { showComments }
className = "flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
Kommentarer
< ChevronsUpDown aria-hidden = "true" className = "size-4 shrink-0 text-muted-foreground" / >
< / button >
{ showComments && (
< div className = "border-t border-border p-3" >
< TournamentRoundMessages
base = { base }
roundId = { roundId }
currentUserId = { currentUserId }
isOrgAdmin = { isOrgAdmin }
/ >
< / div >
) }
< / 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
{ / * B i n g o B a n g o B o n g o ( 2 0 2 6 - 0 7 - 3 0 ) - - M A N U E L L p e r - h u l l - o b s e r v a s j o n ,
uavhengig av gross_strokes - registreringen over . * / }
{ round && scoringMethod === "bingo_bango_bongo" && (
< BBBRoundPanel base = { base } round = { round } participants = { roundParticipants } refreshTick = { refreshTick } / >
) }
{ / * F l a g g t u r n e r i n g ( 2 0 2 6 - 0 7 - 3 0 ) - - p e r - r u n d e - r e s u l t a t ( i k k e
akkumulerbart på tvers av runder , se LeaderboardTab ) . * / }
{ round && scoringMethod === "flag" && (
< FlagRoundPanel base = { base } round = { round } refreshTick = { refreshTick } / >
) }
2026-08-14 10:38:54 +02:00
{ / * G P S - f l a g g p l a n t i n g + r u n d e 2 + ( 2 0 2 6 - 0 8 - 1 4 ) - - f o r D E N V A L G T E
deltakeren ( samme selv - only autorisasjon som selve
scoreføringen over , ingen vits å vise for andre enn den man
faktisk fører for ) . * / }
{ round && scoringMethod === "flag" && roundParticipants . find ( ( rp ) = > rp . id === participantId ) && (
< FlagPlantPanel
base = { base }
round = { round }
participant = { roundParticipants . find ( ( rp ) = > rp . id === participantId ) ! }
refreshTick = { refreshTick }
onChanged = { ( ) = > setRefreshTick ( ( n ) = > n + 1 ) }
/ >
) }
{ / * K a r t o v e r s i k t o v e r f l a g g ( m i g r a s j o n 0 7 1 , " D e l B " , A D R - 0 6 7 ) - -
kun for org - medlem / deltaker ( ingen offentlig tilskuer - gren
for individuelle turneringer , se ADR - 067 ) . * / }
{ round && scoringMethod === "flag" && (
< FlagMapSectionOrg base = { base } round = { round } refreshTick = { refreshTick } / >
) }
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 >
)
}
2026-08-14 10:38:54 +02:00
// --- GPS-flaggplanting + runde 2+ (2026-08-14) -------------------------------
// V0-eksport (zip 11), samme komponent som round-detail.tsx sin
// FlagPlantSheet -- egen kopi her (fil-lokalt konvensjon, se
// FlagPlantSheet i round-detail.tsx for samme innmat/kommentar).
function FlagPlantSheetOrg ( {
open ,
onClose ,
expectedHoleNumber ,
expectedLap ,
onConfirm ,
} : {
open : boolean
onClose : ( ) = > void
expectedHoleNumber : number
expectedLap : number
onConfirm : ( result : {
lat : number
lng : number
onGreen : boolean
distanceToPinCm : number | null
} ) = > Promise < { ok : true } | { ok : false ; message : string } >
} ) {
const [ step , setStep ] = useState < "gps" | "holedOut" | "green" | "done" > ( "gps" )
const [ geoStatus , setGeoStatus ] = useState < "loading" | "error" > ( "loading" )
const [ position , setPosition ] = useState < { lat : number ; lng : number } | null > ( null )
// Step 2 "Ja" reveals a dead-end guidance card (no submission).
const [ holedOutYes , setHoledOutYes ] = useState ( false )
// Step 3 answer + distance inputs (kept as strings for clean input UX).
const [ onGreen , setOnGreen ] = useState < boolean | null > ( null )
const [ meters , setMeters ] = useState ( "" )
const [ centimeters , setCentimeters ] = useState ( "" )
const [ submitting , setSubmitting ] = useState ( false )
const [ serverError , setServerError ] = useState < string | null > ( null )
const [ confirmedSummary , setConfirmedSummary ] = useState < {
onGreen : boolean
distanceToPinCm : number | null
} | null > ( null )
// Reset everything each time the sheet is (re)opened.
useEffect ( ( ) = > {
if ( ! open ) return
setStep ( "gps" )
setGeoStatus ( "loading" )
setPosition ( null )
setHoledOutYes ( false )
setOnGreen ( null )
setMeters ( "" )
setCentimeters ( "" )
setSubmitting ( false )
setServerError ( null )
setConfirmedSummary ( null )
} , [ open ] )
// Step 1 — automatic GPS acquisition. Runs on entering the gps step.
const geoRequested = useRef ( false )
useEffect ( ( ) = > {
if ( ! open || step !== "gps" ) {
geoRequested . current = false
return
}
if ( geoRequested . current ) return
geoRequested . current = true
if ( typeof navigator === "undefined" || ! navigator . geolocation ) {
setGeoStatus ( "error" )
return
}
setGeoStatus ( "loading" )
navigator . geolocation . getCurrentPosition (
( pos ) = > {
setPosition ( { lat : pos.coords.latitude , lng : pos.coords.longitude } )
setStep ( "holedOut" )
} ,
( ) = > setGeoStatus ( "error" ) ,
{ enableHighAccuracy : true , timeout : 10000 } ,
)
} , [ open , step ] )
function retryGeo() {
geoRequested . current = false
setGeoStatus ( "loading" )
if ( typeof navigator === "undefined" || ! navigator . geolocation ) {
setGeoStatus ( "error" )
return
}
geoRequested . current = true
navigator . geolocation . getCurrentPosition (
( pos ) = > {
setPosition ( { lat : pos.coords.latitude , lng : pos.coords.longitude } )
setStep ( "holedOut" )
} ,
( ) = > setGeoStatus ( "error" ) ,
{ enableHighAccuracy : true , timeout : 10000 } ,
)
}
if ( ! open ) return null
// ---- validation for the on-green distance --------------------------------
const metersNum = meters === "" ? null : Number ( meters )
const cmNum = centimeters === "" ? null : Number ( centimeters )
const metersValid = metersNum !== null && Number . isInteger ( metersNum ) && metersNum >= 0
const cmValid = cmNum !== null && Number . isInteger ( cmNum ) && cmNum >= 0 && cmNum <= 99
const greenInputValid = metersValid && cmValid
const canConfirm = onGreen === false || ( onGreen === true && greenInputValid )
async function handleConfirm() {
if ( ! position || onGreen === null || submitting ) return
const distanceToPinCm =
onGreen && metersNum !== null && cmNum !== null ? metersNum * 100 + cmNum : null
setSubmitting ( true )
setServerError ( null )
try {
const res = await onConfirm ( {
lat : position.lat ,
lng : position.lng ,
onGreen ,
distanceToPinCm ,
} )
if ( res . ok ) {
setConfirmedSummary ( { onGreen , distanceToPinCm } )
setStep ( "done" )
} else {
setServerError ( res . message )
}
} catch {
setServerError ( "Noe gikk galt. Sjekk nettforbindelsen og prøv igjen." )
} finally {
setSubmitting ( false )
}
}
function goBack() {
setServerError ( null )
if ( step === "green" ) {
setOnGreen ( null )
setMeters ( "" )
setCentimeters ( "" )
setStep ( "holedOut" )
}
}
const showBack = step === "green"
return (
< div
className = "fixed inset-0 z-50 flex flex-col bg-background text-foreground"
role = "dialog"
aria - modal = "true"
aria - label = { ` Plant flagget, hull ${ expectedHoleNumber } ` }
>
{ /* Header */ }
< header className = "flex shrink-0 items-start justify-between gap-3 border-b border-border px-4 pb-4 pt-[max(1rem,env(safe-area-inset-top))]" >
< div className = "flex items-start gap-2" >
{ showBack ? (
< button
type = "button"
onClick = { goBack }
className = "inline-flex min-h-11 items-center gap-1 rounded-xl px-2 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
< ChevronLeft className = "size-5 shrink-0" aria-hidden = "true" / >
Tilbake
< / button >
) : null }
< div className = "flex items-start gap-2 pt-1.5" >
< span className = "flex size-8 shrink-0 items-center justify-center rounded-full bg-brand-orange/15 text-brand-orange" >
< Flag className = "size-5" aria-hidden = "true" / >
< / span >
< div >
< h2 className = "text-lg font-bold leading-tight text-balance" > Plant flagget < / h2 >
< p className = "mt-0.5 text-sm text-muted-foreground" >
{ ` Hull ${ expectedHoleNumber } · runde ${ expectedLap } ` }
< / p >
< / div >
< / div >
< / div >
< button
type = "button"
onClick = { onClose }
className = "inline-flex min-h-11 items-center gap-1 rounded-xl px-3 text-base font-semibold text-muted-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
< X className = "size-5 shrink-0" aria-hidden = "true" / >
Lukk
< / button >
< / header >
{ /* Main */ }
< main className = "flex-1 overflow-y-auto p-4" >
{ /* Step 1 — GPS */ }
{ step === "gps" ? (
< div className = "mx-auto flex max-w-md flex-col items-center gap-4 pt-12 text-center" >
{ geoStatus === "loading" ? (
< >
< div className = "size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" / >
< p className = "text-lg font-semibold" > Finner posisjonen din … < / p >
< p className = "text-base text-muted-foreground text-pretty" >
Stå der du gikk tom for slag mens vi henter GPS - posisjonen .
< / p >
< / >
) : (
< div className = "flex w-full flex-col gap-3 rounded-2xl border border-border bg-card p-5 text-left" >
< p role = "alert" className = "text-base text-destructive text-pretty" >
Fant ikke posisjonen din . Sjekk at posisjonstjenester er på for
TeeCup , og prøv igjen .
< / p >
< button
type = "button"
onClick = { retryGeo }
className = "inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
< Crosshair className = "size-5 shrink-0" aria-hidden = "true" / >
Prøv igjen
< / button >
< / div >
) }
< / div >
) : null }
{ /* Step 2 — Holed out on this hole? */ }
{ step === "holedOut" ? (
< div className = "mx-auto flex max-w-md flex-col gap-5 pt-2" >
< h3 className = "text-2xl font-bold text-balance" >
{ ` Hullet du ut på hull ${ expectedHoleNumber } ? ` }
< / h3 >
< p className = "text-base text-muted-foreground text-pretty" >
Er hullet fullført , må scoren føres inn før du kan plante flagget .
< / p >
< div className = "grid grid-cols-2 gap-3" >
< button
type = "button"
onClick = { ( ) = > setHoledOutYes ( true ) }
aria - pressed = { holedOutYes }
className = { cn (
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" ,
holedOutYes
? "border-brand-orange bg-brand-orange text-brand-orange-foreground"
: "border-border bg-card text-foreground hover:bg-muted" ,
) }
>
Ja
< / button >
< button
type = "button"
onClick = { ( ) = > {
setHoledOutYes ( false )
setStep ( "green" )
} }
className = "inline-flex min-h-14 items-center justify-center rounded-xl border-2 border-border bg-card px-6 text-lg font-bold text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Nei
< / button >
< / div >
{ /* "Ja" is a friendly dead end — guidance + Lukk, no submission. */ }
{ holedOutYes ? (
< div className = "flex flex-col gap-4 rounded-2xl border border-brand-orange/40 bg-brand-orange/5 p-4" >
< p role = "alert" className = "text-base text-foreground text-pretty" >
{ ` Da må du først føre inn scoren for hull ${ expectedHoleNumber } . Trykk «Plant flagget» på nytt når du er ved neste utslag. ` }
< / p >
< button
type = "button"
onClick = { onClose }
className = "inline-flex min-h-14 items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Lukk
< / button >
< / div >
) : null }
< / div >
) : null }
{ /* Step 3 — On the green? + distance */ }
{ step === "green" ? (
< div className = "mx-auto flex max-w-md flex-col gap-5 pt-2" >
< h3 className = "text-2xl font-bold text-balance" > Landet du på green ? < / h3 >
< div className = "grid grid-cols-2 gap-3" >
< button
type = "button"
onClick = { ( ) = > setOnGreen ( true ) }
aria - pressed = { onGreen === true }
className = { cn (
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" ,
onGreen === true
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-muted" ,
) }
>
Ja
< / button >
< button
type = "button"
onClick = { ( ) = > {
setOnGreen ( false )
setMeters ( "" )
setCentimeters ( "" )
} }
aria - pressed = { onGreen === false }
className = { cn (
"inline-flex min-h-14 items-center justify-center rounded-xl border-2 px-6 text-lg font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" ,
onGreen === false
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-muted" ,
) }
>
Nei
< / button >
< / div >
{ onGreen === true ? (
< div className = "flex flex-col gap-3 rounded-2xl border border-border bg-card p-4" >
< p className = "text-sm text-muted-foreground text-pretty" >
Dette avgjør rekkefølgen mot andre spillere som gikk tom på
samme hull .
< / p >
< div className = "grid grid-cols-2 gap-3" >
< div className = "flex flex-col gap-1.5" >
< label htmlFor = "fp-org-meters" className = "text-base font-semibold" >
Meter
< / label >
< input
id = "fp-org-meters"
inputMode = "numeric"
pattern = "[0-9]*"
value = { meters }
onChange = { ( e ) = > setMeters ( e . target . value . replace ( /[^0-9]/g , "" ) ) }
aria - invalid = { meters !== "" && ! metersValid }
className = "h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder = "0"
/ >
< / div >
< div className = "flex flex-col gap-1.5" >
< label htmlFor = "fp-org-cm" className = "text-base font-semibold" >
Centimeter
< / label >
< input
id = "fp-org-cm"
inputMode = "numeric"
pattern = "[0-9]*"
value = { centimeters }
onChange = { ( e ) = > setCentimeters ( e . target . value . replace ( /[^0-9]/g , "" ) ) }
aria - invalid = { centimeters !== "" && ! cmValid }
className = "h-14 rounded-xl border border-input bg-background px-4 text-lg font-semibold tabular-nums text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder = "0"
/ >
< / div >
< / div >
{ centimeters !== "" && ! cmValid ? (
< p role = "alert" className = "text-sm font-medium text-destructive" >
Centimeter må være mellom 0 og 99 .
< / p >
) : null }
< / div >
) : null }
{ serverError ? (
< p
role = "alert"
className = "rounded-xl border border-destructive/40 bg-destructive/10 p-3 text-base font-medium text-destructive text-pretty"
>
{ serverError }
< / p >
) : null }
< / div >
) : null }
{ /* Step 4 — Success */ }
{ step === "done" && confirmedSummary ? (
< div className = "mx-auto flex max-w-md flex-col items-center gap-4 pt-12 text-center" >
< span className = "flex size-20 items-center justify-center rounded-full bg-primary/10 text-primary" >
< Check className = "size-11" strokeWidth = { 2.5 } aria-hidden = "true" / >
< / span >
< h3 className = "text-2xl font-bold" > Flagget er plantet ! < / h3 >
< div className = "flex flex-col items-center gap-1" >
< span className = "inline-flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-base font-semibold" >
< MapPin className = "size-4 shrink-0" aria-hidden = "true" / >
{ ` Hull ${ expectedHoleNumber } ` }
< / span >
{ confirmedSummary . onGreen && confirmedSummary . distanceToPinCm !== null ? (
< span className = "text-base text-muted-foreground tabular-nums" >
{ ` ${ Math . floor ( confirmedSummary . distanceToPinCm / 100 ) } m ${
confirmedSummary . distanceToPinCm % 100
} cm fra hullet ` }
< / span >
) : null }
< / div >
< / div >
) : null }
< / main >
{ /* Footer — contextual primary action */ }
{ step === "green" ? (
< footer className = "shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]" >
< button
type = "button"
disabled = { ! canConfirm || submitting }
onClick = { handleConfirm }
className = "inline-flex min-h-14 w-full items-center justify-center gap-2 rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{ submitting ? (
< >
< span className = "size-5 animate-spin rounded-full border-2 border-primary-foreground/40 border-t-primary-foreground" / >
Sender …
< / >
) : (
< >
< Flag className = "size-5 shrink-0" aria-hidden = "true" / >
Bekreft plassering
< / >
) }
< / button >
< / footer >
) : null }
{ step === "done" ? (
< footer className = "shrink-0 border-t border-border bg-background p-4 pb-[max(1rem,env(safe-area-inset-bottom))]" >
< button
type = "button"
onClick = { onClose }
className = "inline-flex min-h-14 w-full items-center justify-center rounded-xl bg-primary px-6 text-lg font-bold text-primary-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Ferdig
< / button >
< / footer >
) : null }
< / div >
)
}
function FlagPlantPanel ( {
base ,
round ,
participant ,
refreshTick ,
onChanged ,
} : {
base : string
round : ApiRound
participant : ApiRoundParticipant
refreshTick : number
onChanged : ( ) = > void
} ) {
const playOrder = useMemo ( ( ) = > playedHoleNumbersOrg ( round . hole_config ) , [ round . hole_config ] )
const [ holes , setHoles ] = useState < ApiHole [ ] | null > ( null )
const [ plant , setPlant ] = useState < ApiFlagPlant | null > ( null )
const [ overflow , setOverflow ] = useState < ApiFlagOverflowHole [ ] > ( [ ] )
const [ sheetOpen , setSheetOpen ] = useState ( false )
const [ draft , setDraft ] = useState < Record < number , string > > ( { } )
const [ overflowError , setOverflowError ] = useState < string | null > ( null )
const [ busy , setBusy ] = useState ( false )
useEffect ( ( ) = > {
let cancelled = false
Promise . all ( [
getJson < ApiHole [ ] > ( ` ${ base } /rounds/ ${ round . id } /participants/ ${ participant . id } /holes ` ) ,
getJson < ApiFlagPlant | null > ( ` ${ base } /rounds/ ${ round . id } /participants/ ${ participant . id } /flag-plant ` ) ,
getJson < ApiFlagOverflowHole [ ] > ( ` ${ base } /rounds/ ${ round . id } /participants/ ${ participant . id } /flag-overflow ` ) ,
] ) . then ( ( [ h , p , o ] ) = > {
if ( cancelled ) return
setHoles ( h )
setPlant ( p )
setOverflow ( o ? ? [ ] )
} )
return ( ) = > {
cancelled = true
}
} , [ base , round . id , participant . id , refreshTick ] )
const currentPosition = computeFlagCurrentPositionOrg ( playOrder , holes , overflow )
async function handleUnplant() {
setBusy ( true )
await fetch ( ` ${ base } /rounds/ ${ round . id } /participants/ ${ participant . id } /flag-plant ` , {
method : "DELETE" ,
credentials : "include" ,
} )
setBusy ( false )
setPlant ( null )
onChanged ( )
}
async function submitOverflowHole ( holeNumber : number ) {
const raw = draft [ holeNumber ]
const score = parseInt ( raw ? ? "" , 10 )
if ( ! raw || Number . isNaN ( score ) || score <= 0 ) return
setOverflowError ( null )
const res = await fetch (
` ${ base } /rounds/ ${ round . id } /participants/ ${ participant . id } /flag-overflow/ ${ currentPosition . lap } /holes/ ${ holeNumber } ` ,
{
method : "PUT" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( { gross_strokes : score } ) ,
} ,
)
if ( ! res . ok ) {
const body = await res . json ( ) . catch ( ( ) = > null )
setOverflowError ( body ? . detail ? . message ? ? "Klarte ikke å registrere score." )
return
}
const updated : ApiFlagOverflowHole = await res . json ( )
setOverflow ( ( prev ) = > [ . . . prev . filter ( ( h ) = > ! ( h . lap === updated . lap && h . hole_number === updated . hole_number ) ) , updated ] )
setDraft ( ( prev ) = > ( { . . . prev , [ holeNumber ] : "" } ) )
onChanged ( )
}
if ( ! holes ) return null
const byOverflowHole = new Map ( overflow . filter ( ( h ) = > h . lap === currentPosition . lap ) . map ( ( h ) = > [ h . hole_number , h ] ) )
return (
< div className = "flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8" >
< span className = "text-sm font-bold text-foreground" > GPS - flaggplanting -- { participant . player_name } < / span >
< div className = "flex flex-wrap items-center gap-2" >
{ plant ? (
< >
< span className = "inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-2.5 py-1.5 text-sm font-bold text-primary" >
< Flag aria-hidden = "true" className = "size-4" / >
Flagg plantet · hull { plant . hole_number }
{ plant . lap > 1 ? ` (runde ${ plant . lap } ) ` : "" }
{ plant . on_green && plant . distance_to_pin_cm !== null
? ` · ${ Math . floor ( plant . distance_to_pin_cm / 100 ) } m ${ plant . distance_to_pin_cm % 100 } cm fra hullet `
: "" }
< / span >
< button
type = "button"
disabled = { busy }
onClick = { handleUnplant }
className = "flex min-h-11 items-center justify-center rounded-xl px-2 text-sm font-semibold text-muted-foreground underline underline-offset-2 hover:text-foreground disabled:opacity-60"
>
Angre
< / button >
< / >
) : (
< button
type = "button"
onClick = { ( ) = > setSheetOpen ( true ) }
className = "flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-background px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
< Flag aria-hidden = "true" className = "size-4" / >
Plant flagget
< / button >
) }
< / div >
{ currentPosition . lap >= 2 && (
< div className = "flex flex-col gap-2 border-t border-border pt-3" >
< span className = "text-sm font-bold text-foreground" > Runde { currentPosition . lap } -- før inn slag per hull < / span >
< div className = "flex flex-wrap gap-2" >
{ playOrder . map ( ( h ) = > {
const existing = byOverflowHole . get ( h )
return (
< div key = { h } className = "flex items-center gap-1.5" >
< span className = "text-xs font-semibold text-muted-foreground" > Hull { h } < / span >
{ existing ? . played ? (
< span className = "flex size-9 items-center justify-center rounded-lg bg-primary/10 text-sm font-bold text-primary tabular-nums" >
{ existing . gross_strokes }
< / span >
) : (
< Input
type = "number"
inputMode = "numeric"
min = { 1 }
max = { 20 }
value = { draft [ h ] ? ? "" }
onChange = { ( e ) = > setDraft ( ( prev ) = > ( { . . . prev , [ h ] : e . target . value } ) ) }
onBlur = { ( ) = > submitOverflowHole ( h ) }
className = "h-9 w-14 text-center"
/ >
) }
< / div >
)
} ) }
< / div >
{ overflowError && < p role = "alert" className = "text-sm font-medium text-destructive" > { overflowError } < / p > }
< / div >
) }
< FlagPlantSheetOrg
open = { sheetOpen }
onClose = { ( ) = > setSheetOpen ( false ) }
expectedHoleNumber = { currentPosition . holeNumber }
expectedLap = { currentPosition . lap }
onConfirm = { async ( result ) = > {
const res = await fetch ( ` ${ base } /rounds/ ${ round . id } /participants/ ${ participant . id } /flag-plant ` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( {
lat : result.lat , lng : result.lng ,
hole_number : currentPosition.holeNumber , lap : currentPosition.lap ,
on_green : result.onGreen , distance_to_pin_cm : result.distanceToPinCm ,
} ) ,
} )
if ( ! res . ok ) {
const body = await res . json ( ) . catch ( ( ) = > null )
return { ok : false , message : body?.detail?.message ? ? "Klarte ikke å plante flagget." }
}
const updated : ApiFlagPlant = await res . json ( )
setPlant ( updated )
onChanged ( )
return { ok : true }
} }
/ >
< / div >
)
}
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 -- 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-08-14 10:38:54 +02:00
// --- Flaggturnering: kartoversikt (migrasjon 071, "Del B", ADR-067) --------
// Samme "egen fetch + refreshTick"-mønster som FlagRoundPanel over. Ingen
// offentlig tilskuer-gren her (org-medlem/deltaker-only, se ADR-067 --
// individuelle turneringer har ingen offentlig spectator-side ennå).
const FlagMapOverviewLazy = dynamic ( ( ) = > import ( "@/components/flag-map-overview" ) , { ssr : false } )
type ApiFlagMapOrg = { visible_to_all : boolean ; flags : FlagMapEntry [ ] }
function FlagMapSectionOrg ( {
base ,
round ,
refreshTick ,
} : {
base : string
round : ApiRound
refreshTick : number
} ) {
const [ data , setData ] = useState < ApiFlagMapOrg | null > ( null )
const [ expanded , setExpanded ] = useState ( false )
useEffect ( ( ) = > {
let cancelled = false
getJson < { visible_to_all : boolean ; flags : Array < Record < string , unknown > > } > (
` ${ base } /rounds/ ${ round . id } /flag-map ` ,
) . then ( ( json ) = > {
if ( cancelled || ! json ) return
setData ( {
visible_to_all : json.visible_to_all ,
flags : json.flags.map ( ( f ) = > ( {
participantId : f.participant_id as string ,
displayName : f.display_name as string ,
lap : f.lap as number ,
holeNumber : f.hole_number as number ,
lat : f.lat as number ,
lng : f.lng as number ,
onGreen : f.on_green as boolean ,
distanceToPinCm : f.distance_to_pin_cm as number | null ,
} ) ) ,
} )
} )
return ( ) = > {
cancelled = true
}
} , [ base , round . id , refreshTick ] )
if ( ! data ) return null
return (
< div className = "flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8" >
< button
type = "button"
onClick = { ( ) = > setExpanded ( ( v ) = > ! v ) }
className = "flex min-h-11 items-center justify-between gap-2 text-left"
aria - expanded = { expanded }
>
< span className = "text-sm font-bold text-foreground" > Kartoversikt over flagg < / span >
< span className = "text-sm font-semibold text-primary" > { expanded ? "Skjul" : "Vis kart" } < / span >
< / button >
{ expanded && (
< div className = "h-80 overflow-hidden rounded-xl border border-border" >
< FlagMapOverviewLazy flags = { data . flags } visibleToAll = { data . visible_to_all } / >
< / div >
) }
< / div >
)
}
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"
}
}
2026-08-15 06:32:17 +02:00
// --- Full statistikkregistrering per hull (migrasjon 075, ADR-071) --------
// Enkelt-skjerm (IKKE en flerstegs-veiviser som ScoringWizard i round-
// detail.tsx -- HoleGrid åpner ETT hull om gangen for ÉN allerede valgt
// deltaker, ikke en hel spillerekkefølge, så et enkelt skjermbilde med
// alle aktuelle felt gruppert er tilstrekkelig her). Viser kun feltene
// deltakerens stat_level faktisk ber om.
type HolePatch = {
gross_strokes : number
putts? : number | null
club_off_tee? : string | null
tee_shot_result? : string | null
approach_result? : string | null
chip_count? : number | null
bunker_shot_count? : number | null
penalty_strokes? : number | null
first_putt_distance_bucket? : string | null
anyway_strokes? : number | null
expected_version? : number | null
}
2026-08-16 09:53:16 +02:00
// ADR-078 (2026-08-16): HoleStatsSheet bygget om fra ÉN lang scrollbar
// skjerm til en liten intern steg-flyt -- samme visuelle språk (fulskjerm,
// header/footer) og samme stegrekkefølge som ScoringWizard (frittstående
// runder), etter eksplisitt ønske om at "opplevelsen for brukeren skal
// være lik uansett". IKKE en gjenbruk av ScoringWizard-koden -- lagringen
// er bevisst UENDRET (ÉN batched save() ved siste steg, ikke PATCH per
// felt slik runde-siden gjør) for å unngå å røre en fungerende, annerledes
// arkitektur mekanikk som ikke var en del av forespørselen. Se
// wizardStepsFor()/WizardStep i round-detail.tsx for det speilede
// mønsteret på runde-siden.
2026-08-16 10:21:48 +02:00
// ADR-079 (2026-08-16): "overview" er et nytt FØRSTE steg -- personlig
// hull-historikk flyttet hit ut av selve "strokes"-steget, samme
// begrunnelse som round-siden (se PlayerHoleCards i round-detail.tsx).
// Turnering-siden har ingen ekstern "skjerm før" å legge den kompakte
// oppsummeringen i slik runde-siden har (HoleGrid er et tett rutenett,
// ikke en kortliste) -- blir derfor et eget steg i stedet. For å unngå å
// legge en ekstra obligatorisk trykk-runde til for HVER hull-registrering
// (18 hull × N spillere), hopper steget automatisk videre av seg selv når
// spilleren ikke har noen historikk å vise her (samme selv-skjulende
// oppførsel som HoleHistoryPanel selv har) -- kun synlig når det faktisk
// er noe å vise.
type HoleHistorySummary = {
times_played : number
times_personal : number
times_tournament : number
average_score : number
average_score_vs_par : number
gir_percent : number | null
average_putts : number | null
}
type SheetStep = "overview" | "strokes" | "putts" | "puttDistance" | "direction" | "holeDetails"
2026-08-16 09:53:16 +02:00
2026-08-21 23:00:22 +02:00
function sheetStepsFor ( statLevel : "strokes_only" | "strokes_and_putts" | "full" ) : SheetStep [ ] {
if ( statLevel === "strokes_only" ) return [ "strokes" ]
2026-08-16 10:21:48 +02:00
if ( statLevel === "full" ) return [ "overview" , "strokes" , "putts" , "puttDistance" , "direction" , "holeDetails" ]
return [ "overview" , "strokes" , "putts" , "puttDistance" ]
2026-08-16 09:53:16 +02:00
}
2026-08-21 23:00:22 +02:00
// Kjedet flerspiller-scoring (ADR-099, steg 2 av parity-planen, 2026-08-22)
// -- utvidet fra den opprinnelige ett-spiller HoleStatsSheet (ADR-078/079,
// se historikken over) med en kontekst-rad + Neste/"Neste: {navn}"/Ferdig-
// kjeding, speiler ScoringWizard i round-detail.tsx (der KJEDE-LOGIKKEN
// selv eies av forelderen -- ScoreTab under -- denne komponenten er ren
// presentasjon + onAdvance/onSwitchPlayer). Instansen holdes nå I LIVE på
// tvers av spillerbytter innad på samme hull (ikke lenger remount per
// åpning) -- derfor må BÅDE utkast-feltene OG historikk-/auto-hopp-vaktene
// eksplisitt nullstilles per `activePlayerId`, ikke bare per `step`.
function TournamentScoringWizard ( {
2026-08-15 06:32:17 +02:00
hole ,
playerName ,
statLevel ,
strokesReceived ,
ownBagClubs ,
2026-08-15 06:51:51 +02:00
historyUrl ,
2026-08-17 12:06:47 +02:00
targetPointsBaseUrl ,
2026-08-21 23:00:22 +02:00
players ,
activePlayerId ,
onSwitchPlayer ,
hasNextPlayer ,
nextPlayerName ,
2026-08-15 06:32:17 +02:00
onSave ,
2026-08-21 23:00:22 +02:00
onAdvance ,
2026-08-15 06:32:17 +02:00
onClose ,
} : {
hole : ApiHole
playerName : string
2026-08-21 23:00:22 +02:00
statLevel : "strokes_only" | "strokes_and_putts" | "full"
2026-08-15 06:32:17 +02:00
strokesReceived : number | null
ownBagClubs : string [ ]
2026-08-15 06:51:51 +02:00
historyUrl : string
2026-08-17 12:06:47 +02:00
targetPointsBaseUrl : string
2026-08-21 23:00:22 +02:00
// Kontekst-raden -- skjules når det kun er én spiller (dekker HoleGrid
// sitt enkelt-spiller-kallsted uendret, jf. ADR-099).
players : { id : string ; playerName : string ; registered : boolean } [ ]
activePlayerId : string
onSwitchPlayer : ( playerId : string ) = > void
hasNextPlayer : boolean
nextPlayerName : string | null
2026-08-15 06:32:17 +02:00
onSave : ( patch : HolePatch ) = > Promise < void >
2026-08-21 23:00:22 +02:00
onAdvance : ( ) = > void
2026-08-15 06:32:17 +02:00
onClose : ( ) = > void
} ) {
const [ strokes , setStrokes ] = useState < number | null > ( hole . gross_strokes )
const [ putts , setPutts ] = useState < number | null > ( hole . putts )
const [ puttBucket , setPuttBucket ] = useState < string | null > ( hole . first_putt_distance_bucket )
const [ club , setClub ] = useState < string | null > ( hole . club_off_tee )
const [ teeShot , setTeeShot ] = useState < string | null > ( hole . tee_shot_result )
const [ approach , setApproach ] = useState < string | null > ( hole . approach_result )
const [ chip , setChip ] = useState ( hole . chip_count ? ? 0 )
const [ bunker , setBunker ] = useState ( hole . bunker_shot_count ? ? 0 )
const [ penalty , setPenalty ] = useState ( hole . penalty_strokes ? ? 0 )
const [ anywayStrokes , setAnywayStrokes ] = useState ( hole . anyway_strokes ? ? 0 )
const [ saving , setSaving ] = useState ( false )
const [ error , setError ] = useState < string | null > ( null )
2026-08-16 09:53:16 +02:00
const steps = sheetStepsFor ( statLevel )
const [ stepIndex , setStepIndex ] = useState ( 0 )
const step = steps [ Math . min ( stepIndex , steps . length - 1 ) ]
const isFirstStep = stepIndex === 0
const isLastStep = stepIndex === steps . length - 1
const canProceed = step === "strokes" ? strokes !== null : true
2026-08-21 23:00:22 +02:00
// Nullstill ALLE utkast-felt + steg når AKTIV SPILLER bytter (kontekst-
// rad-trykk ELLER kjede-fremdrift) -- instansen remountes ikke lenger
// mellom spillere, så dette må gjøres eksplisitt (uten dette ville neste
// spillers ark vist FORRIGE spillers allerede utfylte tall).
useEffect ( ( ) = > {
setStrokes ( hole . gross_strokes )
setPutts ( hole . putts )
setPuttBucket ( hole . first_putt_distance_bucket )
setClub ( hole . club_off_tee )
setTeeShot ( hole . tee_shot_result )
setApproach ( hole . approach_result )
setChip ( hole . chip_count ? ? 0 )
setBunker ( hole . bunker_shot_count ? ? 0 )
setPenalty ( hole . penalty_strokes ? ? 0 )
setAnywayStrokes ( hole . anyway_strokes ? ? 0 )
setStepIndex ( 0 )
setError ( null )
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ activePlayerId ] )
// "overview"-steget (ADR-079): hentet på nytt per AKTIV SPILLER (ikke kun
// ved åpning -- se begrunnelsen over). undefined = laster ennå, null =
// ingen historikk.
2026-08-16 10:21:48 +02:00
const [ historySummary , setHistorySummary ] = useState < HoleHistorySummary | null | undefined > ( undefined )
const [ showFullHistory , setShowFullHistory ] = useState ( false )
useEffect ( ( ) = > {
let cancelled = false
2026-08-21 23:00:22 +02:00
setHistorySummary ( undefined )
2026-08-16 10:21:48 +02:00
fetch ( historyUrl , { credentials : "include" } )
. then ( ( res ) = > ( res . ok ? res . json ( ) : null ) )
. then ( ( data ) = > {
if ( ! cancelled ) setHistorySummary ( data )
} )
. catch ( ( ) = > {
if ( ! cancelled ) setHistorySummary ( null )
} )
return ( ) = > {
cancelled = true
}
2026-08-21 23:00:22 +02:00
} , [ historyUrl ] )
2026-08-16 10:21:48 +02:00
// Hopp automatisk forbi "overview" når det ikke er noe å vise -- unngår
// et ekstra obligatorisk trykk for HVER hull-registrering (se
// begrunnelsen på SheetStep over).
useEffect ( ( ) = > {
if ( step === "overview" && historySummary === null ) forward ( )
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ step , historySummary ] )
2026-08-16 09:53:16 +02:00
// Auto-hopp, samme mønster som ScoringWizard (round-detail.tsx) --
// hopper videre av seg selv når steget sitt eget felt får en verdi.
2026-08-21 23:00:22 +02:00
// Vaktene (enteredWithValueRef/firedRef) nullstilles per `activePlayerId`
// I TILLEGG til `step` (round-detail.tsx sitt tilsvarende mønster bruker
// `[step, player.id, hole.holeNumber]`) -- uten dette ville et allerede
// utfylt "Slag"-felt for spiller 1 stille "brukt opp" auto-hoppet for
// spiller 2. Leser fra `hole`-PROPEN, ikke de lokale utkast-feltene --
// funnet i scratch-verifisering: siden reset-effekten over kaller
// `setStrokes(...)` (en STATE-oppdatering, ikke synkron), ville denne
// vakten ellers lest den GAMLE spillerens fortsatt-uendrede `strokes`
// midt i samme effekt-runde, og trodd feltet "allerede hadde en verdi"
// for en spiller som i virkeligheten var helt tom -- auto-hoppet uteble
// da helt for neste spiller i kjeden.
2026-08-16 09:53:16 +02:00
const autoField = step === "strokes" ? "strokes" : step === "putts" ? "putts" : step === "puttDistance" ? "puttBucket" : null
const enteredWithValueRef = useRef ( false )
const firedRef = useRef ( false )
useEffect ( ( ) = > {
firedRef . current = false
enteredWithValueRef . current =
2026-08-21 23:00:22 +02:00
autoField === "strokes"
? hole . gross_strokes !== null
: autoField === "putts"
? hole . putts !== null
: autoField === "puttBucket"
? hole . first_putt_distance_bucket !== null
: false
2026-08-16 09:53:16 +02:00
// eslint-disable-next-line react-hooks/exhaustive-deps
2026-08-21 23:00:22 +02:00
} , [ step , activePlayerId , hole . hole_number ] )
2026-08-16 09:53:16 +02:00
useEffect ( ( ) = > {
if ( ! autoField || firedRef . current || enteredWithValueRef . current ) return
const value = autoField === "strokes" ? strokes : autoField === "putts" ? putts : puttBucket
if ( value !== null ) {
firedRef . current = true
forward ( )
}
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ strokes , putts , puttBucket ] )
// GIR-auto-inferens, samme formel/begrunnelse som ScoringWizard.
useEffect ( ( ) = > {
if ( step !== "direction" ) return
if ( approach !== null ) return
if ( strokes === null || putts === null ) return
if ( strokes - putts <= hole . par - 2 ) setApproach ( "hit" )
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ step , strokes , putts , approach , hole . par ] )
2026-08-15 06:32:17 +02:00
async function save() {
if ( strokes === null ) {
setError ( "Registrer antall slag først." )
return
}
setSaving ( true )
setError ( null )
try {
await onSave ( {
gross_strokes : strokes ,
putts ,
first_putt_distance_bucket : puttBucket ,
club_off_tee : club ,
tee_shot_result : teeShot ,
approach_result : approach ,
chip_count : chip ,
bunker_shot_count : bunker ,
penalty_strokes : penalty ,
anyway_strokes : anywayStrokes ,
expected_version : hole.version ,
} )
2026-08-21 23:00:22 +02:00
setSaving ( false )
onAdvance ( )
2026-08-15 06:32:17 +02:00
} catch {
setError ( "Klarte ikke å lagre. Prøv igjen." )
setSaving ( false )
}
}
2026-08-16 09:53:16 +02:00
function back() {
if ( isFirstStep ) {
onClose ( )
return
}
setStepIndex ( ( i ) = > i - 1 )
}
function forward() {
if ( ! isLastStep ) {
setStepIndex ( ( i ) = > i + 1 )
return
}
void save ( )
}
2026-08-15 06:32:17 +02:00
return (
< div className = "fixed inset-0 z-50 flex flex-col bg-background" >
< header className = "flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3" >
< button
type = "button"
onClick = { onClose }
aria - label = "Lukk"
className = "flex size-11 shrink-0 items-center justify-center rounded-xl text-foreground transition-colors hover:bg-accent"
>
< X aria-hidden = "true" className = "size-5" / >
< / button >
< div className = "flex min-w-0 flex-1 flex-col items-center text-center" >
< span className = "truncate text-base font-extrabold text-foreground" >
Hull { hole . hole_number } · Par { hole . par }
< / span >
< span className = "truncate text-sm font-semibold text-muted-foreground" > { playerName } < / span >
< / div >
< div className = "size-11 shrink-0" aria-hidden = "true" / >
< / header >
2026-08-21 23:00:22 +02:00
{ / * K o n t e k s t - r a d ( A D R - 0 9 9 ) - - k u n n å r d e t f a k t i s k e r e n f l i g h t å b y t t e
mellom ( HoleGrid sitt enkelt - spiller - kallsted sender players med
lengde 1 , ingen rad vises da -- uendret oppførsel der ) . * / }
{ players . length > 1 && (
< div className = "flex shrink-0 gap-2 overflow-x-auto border-b border-border px-3 py-2.5" >
{ players . map ( ( p ) = > (
< button
key = { p . id }
type = "button"
onClick = { ( ) = > onSwitchPlayer ( p . id ) }
aria - pressed = { p . id === activePlayerId }
className = { cn (
"flex min-h-11 shrink-0 items-center gap-1.5 rounded-full border px-3.5 text-sm font-bold transition-colors" ,
p . id === activePlayerId
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-foreground hover:bg-accent/50" ,
) }
>
< span className = "truncate" > { p . playerName } < / span >
{ p . registered && p . id !== activePlayerId && < span className = "text-xs font-semibold" > · Registrert < / span > }
< / button >
) ) }
< / div >
) }
2026-08-15 06:32:17 +02:00
< main className = "flex min-h-0 flex-1 flex-col overflow-y-auto p-5 sm:p-6" >
< div className = "mx-auto flex w-full max-w-sm flex-col gap-6" >
2026-08-17 12:06:47 +02:00
{ / * R a n g e f i n d e r ( m i g r a s j o n 0 7 9 ) - - s a m m e s e l v s k j u l e n d e k o m p o n e n t
som frittstående runder , persistent uansett hvilket steg
brukeren er på ( samme plassering / prinsipp som round - detail . tsx ) . * / }
< HoleTargetDistance baseUrl = { targetPointsBaseUrl } holeNumber = { hole . hole_number } size = "full" / >
2026-08-16 10:21:48 +02:00
{ / * A D R - 0 7 9 : p e r s o n l i g h i s t o r i k k , f l y t t e t h i t u t a v " s t r o k e s " -
steget -- se begrunnelsen for auto - hopp ved SheetStep over .
Samme kompakte oppsummering + klikk - for - full - historikk -
mønster som HoleHistoryPanel ( hole - stat - inputs . tsx ) , men
lokal her siden foreldren allerede har hentet dataene ( for
auto - hopp - avgjørelsen ) -- unngår et redundant andre kall . * / }
{ step === "overview" && historySummary && (
< button
type = "button"
onClick = { ( ) = > setShowFullHistory ( true ) }
className = "flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent/50"
>
< span className = "flex flex-col gap-1" >
< span className = "text-sm font-bold text-foreground" > Din historikk på dette hullet < / span >
< span className = "text-xs leading-relaxed text-muted-foreground text-pretty" >
Spilt { historySummary . times_played } { historySummary . times_played === 1 ? "gang" : "ganger" } før
{ " " } ( { historySummary . times_personal } frittstående , { historySummary . times_tournament } turnering ) --
{ " " } snitt { historySummary . average_score . toFixed ( 1 ) } slag (
{ historySummary . average_score_vs_par >= 0 ? "+" : "" }
{ historySummary . average_score_vs_par . toFixed ( 1 ) } til par )
{ historySummary . gir_percent !== null && ` , GIR ${ Math . round ( historySummary . gir_percent ) } % ` }
{ historySummary . average_putts !== null && ` , ${ historySummary . average_putts . toFixed ( 1 ) } putter i snitt ` }
< / span >
< / span >
< ChevronRight aria-hidden = "true" className = "size-4 shrink-0 text-muted-foreground" / >
< / button >
) }
2026-08-15 06:32:17 +02:00
2026-08-16 10:21:48 +02:00
{ step === "strokes" && (
< NumberPicker
label = "Slag"
value = { strokes }
directValues = { [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 ] }
expandValues = { [ 10 , 11 , 12 , 13 , 14 , 15 , 16 , 17 , 18 , 19 , 20 ] }
expandLabel = "10+"
parValue = { hole . par }
showGolfTerms
strokesReceived = { strokesReceived }
onChange = { setStrokes }
readOnly = { false }
/ >
2026-08-16 09:53:16 +02:00
) }
2026-08-15 06:32:17 +02:00
2026-08-16 09:53:16 +02:00
{ step === "putts" && (
< NumberPicker
label = "Putter"
value = { putts }
directValues = { [ 0 , 1 , 2 , 3 , 4 , 5 , 6 ] }
expandValues = { [ 7 , 8 , 9 , 10 ] }
expandLabel = "7+"
maxValue = { strokes ? ? undefined }
accent = "orange"
onChange = { setPutts }
readOnly = { false }
/ >
) }
{ step === "puttDistance" && (
< ChoiceRow
label = "Avstand første putt"
options = { [
{ value : "<1m" , label : "0-1m" } ,
{ value : "<2m" , label : "1-2m" } ,
{ value : "<3m" , label : "2-3m" } ,
{ value : "<5m" , label : "3-5m" } ,
{ value : "<8m" , label : "5-8m" } ,
{ value : "8m+" , label : "8m+" } ,
] }
value = { puttBucket }
onChange = { setPuttBucket }
readOnly = { false }
/ >
) }
2026-08-15 06:32:17 +02:00
2026-08-16 09:53:16 +02:00
{ step === "direction" && (
2026-08-15 06:32:17 +02:00
< >
< ClubPicker
label = "Kølle brukt ved utslaget"
value = { club ? ? "" }
onChange = { setClub }
ownBagClubs = { ownBagClubs }
readOnly = { false }
/ >
< WizardSection title = "Retning" >
{ hole . par !== 3 && (
< DirectionCross
label = "Utslag"
variant = "horizontal"
centerLabel = "Fairway"
value = { teeShot }
onChange = { setTeeShot }
readOnly = { false }
/ >
) }
< DirectionCross
label = "Innspill"
variant = "full"
centerLabel = "Traff"
value = { approach }
onChange = { setApproach }
readOnly = { false }
/ >
< / WizardSection >
< / >
) }
2026-08-16 09:53:16 +02:00
{ / * T e l l e r - f l i s - r u t e n e t t ( V 0 - d e s i g n e t , A D R - 0 7 8 , 2 0 2 6 - 0 8 - 1 6 ) - -
samme 2 x2 - mønster som round - detail . tsx sitt speilede
holeDetails - steg , se kommentaren der for begrunnelsen . * / }
{ step === "holeDetails" && (
< div className = "grid grid-cols-2 gap-3" >
< Stepper label = "Chip" value = { chip } onChange = { setChip } readOnly = { false } max = { strokes ? ? undefined } / >
< Stepper label = "Bunker" value = { bunker } onChange = { setBunker } readOnly = { false } max = { strokes ? ? undefined } / >
< Stepper label = "Straffeslag" value = { penalty } onChange = { setPenalty } readOnly = { false } max = { strokes ? ? undefined } / >
< Stepper label = "Anywayslag" value = { anywayStrokes } onChange = { setAnywayStrokes } readOnly = { false } max = { strokes ? ? undefined } / >
< / div >
) }
2026-08-15 06:32:17 +02:00
{ error && < p className = "text-sm font-medium text-destructive" > { error } < / p > }
< / div >
< / main >
2026-08-16 09:53:16 +02:00
< footer className = "grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4" >
< Button type = "button" variant = "outline" onClick = { back } className = "h-14 rounded-2xl text-base font-bold" >
{ isFirstStep ? "Avbryt" : "Forrige" }
< / Button >
2026-08-15 06:32:17 +02:00
< Button
type = "button"
2026-08-16 09:53:16 +02:00
disabled = { ! canProceed || saving }
onClick = { forward }
className = "h-14 rounded-2xl text-base font-bold disabled:opacity-40"
2026-08-15 06:32:17 +02:00
>
2026-08-21 23:00:22 +02:00
{ isLastStep ? ( saving ? "Lagrer…" : hasNextPlayer ? ` Neste: ${ nextPlayerName } ` : "Ferdig" ) : "Neste" }
2026-08-15 06:32:17 +02:00
< / Button >
< / footer >
2026-08-16 10:21:48 +02:00
{ showFullHistory && (
< HoleHistoryDetail url = { historyUrl } holeNumber = { hole . hole_number } onClose = { ( ) = > setShowFullHistory ( false ) } / >
) }
2026-08-15 06:32:17 +02:00
< / div >
)
}
2026-07-30 07:55:35 +02:00
function HoleGrid ( {
2026-08-15 06:51:51 +02:00
base ,
roundId ,
participantId ,
2026-07-30 07:55:35 +02:00
holes ,
holeConfig ,
2026-08-15 06:32:17 +02:00
statLevel ,
playerName ,
strokesReceived ,
ownBagClubs ,
onUpdateFull ,
2026-07-30 07:55:35 +02:00
} : {
2026-08-15 06:51:51 +02:00
base : string
roundId : string
participantId : string
2026-07-30 07:55:35 +02:00
holes : ApiHole [ ]
holeConfig : ApiRound [ "hole_config" ]
2026-08-15 06:32:17 +02:00
statLevel : "strokes_only" | "strokes_and_putts" | "full"
playerName : string
strokesReceived : ( holeNumber : number ) = > number | null
ownBagClubs : string [ ]
onUpdateFull : ( holeNumber : number , patch : HolePatch ) = > Promise < void >
2026-07-30 07:55:35 +02:00
} ) {
2026-08-21 23:00:22 +02:00
// Full statistikkdybde (migrasjon 075, ADR-071), utvidet ADR-099 (2026-
// 08-22): ÉN wizard for alle tre statLevel-nivåer nå (strokes_only fikk
// tidligere kun et bart inline-tallfelt her -- fjernet, samme picker-
// opplevelse som resten av appen for alle nivåer, se TournamentScoringWizard).
2026-08-15 06:32:17 +02:00
const [ sheetHole , setSheetHole ] = useState < number | null > ( null )
2026-07-30 07:55:35 +02:00
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
2026-08-21 23:00:22 +02:00
function openEditor ( holeNumber : number ) {
setSheetHole ( holeNumber )
2026-07-30 07:55:35 +02:00
}
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
return (
< button
key = { ` cell- ${ n } ` }
type = "button"
2026-08-21 23:00:22 +02:00
onClick = { ( ) = > openEditor ( n ) }
2026-07-30 07:55:35 +02:00
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 >
) }
2026-08-15 06:32:17 +02:00
2026-08-21 23:00:22 +02:00
{ sheetHole !== null && byNumber . get ( sheetHole ) && (
< TournamentScoringWizard
2026-08-15 06:32:17 +02:00
hole = { byNumber . get ( sheetHole ) ! }
playerName = { playerName }
statLevel = { statLevel }
strokesReceived = { strokesReceived ( sheetHole ) }
ownBagClubs = { ownBagClubs }
2026-08-15 06:51:51 +02:00
historyUrl = { ` ${ base } /rounds/ ${ roundId } /participants/ ${ participantId } /holes/ ${ sheetHole } /history ` }
2026-08-17 12:06:47 +02:00
targetPointsBaseUrl = { ` ${ base } /rounds/ ${ roundId } ` }
2026-08-21 23:00:22 +02:00
players = { [ { id : participantId , playerName , registered : ( byNumber . get ( sheetHole ) ? . gross_strokes ? ? null ) !== null } ] }
activePlayerId = { participantId }
onSwitchPlayer = { ( ) = > { } }
hasNextPlayer = { false }
nextPlayerName = { null }
2026-08-15 06:32:17 +02:00
onSave = { ( patch ) = > onUpdateFull ( sheetHole , patch ) }
2026-08-21 23:00:22 +02:00
onAdvance = { ( ) = > setSheetHole ( null ) }
2026-08-15 06:32:17 +02:00
onClose = { ( ) = > setSheetHole ( null ) }
/ >
) }
2026-07-30 07:55:35 +02:00
< / 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 )
2026-08-14 11:12:25 +02:00
// Eclectic (ADR-067-tillegget "Del C") -- "per-hull beste-kilde"-visning,
// én deltaker utvidet om gangen (samme "trykk for detaljer"-mønster som
// resten av appen, ikke alle utvidet samtidig).
const [ expandedEclecticId , setExpandedEclecticId ] = useState < string | null > ( null )
2026-07-30 07:55:35 +02:00
2026-08-22 05:26:46 +02:00
// Hull-for-hull-utvidelse for slagspill/netto/stableford (ADR-101,
// 2026-08-22) -- portert fra public-individual-live.tsx sin
// LeaderboardRow, tilpasset den autentiserte flerrunde-visningen (ALLE
// spilte runder vises stablet ved utvidelse, ikke ett hull-sett per
// valgt runde -- StrokePlayLeaderboard viser allerede én kolonne per
// runde, ingen ekstra rundevelger trengs her). `expandedStrokeRowKey`
// nøkkeltes på `player_name`, samme identitet StrokePlayLeaderboard
// allerede bruker som sin egen React-key (raden bærer ingen egen id).
const [ expandedStrokeRowKey , setExpandedStrokeRowKey ] = useState < string | null > ( null )
const [ holesByRound , setHolesByRound ] = useState < Record < string , ApiHole [ ] | undefined > > ( { } )
const [ loadingRoundKey , setLoadingRoundKey ] = useState < string | null > ( null )
2026-07-30 07:55:35 +02:00
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-08-14 11:12:25 +02:00
if ( method === "eclectic_gross" ) return { label : "slag" , value : entry.eclectic_total }
if ( method === "eclectic_net" ) return { label : "netto" , value : entry.eclectic_total }
if ( method === "eclectic_stableford" ) return { label : "poeng" , value : entry.eclectic_total }
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 >
)
}
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
// Konkurranseklasser (2026-08-03) -- den mottatte listen er ALLEREDE
// sortert riktig av backend (individual_leaderboard, uendret
// sorteringslogikk); her grupperes den samme rekkefølgen visuelt på
// class_id (stabil gruppering bevarer riktig rangering INNAD i hver
// klasse). Ingen klasser opprettet ennå => alle entries havner i samme
// "Ingen klasse"-gruppe => identisk med tidligere flat visning,
// bakoverkompatibelt uten migrasjonsflagg.
const groups : { key : string ; label : string ; entries : ApiLeaderboardEntry [ ] } [ ] = [ ]
const indexByKey = new Map < string , number > ( )
for ( const entry of entries ) {
const key = entry . class_id ? ? "__none__"
let idx = indexByKey . get ( key )
if ( idx === undefined ) {
idx = groups . length
indexByKey . set ( key , idx )
groups . push ( { key , label : entry.class_name ? ? "Ingen klasse" , entries : [ ] } )
}
groups [ idx ] . entries . push ( entry )
}
// "Ingen klasse" sist, uansett hvor den først dukket opp i sorteringen.
groups . sort ( ( a , b ) = > ( a . key === "__none__" ? 1 : b.key === "__none__" ? - 1 : 0 ) )
const showSectionHeaders = groups . length > 1 || groups [ 0 ] ? . key !== "__none__"
2026-08-04 04:47:27 +02:00
// Augusta-stil resultattavle (2026-08-04) -- kun for brutto/netto/
// stableford, backend har allerede regnet ut alt (POS med uavgjort,
// TODAY/THRU for gjeldende runde, TOTAL, R1-Rn). Denne funksjonen er ren
// formvending, ingen domenelogikk.
const isStrokePlay = method === "stroke_gross" || method === "stroke_net" || method === "stableford"
function toStrokePlayRow ( entry : ApiLeaderboardEntry ) : StrokePlayRow {
return {
position : entry.position ? ? "-" ,
playerName : entry.player_name ,
todayLabel : entry.today_label ,
todayIsUnderPar : null ,
thruLabel : entry.thru_label ? ? "-" ,
2026-08-18 14:23:03 +02:00
// total_label er null når deltakeren ikke har spilt ett eneste hull
// ennå -- nøytral tilstand, IKKE til forveksling med "E" (jevnt med
// par, en ekte score). stroke-play-leaderboard.tsx viser tee-tid/
// starthull i stedet når totalLabel er null.
totalLabel : entry.total_label ,
2026-08-04 04:47:27 +02:00
totalIsUnderPar : false ,
isLeader : entry.is_leader ,
2026-08-18 14:23:03 +02:00
nextTeeTime : entry.next_tee_time ,
nextStartHole : entry.next_start_hole ,
2026-08-04 04:47:27 +02:00
rounds : entry.rounds.map ( ( r ) = > ( { roundNumber : r.round_number , label : r.label , tone : r.tone } ) ) ,
}
}
2026-08-22 05:26:46 +02:00
// StrokePlayRow bærer ingen id (kun playerName, samme identitet
// komponenten selv bruker som React-key) -- dette oppslaget lar
// toggleStrokeExpand/renderStrokeExpanded finne tilbake til den ekte
// ApiLeaderboardEntry-en (med tournament_participant_id og rundenes
// tournament_round_id) for en gitt rad.
const entryByPlayerName = new Map ( entries . map ( ( e ) = > [ e . player_name , e ] ) )
async function toggleStrokeExpand ( row : StrokePlayRow ) {
const next = expandedStrokeRowKey === row . playerName ? null : row . playerName
setExpandedStrokeRowKey ( next )
if ( next === null ) return
const entry = entryByPlayerName . get ( row . playerName )
if ( ! entry ) return
for ( const r of entry . rounds ) {
const key = ` ${ entry . tournament_participant_id } : ${ r . tournament_round_id } `
if ( holesByRound [ key ] !== undefined ) continue
setLoadingRoundKey ( key )
const data = await getJson < ApiHole [ ] > (
` ${ base } /individual-leaderboard/ ${ entry . tournament_participant_id } /rounds/ ${ r . tournament_round_id } /holes ` ,
)
setHolesByRound ( ( prev ) = > ( { . . . prev , [ key ] : data ? ? [ ] } ) )
setLoadingRoundKey ( null )
}
}
function renderStrokeExpanded ( row : StrokePlayRow ) {
const entry = entryByPlayerName . get ( row . playerName )
if ( ! entry || entry . rounds . length === 0 ) {
return (
< p className = "px-4 py-3 text-sm text-muted-foreground" > Ingen hull registrert ennå . < / p >
)
}
return (
< div className = "flex flex-col gap-3 px-3 py-3" >
{ entry . rounds . map ( ( r ) = > {
const key = ` ${ entry . tournament_participant_id } : ${ r . tournament_round_id } `
const holes = holesByRound [ key ]
return (
< div key = { r . tournament_round_id } className = "flex flex-col gap-1.5" >
{ entry . rounds . length > 1 && (
< span className = "text-xs font-bold uppercase tracking-wide text-muted-foreground" >
Runde { r . round_number }
< / span >
) }
{ loadingRoundKey === key || holes === undefined ? (
< p className = "text-sm text-muted-foreground" > Laster hull … < / p >
) : (
< LeaderboardRoundHoles holes = { holes } / >
) }
< / div >
)
} ) }
< / div >
)
}
2026-08-14 11:12:25 +02:00
const isEclectic = method !== null && ECLECTIC_SCORING_METHODS . has ( method )
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
function renderRow ( entry : ApiLeaderboardEntry , rank : number ) {
const { label , value } = valueFor ( entry )
const isLeader = rank === 1 && value !== null
2026-08-14 11:12:25 +02:00
const canExpand = isEclectic && entry . eclectic_holes . length > 0
const expanded = expandedEclecticId === entry . tournament_participant_id
const row = (
< div className = "flex items-center gap-3 px-4 py-3" >
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
< 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
2026-08-14 11:12:25 +02:00
{ canExpand ? ( expanded ? " · skjul hull-for-hull" : " · vis hull-for-hull" ) : "" }
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
< / 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 >
2026-08-14 11:12:25 +02:00
< / div >
)
return (
< li key = { entry . tournament_participant_id } className = { cn ( isLeader && "bg-gold/10" ) } >
{ canExpand ? (
< button
type = "button"
onClick = { ( ) = > setExpandedEclecticId ( expanded ? null : entry . tournament_participant_id ) }
aria - expanded = { expanded }
className = "min-h-11 w-full text-left transition-colors hover:bg-accent/40"
>
{ row }
< / button >
) : (
row
) }
{ expanded ? < EclecticHoleTable holes = { entry . eclectic_holes } valueLabel = { label } / > : null }
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
< / li >
)
}
2026-08-18 17:29:59 +02:00
// Omspill-varsel (migrasjon 086, 2026-08-18) -- vist proaktivt her,
// FØR en eventuell uavgjort faktisk oppstår ("Dette bør informeres om
// før turneringen"), ikke bare når leaderboardet viser en delt
// førsteplass. Én global innstilling, vises én gang over gruppene
// (ikke per klasse-seksjon).
const playoffNote =
isStrokePlay && tournament . tiebreak_winner_method === "playoff" && tournament . tiebreak_playoff_rule
? tournament . tiebreak_playoff_rule
: null
2026-08-21 13:54:01 +02:00
// base = /orgs/{organizationId}/tournaments/{tournamentId} -- utledet i
// stedet for å utvide LeaderboardTab sitt propsett kun for utskrifts-lenken.
const [ , , printOrgId , , printTournamentId ] = base . split ( "/" )
2026-07-30 07:55:35 +02:00
return (
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
< div className = "flex flex-col gap-4" >
2026-08-21 13:54:01 +02:00
< div className = "flex justify-end" >
< Link
href = { ` /tournaments/ ${ printTournamentId } /print/resultlist?org= ${ printOrgId } ` }
className = "inline-flex min-h-9 items-center gap-1.5 rounded-full border border-border px-3 text-xs font-bold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
Skriv ut resultatliste
< / Link >
< / div >
2026-08-18 17:29:59 +02:00
{ playoffNote && (
< div className = "flex items-start gap-3 rounded-2xl border border-gold bg-gold/10 px-4 py-3" >
< Trophy aria-hidden = "true" className = "mt-0.5 size-5 shrink-0 text-gold-foreground" / >
< p className = "text-sm font-semibold leading-relaxed text-foreground text-pretty" >
Ved likt resultat for 1 . plass : { playoffNote }
< / p >
< / div >
) }
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
{ groups . map ( ( group ) = > (
< div key = { group . key } className = "flex flex-col gap-2" >
{ showSectionHeaders && (
< h3 className = "text-xs font-bold uppercase tracking-wide text-muted-foreground" >
{ group . label }
< / h3 >
) }
2026-08-04 04:47:27 +02:00
{ isStrokePlay ? (
< StrokePlayLeaderboard
rows = { group . entries . map ( toStrokePlayRow ) }
caption = { showSectionHeaders ? ` Resultattavle – ${ group . label } ` : "Resultattavle" }
2026-08-22 05:26:46 +02:00
expandedId = { expandedStrokeRowKey }
onToggleExpand = { toggleStrokeExpand }
renderExpanded = { renderStrokeExpanded }
2026-08-04 04:47:27 +02:00
/ >
) : (
< ul className = "flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8" >
{ group . entries . map ( ( entry , index ) = > renderRow ( entry , index + 1 ) ) }
< / ul >
) }
Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
Hva som skjer:
Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres).
docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes.
Hva som er verifisert:
Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg)
I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet
I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum)
Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen)
Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt)
98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild
2026-08-04 03:44:16 +02:00
< / div >
) ) }
< / div >
2026-07-30 07:55:35 +02:00
)
}
2026-08-04 11:34:28 +02:00
2026-08-22 05:26:46 +02:00
// Hull-for-hull-utvidelse for slagspill/netto/stableford (ADR-101,
// 2026-08-22) -- Ut/Inn-tabeller portert fra public-individual-live.tsx
// sin HoleNine, men netto-par-bevisst (bruker filens EGNE classify/
// scoreMarkClasses via strokes_received, samme som HoleGrid over) i
// stedet for den offentlige sidens enklere brutto-only inndeling --
// konsistent med resten av den autentiserte scoreføringen/scorekortet.
function LeaderboardHoleNine ( { holes , label } : { holes : ApiHole [ ] ; label : string } ) {
const parSum = holes . reduce ( ( s , h ) = > s + h . par , 0 )
const played = holes . filter ( ( h ) = > h . gross_strokes !== null )
const strokeSum = played . length > 0 ? played . reduce ( ( s , h ) = > s + ( h . gross_strokes as number ) , 0 ) : null
return (
< div className = "overflow-hidden rounded-xl border border-border" >
< div
className = "grid items-center bg-muted/60 text-center text-[11px] font-semibold text-muted-foreground"
style = { { gridTemplateColumns : ` 2rem repeat( ${ holes . length } , minmax(0, 1fr)) 2.4rem ` } }
>
< div className = "py-1" > { label } < / div >
{ holes . map ( ( h ) = > (
< div key = { h . hole_number } className = "py-1 tabular-nums" >
{ h . hole_number }
< / div >
) ) }
< div className = "py-1" > Sum < / div >
< / div >
< div
className = "grid items-center border-t border-border text-center text-[11px] text-muted-foreground"
style = { { gridTemplateColumns : ` 2rem repeat( ${ holes . length } , minmax(0, 1fr)) 2.4rem ` } }
>
< div className = "py-1 font-semibold" > Par < / div >
{ holes . map ( ( h ) = > (
< div key = { h . hole_number } className = "py-1 tabular-nums" >
{ h . par }
< / div >
) ) }
< div className = "py-1 font-semibold tabular-nums" > { parSum } < / div >
< / div >
< div
className = "grid items-center justify-items-center border-t border-border py-1.5 text-center"
style = { { gridTemplateColumns : ` 2rem repeat( ${ holes . length } , minmax(0, 1fr)) 2.4rem ` } }
>
< div className = "text-[11px] font-semibold text-foreground" > Slag < / div >
{ holes . map ( ( h ) = > {
const gross = h . gross_strokes
const net = gross !== null && h . strokes_received !== null && h . strokes_received !== undefined ? gross - h.strokes_received : gross
const diff = net !== null ? net - h.par : null
const kind = diff !== null ? classify ( diff ) : null
return gross !== null ? (
< span
key = { h . hole_number }
className = { cn (
"flex size-7 items-center justify-center text-xs font-extrabold tabular-nums" ,
kind ? scoreMarkClasses ( kind ) : "rounded-[4px] border-2 border-border text-foreground" ,
) }
>
{ gross }
< / span >
) : (
< span key = { h . hole_number } className = "text-xs text-muted-foreground" aria-hidden = "true" >
–
< / span >
)
} ) }
< div className = "text-xs font-bold tabular-nums text-foreground" > { strokeSum ? ? "– " } < / div >
< / div >
< / div >
)
}
function LeaderboardRoundHoles ( { holes } : { holes : ApiHole [ ] } ) {
if ( holes . length === 0 ) {
return < p className = "text-sm text-muted-foreground" > Ingen hull registrert for denne runden ennå . < / p >
}
const sorted = [ . . . holes ] . sort ( ( a , b ) = > a . hole_number - b . hole_number )
const front = sorted . filter ( ( h ) = > h . hole_number <= 9 )
const back = sorted . filter ( ( h ) = > h . hole_number > 9 )
return (
< div className = "flex flex-col gap-2" >
{ front . length > 0 && < LeaderboardHoleNine holes = { front } label = "Ut" / > }
{ back . length > 0 && < LeaderboardHoleNine holes = { back } label = "Inn" / > }
< / div >
)
}
2026-08-14 11:12:25 +02:00
// Eclectic (ADR-067-tillegget "Del C") -- "per-hull beste-kilde"-visning:
// hvilket hull, hva slaget/poenget ble, og HVILKEN runde det kom fra.
// Beviser for spilleren at "drømmerunden" faktisk er satt sammen av det
// beste fra flere runder, ikke bare den beste hele runden.
function EclecticHoleTable ( { holes , valueLabel } : { holes : ApiEclecticHoleCell [ ] ; valueLabel : string } ) {
const sorted = [ . . . holes ] . sort ( ( a , b ) = > a . hole_number - b . hole_number )
return (
< div className = "overflow-x-auto border-t border-border bg-muted/30 px-4 py-3" >
< table className = "w-full min-w-[420px] border-collapse text-sm" >
< thead >
< tr className = "text-xs font-semibold uppercase tracking-wide text-muted-foreground" >
< th scope = "col" className = "px-2 py-1 text-left" >
Hull
< / th >
< th scope = "col" className = "px-2 py-1 text-right" >
Par
< / th >
< th scope = "col" className = "px-2 py-1 text-right" >
{ valueLabel === "poeng" ? "Poeng" : valueLabel === "netto" ? "Netto" : "Slag" }
< / th >
< th scope = "col" className = "px-2 py-1 text-right" >
Fra runde
< / th >
< / tr >
< / thead >
< tbody >
{ sorted . map ( ( h ) = > (
< tr key = { h . hole_number } className = "border-t border-border/60" >
< td className = "px-2 py-1 font-semibold text-foreground" > { h . hole_number } < / td >
< td className = "px-2 py-1 text-right tabular-nums text-muted-foreground" > { h . par } < / td >
< td className = "px-2 py-1 text-right font-bold tabular-nums text-foreground" > { h . value } < / td >
< td className = "px-2 py-1 text-right tabular-nums text-muted-foreground" > Runde { h . round_number } < / td >
< / tr >
) ) }
< / tbody >
< / table >
< / div >
)
}
2026-08-04 11:34:28 +02:00
// --- TeeOff-baneimport (2026-08-04) -----------------------------------------
// Samme mønster/endepunkter som tournament-program.tsx sin OfficialCourse
// Search (ADR-019) -- kopiert hit i stedet for delt via import, samme
// duplisering-mellom-de-to-turneringstype-filene-konvensjon som ClassesCard
// allerede fulgte. Var tidligere KUN tilgjengelig for lagturneringer; denne
// runden fikset gapet der individuelle turneringer manglet TeeOff-import helt.
type ApiOfficialFacility = {
slug : string
name : string
city : string | null
county : string | null
}
type ApiOfficialCourseOption = {
teeoff_course_id : number
name : string
is_main_course : boolean
}
function OfficialCourseSearch ( {
organizationId ,
onClose ,
onImported ,
} : {
organizationId : string
onClose : ( ) = > void
onImported : ( course : ApiCourse ) = > void
} ) {
const [ query , setQuery ] = useState ( "" )
const [ facilities , setFacilities ] = useState < ApiOfficialFacility [ ] | null > ( null )
const [ selectedFacility , setSelectedFacility ] = useState < ApiOfficialFacility | null > ( null )
const [ courseOptions , setCourseOptions ] = useState < ApiOfficialCourseOption [ ] | null > ( null )
const [ searching , setSearching ] = useState ( false )
const [ importing , setImporting ] = useState ( false )
const [ error , setError ] = useState < string | null > ( null )
async function runSearch() {
setSearching ( true )
setError ( null )
try {
const res = await fetch (
` /orgs/ ${ organizationId } /courses/official-search?q= ${ encodeURIComponent ( query . trim ( ) ) } ` ,
{ credentials : "include" } ,
)
if ( ! res . ok ) throw new Error ( ` search: ${ res . status } ` )
setFacilities ( await res . json ( ) )
} catch {
setError ( "Klarte ikke å søke i teeoff sine baner akkurat nå." )
setFacilities ( [ ] )
} finally {
setSearching ( false )
}
}
async function pickFacility ( facility : ApiOfficialFacility ) {
setSelectedFacility ( facility )
setError ( null )
setCourseOptions ( null )
try {
const res = await fetch ( ` /orgs/ ${ organizationId } /courses/official-search/ ${ facility . slug } ` , {
credentials : "include" ,
} )
if ( ! res . ok ) throw new Error ( ` facility detail: ${ res . status } ` )
const detail : { courses : ApiOfficialCourseOption [ ] } = await res . json ( )
setCourseOptions ( detail . courses )
} catch {
setError ( "Klarte ikke å hente baner for dette anlegget." )
setCourseOptions ( [ ] )
}
}
async function importCourse ( course : ApiOfficialCourseOption ) {
if ( ! selectedFacility ) return
setImporting ( true )
setError ( null )
try {
const res = await fetch ( ` /orgs/ ${ organizationId } /courses/official-import ` , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
credentials : "include" ,
body : JSON.stringify ( {
facility_slug : selectedFacility.slug ,
teeoff_course_id : course.teeoff_course_id ,
} ) ,
} )
if ( ! res . ok ) {
const body = await res . json ( ) . catch ( ( ) = > null )
const code = body ? . detail ? . code
if ( code === "EXTERNAL_DATA_INCOMPLETE" ) {
setError ( "Denne banen mangler nok data i teeoff til å importeres ennå." )
2026-08-21 17:11:41 +02:00
} else if ( code === "EXTERNAL_DATA_INVALID" ) {
setError (
body ? . detail ? . message ? ?
"Banedataen fra teeoff er ugyldig (f.eks. duplisert hull-indeks) -- dette må rettes hos teeoff før banen kan importeres." ,
)
2026-08-04 11:34:28 +02:00
} else if ( code === "DUPLICATE" ) {
setError ( "Denne banen er allerede importert til organisasjonen." )
} else {
setError ( "Klarte ikke å importere banen. Prøv igjen." )
}
return
}
onImported ( await res . json ( ) )
} catch {
setError ( "Klarte ikke å importere banen. Prøv igjen." )
} finally {
setImporting ( false )
}
}
return (
< div className = "flex flex-col gap-3 rounded-2xl border border-border bg-background p-4" >
< div className = "flex items-center justify-between gap-2" >
< span className = "text-sm font-bold text-foreground" > Hent bane fra teeoff < / span >
< Button
type = "button"
variant = "ghost"
size = "icon"
onClick = { onClose }
className = "size-8 rounded-lg text-muted-foreground"
aria - label = "Lukk"
>
< X aria-hidden = "true" className = "size-4" / >
< / Button >
< / div >
{ error && < p className = "text-sm font-medium text-destructive" > { error } < / p > }
{ ! selectedFacility ? (
< >
{ / * B e v i s s t I K K E e t e g e t < f o r m > h e r - - d e n n e k o m p o n e n t e n r e n d r e s i n n e
i NewRoundForm sitt < form onSubmit = { handleSubmit } > , og nestede
< form > - elementer er ugyldig HTML ( samme feilrapport - funn som i
tournament - program . tsx sin variant -- "Søk" - knappen ville ellers
i praksis submitte det YTRE runde - skjemaet ) . * / }
< div className = "flex items-center gap-2" >
< Input
autoFocus
placeholder = "Søk anleggsnavn…"
value = { query }
onChange = { ( e ) = > setQuery ( e . target . value ) }
onKeyDown = { ( e ) = > {
if ( e . key === "Enter" ) {
e . preventDefault ( )
runSearch ( )
}
} }
className = "h-11 flex-1 rounded-xl text-base"
/ >
< Button
type = "button"
onClick = { ( ) = > runSearch ( ) }
disabled = { searching }
className = "h-11 shrink-0 rounded-xl font-bold"
>
{ searching ? "Søker…" : "Søk" }
< / Button >
< / div >
{ facilities && (
< ul className = "flex max-h-56 flex-col overflow-auto rounded-xl border border-border" >
{ facilities . map ( ( f ) = > (
< li key = { f . slug } className = "border-b border-border last:border-b-0" >
< button
type = "button"
onClick = { ( ) = > pickFacility ( f ) }
className = "flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
>
< span className = "text-sm font-semibold text-foreground" > { f . name } < / span >
{ ( f . city || f . county ) && (
< span className = "text-xs text-muted-foreground" >
{ [ f . city , f . county ] . filter ( Boolean ) . join ( ", " ) }
< / span >
) }
< / button >
< / li >
) ) }
{ facilities . length === 0 && (
< li className = "px-4 py-3 text-center text-sm text-muted-foreground" > Ingen treff . < / li >
) }
< / ul >
) }
< / >
) : (
< >
< button
type = "button"
onClick = { ( ) = > {
setSelectedFacility ( null )
setCourseOptions ( null )
} }
className = "self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
>
← { selectedFacility . name }
< / button >
{ courseOptions === null ? (
< p className = "text-sm text-muted-foreground" > Laster baner … < / p >
) : (
< ul className = "flex flex-col overflow-hidden rounded-xl border border-border" >
{ courseOptions . map ( ( c ) = > (
< li key = { c . teeoff_course_id } className = "border-b border-border last:border-b-0" >
< button
type = "button"
disabled = { importing }
onClick = { ( ) = > importCourse ( c ) }
className = "flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
>
{ c . name }
{ importing && < span className = "text-xs font-normal text-muted-foreground" > Importerer … < / span > }
< / button >
< / li >
) ) }
{ courseOptions . length === 0 && (
< li className = "px-4 py-3 text-center text-sm text-muted-foreground" >
Ingen 18 - hulls baner å importere hos dette anlegget ennå .
< / li >
) }
< / ul >
) }
< / >
) }
< / div >
)
}