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,
|
|
|
|
|
|
Check,
|
|
|
|
|
|
ChevronDown,
|
2026-08-14 10:38:54 +02:00
|
|
|
|
ChevronLeft,
|
2026-08-16 10:21:48 +02:00
|
|
|
|
ChevronRight,
|
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-14 10:38:54 +02:00
|
|
|
|
MapPin,
|
2026-07-30 07:55:35 +02:00
|
|
|
|
Medal,
|
|
|
|
|
|
Plus,
|
|
|
|
|
|
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-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-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
|
|
|
|
|
|
status: TournamentStatus
|
|
|
|
|
|
join_code: string
|
|
|
|
|
|
format_type: string
|
|
|
|
|
|
scoring_method: string | null
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
// KUN meningsfullt når scoring_method="bingo_bango_bongo" (2026-07-30).
|
|
|
|
|
|
bbb_sweep_bonus_enabled: boolean
|
2026-08-14 10:38:54 +02:00
|
|
|
|
// Flaggturnering: kartoversikt-bryter (migrasjon 071, "Del B", ADR-067).
|
|
|
|
|
|
flag_map_visible: boolean
|
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"
|
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
|
|
|
|
|
|
// utslag-forhåndsutfylling (AssignRoundParticipantControl) OG en egen
|
|
|
|
|
|
// resultatliste-seksjon per klasse (LeaderboardTab) -- se
|
|
|
|
|
|
// 053_tournament_classes.sql for full begrunnelse og forskjellen fra
|
|
|
|
|
|
// lagturneringer (tournament-detail.tsx), hvor klasse KUN styrer utslag.
|
|
|
|
|
|
type ApiTournamentClass = {
|
|
|
|
|
|
id: string
|
|
|
|
|
|
name: string
|
|
|
|
|
|
default_tee_id: string | null
|
|
|
|
|
|
default_tee_name: string | null
|
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-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-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
|
|
|
|
|
|
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-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))
|
|
|
|
|
|
.then((me: { organizations: { organization_id: string; role: "owner" | "admin" | "member" }[] } | null) => {
|
|
|
|
|
|
if (cancelled || !me) return
|
|
|
|
|
|
setMyRole(me.organizations.find((o) => o.organization_id === organizationId)?.role ?? null)
|
|
|
|
|
|
})
|
|
|
|
|
|
.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-07-30 07:55:35 +02:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
try {
|
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 [tournamentsData, participantsData, roundsData, coursesData, classesData] = await Promise.all([
|
2026-07-30 07:55:35 +02:00
|
|
|
|
getJson<ApiTournamentInfo[]>(`/orgs/${organizationId}/tournaments`),
|
|
|
|
|
|
getJson<ApiParticipant[]>(`${base}/participants`),
|
|
|
|
|
|
getJson<ApiRound[]>(`${base}/rounds`),
|
|
|
|
|
|
getJson<ApiCourse[]>(`/orgs/${organizationId}/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
|
|
|
|
getJson<ApiTournamentClass[]>(`${base}/classes`),
|
2026-07-30 07:55:35 +02:00
|
|
|
|
])
|
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
|
const mine = tournamentsData?.find((t) => t.id === tournamentId) ?? null
|
|
|
|
|
|
if (!mine) {
|
|
|
|
|
|
setError("Fant ikke turneringen.")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setTournament(mine)
|
|
|
|
|
|
setParticipants(participantsData ?? [])
|
|
|
|
|
|
setRounds(roundsData ?? [])
|
|
|
|
|
|
setCourses(coursesData ?? [])
|
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
|
|
|
|
setClasses(classesData ?? [])
|
2026-07-30 07:55:35 +02:00
|
|
|
|
} catch {
|
|
|
|
|
|
if (!cancelled) setError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.")
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (!cancelled) setLoading(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
load()
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [organizationId, tournamentId, base])
|
|
|
|
|
|
|
|
|
|
|
|
async function updateStatus(newStatus: TournamentStatus) {
|
|
|
|
|
|
if (!tournament) return
|
|
|
|
|
|
const previous = tournament
|
|
|
|
|
|
setTournament({ ...tournament, status: newStatus })
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({ status: newStatus }),
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!res.ok) throw new Error()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setTournament(previous)
|
|
|
|
|
|
setError("Klarte ikke å endre turnering-status. Prøv igjen.")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function updateScoringMethod(method: string) {
|
|
|
|
|
|
if (!tournament) return
|
|
|
|
|
|
const previous = tournament
|
|
|
|
|
|
setTournament({ ...tournament, scoring_method: method })
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({ scoring_method: method }),
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!res.ok) throw new Error()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setTournament(previous)
|
|
|
|
|
|
setError("Klarte ikke å endre scoringsmetode. Prøv igjen.")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
async function updateBbbSweepBonus(enabled: boolean) {
|
|
|
|
|
|
if (!tournament) return
|
|
|
|
|
|
const previous = tournament
|
|
|
|
|
|
setTournament({ ...tournament, bbb_sweep_bonus_enabled: enabled })
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, {
|
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({ bbb_sweep_bonus_enabled: enabled }),
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!res.ok) throw new Error()
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setTournament(previous)
|
|
|
|
|
|
setError("Klarte ikke å endre bonuspoeng-innstillingen. Prøv igjen.")
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-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-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)))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
{/* Leaderboard-fanen er BEVISST unntatt fra den delte 4xl-bredden
|
|
|
|
|
|
(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 4xl-wrapperen (896px, samme wrapper som skjema-tunge Oppsett/
|
|
|
|
|
|
Presentasjon-fanene bruker) fikk den aldri sjansen til det -- felles
|
|
|
|
|
|
med resten av siden siden komponenten ble bygget/testet isolert. */}
|
|
|
|
|
|
<main
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"mx-auto w-full flex-1 px-5 py-6 sm:py-8",
|
|
|
|
|
|
tab === "leaderboard" ? "max-w-[110rem]" : "max-w-4xl",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
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-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-07-30 07:55:35 +02:00
|
|
|
|
onError={setError}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{tab === "score" && (
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
<ScoreTab base={base} rounds={rounds} scoringMethod={tournament.scoring_method} onError={setError} />
|
2026-07-30 07:55:35 +02:00
|
|
|
|
)}
|
|
|
|
|
|
{tab === "leaderboard" && (
|
|
|
|
|
|
<LeaderboardTab base={base} tournament={tournament} />
|
|
|
|
|
|
)}
|
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
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
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-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-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-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-07-30 07:55:35 +02:00
|
|
|
|
onError: (message: string) => void
|
|
|
|
|
|
}) {
|
|
|
|
|
|
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])
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-6">
|
|
|
|
|
|
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
|
|
|
|
|
<h2 className="text-base font-bold text-foreground">Scoringsmetode</h2>
|
|
|
|
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
|
|
|
|
Avgjør hvordan totalen på leaderboardet regnes ut, og om deltakere trenger
|
|
|
|
|
|
registrert handicap og kjønn.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
|
<DropdownMenuTrigger className="flex min-h-11 w-fit items-center gap-2 rounded-xl border border-border bg-background px-4 text-sm font-bold text-foreground transition-colors hover:bg-accent/50">
|
|
|
|
|
|
{tournament.scoring_method ? SCORING_METHOD_LABELS[tournament.scoring_method] : "Ikke satt"}
|
|
|
|
|
|
<ChevronDown aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
|
<DropdownMenuContent align="start" className="w-56 rounded-2xl p-1.5">
|
|
|
|
|
|
{Object.entries(SCORING_METHOD_LABELS).map(([value, label]) => (
|
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
|
key={value}
|
|
|
|
|
|
onClick={() => onUpdateScoringMethod(value)}
|
|
|
|
|
|
className="cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold"
|
|
|
|
|
|
>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
|
</DropdownMenu>
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
{tournament.scoring_method === "bingo_bango_bongo" && (
|
|
|
|
|
|
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
checked={tournament.bbb_sweep_bonus_enabled}
|
|
|
|
|
|
onChange={(e) => onUpdateBbbSweepBonus(e.target.checked)}
|
|
|
|
|
|
className="mt-1 size-5 shrink-0 accent-primary"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<span className="flex flex-col gap-0.5">
|
|
|
|
|
|
<span className="text-sm font-bold text-foreground">Bonuspoeng ved "sveip"</span>
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
|
Ekstra poeng til en spiller som vinner alle tre kategoriene på samme hull.
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
)}
|
2026-08-14 10:38:54 +02:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-07-30 07:55:35 +02:00
|
|
|
|
</section>
|
|
|
|
|
|
|
2026-08-04 11:34:28 +02:00
|
|
|
|
<RoundsCard
|
|
|
|
|
|
base={base}
|
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
|
rounds={rounds}
|
|
|
|
|
|
courses={courses}
|
|
|
|
|
|
participants={participants}
|
|
|
|
|
|
classes={classes}
|
|
|
|
|
|
scoringMethod={tournament.scoring_method}
|
|
|
|
|
|
onAddRound={onAddRound}
|
|
|
|
|
|
onDeleteRound={onDeleteRound}
|
|
|
|
|
|
onAddCourse={onAddCourse}
|
|
|
|
|
|
onCourseImported={onCourseImported}
|
|
|
|
|
|
onError={onError}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
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
|
|
|
|
<ClassesCard
|
|
|
|
|
|
classes={classes}
|
|
|
|
|
|
courses={courses}
|
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
|
onCreate={onCreateClass}
|
|
|
|
|
|
onDelete={onDeleteClass}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-07-30 07:55:35 +02:00
|
|
|
|
<ParticipantsCard
|
2026-08-17 14:53:55 +02:00
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
|
tournamentId={tournament.id}
|
2026-07-30 07:55:35 +02:00
|
|
|
|
participants={participants}
|
|
|
|
|
|
pool={pool}
|
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
|
|
|
|
onAddParticipant={onAddParticipant}
|
|
|
|
|
|
onAddNewPlayer={onAddNewPlayer}
|
2026-08-17 14:53:55 +02:00
|
|
|
|
onImportComplete={async () => {
|
|
|
|
|
|
await Promise.all([refreshPool(), onImportComplete()])
|
|
|
|
|
|
}}
|
2026-07-30 07:55:35 +02:00
|
|
|
|
onRemoveParticipant={onRemoveParticipant}
|
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
|
|
|
|
onSetClass={onSetParticipantClass}
|
2026-08-15 06:32:17 +02:00
|
|
|
|
onSetStatLevel={onSetParticipantStatLevel}
|
2026-07-30 07:55:35 +02:00
|
|
|
|
/>
|
|
|
|
|
|
</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
|
|
|
|
// 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-15 06:32:17 +02:00
|
|
|
|
const STAT_LEVEL_LABELS: Record<string, string> = {
|
|
|
|
|
|
strokes_only: "Kun slag",
|
|
|
|
|
|
strokes_and_putts: "Slag og putter",
|
|
|
|
|
|
full: "All statistikk",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 07:55:35 +02:00
|
|
|
|
function ParticipantsCard({
|
2026-08-17 14:53:55 +02:00
|
|
|
|
organizationId,
|
|
|
|
|
|
tournamentId,
|
2026-07-30 07:55:35 +02:00
|
|
|
|
participants,
|
|
|
|
|
|
pool,
|
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
|
|
|
|
onAddParticipant,
|
|
|
|
|
|
onAddNewPlayer,
|
2026-08-17 14:53:55 +02:00
|
|
|
|
onImportComplete,
|
2026-07-30 07:55:35 +02:00
|
|
|
|
onRemoveParticipant,
|
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
|
|
|
|
onSetClass,
|
2026-08-15 06:32:17 +02:00
|
|
|
|
onSetStatLevel,
|
2026-07-30 07:55:35 +02:00
|
|
|
|
}: {
|
2026-08-17 14:53:55 +02:00
|
|
|
|
organizationId: string
|
|
|
|
|
|
tournamentId: string
|
2026-07-30 07:55:35 +02:00
|
|
|
|
participants: ApiParticipant[]
|
|
|
|
|
|
pool: ApiPlayer[]
|
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
|
|
|
|
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>
|
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
|
|
|
|
onSetClass: (participantId: string, classId: string | null) => Promise<void>
|
2026-08-15 06:32:17 +02:00
|
|
|
|
onSetStatLevel: (
|
|
|
|
|
|
participantId: string,
|
|
|
|
|
|
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
|
|
|
|
|
) => Promise<void>
|
2026-07-30 07:55:35 +02:00
|
|
|
|
}) {
|
2026-08-17 14:53:55 +02:00
|
|
|
|
const [showImport, setShowImport] = useState(false)
|
|
|
|
|
|
|
2026-07-30 07:55:35 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
|
<h2 className="text-base font-bold text-foreground">Deltakere</h2>
|
|
|
|
|
|
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
|
|
|
|
|
|
{participants.length}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-17 14:53:55 +02:00
|
|
|
|
<Button type="button" variant="outline" onClick={() => setShowImport(true)} className="min-h-11 self-start">
|
|
|
|
|
|
<Upload aria-hidden="true" className="mr-2 size-4" />
|
|
|
|
|
|
Importer fra CSV
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
|
|
|
|
|
|
{showImport && (
|
|
|
|
|
|
<PlayerImportPanel
|
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
|
tournamentId={tournamentId}
|
|
|
|
|
|
mode={{ kind: "individual", classes: classes.map((c) => ({ id: c.id, name: c.name })) }}
|
|
|
|
|
|
onClose={() => setShowImport(false)}
|
|
|
|
|
|
onImportComplete={() => {
|
|
|
|
|
|
void onImportComplete()
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-07-30 07:55:35 +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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
|
|
|
|
|
{participants.map((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
|
|
|
|
<li key={p.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
|
2026-07-30 07:55:35 +02:00
|
|
|
|
<div className="flex min-w-0 flex-col">
|
|
|
|
|
|
<span className="truncate text-sm font-bold text-foreground">{p.player_name}</span>
|
|
|
|
|
|
<span className="text-xs font-semibold text-muted-foreground">
|
|
|
|
|
|
{p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
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.length > 0 && (
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={p.class_id ?? ""}
|
|
|
|
|
|
onChange={(e) => onSetClass(p.id, e.target.value || null)}
|
|
|
|
|
|
aria-label={`Klasse for ${p.player_name}`}
|
|
|
|
|
|
className="h-9 shrink-0 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">Ingen klasse</option>
|
|
|
|
|
|
{classes.map((c) => (
|
|
|
|
|
|
<option key={c.id} value={c.id}>
|
|
|
|
|
|
{c.name}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
)}
|
2026-08-15 06:32:17 +02:00
|
|
|
|
{/* Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye
|
|
|
|
|
|
HoleGrid/HoleStatsSheet ber om ved scoring for DENNE
|
|
|
|
|
|
deltakeren, tournament-bredt (ikke per runde). */}
|
|
|
|
|
|
<select
|
|
|
|
|
|
value={p.stat_level}
|
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
|
onSetStatLevel(p.id, e.target.value as "strokes_only" | "strokes_and_putts" | "full")
|
|
|
|
|
|
}
|
|
|
|
|
|
aria-label={`Statistikknivå for ${p.player_name}`}
|
|
|
|
|
|
className="h-9 shrink-0 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none"
|
|
|
|
|
|
>
|
|
|
|
|
|
{Object.entries(STAT_LEVEL_LABELS).map(([value, label]) => (
|
|
|
|
|
|
<option key={value} value={value}>
|
|
|
|
|
|
{label}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
2026-07-30 07:55:35 +02:00
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
onClick={() => onRemoveParticipant(p.id)}
|
|
|
|
|
|
aria-label={`Fjern ${p.player_name}`}
|
|
|
|
|
|
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 aria-hidden="true" className="size-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
|
|
|
|
|
|
<AddParticipantControl
|
|
|
|
|
|
pool={pool}
|
|
|
|
|
|
alreadyIn={new Set(participants.map((p) => p.player_id))}
|
|
|
|
|
|
onAddExisting={onAddParticipant}
|
|
|
|
|
|
onAddNew={onAddNewPlayer}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
participants,
|
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
|
|
|
|
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[]
|
|
|
|
|
|
participants: ApiParticipant[]
|
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
|
|
|
|
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}
|
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
|
round={r}
|
|
|
|
|
|
participants={participants}
|
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
|
|
|
|
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,
|
|
|
|
|
|
organizationId,
|
|
|
|
|
|
round,
|
|
|
|
|
|
participants,
|
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
|
|
|
|
scoringMethod,
|
|
|
|
|
|
onDelete,
|
|
|
|
|
|
onError,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
base: string
|
|
|
|
|
|
organizationId: string
|
|
|
|
|
|
round: ApiRound
|
|
|
|
|
|
participants: ApiParticipant[]
|
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
|
|
|
|
scoringMethod: string | null
|
|
|
|
|
|
onDelete: () => Promise<void>
|
|
|
|
|
|
onError: (message: string) => void
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
|
|
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null)
|
|
|
|
|
|
const [tees, setTees] = useState<ApiTee[]>([])
|
|
|
|
|
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
|
|
|
|
|
|
|
|
|
|
|
async function loadRoundParticipants() {
|
|
|
|
|
|
const [rpData, teeData] = await Promise.all([
|
|
|
|
|
|
getJson<ApiRoundParticipant[]>(`${base}/rounds/${round.id}/participants`),
|
|
|
|
|
|
getJson<ApiTee[]>(`/orgs/${organizationId}/courses/${round.course_id}/tees`),
|
|
|
|
|
|
])
|
|
|
|
|
|
setRoundParticipants(rpData ?? [])
|
|
|
|
|
|
setTees(teeData ?? [])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (expanded && roundParticipants === null) {
|
|
|
|
|
|
loadRoundParticipants()
|
|
|
|
|
|
}
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [expanded])
|
|
|
|
|
|
|
|
|
|
|
|
async function addRoundParticipant(tournamentParticipantId: string, teeId: string) {
|
|
|
|
|
|
onError("")
|
|
|
|
|
|
const res = await fetch(`${base}/rounds/${round.id}/participants`, {
|
|
|
|
|
|
method: "POST",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({ tournament_participant_id: tournamentParticipantId, tee_id: teeId }),
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
onError(await errorMessage(res, "Klarte ikke å legge til deltakeren i runden."))
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const created: ApiRoundParticipant = await res.json()
|
|
|
|
|
|
setRoundParticipants((prev) => [...(prev ?? []), created])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function removeRoundParticipant(id: string) {
|
|
|
|
|
|
const res = await fetch(`${base}/rounds/${round.id}/participants/${id}`, {
|
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
onError("Klarte ikke å fjerne deltakeren fra runden.")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
setRoundParticipants((prev) => (prev ?? []).filter((rp) => rp.id !== id))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const assignedIds = new Set((roundParticipants ?? []).map((rp) => rp.tournament_participant_id))
|
|
|
|
|
|
const unassigned = participants.filter((p) => !assignedIds.has(p.id))
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="overflow-hidden rounded-xl border border-border">
|
|
|
|
|
|
<div className="flex items-center gap-3 bg-background px-4 py-3">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setExpanded((v) => !v)}
|
|
|
|
|
|
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
|
|
|
|
|
aria-expanded={expanded}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-full bg-info/15 text-sm font-extrabold tabular-nums text-info">
|
|
|
|
|
|
{round.sequence}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="flex min-w-0 flex-col">
|
|
|
|
|
|
<span className="truncate text-sm font-bold text-foreground">
|
|
|
|
|
|
{round.name || `Runde ${round.sequence}`}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="truncate text-xs font-semibold text-muted-foreground">
|
|
|
|
|
|
{round.course_name} · {HOLE_CONFIG_LABELS[round.hole_config]}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<ChevronDown
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{!confirmingDelete ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
onClick={() => setConfirmingDelete(true)}
|
|
|
|
|
|
aria-label={`Slett ${round.name || `runde ${round.sequence}`}`}
|
|
|
|
|
|
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Trash2 aria-hidden="true" className="size-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="flex shrink-0 gap-1">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="destructive"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={onDelete}
|
|
|
|
|
|
className="h-9 rounded-lg text-xs font-bold"
|
|
|
|
|
|
>
|
|
|
|
|
|
Slett
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => setConfirmingDelete(false)}
|
|
|
|
|
|
className="h-9 rounded-lg text-xs font-bold text-muted-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
Avbryt
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{expanded && (
|
|
|
|
|
|
<div className="flex flex-col gap-2 border-t border-border p-3">
|
|
|
|
|
|
{roundParticipants === null ? (
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">Laster…</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{roundParticipants.length === 0 && (
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">Ingen deltakere i denne runden ennå.</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-lg border border-border">
|
|
|
|
|
|
{roundParticipants.map((rp) => (
|
|
|
|
|
|
<li key={rp.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
|
|
|
|
|
<div className="flex min-w-0 flex-col">
|
|
|
|
|
|
<span className="truncate text-sm font-bold text-foreground">{rp.player_name}</span>
|
|
|
|
|
|
<span className="truncate text-xs font-semibold text-muted-foreground">
|
|
|
|
|
|
{rp.tee_name}
|
|
|
|
|
|
{rp.playing_handicap !== null ? ` · HCP ${rp.playing_handicap}` : ""}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
onClick={() => removeRoundParticipant(rp.id)}
|
|
|
|
|
|
aria-label={`Fjern ${rp.player_name} fra runden`}
|
|
|
|
|
|
className="size-8 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X aria-hidden="true" className="size-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
{unassigned.length > 0 && tees.length > 0 && (
|
|
|
|
|
|
<AssignRoundParticipantControl
|
|
|
|
|
|
candidates={unassigned}
|
|
|
|
|
|
tees={tees}
|
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
|
|
|
|
onAssign={addRoundParticipant}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{unassigned.length > 0 && tees.length === 0 && (
|
|
|
|
|
|
<p className="text-xs font-medium text-brand-orange">
|
|
|
|
|
|
Banen «{round.course_name}» har ingen registrerte utslag ennå -- kan ikke legge til
|
|
|
|
|
|
deltakere før det er på plass.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function AssignRoundParticipantControl({
|
|
|
|
|
|
candidates,
|
|
|
|
|
|
tees,
|
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
|
|
|
|
onAssign,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
candidates: ApiParticipant[]
|
|
|
|
|
|
tees: ApiTee[]
|
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
|
|
|
|
onAssign: (tournamentParticipantId: string, teeId: string) => Promise<void>
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [participantId, setParticipantId] = useState(candidates[0]?.id ?? "")
|
|
|
|
|
|
const [teeId, setTeeId] = useState(tees[0]?.id ?? "")
|
|
|
|
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!candidates.some((c) => c.id === participantId)) setParticipantId(candidates[0]?.id ?? "")
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [candidates])
|
|
|
|
|
|
|
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) -- forhåndsvelg deltakerens klasses
|
|
|
|
|
|
// standardutslag, kun hvis det utslaget faktisk finnes på DENNE rundens
|
|
|
|
|
|
// bane (klassens standardutslag kan tilhøre en annen bane). Fritt
|
|
|
|
|
|
// overstyrbart under.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!participantId) return
|
|
|
|
|
|
const candidate = candidates.find((c) => c.id === participantId)
|
|
|
|
|
|
const cls = candidate?.class_id ? classes.find((c) => c.id === candidate.class_id) : null
|
|
|
|
|
|
const defaultTee = cls?.default_tee_id ? tees.find((t) => t.id === cls.default_tee_id) : null
|
|
|
|
|
|
if (defaultTee) setTeeId(defaultTee.id)
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [participantId])
|
|
|
|
|
|
|
2026-07-30 07:55:35 +02:00
|
|
|
|
async function handleAssign() {
|
|
|
|
|
|
if (!participantId || !teeId || submitting) return
|
|
|
|
|
|
setSubmitting(true)
|
|
|
|
|
|
await onAssign(participantId, teeId)
|
|
|
|
|
|
setSubmitting(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-border p-2.5 sm:flex-row sm:items-end">
|
|
|
|
|
|
<div className="flex flex-1 flex-col gap-1">
|
|
|
|
|
|
<Label htmlFor="assign-participant" className="text-xs font-semibold text-muted-foreground">
|
|
|
|
|
|
Deltaker
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
id="assign-participant"
|
|
|
|
|
|
value={participantId}
|
|
|
|
|
|
onChange={(e) => setParticipantId(e.target.value)}
|
|
|
|
|
|
className="h-10 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
|
|
|
|
|
|
>
|
|
|
|
|
|
{candidates.map((c) => (
|
|
|
|
|
|
<option key={c.id} value={c.id}>
|
|
|
|
|
|
{c.player_name}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex flex-1 flex-col gap-1">
|
|
|
|
|
|
<Label htmlFor="assign-tee" className="text-xs font-semibold text-muted-foreground">
|
|
|
|
|
|
Utslag
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
id="assign-tee"
|
|
|
|
|
|
value={teeId}
|
|
|
|
|
|
onChange={(e) => setTeeId(e.target.value)}
|
|
|
|
|
|
className="h-10 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
|
|
|
|
|
|
>
|
|
|
|
|
|
{tees.map((t) => (
|
|
|
|
|
|
<option key={t.id} value={t.id}>
|
|
|
|
|
|
{t.name}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={handleAssign}
|
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
|
className="h-10 shrink-0 rounded-lg text-xs font-bold"
|
|
|
|
|
|
>
|
|
|
|
|
|
Legg til
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
// Scorekort-fane
|
|
|
|
|
|
// =========================================================================
|
|
|
|
|
|
|
|
|
|
|
|
function ScoreTab({
|
|
|
|
|
|
base,
|
|
|
|
|
|
rounds,
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
scoringMethod,
|
2026-07-30 07:55:35 +02:00
|
|
|
|
onError,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
base: string
|
|
|
|
|
|
rounds: ApiRound[]
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
scoringMethod: string | null
|
2026-07-30 07:55:35 +02:00
|
|
|
|
onError: (message: string) => void
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [roundId, setRoundId] = useState(rounds[0]?.id ?? "")
|
|
|
|
|
|
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[]>([])
|
|
|
|
|
|
const [participantId, setParticipantId] = useState("")
|
|
|
|
|
|
const [holes, setHoles] = useState<ApiHole[] | null>(null)
|
|
|
|
|
|
const round = rounds.find((r) => r.id === roundId) ?? null
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
// Bumpes ved hver hull-registrering, slik at BBB-/Flag-panelene (egen
|
|
|
|
|
|
// fetch) vet de skal hente på nytt.
|
|
|
|
|
|
const [refreshTick, setRefreshTick] = useState(0)
|
2026-07-30 07:55:35 +02:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!roundId) return
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
getJson<ApiRoundParticipant[]>(`${base}/rounds/${roundId}/participants`).then((data) => {
|
|
|
|
|
|
if (cancelled) return
|
|
|
|
|
|
const list = data ?? []
|
|
|
|
|
|
setRoundParticipants(list)
|
|
|
|
|
|
setParticipantId(list[0]?.id ?? "")
|
|
|
|
|
|
})
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [base, roundId])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!roundId || !participantId) {
|
|
|
|
|
|
setHoles(null)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
getJson<ApiHole[]>(`${base}/rounds/${roundId}/participants/${participantId}/holes`).then((data) => {
|
|
|
|
|
|
if (!cancelled) setHoles(data ?? [])
|
|
|
|
|
|
})
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [base, roundId, participantId])
|
|
|
|
|
|
|
|
|
|
|
|
async function updateHole(holeNumber: number, grossStrokes: number) {
|
|
|
|
|
|
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, {
|
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify({ gross_strokes: grossStrokes }),
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
onError("Klarte ikke å lagre slaget. Prøv igjen.")
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
const updated: ApiHole = await res.json()
|
|
|
|
|
|
setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev))
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
setRefreshTick((n) => n + 1)
|
2026-07-30 07:55:35 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 06:32:17 +02:00
|
|
|
|
// Full statistikkdybde (migrasjon 075, ADR-071) -- samme endepunkt som
|
|
|
|
|
|
// updateHole over, men med hele HolePatch-kroppen fra HoleStatsSheet i
|
|
|
|
|
|
// stedet for kun gross_strokes. Kaster videre ved feil (409 STALE_VERSION
|
|
|
|
|
|
// inkludert) -- HoleStatsSheet fanger og viser en generisk feilmelding,
|
|
|
|
|
|
// beholder brukerens utfylte skjema i stedet for å lukke sheet-et blindt.
|
|
|
|
|
|
async function updateHoleFull(holeNumber: number, patch: HolePatch) {
|
|
|
|
|
|
const res = await fetch(`${base}/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`, {
|
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
|
credentials: "include",
|
|
|
|
|
|
body: JSON.stringify(patch),
|
|
|
|
|
|
})
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
throw new Error("Klarte ikke å lagre.")
|
|
|
|
|
|
}
|
|
|
|
|
|
const updated: ApiHole = await res.json()
|
|
|
|
|
|
setHoles((prev) => (prev ? prev.map((h) => (h.hole_number === holeNumber ? updated : h)) : prev))
|
|
|
|
|
|
setRefreshTick((n) => n + 1)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-30 07:55:35 +02:00
|
|
|
|
if (rounds.length === 0) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="rounded-2xl border border-dashed border-border p-6 text-center">
|
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground text-pretty">
|
|
|
|
|
|
Opprett en runde under «Oppsett» først.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-5">
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
{rounds.map((r) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={r.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setRoundId(r.id)}
|
|
|
|
|
|
aria-pressed={r.id === roundId}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"flex min-h-11 items-center gap-2 rounded-full border px-4 text-sm font-bold transition-colors",
|
|
|
|
|
|
r.id === roundId
|
|
|
|
|
|
? "border-info bg-info/15 text-info"
|
|
|
|
|
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Flag aria-hidden="true" className="size-4" />
|
|
|
|
|
|
{r.name || `Runde ${r.sequence}`}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{roundParticipants.length === 0 ? (
|
|
|
|
|
|
<p className="text-sm font-medium text-muted-foreground">
|
|
|
|
|
|
Ingen deltakere i denne runden ennå -- legg til under «Oppsett».
|
|
|
|
|
|
</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
|
{roundParticipants.map((rp) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={rp.id}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setParticipantId(rp.id)}
|
|
|
|
|
|
aria-pressed={rp.id === participantId}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"min-h-11 rounded-full border px-4 text-sm font-bold transition-colors",
|
|
|
|
|
|
rp.id === participantId
|
|
|
|
|
|
? "border-primary bg-primary text-primary-foreground"
|
|
|
|
|
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{rp.player_name}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{round && holes && (
|
2026-08-15 06:32:17 +02:00
|
|
|
|
<HoleGrid
|
2026-08-15 06:51:51 +02:00
|
|
|
|
base={base}
|
|
|
|
|
|
roundId={roundId}
|
|
|
|
|
|
participantId={participantId}
|
2026-08-15 06:32:17 +02:00
|
|
|
|
holes={holes}
|
|
|
|
|
|
holeConfig={round.hole_config}
|
|
|
|
|
|
statLevel={roundParticipants.find((rp) => rp.id === participantId)?.stat_level ?? "strokes_only"}
|
|
|
|
|
|
playerName={roundParticipants.find((rp) => rp.id === participantId)?.player_name ?? ""}
|
|
|
|
|
|
strokesReceived={(n) => holes.find((h) => h.hole_number === n)?.strokes_received ?? null}
|
|
|
|
|
|
ownBagClubs={[]}
|
|
|
|
|
|
onUpdate={updateHole}
|
|
|
|
|
|
onUpdateFull={updateHoleFull}
|
|
|
|
|
|
/>
|
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
|
|
|
|
|
|
|
|
|
|
{/* Bingo Bango Bongo (2026-07-30) -- MANUELL per-hull-observasjon,
|
|
|
|
|
|
uavhengig av gross_strokes-registreringen over. */}
|
|
|
|
|
|
{round && scoringMethod === "bingo_bango_bongo" && (
|
|
|
|
|
|
<BBBRoundPanel base={base} round={round} participants={roundParticipants} refreshTick={refreshTick} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Flaggturnering (2026-07-30) -- per-runde-resultat (ikke
|
|
|
|
|
|
akkumulerbart på tvers av runder, se LeaderboardTab). */}
|
|
|
|
|
|
{round && scoringMethod === "flag" && (
|
|
|
|
|
|
<FlagRoundPanel base={base} round={round} refreshTick={refreshTick} />
|
|
|
|
|
|
)}
|
2026-08-14 10:38:54 +02:00
|
|
|
|
|
|
|
|
|
|
{/* GPS-flaggplanting + runde 2+ (2026-08-14) -- for DEN VALGTE
|
|
|
|
|
|
deltakeren (samme selv-only autorisasjon som selve
|
|
|
|
|
|
scoreføringen over, ingen vits å vise for andre enn den man
|
|
|
|
|
|
faktisk fører for). */}
|
|
|
|
|
|
{round && scoringMethod === "flag" && roundParticipants.find((rp) => rp.id === participantId) && (
|
|
|
|
|
|
<FlagPlantPanel
|
|
|
|
|
|
base={base}
|
|
|
|
|
|
round={round}
|
|
|
|
|
|
participant={roundParticipants.find((rp) => rp.id === participantId)!}
|
|
|
|
|
|
refreshTick={refreshTick}
|
|
|
|
|
|
onChanged={() => setRefreshTick((n) => n + 1)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Kartoversikt over flagg (migrasjon 071, "Del B", ADR-067) --
|
|
|
|
|
|
kun for org-medlem/deltaker (ingen offentlig tilskuer-gren
|
|
|
|
|
|
for individuelle turneringer, se ADR-067). */}
|
|
|
|
|
|
{round && scoringMethod === "flag" && (
|
|
|
|
|
|
<FlagMapSectionOrg base={base} round={round} refreshTick={refreshTick} />
|
|
|
|
|
|
)}
|
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
|
|
|
|
|
|
|
|
|
|
function sheetStepsFor(statLevel: "strokes_and_putts" | "full"): SheetStep[] {
|
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-15 06:32:17 +02:00
|
|
|
|
function HoleStatsSheet({
|
|
|
|
|
|
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-15 06:32:17 +02:00
|
|
|
|
onSave,
|
|
|
|
|
|
onClose,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
hole: ApiHole
|
|
|
|
|
|
playerName: string
|
|
|
|
|
|
statLevel: "strokes_and_putts" | "full"
|
|
|
|
|
|
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-15 06:32:17 +02:00
|
|
|
|
onSave: (patch: HolePatch) => Promise<void>
|
|
|
|
|
|
onClose: () => void
|
|
|
|
|
|
}) {
|
|
|
|
|
|
const [strokes, setStrokes] = useState<number | null>(hole.gross_strokes)
|
|
|
|
|
|
const [putts, setPutts] = useState<number | null>(hole.putts)
|
|
|
|
|
|
const [puttBucket, setPuttBucket] = useState<string | null>(hole.first_putt_distance_bucket)
|
|
|
|
|
|
const [club, setClub] = useState<string | null>(hole.club_off_tee)
|
|
|
|
|
|
const [teeShot, setTeeShot] = useState<string | null>(hole.tee_shot_result)
|
|
|
|
|
|
const [approach, setApproach] = useState<string | null>(hole.approach_result)
|
|
|
|
|
|
const [chip, setChip] = useState(hole.chip_count ?? 0)
|
|
|
|
|
|
const [bunker, setBunker] = useState(hole.bunker_shot_count ?? 0)
|
|
|
|
|
|
const [penalty, setPenalty] = useState(hole.penalty_strokes ?? 0)
|
|
|
|
|
|
const [anywayStrokes, setAnywayStrokes] = useState(hole.anyway_strokes ?? 0)
|
|
|
|
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
|
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-16 10:21:48 +02:00
|
|
|
|
// "overview"-steget (ADR-079): hentet én gang ved åpning, ikke re-hentet
|
|
|
|
|
|
// per steg-bytte. undefined = laster ennå, null = ingen historikk.
|
|
|
|
|
|
const [historySummary, setHistorySummary] = useState<HoleHistorySummary | null | undefined>(undefined)
|
|
|
|
|
|
const [showFullHistory, setShowFullHistory] = useState(false)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
fetch(historyUrl, { credentials: "include" })
|
|
|
|
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
|
|
|
|
.then((data) => {
|
|
|
|
|
|
if (!cancelled) setHistorySummary(data)
|
|
|
|
|
|
})
|
|
|
|
|
|
.catch(() => {
|
|
|
|
|
|
if (!cancelled) setHistorySummary(null)
|
|
|
|
|
|
})
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
cancelled = true
|
|
|
|
|
|
}
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [])
|
|
|
|
|
|
// Hopp automatisk forbi "overview" når det ikke er noe å vise -- unngår
|
|
|
|
|
|
// et ekstra obligatorisk trykk for HVER hull-registrering (se
|
|
|
|
|
|
// begrunnelsen på SheetStep over).
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (step === "overview" && historySummary === null) forward()
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [step, historySummary])
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
const autoField = step === "strokes" ? "strokes" : step === "putts" ? "putts" : step === "puttDistance" ? "puttBucket" : null
|
|
|
|
|
|
const enteredWithValueRef = useRef(false)
|
|
|
|
|
|
const firedRef = useRef(false)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
firedRef.current = false
|
|
|
|
|
|
enteredWithValueRef.current =
|
|
|
|
|
|
autoField === "strokes" ? strokes !== null : autoField === "putts" ? putts !== null : autoField === "puttBucket" ? puttBucket !== null : false
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [step])
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!autoField || firedRef.current || enteredWithValueRef.current) return
|
|
|
|
|
|
const value = autoField === "strokes" ? strokes : autoField === "putts" ? putts : puttBucket
|
|
|
|
|
|
if (value !== null) {
|
|
|
|
|
|
firedRef.current = true
|
|
|
|
|
|
forward()
|
|
|
|
|
|
}
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [strokes, putts, puttBucket])
|
|
|
|
|
|
|
|
|
|
|
|
// GIR-auto-inferens, samme formel/begrunnelse som ScoringWizard.
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (step !== "direction") return
|
|
|
|
|
|
if (approach !== null) return
|
|
|
|
|
|
if (strokes === null || putts === null) return
|
|
|
|
|
|
if (strokes - putts <= hole.par - 2) setApproach("hit")
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [step, strokes, putts, approach, hole.par])
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
})
|
|
|
|
|
|
onClose()
|
|
|
|
|
|
} 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>
|
|
|
|
|
|
|
|
|
|
|
|
<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
|
|
|
|
{/* Rangefinder (migrasjon 079) -- samme selvskjulende komponent
|
|
|
|
|
|
som frittstående runder, persistent uansett hvilket steg
|
|
|
|
|
|
brukeren er på (samme plassering/prinsipp som round-detail.tsx). */}
|
|
|
|
|
|
<HoleTargetDistance baseUrl={targetPointsBaseUrl} holeNumber={hole.hole_number} size="full" />
|
|
|
|
|
|
|
2026-08-16 10:21:48 +02:00
|
|
|
|
{/* ADR-079: personlig historikk, flyttet hit ut av "strokes"-
|
|
|
|
|
|
steget -- se begrunnelsen for auto-hopp ved SheetStep over.
|
|
|
|
|
|
Samme kompakte oppsummering + klikk-for-full-historikk-
|
|
|
|
|
|
mønster som HoleHistoryPanel (hole-stat-inputs.tsx), men
|
|
|
|
|
|
lokal her siden foreldren allerede har hentet dataene (for
|
|
|
|
|
|
auto-hopp-avgjørelsen) -- unngår et redundant andre kall. */}
|
|
|
|
|
|
{step === "overview" && historySummary && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setShowFullHistory(true)}
|
|
|
|
|
|
className="flex items-center justify-between gap-3 rounded-2xl border border-border bg-card p-4 text-left transition-colors hover:bg-accent/50"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="flex flex-col gap-1">
|
|
|
|
|
|
<span className="text-sm font-bold text-foreground">Din historikk 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
|
|
|
|
{/* Teller-flis-rutenett (V0-designet, ADR-078, 2026-08-16) --
|
|
|
|
|
|
samme 2x2-mønster som round-detail.tsx sitt speilede
|
|
|
|
|
|
holeDetails-steg, se kommentaren der for begrunnelsen. */}
|
|
|
|
|
|
{step === "holeDetails" && (
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
|
<Stepper label="Chip" value={chip} onChange={setChip} readOnly={false} max={strokes ?? undefined} />
|
|
|
|
|
|
<Stepper label="Bunker" value={bunker} onChange={setBunker} readOnly={false} max={strokes ?? undefined} />
|
|
|
|
|
|
<Stepper label="Straffeslag" value={penalty} onChange={setPenalty} readOnly={false} max={strokes ?? undefined} />
|
|
|
|
|
|
<Stepper label="Anywayslag" value={anywayStrokes} onChange={setAnywayStrokes} readOnly={false} max={strokes ?? undefined} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
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-16 09:53:16 +02:00
|
|
|
|
{isLastStep ? (saving ? "Lagrer…" : "Lagre") : "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,
|
2026-07-30 07:55:35 +02:00
|
|
|
|
onUpdate,
|
2026-08-15 06:32:17 +02:00
|
|
|
|
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[]
|
2026-07-30 07:55:35 +02:00
|
|
|
|
onUpdate: (holeNumber: number, grossStrokes: number) => Promise<void>
|
2026-08-15 06:32:17 +02:00
|
|
|
|
onUpdateFull: (holeNumber: number, patch: HolePatch) => Promise<void>
|
2026-07-30 07:55:35 +02:00
|
|
|
|
}) {
|
|
|
|
|
|
const [editingHole, setEditingHole] = useState<number | null>(null)
|
|
|
|
|
|
const [draft, setDraft] = useState("")
|
2026-08-15 06:32:17 +02:00
|
|
|
|
// Full statistikkdybde (migrasjon 075, ADR-071): en annen deltaker-
|
|
|
|
|
|
// celle enn den enkle inline-tekstboksen under -- kun for statLevel !=
|
|
|
|
|
|
// "strokes_only". HoleGrid sin egen strokes_only-vei (editingHole/
|
|
|
|
|
|
// draft/openEditor/commit over) er BEVISST uendret, ingen regresjon.
|
|
|
|
|
|
const [sheetHole, setSheetHole] = useState<number | null>(null)
|
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
|
|
|
|
|
|
|
|
|
|
|
|
function openEditor(holeNumber: number, current: number | null) {
|
2026-08-15 06:32:17 +02:00
|
|
|
|
if (statLevel !== "strokes_only") {
|
|
|
|
|
|
setSheetHole(holeNumber)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-07-30 07:55:35 +02:00
|
|
|
|
setEditingHole(holeNumber)
|
|
|
|
|
|
setDraft(current !== null ? String(current) : "")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function commit(holeNumber: number) {
|
|
|
|
|
|
const value = Number(draft)
|
|
|
|
|
|
if (Number.isFinite(value) && value >= 1 && value <= 20) {
|
|
|
|
|
|
await onUpdate(holeNumber, value)
|
|
|
|
|
|
}
|
|
|
|
|
|
setEditingHole(null)
|
|
|
|
|
|
setDraft("")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
|
<div className="inline-grid min-w-full gap-y-1" style={{ gridTemplateColumns: `repeat(${numbers.length}, minmax(2.5rem, 1fr))` }}>
|
|
|
|
|
|
{numbers.map((n) => (
|
|
|
|
|
|
<span key={`num-${n}`} className="text-center text-[11px] font-bold text-muted-foreground">
|
|
|
|
|
|
{n}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{numbers.map((n) => (
|
|
|
|
|
|
<span key={`par-${n}`} className="text-center text-[10px] font-semibold tabular-nums text-muted-foreground">
|
|
|
|
|
|
Par {byNumber.get(n)?.par ?? "–"}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{numbers.map((n) => {
|
|
|
|
|
|
const hole = byNumber.get(n)
|
|
|
|
|
|
const gross = hole?.gross_strokes ?? null
|
|
|
|
|
|
const net = gross !== null && hole?.strokes_received !== null && hole?.strokes_received !== undefined
|
|
|
|
|
|
? gross - hole.strokes_received
|
|
|
|
|
|
: gross
|
|
|
|
|
|
const diff = net !== null && hole ? net - hole.par : null
|
|
|
|
|
|
const kind = diff !== null ? classify(diff) : null
|
|
|
|
|
|
const isEditing = editingHole === n
|
|
|
|
|
|
|
|
|
|
|
|
if (isEditing) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<input
|
|
|
|
|
|
key={`cell-${n}`}
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
|
value={draft}
|
|
|
|
|
|
onChange={(e) => setDraft(e.target.value.replace(/[^0-9]/g, ""))}
|
|
|
|
|
|
onBlur={() => commit(n)}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key === "Enter") commit(n)
|
|
|
|
|
|
if (e.key === "Escape") {
|
|
|
|
|
|
setEditingHole(null)
|
|
|
|
|
|
setDraft("")
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="mx-auto flex size-9 items-center justify-center rounded-[4px] border-2 border-info bg-background text-center text-sm font-extrabold tabular-nums text-foreground outline-none"
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={`cell-${n}`}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => openEditor(n, gross)}
|
|
|
|
|
|
aria-label={
|
|
|
|
|
|
gross !== null
|
|
|
|
|
|
? `Hull ${n}, ${gross} slag, trykk for å endre`
|
|
|
|
|
|
: `Hull ${n}, ikke registrert, trykk for å registrere`
|
|
|
|
|
|
}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"mx-auto flex size-9 items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
|
|
|
|
|
|
kind
|
|
|
|
|
|
? scoreMarkClasses(kind)
|
|
|
|
|
|
: "rounded-[4px] border-2 border-dashed border-border text-muted-foreground hover:bg-accent/50",
|
|
|
|
|
|
)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{gross ?? "–"}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{totalGross !== null && totalPar !== null && (
|
|
|
|
|
|
<div className="flex items-center justify-between border-t border-border pt-3 text-sm">
|
|
|
|
|
|
<span className="font-semibold text-muted-foreground">
|
|
|
|
|
|
{played.length} av {numbers.length} hull spilt
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span className="font-extrabold tabular-nums text-foreground">
|
|
|
|
|
|
{totalGross} slag ({totalGross - totalPar >= 0 ? "+" : ""}
|
|
|
|
|
|
{totalGross - totalPar} til par)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-08-15 06:32:17 +02:00
|
|
|
|
|
|
|
|
|
|
{sheetHole !== null && statLevel !== "strokes_only" && byNumber.get(sheetHole) && (
|
|
|
|
|
|
<HoleStatsSheet
|
|
|
|
|
|
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-15 06:32:17 +02:00
|
|
|
|
onSave={(patch) => onUpdateFull(sheetHole, patch)}
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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 ?? "-",
|
|
|
|
|
|
totalLabel: entry.total_label ?? "E",
|
|
|
|
|
|
totalIsUnderPar: false,
|
|
|
|
|
|
isLeader: entry.is_leader,
|
|
|
|
|
|
rounds: entry.rounds.map((r) => ({ roundNumber: r.round_number, label: r.label, tone: r.tone })),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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-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">
|
|
|
|
|
|
{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"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<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-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å.")
|
|
|
|
|
|
} else if (code === "DUPLICATE") {
|
|
|
|
|
|
setError("Denne banen er allerede importert til organisasjonen.")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setError("Klarte ikke å importere banen. Prøv igjen.")
|
|
|
|
|
|
}
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
onImported(await res.json())
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setError("Klarte ikke å importere banen. Prøv igjen.")
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setImporting(false)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
|
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
|
<span className="text-sm font-bold text-foreground">Hent bane fra teeoff</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="ghost"
|
|
|
|
|
|
size="icon"
|
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
|
className="size-8 rounded-lg text-muted-foreground"
|
|
|
|
|
|
aria-label="Lukk"
|
|
|
|
|
|
>
|
|
|
|
|
|
<X aria-hidden="true" className="size-4" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
|
|
|
|
|
|
|
|
|
|
{!selectedFacility ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
{/* Bevisst IKKE et eget <form> her -- denne komponenten rendres inne
|
|
|
|
|
|
i NewRoundForm sitt <form onSubmit={handleSubmit}>, og nestede
|
|
|
|
|
|
<form>-elementer er ugyldig HTML (samme feilrapport-funn som i
|
|
|
|
|
|
tournament-program.tsx sin variant -- "Søk"-knappen ville ellers
|
|
|
|
|
|
i praksis submitte det YTRE runde-skjemaet). */}
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
placeholder="Søk anleggsnavn…"
|
|
|
|
|
|
value={query}
|
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
|
runSearch()
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="h-11 flex-1 rounded-xl text-base"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => runSearch()}
|
|
|
|
|
|
disabled={searching}
|
|
|
|
|
|
className="h-11 shrink-0 rounded-xl font-bold"
|
|
|
|
|
|
>
|
|
|
|
|
|
{searching ? "Søker…" : "Søk"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{facilities && (
|
|
|
|
|
|
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
|
|
|
|
|
{facilities.map((f) => (
|
|
|
|
|
|
<li key={f.slug} className="border-b border-border last:border-b-0">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => pickFacility(f)}
|
|
|
|
|
|
className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="text-sm font-semibold text-foreground">{f.name}</span>
|
|
|
|
|
|
{(f.city || f.county) && (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
|
{[f.city, f.county].filter(Boolean).join(", ")}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{facilities.length === 0 && (
|
|
|
|
|
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setSelectedFacility(null)
|
|
|
|
|
|
setCourseOptions(null)
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
|
|
|
|
|
|
>
|
|
|
|
|
|
← {selectedFacility.name}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{courseOptions === null ? (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">Laster baner…</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
|
|
|
|
|
|
{courseOptions.map((c) => (
|
|
|
|
|
|
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
disabled={importing}
|
|
|
|
|
|
onClick={() => importCourse(c)}
|
|
|
|
|
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
|
|
|
|
|
|
>
|
|
|
|
|
|
{c.name}
|
|
|
|
|
|
{importing && <span className="text-xs font-normal text-muted-foreground">Importerer…</span>}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
{courseOptions.length === 0 && (
|
|
|
|
|
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
Ingen 18-hulls baner å importere hos dette anlegget ennå.
|
|
|
|
|
|
</li>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|