Fiks turneringsoppsett-rekkefølge, koble til TeeOff-import, og bygg delt bane-mal-bibliotek

Klasser vises nå etter bane-/rundeoppsett (var meningsløst å velge
standardutslag før banen fantes). TeeOff-import koblet inn i
individuelle turneringer. Ny mal-basert baneoppretting i
turneringsmodulen (fra bunnen av / TeeOff / offentlig custom-bane),
med personal_course gjenbrukt som et delt, plattform-omfattende
bane-bibliotek på tvers av org- og single-runde-modulen (ADR-042,
migrasjon 054).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-04 11:34:28 +02:00
parent 828b508c53
commit aee1662de2
12 changed files with 2382 additions and 301 deletions

View file

@ -0,0 +1,23 @@
-- Bane-mal-bibliotek, del II (2026-08-04) -- brukeren oppdaget at
-- turneringsmodulen manglet TeeOff-import for individuelle turneringer OG at
-- "opprett manuell bane" der bare var et bart navnefelt (ingen hull/utslag-
-- skjema fantes). Løsningen: la BEGGE moduler (turnering + single-runde)
-- tilby "bruk en eksisterende bane som mal" -- enten fra TeeOff eller fra en
-- annen bruker/organisasjons offentlige custom-bane -- som forhåndsutfyller
-- et redigerbart hull/utslag-skjema før lagring.
--
-- personal_course (020_personal_rounds.sql) er, til tross for navnet,
-- ALLEREDE et globalt, plattform-omfattende bibliotek -- GET /personal-
-- courses søker på tvers av ALLE brukeres baner uten eier- eller org-
-- filtrering (rounds.py:142-149). Bekreftet med bruker: dette biblioteket
-- er nettopp det "offentlige" stedet begge moduler skal dele -- ingen ny
-- tabell trengs, kun denne ene kolonnen for opphav/attribusjon når en bane
-- er en redigert kopi ("fork") av en annen.
--
-- Eierskaps-håndhevelse for de nye endre-/dupliser-/slette-endepunktene
-- (rounds.py) skjer i APPLIKASJONSLAGET (created_by_user_id ==
-- innlogget bruker), IKKE via RLS -- personal_course har aldri vært
-- org-scopet eller RLS-dekket (bevisst, siden opprettelsen i migrasjon 020),
-- og det er ingen grunn til å endre det prinsippet nå.
ALTER TABLE personal_course
ADD COLUMN forked_from_id uuid REFERENCES personal_course (id) ON DELETE SET NULL;

View file

@ -3899,6 +3899,85 @@ for-tall-bekreftet leaderboard-rangering per klasse i ekte nettleser).
--- ---
## ADR-042: Delt, plattform-omfattende bane-mal-bibliotek (`personal_course`)
**Kontekst:** Brukeren oppdaget at turneringsmodulens "opprett manuell
bane" i praksis var ubrukelig (kun et navnefelt, ingen vei til hull/
utslag), og ba om at BÅDE turneringsmodulen og single-runde-modulen skal
tilby "bruk en eksisterende bane (TeeOff eller andres custom-bane) som
mal" ved manuell baneoppretting. Et oppfølgingsspørsmål presiserte at
disse custom-banene "bør lagres, og de må være offentlige" — synlige og
gjenbrukbare av andre.
**Beslutning A — Gjenbruk `personal_course`, ikke en ny tabell.**
`personal_course` (020_personal_rounds.sql, opprinnelig bygget for
frittstående runder) er, til tross for navnet og til tross for at ingen
tidligere runde eksplisitt utnyttet det, ALLEREDE en global,
plattform-omfattende katalog — `GET /personal-courses` søker på tvers av
ALLE brukeres baner uten eier- eller organisasjonsfiltrering, siden
tabellen (bevisst, fra migrasjon 020) aldri har vært RLS-/org-scopet.
Fremfor å bygge en ny `community_course`-tabell (vurdert og forkastet)
gjenbrukes denne eksisterende, allerede-globale tabellen direkte som det
delte mal-biblioteket — eneste tilføyelse er én kolonne,
`forked_from_id` (migrasjon 054, selvreferende FK, `ON DELETE SET NULL`)
for proveniens/attribusjon. Dette er også den ENESTE farbare veien for
at single-runde-modulen (som ikke har noe organisasjons-begrep i det
hele tatt) kan dele samme bibliotek som org-turneringsmodulen.
**Beslutning B — "Offentlig" betyr hele plattformen, ikke bare egen
organisasjon.** Bekreftet eksplisitt med bruker (`AskUserQuestion`) etter
at jeg flagget spenningen: skal en publisert custom-bane være synlig for
ALLE organisasjoner + alle frittstående brukere, eller kun innad i én
organisasjon? Svaret var plattform-omfattende — organisasjonsgrensen
gjelder domenedata (turneringer, spillere, resultater), ikke dette
delte, lavsensitive bane-referansebiblioteket. En org-`course`-rad
(brukt til faktisk spill i en turnering) forblir like fullt org-scopet
og RLS-beskyttet som før — publisering til `personal_course` skjer som
en SEPARAT, samtidig innsetting i samme transaksjon (`courses.py`), ikke
en endring av `course`-tabellens egen skoping.
**Beslutning C — Eierskap håndheves i applikasjonslaget, ikke RLS.**
Siden `personal_course` bevisst aldri har vært org-scopet, finnes det
ingen `app.current_org`-kontekst å håndheve mot. De nye
endre-/dupliser-/slette-endepunktene (`rounds.py`) sjekker eksplisitt
`created_by_user_id == innlogget bruker` i handler-koden — samme
`plain_connection()`-uten-RLS-mønster som resten av `personal_course`/
`round`-familien allerede bruker (ADR-033 Beslutning A).
**Beslutning D — Rediger forker automatisk hvis du ikke eier raden;
Dupliser er en egen, eksplisitt handling.** `PATCH /personal-courses/{id}`:
eier den innloggede brukeren raden, oppdateres den i sted; eier
brukeren IKKE raden, opprettes automatisk en NY rad (eid av innlogget
bruker, `forked_from_id` satt til originalen) — originalen selv røres
aldri. Ett endepunkt dekker begge casene uten at frontend selv må
forgrene på eierskap. `POST .../duplicate` er en SEPARAT, eksplisitt
handling tilgjengelig uansett eierskap på kilden — dekker f.eks. å lage
en variant av DIN EGEN bane uten å miste originalen, noe fork-ved-
redigering alene ikke gjør (den trigges kun når raden IKKE er din).
Bekreftet eksplisitt med bruker at begge mekanismene skulle beholdes
side om side.
**Beslutning E — Mal-bruk er en engangs-kopi, ingen vedvarende kobling.**
Når en org-`course` opprettes med en `personal_course` (eller en
TeeOff-bane) som utgangspunkt, kopieres dataene inn i den nye raden ved
opprettelsestidspunktet — ingen fremmednøkkel eller synk-mekanisme
knytter dem sammen etterpå. Samme filosofi som offisiell TeeOff-import
allerede etablerte (ADR-019): en mal er et utgangspunkt å redigere fritt
fra, ikke en levende referanse. Konsekvens: sletting av en
`personal_course`-mal påvirker ALDRI en org-`course`-rad som tidligere
ble opprettet fra den.
**Konsekvens/bevisst utenfor omfang:** ingen moderasjon/vetting av
offentlige baner (tillitsbasert, matcher appens øvrige
lukkede-plattform-antagelser); ingen privat/kun-min-org-synlighet per
bane (alt publisert via denne veien er plattform-offentlig, ingen
per-rad-innstilling bygget). Se CHANGELOG.md 2026-08-04 for full
bygge-/verifiseringsdetalj (34/34 håndregnede API-sjekker, ekte
nettleser-verifisering på tvers av to brukere/to organisasjoner).
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt.
---
## Åpne spørsmål (ikke besluttet ennå) ## Åpne spørsmål (ikke besluttet ennå)
Disse må avklares før eller under de relevante fasene: Disse må avklares før eller under de relevante fasene:

View file

@ -7207,3 +7207,184 @@ Neste steg:
teecup_api teecup_frontend`, begge containere boot-et rent, teecup_api teecup_frontend`, begge containere boot-et rent,
`/health`/`/dashboard` → 200. V0-zip-en slettet etter merge, per `/health`/`/dashboard` → 200. V0-zip-en slettet etter merge, per
etablert rutine. etablert rutine.
19. **Baneoppsett i turneringer: rekkefølge, TeeOff-import og delt
bane-mal-bibliotek — BYGGET OG SCRATCH-VERIFISERT GRUNDIG 2026-08-04,
migrasjon 054, IKKE ENNÅ RULLET UT MOT ekte `teecup_db`.** Brukeren
oppdaget, mens hen brukte den live turneringsmodulen, at "Klasser"
(med sin Standardutslag-velger, ADR-041) vises FØR banen/runden i det
hele tatt er satt opp -- meningsløst å velge standardutslag for en
klasse før man vet hvilke utslag banen har. Bemerket samtidig at
TeeOff-henting av baner ikke så ut til å være tilgjengelig i
turneringsmodulen, og ba om en helt ny funksjon: la BÅDE
turneringsmodulen OG single-runde-modulen tilby "bruk en eksisterende
bane som mal" (fra TeeOff, eller fra en annens custom-bane) når man
oppretter en manuell bane.
**Del I -- rekkefølge + TeeOff-import-gap (ingen skjemaendring):**
Explore-agent bekreftet klagen presist og avdekket at det var verre
for lagturneringer enn antatt: for individuelle turneringer lå
`ClassesCard` FØR `RoundsCard` i samme Oppsett-fane
(`individual-tournament-detail.tsx`); for lagturneringer lå Klasser
på selve FØRSTE fanen ("Lag og spillere"), mens bane-/rundeoppsett
krevde en hel sidenavigering til "Program". TeeOff offisiell-import
(ADR-019, ferdig bygget) var KUN koblet til lagturnerings-økt-UI-et,
fullstendig fraværende fra den individuelle turneringsflyten.
Fikset: `ClassesCard` flyttet til å rendres ETTER `RoundsCard` i
individuelle turneringer; Klasser-kortet flyttet fysisk fra
`tournament-detail.tsx` ("Lag og spillere") til `tournament-program.tsx`
("Program"), rett etter økt-/rundeoppsettet -- `classes`-state (brukt
av `TeamPanel` til roster-klassevisning) ble værende i
`tournament-detail.tsx`, men create/delete-handlerne og selve
`ClassesCard`-komponentdefinisjonen flyttet med til `tournament-program.tsx`.
`OfficialCourseSearch`-komponentmønsteret (allerede i
`tournament-program.tsx`) kopiert inn i `individual-tournament-detail.tsx`
(samme duplisering-mellom-turneringstype-filer-konvensjon som
`ClassesCard` allerede fulgte), koblet til `NewRoundForm` sitt
eksisterende "+ Ny bane"-felt.
**Det største funnet (Explore-agent, avdekket FØR bygging):**
turneringsmodulens "opprett manuell bane" var i praksis ubrukelig --
KUN et navnefelt (`POST /orgs/{id}/courses` tok bare imot `{name}`),
ingen vei til hull/par/hcp-indeks/utslag i det hele tatt. To
sub-ressurs-endepunkter for å legge til dette i etterkant fantes
(`courses.py` sine `create_tee`/`create_holes`), men hadde ALDRI fått
noe frontend-kallsted -- funksjonen ble aldri fullført.
**Del II -- mal-basert baneoppretting + delt offentlig bane-bibliotek.**
Et nøkkelfunn forenklet løsningen betraktelig: `personal_course`
(020_personal_rounds.sql, "frittstående runder") er, til tross for
navnet, ALLEREDE et globalt, plattform-omfattende bibliotek -- `GET
/personal-courses` søker på tvers av ALLE brukeres baner uten eier-
eller org-filtrering, og `POST /personal-courses` tar allerede imot
full hull-/utslagdata i ett atomisk kall. Ingen ny tabell trengtes --
kun én kolonne (`forked_from_id`, migrasjon 054) for
proveniens/attribusjon.
Tre `AskUserQuestion`-runder avklarte omfanget presist FØR bygging
(samme disiplin som ADR-037/039/041): (1) ren reorder, ingen
blokkering; (2) et helt NYTT, kompakt hull-/utslag-editorskjema for
turneringsmodulen (speiler single-runde-modulens `OwnCreateStep` i
funksjon, egen stil), med "bruk som mal"-vei fra BÅDE TeeOff og
offentlig custom-bane; (3) alle tre moduler (individuell,
lagturnering, frittstående runde) skal ha funksjonen.
Et oppfølgingsspørsmål fra brukeren ("disse custom banene bør lagres,
og de må være offentlige... er det noe jeg ikke har tenkt på?") ble
tatt til en fjerde avklaringsrunde: (a) "offentlig" betyr HELE
TeeCup-plattformen (ikke bare egen org) -- eneste måte
single-runde-modulen faktisk kan dele samme mal-bibliotek som
turneringsmodulen, siden den ikke har noe org-begrep; (b) "Dupliser"
beholdes som egen, eksplisitt handling i tillegg til at redigering av
en ANNENS bane automatisk forker en kopi (redigering av DIN EGEN bane
endrer den i stedet, ingen fork).
**Backend:**
- `054_personal_course_provenance.sql`: `personal_course.forked_from_id`
(selvreferende FK, `ON DELETE SET NULL`). Ingen RLS-endring --
tabellen har aldri vært org-scopet (bevisst siden migrasjon 020).
- `rounds.py`: `PersonalCourseOut`/`PersonalCourseDetail` utvidet med
`is_mine`/`created_by_display_name` (attribusjon i mal-søket, FULLT
navn per navneformat-regelen -- dette er en administrasjonsvisning,
ikke direkte adressering) og (kun Detail) `holes`/`full_tees`/
`forked_from_id` (forhåndsutfylling). Nye endepunkter: `PATCH
/personal-courses/{id}` (eier: oppdaterer i sted; IKKE eier: forker
automatisk -- ett endepunkt dekker begge casene uten at frontend må
forgrene på eierskap), `POST .../duplicate` (eksplisitt, uavhengig
av eierskap), `DELETE ...` (kun eier, 403 ellers; fanger opp
`asyncpg.ForeignKeyViolationError` fra `round.personal_course_id`
sin allerede-eksisterende `NO ACTION`-FK og gir en vennlig 409
"IN_USE" -- IKKE via den delte `translate_db_errors()`, som ville
gitt feil retning/melding for akkurat denne casen). `GET
/personal-courses` fikk en `mine=true`-parameter (for
"Mine baner"-administrasjonen, unngår den vanlige 20-treffs-
begrensningen på navnesøket).
- `courses.py`: `POST /orgs/{id}/courses` utvidet til valgfritt å ta
imot samme `holes`/`tees`-form som `PersonalCourseCreate` --
populert setter den BÅDE org-`course`-raden (med hull/utslag,
samme SQL-mønster som de eksisterende men ubrukte sub-ressurs-
endepunktene) OG en tilsvarende `personal_course`-rad (eid av
innlogget bruker, "offentliggjøringen" brukeren ba om) i SAMME
transaksjon -- ingen vedvarende kobling mellom de to radene etterpå
(samme "engangs-kopi"-filosofi som offisiell TeeOff-import,
ADR-019). `official-import`-endepunktet selv er UENDRET.
- `rounds.py` sin `GET /rounds/official-search/{slug}` (personlig
rundemodul) utvidet med fulle `holes`/`full_tees` PER TeeOff-bane
(kun populert når teeoff-dataene er komplette nok -- samme
fullstendighetskrav som ADR-019-importen allerede håndhever) --
nødvendig fordi frittstående runder ALDRI persisterer teeoff-baner
(ADR-033 Beslutning C, live oppslag), så "bruk som mal" der må få
dataene tilbake i selve søkesvaret i stedet for å runde-trippe
gjennom en lagret rad slik org-siden kan.
**Frontend:**
- Ny delt fil `course-template-editor.tsx`: `CourseTemplateEditor`
(kompakt hull-/utslag-skjema, forhåndsutfyllbar via `initial`-prop,
ren UI + lokal validering -- kalleren avgjør hvor data persisteres,
matcher prosjektets etablerte adaptermønster) + `CourseTemplatePicker`
(velger: "Fra bunnen av" / "TeeOff-bane som mal" / "Offentlig bane
som mal" -- sistnevnte med BÅDE "Bruk direkte" og "Tilpass før
bruk"). Brukt fra `tournament-program.tsx` (både ny økt OG endre
eksisterende økts bane) og `individual-tournament-detail.tsx`
(`NewRoundForm`).
- `new-round.tsx`: `OwnCreateStep` utvidet med valgfrie
`initial`/`forkedFromId`-props (samme prefyll-mekanisme). Nye
"Bruk som mal for egen bane"-knapper i BÅDE `CourseList` (TeeOff-
baner, kun synlig når komplette maldata finnes) og `OwnSearch`
(offentlige custom-baner, nå med attribusjon "Opprettet av X" /
"Opprettet av deg" -- samme navneformat-regel som backend).
- `account-settings.tsx`: ny "Mine baner"-seksjon (`MyCoursesSection`)
-- liste over EGNE `personal_course`-rader (`?mine=true`), med
Rediger (gjenbruker `CourseTemplateEditor`, PATCH), Dupliser
(POST duplicate) og Slett (DELETE, med vennlig 409/i-bruk-melding)
per rad. Andres baner vises IKKE her -- kun i mal-søket i de
respektive modulene.
**Reell bug funnet og rettet UNDER scratch-verifisering** (ikke
antatt riktig fra koden alene): `CourseTemplateEditor` ble først
bygget med sitt eget `<form onSubmit>` -- men komponenten rendres
INNI et allerede eksisterende `<form>` (`NewRoundForm`/
`CreateSessionCard`), og nestede `<form>`-elementer er ugyldig HTML.
"Opprett bane"-knappen submittet i praksis det YTRE runde-/økt-
skjemaet i stedet, en ekte side-navigasjon som vasket bort
`?org=...`-parameteren fra URL-en -- funnet ved en ekte klikk-
gjennom-test i nettleser (fetch-kallet nådde aldri serveren, bekreftet
ved å sjekke databasen). Samme feilklasse som `OfficialCourseSearch`
allerede hadde en kommentar om fra en tidligere runde. Rettet: `<form>`
`<div>`, `type="submit"``type="button"` med eksplisitt
`onClick`-håndtering.
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
isolert scratch-MinIO + engangs API-/frontend-container, ekte
nettleser-innlogging som TO forskjellige brukere på tvers av TO
forskjellige organisasjoner):
- API-nivå: eget Python-testskript, 34/34 håndregnede sjekker bestått
(publisering-ved-opprettelse, kryss-bruker/kryss-org synlighet,
attribusjon, fork-ved-ikke-eier-redigering med verifisert uendret
original, eier-redigering-i-sted, eksplisitt duplisering,
eierskaps-gatede 403-er, og slette-blokkert-mens-i-bruk med riktig
409/IN_USE-kode).
- Ekte nettleser: rekkefølge-fiksen bekreftet i begge turneringstyper;
"Opprett bane med hull/utslag" fra bunnen av OG fra TeeOff-mal
(ekte Bergen Golfklubb-data, redigert par på hull 1 før lagring,
bekreftet BÅDE org-`course`- og `personal_course`-raden ble
opprettet riktig i databasen); den nye offentlige banen dukket
umiddelbart opp i single-runde-modulens banesøk MED riktig
attribusjon; "bruk som mal"-broen fra der forhåndsutfylte
`OwnCreateStep` korrekt fra en ANNEN brukers bane og forket en ny,
egen-eid rad ved lagring (bekreftet i databasen: ny rad eid av
innlogget bruker, `forked_from_id` pekende på originalen, originalen
selv uendret); "Mine baner" i kontoinnstillinger bekreftet å vise
KUN egne baner, Rediger (i sted, verifisert i databasen), Dupliser
og Slett (inkl. `confirm()`-dialoghåndtering) alle fungerende.
- `test_isolation.sql` 12/12 bestått (additiv migrasjon, ingen
RLS-endring). Ekte typesjekket produksjonsbuild (`docker build`,
samme steg som `Dockerfile` faktisk bruker) kjørt flere ganger
gjennom byggerunden, null TypeScript-feil.
Scratch-miljøet (database, rolle, MinIO, containere, images,
midlertidige hemmeligheter) fullstendig ryddet opp etter
verifisering.
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt ("Ja
takk"): migrasjon 054 kjørt mot ekte `teecup_db` (kun
`forked_from_id`-kolonnen, bekreftet tom/nullable, ingen
databrudd), `docker compose up -d --build teecup_api
teecup_frontend`, begge containere boot-et rent, `/health` og
`/dashboard` → 200 over https.

View file

@ -4050,6 +4050,50 @@ og 1920px storskjerm. Full detalj i CHANGELOG.md 2026-08-04.
--- ---
## Baneoppsett i turneringer: rekkefølge, TeeOff-import og delt bane-mal-bibliotek — ✅ HELT FERDIG, BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-04 (ADR-042, migrasjon 054)
Brukeren oppdaget at "Klasser" (med Standardutslag-velgeren, ADR-041)
vises FØR bane-/rundeoppsett i turneringsmodulen — meningsløst å velge
utslag for en klasse før man vet hvilke utslag banen har. Samtidig
bemerket hen at TeeOff-baneimport ikke var tilgjengelig i
turneringsmodulen, og ba om en ny funksjon: "bruk en eksisterende
TeeOff-bane som mal" ved manuell baneoppretting, i BÅDE
turneringsmodulen og single-runde-modulen.
**Del I (rekkefølge + TeeOff-import-gap):** `ClassesCard` flyttet til å
vises etter runde-/baneoppsett i begge turneringstyper (individuelle:
etter `RoundsCard` i samme fane; lagturneringer: flyttet fysisk fra
"Lag og spillere"-siden til "Program"-siden). TeeOff-import
(`OfficialCourseSearch`-mønsteret) koblet inn i den individuelle
turneringsflyten, som manglet det helt fra før.
**Del II (mal-basert baneoppretting + delt bibliotek, ADR-042):** avdekket
under planlegging at turneringsmodulens "opprett manuell bane" i praksis
var ubrukelig (kun et navnefelt, ingen hull-/utslag-skjema fantes noe
sted der). Bygget: et nytt, kompakt hull-/utslag-editorskjema for
turneringsmodulen; en malvelger (fra bunnen av / TeeOff-bane som mal /
offentlig custom-bane som mal); gjenbruk av `personal_course`
(020_personal_rounds.sql) som et allerede-globalt, plattform-omfattende
bane-bibliotek brukerens egen oppfølging ba om ("disse banene bør lagres
og være offentlige") — se ADR-042 for de fem arkitektoniske
beslutningene (delt tabell fremfor ny, plattform- IKKE org-omfattende
synlighet, applikasjonslags-eierskap, fork-ved-fremmed-redigering +
egen dupliser-handling, engangs-kopi ingen vedvarende kobling). Ny
"Mine baner"-administrasjon i kontoinnstillinger (rediger/dupliser/
slett egne publiserte baner).
Reell nestet-`<form>`-HTML-bug funnet og rettet UNDER scratch-
verifisering (ikke antatt riktig fra koden alene) — se CHANGELOG.md
2026-08-04 for full detalj. 34/34 håndregnede API-sjekker + grundig
ekte nettleser-verifisering (to brukere, to organisasjoner) bestått,
`test_isolation.sql` 12/12 uendret.
**Bevisst utenfor omfang:** ingen moderasjon/vetting av offentlige
baner; ingen privat/kun-min-org-synlighet per bane (alt publisert via
denne veien er plattform-offentlig).
---
## Bevisst endret fra opprinnelige (Gemini-)råd ## Bevisst endret fra opprinnelige (Gemini-)råd
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte - 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte

View file

@ -23,10 +23,6 @@ from pydantic import BaseModel, Field
router = APIRouter() router = APIRouter()
class CourseCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
class Course(BaseModel): class Course(BaseModel):
id: str id: str
name: str name: str
@ -45,24 +41,6 @@ async def list_courses(
return [Course(**dict(r)) for r in rows] return [Course(**dict(r)) for r in rows]
@router.post("/orgs/{organization_id}/courses", response_model=Course, status_code=201)
async def create_course(
body: CourseCreate,
organization_id: str = Depends(get_authorized_org),
) -> Course:
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
f"""
INSERT INTO course (organization_id, name, source)
VALUES ($1, $2, 'custom')
RETURNING {_COURSE_COLUMNS}
""",
organization_id,
body.name,
)
return Course(**dict(row))
# --- Tee-er (ADR-013-relatert: match_participant.tee_id krever en ekte tee) -- # --- Tee-er (ADR-013-relatert: match_participant.tee_id krever en ekte tee) --
# #
# Manglet helt frem til nå -- verken en liste-vei (selv offisielt importerte # Manglet helt frem til nå -- verken en liste-vei (selv offisielt importerte
@ -282,6 +260,147 @@ async def create_holes(
return sorted((Hole(**dict(r)) for r in rows), key=lambda h: h.hole_number) return sorted((Hole(**dict(r)) for r in rows), key=lambda h: h.hole_number)
# --- Manuell baneoppretting med fullt hull-/utslagoppsett (2026-08-04) ------
#
# `create_course` over tok frem til nå KUN imot et navn -- ingen vei til
# hull/utslag fantes for en manuelt opprettet org-bane (kun de to ubrukte
# sub-ressurs-endepunktene over, `create_tee`/`create_holes`, som ALDRI fikk
# noe frontend-kallsted). Brukeren oppdaget dette hullet: "opprett manuell
# bane" i turneringsmodulen var i praksis ubrukelig uten et sted å taste inn
# hull/par/hcp-indeks/utslag etterpå.
#
# Denne utvidede varianten dekker begge deler i ÉN innsending (speiler
# `PersonalCourseCreate` i rounds.py sin form), OG publiserer samtidig en
# kopi til det delte, plattform-omfattende bane-biblioteket
# (`personal_course`, 020_personal_rounds.sql) -- brukeren bekreftet
# eksplisitt at custom-baner opprettet via den nye mal-baserte flyten skal
# "lagres og være offentlige", slik at samme bane kan brukes som mal av
# andre organisasjoner OG i single-runde-modulen. `personal_course` har
# ingen RLS/organization_id (bevisst globalt siden migrasjon 020) -- å skrive
# til den fra en org-scopet tilkobling er trygt, ingen policy er i veien.
class CourseCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
# Enten BEGGE er med, eller INGEN -- ufullstendig oppsett (kun holes,
# ikke tees) gir en tydelig 400 heller enn en course-rad som stille
# mangler halve dataene.
holes: list[HoleCreate] | None = None
tees: list[TeeCreate] | None = None
# Satt når banen opprettes med en offentlig custom-bane (personal_course)
# som mal -- ren attribusjon på den NYE personal_course-publiseringen
# under, ingen vedvarende kobling på selve org-bane-raden (samme
# "engangs-kopi"-filosofi som offisiell TeeOff-import, ADR-019).
template_personal_course_id: str | None = None
@router.post("/orgs/{organization_id}/courses", response_model=Course, status_code=201)
async def create_course(
body: CourseCreate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> Course:
if (body.holes is None) != (body.tees is None):
raise app_error(400, "VALIDATION_FAILED", "Hull og utslag må enten begge være med, eller ingen av dem.")
if body.holes is not None and body.tees is not None:
numbers = sorted(h.hole_number for h in body.holes)
if len(body.holes) != 18 or numbers != list(range(1, 19)):
raise app_error(400, "VALIDATION_FAILED", "Må oppgi nøyaktig 18 hull, nummerert 1-18.")
for t in body.tees:
genders = [r.gender for r in t.ratings]
if len(genders) != len(set(genders)):
raise app_error(
400, "VALIDATION_FAILED", "Kan ikke oppgi to ratinger for samme kjønn på ett utslag."
)
async with org_connection(organization_id) as conn, translate_db_errors():
async with conn.transaction():
row = await conn.fetchrow(
f"""
INSERT INTO course (organization_id, name, source)
VALUES ($1, $2, 'custom')
RETURNING {_COURSE_COLUMNS}
""",
organization_id,
body.name,
)
if body.holes is not None and body.tees is not None:
for h in body.holes:
await conn.execute(
"""
INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index)
VALUES ($1, $2, $3, $4, $5)
""",
organization_id,
row["id"],
h.hole_number,
h.par,
h.stroke_index,
)
for t in body.tees:
tee_row = await conn.fetchrow(
"INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id",
organization_id,
row["id"],
t.name,
)
for r in t.ratings:
await conn.execute(
"""
INSERT INTO tee_rating
(organization_id, tee_id, scope, gender, course_rating, slope_rating, par)
VALUES ($1, $2, 'full_18', $3, $4, $5, $6)
""",
organization_id,
tee_row["id"],
r.gender,
r.course_rating,
r.slope_rating,
r.par,
)
pc_row = await conn.fetchrow(
"""
INSERT INTO personal_course (name, created_by_user_id, forked_from_id)
VALUES ($1, $2, $3) RETURNING id
""",
body.name,
user.user_id,
body.template_personal_course_id,
)
for h in body.holes:
await conn.execute(
"""
INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index)
VALUES ($1, $2, $3, $4)
""",
pc_row["id"],
h.hole_number,
h.par,
h.stroke_index,
)
for t in body.tees:
pc_tee_row = await conn.fetchrow(
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
pc_row["id"],
t.name,
)
for r in t.ratings:
await conn.execute(
"""
INSERT INTO personal_course_tee_rating
(personal_course_tee_id, gender, course_rating, slope_rating, par)
VALUES ($1, $2, $3, $4, $5)
""",
pc_tee_row["id"],
r.gender,
r.course_rating,
r.slope_rating,
r.par,
)
return Course(**dict(row))
# --- Offisiell banedata fra teeoff (ADR-019) --------------------------------- # --- Offisiell banedata fra teeoff (ADR-019) ---------------------------------

View file

@ -40,6 +40,7 @@ import uuid
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from typing import Literal from typing import Literal
import asyncpg
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field
@ -121,11 +122,18 @@ class PersonalCourseCreate(BaseModel):
name: str name: str
holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18) holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18)
tees: list[PersonalCourseTeeIn] = Field(min_length=1) tees: list[PersonalCourseTeeIn] = Field(min_length=1)
# Satt når banen opprettes med en annen offentlig custom-bane som
# utgangspunkt/mal (turneringsmodul + single-runde-modul, 2026-08-04) --
# ren attribusjon/proveniens, INGEN vedvarende kobling (samme "engangs-
# kopi"-filosofi som offisiell TeeOff-import, ADR-019).
forked_from_id: str | None = None
class PersonalCourseOut(BaseModel): class PersonalCourseOut(BaseModel):
id: str id: str
name: str name: str
is_mine: bool
created_by_display_name: str
class TeeOption(BaseModel): class TeeOption(BaseModel):
@ -137,17 +145,52 @@ class PersonalCourseDetail(BaseModel):
id: str id: str
name: str name: str
tees: list[TeeOption] tees: list[TeeOption]
# Full hull-/utslagdata -- kun brukt til å forhåndsutfylle et redigerbart
# baneoppsett-skjema når denne banen brukes som MAL (2026-08-04). `tees`
# over (kun navn+kjønn) er uendret -- eksisterende kallsteder som kun
# velger EN allerede ferdig bane til en runde bruker fortsatt den formen.
holes: list[PersonalCourseHoleIn]
full_tees: list[PersonalCourseTeeIn]
is_mine: bool
created_by_display_name: str
forked_from_id: str | None
@router.get("/personal-courses", response_model=list[PersonalCourseOut]) @router.get("/personal-courses", response_model=list[PersonalCourseOut])
async def search_personal_courses( async def search_personal_courses(
q: str = "", q: str = "",
# "Mine baner"-administrasjon (account-settings.tsx, 2026-08-04) --
# begrenser til EGNE baner uten å være underlagt den vanlige LIMIT 20-
# navnesøket sitt treffbilde. Ikke kombinert med `q` -- eier-visningen
# trenger ikke tekstsøk, kun en fullstendig liste over egne baner.
mine: bool = False,
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> list[PersonalCourseOut]: ) -> list[PersonalCourseOut]:
async with plain_connection() as conn: async with plain_connection() as conn:
if mine:
rows = await conn.fetch( rows = await conn.fetch(
"SELECT id::text AS id, name FROM personal_course WHERE name ILIKE $1 ORDER BY name LIMIT 20", """
SELECT pc.id::text AS id, pc.name, true AS is_mine, au.display_name AS created_by_display_name
FROM personal_course pc
JOIN app_user au ON au.id = pc.created_by_user_id
WHERE pc.created_by_user_id = $1
ORDER BY pc.name
""",
user.user_id,
)
else:
rows = await conn.fetch(
"""
SELECT pc.id::text AS id, pc.name,
(pc.created_by_user_id::text = $2) AS is_mine,
au.display_name AS created_by_display_name
FROM personal_course pc
JOIN app_user au ON au.id = pc.created_by_user_id
WHERE pc.name ILIKE $1
ORDER BY pc.name LIMIT 20
""",
f"%{q.strip()}%", f"%{q.strip()}%",
user.user_id,
) )
return [PersonalCourseOut(**dict(r)) for r in rows] return [PersonalCourseOut(**dict(r)) for r in rows]
@ -158,12 +201,23 @@ async def get_personal_course(
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> PersonalCourseDetail: ) -> PersonalCourseDetail:
async with plain_connection() as conn: async with plain_connection() as conn:
course_row = await conn.fetchrow("SELECT id::text AS id, name FROM personal_course WHERE id = $1", personal_course_id) course_row = await conn.fetchrow(
"""
SELECT pc.id::text AS id, pc.name, pc.forked_from_id::text AS forked_from_id,
(pc.created_by_user_id::text = $2) AS is_mine,
au.display_name AS created_by_display_name
FROM personal_course pc
JOIN app_user au ON au.id = pc.created_by_user_id
WHERE pc.id = $1
""",
personal_course_id,
user.user_id,
)
if course_row is None: if course_row is None:
raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.") raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
tee_rows = await conn.fetch( tee_rows = await conn.fetch(
""" """
SELECT t.name AS tee_name, r.gender SELECT t.name AS tee_name, r.gender, r.course_rating, r.slope_rating, r.par
FROM personal_course_tee t FROM personal_course_tee t
JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id
WHERE t.personal_course_id = $1 WHERE t.personal_course_id = $1
@ -171,13 +225,71 @@ async def get_personal_course(
""", """,
personal_course_id, personal_course_id,
) )
hole_rows = await conn.fetch(
"""
SELECT hole_number, par, stroke_index FROM personal_course_hole
WHERE personal_course_id = $1 ORDER BY hole_number
""",
personal_course_id,
)
tees: dict[str, list[str]] = {} tees: dict[str, list[str]] = {}
full_tees: dict[str, list[PersonalCourseTeeRatingIn]] = {}
for r in tee_rows: for r in tee_rows:
tees.setdefault(r["tee_name"], []).append(r["gender"]) tees.setdefault(r["tee_name"], []).append(r["gender"])
full_tees.setdefault(r["tee_name"], []).append(
PersonalCourseTeeRatingIn(
gender=r["gender"], course_rating=r["course_rating"], slope_rating=r["slope_rating"], par=r["par"]
)
)
return PersonalCourseDetail( return PersonalCourseDetail(
id=course_row["id"], id=course_row["id"],
name=course_row["name"], name=course_row["name"],
tees=[TeeOption(name=name, genders=genders) for name, genders in tees.items()], tees=[TeeOption(name=name, genders=genders) for name, genders in tees.items()],
holes=[PersonalCourseHoleIn(**dict(h)) for h in hole_rows],
full_tees=[PersonalCourseTeeIn(name=name, ratings=ratings) for name, ratings in full_tees.items()],
is_mine=course_row["is_mine"],
created_by_display_name=course_row["created_by_display_name"],
forked_from_id=course_row["forked_from_id"],
)
async def _replace_personal_course_contents(
conn, personal_course_id: str, name: str, holes: list[PersonalCourseHoleIn], tees: list[PersonalCourseTeeIn]
) -> None:
"""Sletter og setter inn hull/utslag på nytt -- trygt siden datamengden er
liten og fast (18 hull, utslag), samme prinsipp som selve
opprettelsen. Brukt av BÅDE "rediger min egen bane i sted" og
"fork -- opprett en ny rad med disse verdiene"."""
await conn.execute("UPDATE personal_course SET name = $2 WHERE id = $1", personal_course_id, name.strip())
await conn.execute("DELETE FROM personal_course_hole WHERE personal_course_id = $1", personal_course_id)
# personal_course_tee_rating kaskade-slettes med personal_course_tee.
await conn.execute("DELETE FROM personal_course_tee WHERE personal_course_id = $1", personal_course_id)
for h in holes:
await conn.execute(
"INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
personal_course_id,
h.hole_number,
h.par,
h.stroke_index,
)
for t in tees:
tee_row = await conn.fetchrow(
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
personal_course_id,
t.name.strip(),
)
for r in t.ratings:
await conn.execute(
"""
INSERT INTO personal_course_tee_rating
(personal_course_tee_id, gender, course_rating, slope_rating, par)
VALUES ($1, $2, $3, $4, $5)
""",
tee_row["id"],
r.gender,
r.course_rating,
r.slope_rating,
r.par,
) )
@ -192,11 +304,16 @@ async def create_personal_course(
raise app_error(400, "VALIDATION_FAILED", "Alle 18 hullnumre og alle 18 stroke-indekser må være unike, 1-18.") raise app_error(400, "VALIDATION_FAILED", "Alle 18 hullnumre og alle 18 stroke-indekser må være unike, 1-18.")
async with plain_connection() as conn, translate_db_errors(): async with plain_connection() as conn, translate_db_errors():
own_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
async with conn.transaction(): async with conn.transaction():
course_row = await conn.fetchrow( course_row = await conn.fetchrow(
"INSERT INTO personal_course (name, created_by_user_id) VALUES ($1, $2) RETURNING id::text AS id, name", """
INSERT INTO personal_course (name, created_by_user_id, forked_from_id)
VALUES ($1, $2, $3) RETURNING id::text AS id, name
""",
body.name.strip(), body.name.strip(),
user.user_id, user.user_id,
body.forked_from_id,
) )
for h in body.holes: for h in body.holes:
await conn.execute( await conn.execute(
@ -225,7 +342,153 @@ async def create_personal_course(
r.slope_rating, r.slope_rating,
r.par, r.par,
) )
return PersonalCourseOut(**dict(course_row)) return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name)
@router.patch("/personal-courses/{personal_course_id}", response_model=PersonalCourseOut)
async def update_personal_course(
personal_course_id: str,
body: PersonalCourseCreate,
user: CurrentUser = Depends(get_current_user),
) -> PersonalCourseOut:
"""Eier: oppdaterer banen i sted. IKKE eier: forker automatisk -- en NY
rad opprettes, eid av innlogget bruker, med `forked_from_id` satt til
originalen -- originalen selv røres ikke. Ett endepunkt dekker begge
"rediger min egen"/"lag min redigerbare kopi av andres"-casene, se
plan 2026-08-04 -- unngår at frontend selv forgrene eierskap."""
numbers = sorted(h.hole_number for h in body.holes)
indexes = sorted(h.stroke_index for h in body.holes)
if numbers != list(range(1, 19)) or indexes != list(range(1, 19)):
raise app_error(400, "VALIDATION_FAILED", "Alle 18 hullnumre og alle 18 stroke-indekser må være unike, 1-18.")
async with plain_connection() as conn, translate_db_errors():
owner_id = await conn.fetchval(
"SELECT created_by_user_id::text FROM personal_course WHERE id = $1", personal_course_id
)
if owner_id is None:
raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
own_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
if owner_id == user.user_id:
async with conn.transaction():
await _replace_personal_course_contents(conn, personal_course_id, body.name, body.holes, body.tees)
return PersonalCourseOut(
id=personal_course_id, name=body.name.strip(), is_mine=True, created_by_display_name=own_name
)
async with conn.transaction():
new_row = await conn.fetchrow(
"""
INSERT INTO personal_course (name, created_by_user_id, forked_from_id)
VALUES ($1, $2, $3) RETURNING id::text AS id
""",
body.name.strip(),
user.user_id,
personal_course_id,
)
await _replace_personal_course_contents(conn, new_row["id"], body.name, body.holes, body.tees)
return PersonalCourseOut(id=new_row["id"], name=body.name.strip(), is_mine=True, created_by_display_name=own_name)
@router.post("/personal-courses/{personal_course_id}/duplicate", response_model=PersonalCourseOut, status_code=201)
async def duplicate_personal_course(
personal_course_id: str,
user: CurrentUser = Depends(get_current_user),
) -> PersonalCourseOut:
"""Eksplisitt "lag en kopi"-handling, uavhengig av eierskap på kilden --
i tillegg til (ikke erstatning for) fork-ved-rediger over. Dekker f.eks.
å lage en variant av DIN EGEN bane uten å endre originalen, noe
fork-ved-rediger alene ikke gjør (den trigges kun ANDRES baner)."""
async with plain_connection() as conn, translate_db_errors():
source = await conn.fetchrow("SELECT name FROM personal_course WHERE id = $1", personal_course_id)
if source is None:
raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
own_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
hole_rows = await conn.fetch(
"SELECT hole_number, par, stroke_index FROM personal_course_hole WHERE personal_course_id = $1",
personal_course_id,
)
tee_rows = await conn.fetch(
"""
SELECT t.name AS tee_name, r.gender, r.course_rating, r.slope_rating, r.par
FROM personal_course_tee t
JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id
WHERE t.personal_course_id = $1
""",
personal_course_id,
)
tees_by_name: dict[str, list[PersonalCourseTeeRatingIn]] = {}
for r in tee_rows:
tees_by_name.setdefault(r["tee_name"], []).append(
PersonalCourseTeeRatingIn(
gender=r["gender"], course_rating=r["course_rating"], slope_rating=r["slope_rating"], par=r["par"]
)
)
new_name = f"{source['name']} (kopi)"
async with conn.transaction():
new_row = await conn.fetchrow(
"""
INSERT INTO personal_course (name, created_by_user_id, forked_from_id)
VALUES ($1, $2, $3) RETURNING id::text AS id
""",
new_name,
user.user_id,
personal_course_id,
)
for h in hole_rows:
await conn.execute(
"INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index) VALUES ($1, $2, $3, $4)",
new_row["id"],
h["hole_number"],
h["par"],
h["stroke_index"],
)
for tee_name, ratings in tees_by_name.items():
tee_row = await conn.fetchrow(
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
new_row["id"],
tee_name,
)
for r in ratings:
await conn.execute(
"""
INSERT INTO personal_course_tee_rating
(personal_course_tee_id, gender, course_rating, slope_rating, par)
VALUES ($1, $2, $3, $4, $5)
""",
tee_row["id"],
r.gender,
r.course_rating,
r.slope_rating,
r.par,
)
return PersonalCourseOut(id=new_row["id"], name=new_name, is_mine=True, created_by_display_name=own_name)
@router.delete("/personal-courses/{personal_course_id}", status_code=204)
async def delete_personal_course(
personal_course_id: str,
user: CurrentUser = Depends(get_current_user),
) -> None:
async with plain_connection() as conn:
owner_id = await conn.fetchval(
"SELECT created_by_user_id::text FROM personal_course WHERE id = $1", personal_course_id
)
if owner_id is None:
raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
if owner_id != user.user_id:
raise app_error(403, "FORBIDDEN", "Du kan bare slette dine egne baner.")
try:
await conn.execute("DELETE FROM personal_course WHERE id = $1", personal_course_id)
except asyncpg.ForeignKeyViolationError:
# round.personal_course_id har ingen ON DELETE-klausul (default
# NO ACTION, 020_personal_rounds.sql) -- en bane som er brukt i
# minst én runde er derfor allerede trygt beskyttet mot sletting
# på databasenivå. IKKE `translate_db_errors()` her -- den ville
# gitt "INVALID_REFERENCE" (feil retning, ment for INSERT/UPDATE
# som peker på noe som ikke finnes), ikke denne "i bruk"-casen.
raise app_error(409, "IN_USE", "Denne banen er i bruk i én eller flere runder og kan ikke slettes.")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -246,6 +509,16 @@ class OfficialCourseOption(BaseModel):
name: str name: str
is_main_course: bool is_main_course: bool
tees: list[TeeOption] tees: list[TeeOption]
# Fullt hull-/utslagoppsett -- KUN populert når teeoff har komplette data
# (alle 18 hull med par+hcp-indeks, minst én tee med rating). Brukt til å
# forhåndsutfylle et redigerbart baneoppsett-skjema når spilleren velger
# "bruk som mal" i stedet for direkte spill på banen (2026-08-04, samme
# mønster som org-siden sin `official-import` allerede parser -- her
# returneres dataene i stedet for å skrives, siden frittstående runder
# aldri persisterer teeoff-baner, ADR-033 Beslutning C). Tom liste = ikke
# komplett nok til å brukes som mal ennå.
holes: list[PersonalCourseHoleIn]
full_tees: list[PersonalCourseTeeIn]
class OfficialFacilityDetail(BaseModel): class OfficialFacilityDetail(BaseModel):
@ -321,23 +594,54 @@ async def get_official_facility_for_round(
courses: list[OfficialCourseOption] = [] courses: list[OfficialCourseOption] = []
for c in facility.get("courses", []): for c in facility.get("courses", []):
if len(c.get("holes") or []) != 18: raw_holes = c.get("holes") or []
if len(raw_holes) != 18:
continue continue
tees: list[TeeOption] = [] tees: list[TeeOption] = []
full_tees: list[PersonalCourseTeeIn] = []
for t in c.get("tees") or []: for t in c.get("tees") or []:
genders: list[str] = [] genders: list[str] = []
ratings: list[PersonalCourseTeeRatingIn] = []
par_total = sum(h["par"] for h in raw_holes if h.get("par") is not None)
if t.get("cr_men") is not None and t.get("slope_men") is not None: if t.get("cr_men") is not None and t.get("slope_men") is not None:
genders.append("m") genders.append("m")
ratings.append(
PersonalCourseTeeRatingIn(
gender="m", course_rating=float(t["cr_men"]), slope_rating=int(t["slope_men"]), par=par_total
)
)
if t.get("cr_women") is not None and t.get("slope_women") is not None: if t.get("cr_women") is not None and t.get("slope_women") is not None:
genders.append("f") genders.append("f")
ratings.append(
PersonalCourseTeeRatingIn(
gender="f", course_rating=float(t["cr_women"]), slope_rating=int(t["slope_women"]), par=par_total
)
)
if genders: if genders:
tees.append(TeeOption(name=t.get("name") or "Tee", genders=genders)) tees.append(TeeOption(name=t.get("name") or "Tee", genders=genders))
full_tees.append(PersonalCourseTeeIn(name=t.get("name") or "Tee", ratings=ratings))
# Malbruk krever komplette data (samme fullstendighetskrav som
# org-siden sin ADR-019-import) -- ellers tomme lister, kursets
# øvrige felt (navn/tees for direktevalg) er fortsatt fylt ut.
holes_complete = all(h.get("par") is not None and h.get("hcp_index") is not None for h in raw_holes)
template_holes = (
[
PersonalCourseHoleIn(hole_number=h["hole_number"], par=h["par"], stroke_index=h["hcp_index"])
for h in raw_holes
]
if holes_complete and full_tees
else []
)
courses.append( courses.append(
OfficialCourseOption( OfficialCourseOption(
teeoff_course_id=c["id"], teeoff_course_id=c["id"],
name=c["name"], name=c["name"],
is_main_course=bool(c.get("is_main_course")), is_main_course=bool(c.get("is_main_course")),
tees=tees, tees=tees,
holes=template_holes,
full_tees=full_tees if template_holes else [],
) )
) )
return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses) return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses)

View file

@ -14,10 +14,13 @@ import {
Bell, Bell,
BellRing, BellRing,
Camera, Camera,
Copy,
Flag,
KeyRound, KeyRound,
Lock, Lock,
LogOut, LogOut,
Mail, Mail,
Pencil,
Plus, Plus,
ShieldCheck, ShieldCheck,
ShieldOff, ShieldOff,
@ -31,6 +34,7 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Wordmark } from "@/components/wordmark" import { Wordmark } from "@/components/wordmark"
import { TwoFactorSetupForm } from "@/components/two-factor-flow" import { TwoFactorSetupForm } from "@/components/two-factor-flow"
import { CourseTemplateEditor, type CourseTemplateValues } from "@/components/course-template-editor"
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de // ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
// org-scopede `player`-radene organisatorer administrerer. // org-scopede `player`-radene organisatorer administrerer.
@ -284,6 +288,8 @@ export function AccountSettings() {
<PushNotificationSection /> <PushNotificationSection />
<MyCoursesSection />
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6"> <section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted"> <div className="flex size-10 items-center justify-center rounded-xl bg-muted">
@ -1425,6 +1431,177 @@ function PushNotificationSection() {
) )
} }
// --- Mine baner (2026-08-04) -------------------------------------------------
// Administrasjon av `personal_course`-rader brukeren eier -- det delte,
// plattform-omfattende bane-biblioteket som både turneringsmodulen og
// single-runde-modulen nå bruker som mal-kilde. Redigering skjer i sted
// (PATCH, samme eier -- ingen fork utløses); dupliser/slett er egne,
// eksplisitte handlinger. Andres baner vises IKKE her -- kun i mal-søket
// i de respektive modulene (denne siden er ren eierskapsadministrasjon).
type ApiMyCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
type ApiMyCourseDetail = {
id: string
name: string
holes: CourseTemplateValues["holes"]
full_tees: CourseTemplateValues["tees"]
}
function MyCoursesSection() {
const [courses, setCourses] = useState<ApiMyCourse[] | null>(null)
const [editingId, setEditingId] = useState<string | null>(null)
const [editingInitial, setEditingInitial] = useState<CourseTemplateValues | null>(null)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function load() {
const res = await fetch("/personal-courses?mine=true", { credentials: "include" })
if (res.ok) setCourses(await res.json())
}
useEffect(() => {
void load()
}, [])
async function startEdit(id: string) {
setError(null)
const res = await fetch(`/personal-courses/${id}`, { credentials: "include" })
if (!res.ok) {
setError("Klarte ikke å hente banedetaljer.")
return
}
const detail: ApiMyCourseDetail = await res.json()
setEditingInitial({ name: detail.name, holes: detail.holes, tees: detail.full_tees })
setEditingId(id)
}
async function saveEdit(values: CourseTemplateValues) {
if (!editingId) return
setSubmitting(true)
setError(null)
try {
const res = await fetch(`/personal-courses/${editingId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(values),
})
if (!res.ok) throw new Error(`update: ${res.status}`)
setEditingId(null)
setEditingInitial(null)
await load()
} catch {
setError("Klarte ikke å lagre endringene.")
} finally {
setSubmitting(false)
}
}
async function duplicate(id: string) {
setError(null)
const res = await fetch(`/personal-courses/${id}/duplicate`, { method: "POST", credentials: "include" })
if (!res.ok) {
setError("Klarte ikke å duplisere banen.")
return
}
await load()
}
async function remove(id: string) {
if (!confirm("Slette denne banen? Dette kan ikke angres.")) return
setError(null)
const res = await fetch(`/personal-courses/${id}`, { method: "DELETE", credentials: "include" })
if (res.status === 204) {
await load()
return
}
const body = await res.json().catch(() => null)
setError(
body?.detail?.code === "IN_USE"
? "Denne banen er i bruk i én eller flere runder og kan ikke slettes."
: "Klarte ikke å slette banen.",
)
}
return (
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Flag aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<h2 className="text-base font-bold text-foreground">Mine baner</h2>
<p className="text-xs text-muted-foreground text-pretty">
Egendefinerte baner du har opprettet -- offentlig synlige og brukbare som mal av andre,
i både turneringsmodulen og single-runde-modulen.
</p>
</div>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{editingId ? (
<CourseTemplateEditor
initial={editingInitial ?? undefined}
submitLabel="Lagre endringer"
submitting={submitting}
error={null}
onSubmit={saveEdit}
onCancel={() => {
setEditingId(null)
setEditingInitial(null)
}}
/>
) : courses === null ? (
<p className="text-sm text-muted-foreground">Laster</p>
) : courses.length === 0 ? (
<p className="text-sm text-muted-foreground">
Ingen egne baner ennå -- opprett en via "Egen bane" i ny-runde-veiviseren, eller "Opprett
bane med hull/utslag" i en turnering.
</p>
) : (
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
{courses.map((c) => (
<li key={c.id} className="flex items-center gap-3 px-4 py-3">
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{c.name}</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => startEdit(c.id)}
aria-label={`Rediger ${c.name}`}
className="size-9 rounded-lg text-muted-foreground"
>
<Pencil aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => duplicate(c.id)}
aria-label={`Dupliser ${c.name}`}
className="size-9 rounded-lg text-muted-foreground"
>
<Copy aria-hidden="true" className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(c.id)}
aria-label={`Slett ${c.name}`}
className="size-9 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
)}
</section>
)
}
function NotificationEmailPrefsSection() { function NotificationEmailPrefsSection() {
const [types, setTypes] = useState<string[]>([]) const [types, setTypes] = useState<string[]>([])
const [loaded, setLoaded] = useState(false) const [loaded, setLoaded] = useState(false)

View file

@ -0,0 +1,698 @@
"use client"
// Kompakt hull-/utslag-editor for org-turneringsmodulen (2026-08-04) --
// "opprett manuell bane" der var frem til nå KUN et navnefelt (ingen vei til
// hull/par/hcp-indeks/utslag fantes i det hele tatt, POST /orgs/{id}/courses
// tok bare imot `{name}`). Speiler single-runde-modulens `OwnCreateStep`
// (new-round.tsx) i FUNKSJON, men i et kompakt, kort-innebygd formspråk som
// matcher resten av turneringsmodulens skjemaer (h-10/h-11, text-sm) i
// stedet for enkeltrunde-modulens store veiviser-stil.
//
// Ren UI + lokal validering -- kalleren avgjør hvor dataene faktisk
// persisteres (org-`course` via POST /orgs/{id}/courses, som nå valgfritt
// tar imot samme `holes`/`tees`-form i tillegg til navn), matcher
// prosjektets etablerte "hold komponenten dum"-adaptermønster.
//
// `CourseTemplatePicker` under er selve inngangspunktet brukt fra
// tournament-program.tsx/individual-tournament-detail.tsx: tilbyr "fra
// bunnen av", "TeeOff-bane som mal" og "offentlig custom-bane som mal"
// (søk mot GET /personal-courses -- allerede, litt overraskende, et
// PLATTFORM-OMFATTENDE bibliotek uansett org/bruker, se rounds.py).
import { useEffect, useState } from "react"
import { Plus, Trash2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export type ApiCourseHole = { hole_number: number; par: number; stroke_index: number }
export type ApiCourseTeeRating = { gender: "m" | "f"; course_rating: number; slope_rating: number; par: number }
export type ApiCourseTee = { name: string; ratings: ApiCourseTeeRating[] }
export type CourseTemplateValues = { name: string; holes: ApiCourseHole[]; tees: ApiCourseTee[] }
export type ApiCourseSummary = { id: string; name: string }
function blankHoles(): ApiCourseHole[] {
return Array.from({ length: 18 }, (_, i) => ({ hole_number: i + 1, par: 4, stroke_index: i + 1 }))
}
function blankTees(): ApiCourseTee[] {
return [{ name: "", ratings: [{ gender: "m", course_rating: 70, slope_rating: 113, par: 72 }] }]
}
type TeeRow = {
key: string
name: string
men: boolean
women: boolean
menRating: { courseRating: string; slope: string; par: string }
womenRating: { courseRating: string; slope: string; par: string }
}
function teesToRows(tees: ApiCourseTee[]): TeeRow[] {
return tees.map((t, i) => {
const m = t.ratings.find((r) => r.gender === "m")
const f = t.ratings.find((r) => r.gender === "f")
return {
key: `${i}-${t.name}`,
name: t.name,
men: !!m,
women: !!f,
menRating: m
? { courseRating: String(m.course_rating), slope: String(m.slope_rating), par: String(m.par) }
: { courseRating: "", slope: "", par: "" },
womenRating: f
? { courseRating: String(f.course_rating), slope: String(f.slope_rating), par: String(f.par) }
: { courseRating: "", slope: "", par: "" },
}
})
}
/** Kompakt skjema: 18-hulls par/stroke-indeks-rutenett + utslag med rating
* per kjønn. Forhåndsutfylt fra `initial` når banen opprettes som mal-basert
* (fra TeeOff eller en offentlig custom-bane). */
export function CourseTemplateEditor({
initial,
submitLabel,
submitting,
error,
onSubmit,
onCancel,
}: {
initial?: CourseTemplateValues
submitLabel: string
submitting: boolean
error: string | null
onSubmit: (values: CourseTemplateValues) => void
onCancel: () => void
}) {
const [name, setName] = useState(initial?.name ?? "")
const [holes, setHoles] = useState<ApiCourseHole[]>(initial?.holes ?? blankHoles())
const [tees, setTees] = useState<TeeRow[]>(teesToRows(initial?.tees ?? blankTees()))
function updateHole(index: number, field: "par" | "stroke_index", value: number) {
setHoles((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h)))
}
function updateTee(key: string, patch: Partial<TeeRow>) {
setTees((prev) => prev.map((t) => (t.key === key ? { ...t, ...patch } : t)))
}
function addTee() {
setTees((prev) => [
...prev,
{ key: `new-${Date.now()}`, name: "", men: false, women: false, menRating: { courseRating: "", slope: "", par: "" }, womenRating: { courseRating: "", slope: "", par: "" } },
])
}
function removeTee(key: string) {
setTees((prev) => (prev.length > 1 ? prev.filter((t) => t.key !== key) : prev))
}
const indexesValid = new Set(holes.map((h) => h.stroke_index)).size === 18
const teesValid = tees.every(
(t) =>
t.name.trim() !== "" &&
(t.men || t.women) &&
(!t.men || (t.menRating.courseRating.trim() && t.menRating.slope.trim() && t.menRating.par.trim())) &&
(!t.women || (t.womenRating.courseRating.trim() && t.womenRating.slope.trim() && t.womenRating.par.trim())),
)
const canSubmit = name.trim() !== "" && indexesValid && teesValid && !submitting
// Bevisst IKKE et <form onSubmit>: denne komponenten rendres ofte inne i
// et YTRE skjema (NewRoundForm/CreateSessionCard), og nestede <form>-
// elementer er ugyldig HTML -- funnet ved en ekte scratch-verifisering
// 2026-08-04 (submit-knappen submittet i praksis det ytre runde-/økt-
// skjemaet i stedet, en reell side-navigasjon som vasket bort ?org=...
// fra URL-en). Samme feilklasse/fiks som OfficialCourseSearch fra før.
function handleSubmit() {
if (!canSubmit) return
const parseDecimal = (v: string) => Number(v.replace(",", "."))
onSubmit({
name: name.trim(),
holes,
tees: tees.map((t) => ({
name: t.name.trim(),
ratings: [
...(t.men
? [{ gender: "m" as const, course_rating: parseDecimal(t.menRating.courseRating), slope_rating: Number(t.menRating.slope), par: Number(t.menRating.par) }]
: []),
...(t.women
? [{ gender: "f" as const, course_rating: parseDecimal(t.womenRating.courseRating), slope_rating: Number(t.womenRating.slope), par: Number(t.womenRating.par) }]
: []),
],
})),
})
}
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-background p-4">
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<div className="flex flex-col gap-1.5">
<Label htmlFor="ct-name" className="text-xs font-semibold">
Navn banen
</Label>
<Input id="ct-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="F.eks. Hjemmebanen" className="h-10 rounded-xl text-sm" />
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold">Hull -- par og stroke-indeks</span>
{!indexesValid && (
<p role="alert" className="text-xs font-medium text-destructive">
Hver stroke-indeks (1-18) brukes nøyaktig én gang.
</p>
)}
<div className="max-h-72 overflow-y-auto rounded-xl border border-border">
<div className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 border-b border-border bg-muted/60 px-3 py-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<span className="w-12">Hull</span>
<span>Par</span>
<span>SI</span>
</div>
{holes.map((h, index) => (
<div key={h.hole_number} className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 border-b border-border px-3 py-1.5 last:border-b-0">
<span className="w-12 text-sm font-semibold text-foreground">{h.hole_number}</span>
<select
aria-label={`Par for hull ${h.hole_number}`}
value={h.par}
onChange={(e) => updateHole(index, "par", Number(e.target.value))}
className="h-9 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{[3, 4, 5, 6].map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<select
aria-label={`Stroke-indeks for hull ${h.hole_number}`}
value={h.stroke_index}
onChange={(e) => updateHole(index, "stroke_index", Number(e.target.value))}
className="h-9 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{Array.from({ length: 18 }, (_, i) => i + 1).map((si) => (
<option key={si} value={si}>
{si}
</option>
))}
</select>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold">Utslag</span>
{tees.map((t, index) => (
<div key={t.key} className="flex flex-col gap-3 rounded-xl border border-border bg-card p-3">
<div className="flex items-end gap-2">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor={`ct-tee-${t.key}`} className="text-xs font-semibold">
Navn utslag {index + 1}
</Label>
<Input id={`ct-tee-${t.key}`} value={t.name} onChange={(e) => updateTee(t.key, { name: e.target.value })} placeholder="F.eks. Gul" className="h-10 rounded-xl text-sm" />
</div>
{tees.length > 1 && (
<Button type="button" variant="ghost" onClick={() => removeTee(t.key)} className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold text-muted-foreground">
<Trash2 aria-hidden="true" className="size-4" />
</Button>
)}
</div>
<RatingRow
label="Herre"
enabled={t.men}
rating={t.menRating}
onToggle={(men) => updateTee(t.key, { men })}
onChange={(menRating) => updateTee(t.key, { menRating })}
idPrefix={`ct-men-${t.key}`}
/>
<RatingRow
label="Dame"
enabled={t.women}
rating={t.womenRating}
onToggle={(women) => updateTee(t.key, { women })}
onChange={(womenRating) => updateTee(t.key, { womenRating })}
idPrefix={`ct-women-${t.key}`}
/>
</div>
))}
<Button type="button" variant="outline" onClick={addTee} className="h-10 self-start rounded-xl text-xs font-bold">
<Plus aria-hidden="true" className="size-4" />
Legg til utslag
</Button>
</div>
<div className="flex gap-2">
<Button type="button" onClick={handleSubmit} disabled={!canSubmit} className="h-11 flex-1 rounded-xl text-sm font-bold">
{submitting ? "Lagrer…" : submitLabel}
</Button>
<Button type="button" variant="ghost" onClick={onCancel} className="h-11 rounded-xl text-sm font-bold text-muted-foreground">
Avbryt
</Button>
</div>
</div>
)
}
function RatingRow({
label,
enabled,
rating,
onToggle,
onChange,
idPrefix,
}: {
label: string
enabled: boolean
rating: { courseRating: string; slope: string; par: string }
onToggle: (enabled: boolean) => void
onChange: (rating: { courseRating: string; slope: string; par: string }) => void
idPrefix: string
}) {
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-background/60 p-2.5">
<label className="flex items-center gap-2 text-xs font-semibold text-foreground">
<input
type="checkbox"
checked={enabled}
onChange={(e) => onToggle(e.target.checked)}
className="size-4 shrink-0 accent-primary"
/>
{label}-rating
</label>
{enabled && (
<div className="grid grid-cols-3 gap-2">
<Input
aria-label={`${label} course rating`}
inputMode="decimal"
value={rating.courseRating}
onChange={(e) => onChange({ ...rating, courseRating: e.target.value })}
placeholder="CR"
className="h-9 rounded-lg text-sm"
/>
<Input
aria-label={`${label} slope`}
inputMode="numeric"
value={rating.slope}
onChange={(e) => onChange({ ...rating, slope: e.target.value })}
placeholder="Slope"
className="h-9 rounded-lg text-sm"
/>
<Input
aria-label={`${label} par`}
inputMode="numeric"
value={rating.par}
onChange={(e) => onChange({ ...rating, par: e.target.value })}
placeholder="Par"
className="h-9 rounded-lg text-sm"
/>
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Mal-velger: "fra bunnen av" / "TeeOff-bane som mal" / "offentlig custom-
// bane som mal". Brukt som selve "opprett bane"-inngangen i org-
// turneringsmodulen (erstatter det tidligere bare-navnefeltet).
// ---------------------------------------------------------------------------
type ApiPublicCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
type ApiPublicCourseDetail = {
id: string
name: string
holes: ApiCourseHole[]
full_tees: ApiCourseTee[]
}
type ApiOfficialFacility = { slug: string; name: string; city: string | null; county: string | null }
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean }
type ApiOfficialTeeOption = { name: string; genders: ("m" | "f")[] }
type ApiOfficialCourseDetail = { id: number; name: string; holes: { hole_number: number; par: number; stroke_index: number }[]; tees: (ApiOfficialTeeOption & { ratings?: ApiCourseTeeRating[] })[] }
type PickerStep =
| { kind: "choose" }
| { kind: "official-search" }
| { kind: "official-facility"; facility: ApiOfficialFacility }
| { kind: "public-search" }
| { kind: "editor"; initial?: CourseTemplateValues; templatePersonalCourseId?: string }
export function CourseTemplatePicker({
organizationId,
onCreated,
onCancel,
}: {
organizationId: string
onCreated: (course: ApiCourseSummary) => void
onCancel: () => void
}) {
const [step, setStep] = useState<PickerStep>({ kind: "choose" })
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function submitCourse(values: CourseTemplateValues, templatePersonalCourseId?: string) {
setSubmitting(true)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ ...values, template_personal_course_id: templatePersonalCourseId ?? null }),
})
if (!res.ok) throw new Error(`create course: ${res.status}`)
const created: ApiCourseSummary = await res.json()
onCreated(created)
} catch {
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
} finally {
setSubmitting(false)
}
}
if (step.kind === "choose") {
return (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-background p-4">
<span className="text-xs font-semibold">Opprett manuell bane</span>
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={() => setStep({ kind: "editor" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
Fra bunnen av
</Button>
<Button type="button" variant="outline" onClick={() => setStep({ kind: "official-search" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
TeeOff-bane som mal
</Button>
<Button type="button" variant="outline" onClick={() => setStep({ kind: "public-search" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
Offentlig bane som mal
</Button>
</div>
<Button type="button" variant="ghost" onClick={onCancel} className="h-9 self-start rounded-xl text-xs font-semibold text-muted-foreground">
Avbryt
</Button>
</div>
)
}
if (step.kind === "official-search") {
return (
<OfficialFacilitySearch
organizationId={organizationId}
onPickFacility={(facility) => setStep({ kind: "official-facility", facility })}
onClose={() => setStep({ kind: "choose" })}
/>
)
}
if (step.kind === "official-facility") {
return (
<OfficialCourseList
organizationId={organizationId}
facility={step.facility}
onBack={() => setStep({ kind: "official-search" })}
onPicked={(values) => setStep({ kind: "editor", initial: values })}
/>
)
}
if (step.kind === "public-search") {
return (
<PublicCourseSearch
onPickDirect={(values, sourceId) => submitCourse(values, sourceId)}
onPickCustomize={(values, sourceId) => setStep({ kind: "editor", initial: values, templatePersonalCourseId: sourceId })}
onClose={() => setStep({ kind: "choose" })}
/>
)
}
return (
<CourseTemplateEditor
initial={step.initial}
submitLabel="Opprett bane"
submitting={submitting}
error={error}
onSubmit={(values) => submitCourse(values, step.templatePersonalCourseId)}
onCancel={() => setStep({ kind: "choose" })}
/>
)
}
function OfficialFacilitySearch({
organizationId,
onPickFacility,
onClose,
}: {
organizationId: string
onPickFacility: (facility: ApiOfficialFacility) => void
onClose: () => void
}) {
const [query, setQuery] = useState("")
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
const [searching, setSearching] = 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)
}
}
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">TeeOff-bane som mal</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>}
<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={() => onPickFacility(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>
)}
</div>
)
}
function OfficialCourseList({
organizationId,
facility,
onBack,
onPicked,
}: {
organizationId: string
facility: ApiOfficialFacility
onBack: () => void
onPicked: (values: CourseTemplateValues) => void
}) {
const [courses, setCourses] = useState<ApiOfficialCourseOption[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [loadingCourseId, setLoadingCourseId] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : { courses: [] }))
.then((detail: { courses: ApiOfficialCourseOption[] }) => {
if (!cancelled) setCourses(detail.courses)
})
.catch(() => {
if (!cancelled) setError("Klarte ikke å hente baner for dette anlegget.")
})
return () => {
cancelled = true
}
}, [organizationId, facility.slug])
async function pick(course: ApiOfficialCourseOption) {
// Henter full bane-/utslagdata via samme offisiell-import-endepunkt som
// "vanlig" import bruker -- men i STEDET for å lagre importen som en
// ferdig org-bane, brukes svaret KUN til å forhåndsutfylle det
// redigerbare skjemaet (ingen kobling til TeeOff bevares etterpå, se
// migrasjon 054 sin kommentar om "engangs-kopi, ingen vedvarende
// kobling"). Trygt å kalle -- import er idempotent (courses.py).
setLoadingCourseId(course.teeoff_course_id)
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: facility.slug, teeoff_course_id: course.teeoff_course_id }),
})
if (!res.ok) throw new Error(`import: ${res.status}`)
const imported: { id: string; name: string } = await res.json()
const [holesRes, teesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/courses/${imported.id}/holes`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/courses/${imported.id}/tees`, { credentials: "include" }),
])
const holes: ApiCourseHole[] = holesRes.ok ? await holesRes.json() : []
const teesRaw: { name: string; ratings: ApiCourseTeeRating[] }[] = teesRes.ok ? await teesRes.json() : []
onPicked({ name: imported.name, holes, tees: teesRaw.map((t) => ({ name: t.name, ratings: t.ratings })) })
} catch {
setError("Klarte ikke å hente banedata fra teeoff. Prøv igjen.")
} finally {
setLoadingCourseId(null)
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<button type="button" onClick={onBack} className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground">
{facility.name}
</button>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{courses === null ? (
<p className="text-sm text-muted-foreground">Laster baner</p>
) : (
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
{courses.map((c) => (
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
<button
type="button"
disabled={loadingCourseId !== null}
onClick={() => pick(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}
{loadingCourseId === c.teeoff_course_id && <span className="text-xs font-normal text-muted-foreground">Henter</span>}
</button>
</li>
))}
{courses.length === 0 && <li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen 18-hulls baner hos dette anlegget ennå.</li>}
</ul>
)}
</div>
)
}
function PublicCourseSearch({
onPickDirect,
onPickCustomize,
onClose,
}: {
onPickDirect: (values: CourseTemplateValues, sourcePersonalCourseId: string) => void
onPickCustomize: (values: CourseTemplateValues, sourcePersonalCourseId: string) => void
onClose: () => void
}) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<ApiPublicCourse[]>([])
const [loadingId, setLoadingId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const timer = setTimeout(() => {
fetch(`/personal-courses?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiPublicCourse[]) => {
if (!cancelled) setResults(data)
})
.catch(() => {})
}, 250)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [query])
async function fetchDetail(course: ApiPublicCourse): Promise<CourseTemplateValues | null> {
setLoadingId(course.id)
setError(null)
try {
const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
if (!res.ok) throw new Error(`detail: ${res.status}`)
const detail: ApiPublicCourseDetail = await res.json()
return { name: detail.name, holes: detail.holes, tees: detail.full_tees }
} catch {
setError("Klarte ikke å hente banedetaljer.")
return null
} finally {
setLoadingId(null)
}
}
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">Offentlig bane som mal</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>}
<Input autoFocus value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Søk etter banenavn…" className="h-11 rounded-xl text-sm" />
<ul className="flex max-h-72 flex-col gap-2 overflow-auto">
{results.map((c) => (
<li key={c.id} className="flex flex-col gap-2 rounded-xl border border-border p-3">
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{c.name}</span>
<span className="text-xs text-muted-foreground">{c.is_mine ? "Opprettet av deg" : `Opprettet av ${c.created_by_display_name}`}</span>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
disabled={loadingId !== null}
onClick={async () => {
const values = await fetchDetail(c)
if (values) onPickDirect(values, c.id)
}}
className="h-9 flex-1 rounded-lg text-xs font-bold"
>
Bruk direkte
</Button>
<Button
type="button"
variant="outline"
disabled={loadingId !== null}
onClick={async () => {
const values = await fetchDetail(c)
if (values) onPickCustomize(values, c.id)
}}
className="h-9 flex-1 rounded-lg text-xs font-bold"
>
Tilpass før bruk
</Button>
</div>
</li>
))}
{results.length === 0 && (
<li className="px-3 py-4 text-center text-sm text-muted-foreground">
{query.trim() ? `Ingen offentlige baner matcher «${query}».` : "Skriv for å søke."}
</li>
)}
</ul>
</div>
)
}

View file

@ -41,6 +41,7 @@ import {
import { TournamentPresentationPanel } from "@/components/tournament-presentation" import { TournamentPresentationPanel } from "@/components/tournament-presentation"
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard" import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge" import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
import { CourseTemplatePicker } from "@/components/course-template-editor"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) -- // --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) --
@ -444,6 +445,10 @@ export function IndividualTournamentDetail({
return created return created
} }
function handleCourseImported(course: ApiCourse) {
setCourses((prev) => [...prev, course].sort((a, b) => a.name.localeCompare(b.name)))
}
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background"> <div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
@ -551,6 +556,7 @@ export function IndividualTournamentDetail({
onAddRound={addRound} onAddRound={addRound}
onDeleteRound={deleteRound} onDeleteRound={deleteRound}
onAddCourse={addCourse} onAddCourse={addCourse}
onCourseImported={handleCourseImported}
onCreateClass={createClass} onCreateClass={createClass}
onDeleteClass={deleteClass} onDeleteClass={deleteClass}
onSetParticipantClass={setParticipantClass} onSetParticipantClass={setParticipantClass}
@ -620,6 +626,7 @@ function SetupTab({
onAddRound, onAddRound,
onDeleteRound, onDeleteRound,
onAddCourse, onAddCourse,
onCourseImported,
onCreateClass, onCreateClass,
onDeleteClass, onDeleteClass,
onSetParticipantClass, onSetParticipantClass,
@ -640,6 +647,7 @@ function SetupTab({
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void> onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onDeleteRound: (id: string) => Promise<void> onDeleteRound: (id: string) => Promise<void>
onAddCourse: (name: string) => Promise<ApiCourse | null> onAddCourse: (name: string) => Promise<ApiCourse | null>
onCourseImported: (course: ApiCourse) => void
onCreateClass: (name: string, defaultTeeId: string | null) => Promise<void> onCreateClass: (name: string, defaultTeeId: string | null) => Promise<void>
onDeleteClass: (id: string) => Promise<void> onDeleteClass: (id: string) => Promise<void>
onSetParticipantClass: (participantId: string, classId: string | null) => Promise<void> onSetParticipantClass: (participantId: string, classId: string | null) => Promise<void>
@ -700,6 +708,21 @@ function SetupTab({
)} )}
</section> </section>
<RoundsCard
base={base}
organizationId={organizationId}
rounds={rounds}
courses={courses}
participants={participants}
classes={classes}
scoringMethod={tournament.scoring_method}
onAddRound={onAddRound}
onDeleteRound={onDeleteRound}
onAddCourse={onAddCourse}
onCourseImported={onCourseImported}
onError={onError}
/>
<ClassesCard <ClassesCard
classes={classes} classes={classes}
courses={courses} courses={courses}
@ -717,20 +740,6 @@ function SetupTab({
onRemoveParticipant={onRemoveParticipant} onRemoveParticipant={onRemoveParticipant}
onSetClass={onSetParticipantClass} onSetClass={onSetParticipantClass}
/> />
<RoundsCard
base={base}
organizationId={organizationId}
rounds={rounds}
courses={courses}
participants={participants}
classes={classes}
scoringMethod={tournament.scoring_method}
onAddRound={onAddRound}
onDeleteRound={onDeleteRound}
onAddCourse={onAddCourse}
onError={onError}
/>
</div> </div>
) )
} }
@ -1150,6 +1159,7 @@ function RoundsCard({
onAddRound, onAddRound,
onDeleteRound, onDeleteRound,
onAddCourse, onAddCourse,
onCourseImported,
onError, onError,
}: { }: {
base: string base: string
@ -1162,6 +1172,7 @@ function RoundsCard({
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void> onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onDeleteRound: (id: string) => Promise<void> onDeleteRound: (id: string) => Promise<void>
onAddCourse: (name: string) => Promise<ApiCourse | null> onAddCourse: (name: string) => Promise<ApiCourse | null>
onCourseImported: (course: ApiCourse) => void
onError: (message: string) => void onError: (message: string) => void
}) { }) {
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
@ -1210,7 +1221,9 @@ function RoundsCard({
</Button> </Button>
) : ( ) : (
<NewRoundForm <NewRoundForm
organizationId={organizationId}
courses={courses} courses={courses}
onCourseImported={onCourseImported}
onCreate={async (name, courseId, holeConfig) => { onCreate={async (name, courseId, holeConfig) => {
await onAddRound(name, courseId, holeConfig) await onAddRound(name, courseId, holeConfig)
setCreating(false) setCreating(false)
@ -1224,14 +1237,18 @@ function RoundsCard({
} }
function NewRoundForm({ function NewRoundForm({
organizationId,
courses, courses,
onCreate, onCreate,
onCreateCourse, onCreateCourse,
onCourseImported,
onCancel, onCancel,
}: { }: {
organizationId: string
courses: ApiCourse[] courses: ApiCourse[]
onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void> onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onCreateCourse: (name: string) => Promise<ApiCourse | null> onCreateCourse: (name: string) => Promise<ApiCourse | null>
onCourseImported: (course: ApiCourse) => void
onCancel: () => void onCancel: () => void
}) { }) {
const [name, setName] = useState("") const [name, setName] = useState("")
@ -1239,6 +1256,8 @@ function NewRoundForm({
const [holeConfig, setHoleConfig] = useState<ApiRound["hole_config"]>("full_18") const [holeConfig, setHoleConfig] = useState<ApiRound["hole_config"]>("full_18")
const [newCourseName, setNewCourseName] = useState("") const [newCourseName, setNewCourseName] = useState("")
const [addingCourse, setAddingCourse] = useState(false) const [addingCourse, setAddingCourse] = useState(false)
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
async function handleCreateCourse() { async function handleCreateCourse() {
@ -1334,6 +1353,46 @@ function NewRoundForm({
Banen ha registrert hull (par/hcp-indeks) og utslag før scoring kan Banen ha registrert hull (par/hcp-indeks) og utslag før scoring kan
begynne -- sett opp dette under organisasjonens baner om det mangler. begynne -- sett opp dette under organisasjonens baner om det mangler.
</p> </p>
{!officialSearchOpen && !templatePickerOpen ? (
<div className="flex flex-wrap gap-x-4 gap-y-1">
<button
type="button"
onClick={() => setOfficialSearchOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
</button>
<button
type="button"
onClick={() => setTemplatePickerOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Opprett bane med hull/utslag i stedet
</button>
</div>
) : officialSearchOpen ? (
<OfficialCourseSearch
organizationId={organizationId}
onClose={() => setOfficialSearchOpen(false)}
onImported={(course) => {
onCourseImported(course)
setCourseId(course.id)
setAddingCourse(false)
setOfficialSearchOpen(false)
}}
/>
) : (
<CourseTemplatePicker
organizationId={organizationId}
onCreated={(created) => {
onCourseImported({ id: created.id, name: created.name, source: "custom" })
setCourseId(created.id)
setAddingCourse(false)
setTemplatePickerOpen(false)
}}
onCancel={() => setTemplatePickerOpen(false)}
/>
)}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@ -2279,3 +2338,223 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
</div> </div>
) )
} }
// --- TeeOff-baneimport (2026-08-04) -----------------------------------------
// Samme mønster/endepunkter som tournament-program.tsx sin OfficialCourse
// Search (ADR-019) -- kopiert hit i stedet for delt via import, samme
// duplisering-mellom-de-to-turneringstype-filene-konvensjon som ClassesCard
// allerede fulgte. Var tidligere KUN tilgjengelig for lagturneringer; denne
// runden fikset gapet der individuelle turneringer manglet TeeOff-import helt.
type ApiOfficialFacility = {
slug: string
name: string
city: string | null
county: string | null
}
type ApiOfficialCourseOption = {
teeoff_course_id: number
name: string
is_main_course: boolean
}
function OfficialCourseSearch({
organizationId,
onClose,
onImported,
}: {
organizationId: string
onClose: () => void
onImported: (course: ApiCourse) => void
}) {
const [query, setQuery] = useState("")
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
const [selectedFacility, setSelectedFacility] = useState<ApiOfficialFacility | null>(null)
const [courseOptions, setCourseOptions] = useState<ApiOfficialCourseOption[] | null>(null)
const [searching, setSearching] = useState(false)
const [importing, setImporting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function runSearch() {
setSearching(true)
setError(null)
try {
const res = await fetch(
`/orgs/${organizationId}/courses/official-search?q=${encodeURIComponent(query.trim())}`,
{ credentials: "include" },
)
if (!res.ok) throw new Error(`search: ${res.status}`)
setFacilities(await res.json())
} catch {
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
setFacilities([])
} finally {
setSearching(false)
}
}
async function pickFacility(facility: ApiOfficialFacility) {
setSelectedFacility(facility)
setError(null)
setCourseOptions(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, {
credentials: "include",
})
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
setCourseOptions(detail.courses)
} catch {
setError("Klarte ikke å hente baner for dette anlegget.")
setCourseOptions([])
}
}
async function importCourse(course: ApiOfficialCourseOption) {
if (!selectedFacility) return
setImporting(true)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
facility_slug: selectedFacility.slug,
teeoff_course_id: course.teeoff_course_id,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
const code = body?.detail?.code
if (code === "EXTERNAL_DATA_INCOMPLETE") {
setError("Denne banen mangler nok data i teeoff til å importeres ennå.")
} else if (code === "DUPLICATE") {
setError("Denne banen er allerede importert til organisasjonen.")
} else {
setError("Klarte ikke å importere banen. Prøv igjen.")
}
return
}
onImported(await res.json())
} catch {
setError("Klarte ikke å importere banen. Prøv igjen.")
} finally {
setImporting(false)
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">Hent bane fra teeoff</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="size-8 rounded-lg text-muted-foreground"
aria-label="Lukk"
>
<X aria-hidden="true" className="size-4" />
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{!selectedFacility ? (
<>
{/* Bevisst IKKE et eget <form> her -- denne komponenten rendres inne
i NewRoundForm sitt <form onSubmit={handleSubmit}>, og nestede
<form>-elementer er ugyldig HTML (samme feilrapport-funn som i
tournament-program.tsx sin variant -- "Søk"-knappen ville ellers
i praksis submitte det YTRE runde-skjemaet). */}
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="Søk anleggsnavn…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
runSearch()
}
}}
className="h-11 flex-1 rounded-xl text-base"
/>
<Button
type="button"
onClick={() => runSearch()}
disabled={searching}
className="h-11 shrink-0 rounded-xl font-bold"
>
{searching ? "Søker…" : "Søk"}
</Button>
</div>
{facilities && (
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
{facilities.map((f) => (
<li key={f.slug} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => pickFacility(f)}
className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
>
<span className="text-sm font-semibold text-foreground">{f.name}</span>
{(f.city || f.county) && (
<span className="text-xs text-muted-foreground">
{[f.city, f.county].filter(Boolean).join(", ")}
</span>
)}
</button>
</li>
))}
{facilities.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
)}
</ul>
)}
</>
) : (
<>
<button
type="button"
onClick={() => {
setSelectedFacility(null)
setCourseOptions(null)
}}
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
>
{selectedFacility.name}
</button>
{courseOptions === null ? (
<p className="text-sm text-muted-foreground">Laster baner</p>
) : (
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
{courseOptions.map((c) => (
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
<button
type="button"
disabled={importing}
onClick={() => importCourse(c)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
>
{c.name}
{importing && <span className="text-xs font-normal text-muted-foreground">Importerer</span>}
</button>
</li>
))}
{courseOptions.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
Ingen 18-hulls baner å importere hos dette anlegget ennå.
</li>
)}
</ul>
)}
</>
)}
</div>
)
}

View file

@ -39,6 +39,7 @@ import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { Wordmark } from "@/components/wordmark" import { Wordmark } from "@/components/wordmark"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import type { ApiCourseHole, ApiCourseTee, CourseTemplateValues } from "@/components/course-template-editor"
// --- Types ------------------------------------------------------------------- // --- Types -------------------------------------------------------------------
@ -48,7 +49,15 @@ type ApiGender = "m" | "f"
// ikke utslagslisten filtreres riktig per spillers kjønn (samme sjekk // ikke utslagslisten filtreres riktig per spillers kjønn (samme sjekk
// backend selv håndhever ved innsending). // backend selv håndhever ved innsending).
type Tee = { id: string; name: string; genders: ApiGender[] } type Tee = { id: string; name: string; genders: ApiGender[] }
type Course = { id: string; name: string; tees: Tee[] } type Course = {
id: string
name: string
tees: Tee[]
// Fullt hull-/utslagoppsett for "bruk som mal"-broen (2026-08-04) --
// fraværende/tom når kilden (teeoff) ikke har komplette nok data ennå.
templateHoles?: ApiCourseHole[]
templateTees?: ApiCourseTee[]
}
type OfficialClub = { id: string; name: string; location: string; courses: Course[] } type OfficialClub = { id: string; name: string; location: string; courses: Course[] }
// Hvilken bane-kilde+id som faktisk skal sendes til /rounds -- Course over // Hvilken bane-kilde+id som faktisk skal sendes til /rounds -- Course over
@ -226,9 +235,22 @@ type ApiOfficialCourseOption = {
name: string name: string
is_main_course: boolean is_main_course: boolean
tees: ApiTeeOption[] tees: ApiTeeOption[]
// Fullt hull-/utslagoppsett -- tom liste når teeoff ikke har komplette nok
// data til å brukes som mal (2026-08-04, "bruk denne banen som mal"-bro).
holes: ApiCourseHole[]
full_tees: ApiCourseTee[]
}
type ApiPersonalCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
type ApiPersonalCourseDetail = {
id: string
name: string
tees: ApiTeeOption[]
holes: ApiCourseHole[]
full_tees: ApiCourseTee[]
is_mine: boolean
created_by_display_name: string
forked_from_id: string | null
} }
type ApiPersonalCourse = { id: string; name: string }
type ApiPersonalCourseDetail = { id: string; name: string; tees: ApiTeeOption[] }
type PersonMatch = { type PersonMatch = {
id: string id: string
first_name: string first_name: string
@ -1019,6 +1041,9 @@ function Step1(props: {
}) { }) {
const { s1Sub, setS1Sub, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props const { s1Sub, setS1Sub, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props
const [resolvingClub, setResolvingClub] = useState(false) const [resolvingClub, setResolvingClub] = useState(false)
// "Bruk som mal"-broen (2026-08-04): forhåndsutfylling for own-create,
// satt fra enten TeeOff-baneutvalget eller det offentlige bane-søket.
const [templatePrefill, setTemplatePrefill] = useState<{ values: CourseTemplateValues; forkedFromId?: string } | null>(null)
if (s1Sub === "source") { if (s1Sub === "source") {
return ( return (
@ -1062,7 +1087,13 @@ function Step1(props: {
id: facility.slug, id: facility.slug,
name: detail.name, name: detail.name,
location: [facility.city, facility.county].filter(Boolean).join(", "), location: [facility.city, facility.county].filter(Boolean).join(", "),
courses: detail.courses.map((c) => ({ id: String(c.teeoff_course_id), name: c.name, tees: apiTeesToTees(c.tees) })), courses: detail.courses.map((c) => ({
id: String(c.teeoff_course_id),
name: c.name,
tees: apiTeesToTees(c.tees),
templateHoles: c.holes,
templateTees: c.full_tees,
})),
} }
setSelectedClub(club) setSelectedClub(club)
setS1Sub("official-courses") setS1Sub("official-courses")
@ -1083,6 +1114,11 @@ function Step1(props: {
description="Velg hvilken bane du skal spille." description="Velg hvilken bane du skal spille."
courses={selectedClub.courses} courses={selectedClub.courses}
onSelect={(c) => chooseCourse(c, { source: "teeoff", facilitySlug: selectedClub.id, teeoffCourseId: Number(c.id) }, "official")} onSelect={(c) => chooseCourse(c, { source: "teeoff", facilitySlug: selectedClub.id, teeoffCourseId: Number(c.id) }, "official")}
onUseAsTemplate={(c) => {
setTemplatePrefill({ values: { name: c.name, holes: c.templateHoles ?? [], tees: c.templateTees ?? [] } })
setCourseSource("own")
setS1Sub("own-create")
}}
/> />
) )
} }
@ -1100,7 +1136,20 @@ function Step1(props: {
"own", "own",
) )
}} }}
onCreate={() => setS1Sub("own-create")} onUseAsTemplate={async (picked) => {
const res = await fetch(`/personal-courses/${picked.id}`, { credentials: "include" })
if (!res.ok) return
const detail: ApiPersonalCourseDetail = await res.json()
setTemplatePrefill({
values: { name: detail.name, holes: detail.holes, tees: detail.full_tees },
forkedFromId: detail.id,
})
setS1Sub("own-create")
}}
onCreate={() => {
setTemplatePrefill(null)
setS1Sub("own-create")
}}
/> />
) )
} }
@ -1108,8 +1157,13 @@ function Step1(props: {
if (s1Sub === "own-create") { if (s1Sub === "own-create") {
return ( return (
<OwnCreateStep <OwnCreateStep
initial={templatePrefill?.values}
forkedFromId={templatePrefill?.forkedFromId}
onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")} onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")}
onCancel={() => setS1Sub("own-search")} onCancel={() => {
setTemplatePrefill(null)
setS1Sub("own-search")
}}
/> />
) )
} }
@ -1274,17 +1328,29 @@ function ClubRow({ name, location, distance, disabled, onClick }: { name: string
) )
} }
function CourseList({ title, description, courses, onSelect }: { title: string; description: string; courses: Course[]; onSelect: (c: Course) => void }) { function CourseList({
title,
description,
courses,
onSelect,
onUseAsTemplate,
}: {
title: string
description: string
courses: Course[]
onSelect: (c: Course) => void
onUseAsTemplate?: (c: Course) => void
}) {
return ( return (
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<StepHeader title={title} description={description} /> <StepHeader title={title} description={description} />
<ul className="flex flex-col gap-3"> <ul className="flex flex-col gap-3">
{courses.map((course) => ( {courses.map((course) => (
<li key={course.id}> <li key={course.id} className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
<button <button
type="button" type="button"
onClick={() => onSelect(course)} onClick={() => onSelect(course)}
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" className="flex w-full items-center gap-4 text-left transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
> >
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted"> <div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
<Flag aria-hidden="true" className="size-6 text-muted-foreground" /> <Flag aria-hidden="true" className="size-6 text-muted-foreground" />
@ -1294,6 +1360,16 @@ function CourseList({ title, description, courses, onSelect }: { title: string;
<span className="text-base text-muted-foreground">{course.tees.length} utslag</span> <span className="text-base text-muted-foreground">{course.tees.length} utslag</span>
</div> </div>
</button> </button>
{onUseAsTemplate && course.templateHoles && course.templateHoles.length > 0 && (
<Button
type="button"
variant="outline"
onClick={() => onUseAsTemplate(course)}
className="h-10 self-start rounded-xl text-sm font-bold"
>
Bruk som mal for egen bane
</Button>
)}
</li> </li>
))} ))}
{courses.length === 0 && ( {courses.length === 0 && (
@ -1306,7 +1382,15 @@ function CourseList({ title, description, courses, onSelect }: { title: string;
) )
} }
function OwnSearch({ onSelect, onCreate }: { onSelect: (c: ApiPersonalCourse) => void; onCreate: () => void }) { function OwnSearch({
onSelect,
onUseAsTemplate,
onCreate,
}: {
onSelect: (c: ApiPersonalCourse) => void
onUseAsTemplate: (c: ApiPersonalCourse) => void
onCreate: () => void
}) {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [results, setResults] = useState<ApiPersonalCourse[]>([]) const [results, setResults] = useState<ApiPersonalCourse[]>([])
@ -1345,19 +1429,30 @@ function OwnSearch({ onSelect, onCreate }: { onSelect: (c: ApiPersonalCourse) =>
{results.length > 0 ? ( {results.length > 0 ? (
<ul className="flex flex-col gap-3"> <ul className="flex flex-col gap-3">
{results.map((course) => ( {results.map((course) => (
<li key={course.id}> <li key={course.id} className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
<button <button
type="button" type="button"
onClick={() => onSelect(course)} onClick={() => onSelect(course)}
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" className="flex w-full items-center gap-4 text-left transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
> >
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted"> <div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
<Flag aria-hidden="true" className="size-6 text-muted-foreground" /> <Flag aria-hidden="true" className="size-6 text-muted-foreground" />
</div> </div>
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-col">
<span className="text-lg font-bold text-foreground">{course.name}</span> <span className="text-lg font-bold text-foreground">{course.name}</span>
<span className="text-sm text-muted-foreground">
{course.is_mine ? "Opprettet av deg" : `Opprettet av ${course.created_by_display_name}`}
</span>
</div> </div>
</button> </button>
<Button
type="button"
variant="outline"
onClick={() => onUseAsTemplate(course)}
className="h-10 self-start rounded-xl text-sm font-bold"
>
Bruk som mal for egen bane
</Button>
</li> </li>
))} ))}
</ul> </ul>
@ -1386,14 +1481,52 @@ function emptyHoles() {
return Array.from({ length: 18 }, (_, i) => ({ hole: i + 1, par: "4", strokeIndex: String(i + 1) })) return Array.from({ length: 18 }, (_, i) => ({ hole: i + 1, par: "4", strokeIndex: String(i + 1) }))
} }
// "Bruk som mal"-broen (2026-08-04): konverterer fullt hull-/utslagoppsett
// (fra en TeeOff-bane eller en offentlig custom-bane) til det redigerbare
// lokale string-baserte skjema-formatet -- gjenbruker samme string-felt-
// tilnærming som resten av OwnCreateStep (tillater midlertidig ugyldig/
// tom input mens brukeren redigerer).
function templateToHoles(holes: ApiCourseHole[]) {
return holes
.slice()
.sort((a, b) => a.hole_number - b.hole_number)
.map((h) => ({ hole: h.hole_number, par: String(h.par), strokeIndex: String(h.stroke_index) }))
}
function templateToTees(tees: ApiCourseTee[]): CreateTee[] {
return tees.map((t, i) => {
const m = t.ratings.find((r) => r.gender === "m")
const f = t.ratings.find((r) => r.gender === "f")
return {
id: `tpl-${i}-${t.name}`,
name: t.name,
men: !!m,
women: !!f,
menRating: m ? { courseRating: String(m.course_rating), slope: String(m.slope_rating), par: String(m.par) } : undefined,
womenRating: f ? { courseRating: String(f.course_rating), slope: String(f.slope_rating), par: String(f.par) } : undefined,
}
})
}
function parseDecimal(value: string): number { function parseDecimal(value: string): number {
return Number(value.replace(",", ".")) return Number(value.replace(",", "."))
} }
function OwnCreateStep({ onCreated, onCancel }: { onCreated: (course: Course) => void; onCancel: () => void }) { function OwnCreateStep({
const [name, setName] = useState("") initial,
const [holes, setHoles] = useState(emptyHoles) forkedFromId,
const [tees, setTees] = useState<CreateTee[]>([{ id: `nt-${Date.now()}`, name: "", men: true, women: false }]) onCreated,
onCancel,
}: {
initial?: CourseTemplateValues
forkedFromId?: string
onCreated: (course: Course) => void
onCancel: () => void
}) {
const [name, setName] = useState(initial?.name ?? "")
const [holes, setHoles] = useState(() => (initial ? templateToHoles(initial.holes) : emptyHoles()))
const [tees, setTees] = useState<CreateTee[]>(() =>
initial ? templateToTees(initial.tees) : [{ id: `nt-${Date.now()}`, name: "", men: true, women: false }],
)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -1440,6 +1573,7 @@ function OwnCreateStep({ onCreated, onCancel }: { onCreated: (course: Course) =>
: []), : []),
], ],
})), })),
forked_from_id: forkedFromId ?? null,
} }
const res = await fetch("/personal-courses", { const res = await fetch("/personal-courses", {
method: "POST", method: "POST",

View file

@ -70,16 +70,6 @@ type ApiTournamentClass = {
default_tee_name: string | null default_tee_name: string | null
} }
type ApiCourse = {
id: string
name: string
}
type ApiTee = {
id: string
name: string
}
// Team colors er DATA brukt på scorekort senere -- bevisst atskilt fra // Team colors er DATA brukt på scorekort senere -- bevisst atskilt fra
// app-ens merkevare-grønn/oransje. // app-ens merkevare-grønn/oransje.
const TEAM_COLORS: { value: string; label: string }[] = [ const TEAM_COLORS: { value: string; label: string }[] = [
@ -107,7 +97,6 @@ export function TournamentDetail({
const [joinCode, setJoinCode] = useState<string | null>(null) const [joinCode, setJoinCode] = useState<string | null>(null)
const [status, setStatus] = useState<TournamentStatus | null>(null) const [status, setStatus] = useState<TournamentStatus | null>(null)
const [classes, setClasses] = useState<ApiTournamentClass[]>([]) const [classes, setClasses] = useState<ApiTournamentClass[]>([])
const [courses, setCourses] = useState<ApiCourse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -115,7 +104,7 @@ export function TournamentDetail({
let cancelled = false let cancelled = false
async function load() { async function load() {
try { try {
const [teamsRes, poolRes, tournamentsRes, classesRes, coursesRes] = await Promise.all([ const [teamsRes, poolRes, tournamentsRes, classesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
credentials: "include", credentials: "include",
}), }),
@ -124,7 +113,6 @@ export function TournamentDetail({
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, { fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
credentials: "include", credentials: "include",
}), }),
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
]) ])
if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed") if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed")
const teamsData: { id: string; name: string; color: string | null }[] = const teamsData: { id: string; name: string; color: string | null }[] =
@ -143,7 +131,6 @@ export function TournamentDetail({
} }
} }
if (classesRes.ok) setClasses(await classesRes.json()) if (classesRes.ok) setClasses(await classesRes.json())
if (coursesRes.ok) setCourses(await coursesRes.json())
const withRosters = await Promise.all( const withRosters = await Promise.all(
teamsData.map(async (t) => { teamsData.map(async (t) => {
@ -300,53 +287,6 @@ export function TournamentDetail({
} }
} }
async function createClass(name: string, defaultTeeId: string | null) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name, default_tee_id: defaultTeeId }),
})
if (!res.ok) throw new Error(`create class: ${res.status}`)
const created: ApiTournamentClass = await res.json()
setClasses((prev) => [...prev, created])
} catch {
setError("Klarte ikke å opprette klassen. Prøv igjen.")
}
}
async function deleteClass(classId: string) {
setError(null)
try {
const res = await fetch(
`/orgs/${organizationId}/tournaments/${tournamentId}/classes/${classId}`,
{ method: "DELETE", credentials: "include" },
)
if (res.status !== 204) throw new Error(`delete class: ${res.status}`)
setClasses((prev) => prev.filter((c) => c.id !== classId))
// Roster-rader som pekte på klassen mister den lokalt også (backend
// satte allerede class_id=NULL via ON DELETE SET NULL) -- unngår en
// hengende, ugyldig klasse-referanse i UI-et inntil neste full reload.
setTeams(
(prev) =>
prev.map((t) =>
t
? {
...t,
roster: t.roster.map((r) =>
r.class_id === classId ? { ...r, class_id: null, class_name: null } : r,
),
}
: t,
) as [Team | null, Team | null],
)
} catch {
setError("Klarte ikke å fjerne klassen. Prøv igjen.")
}
}
async function removePlayer(teamId: string, rosterId: string) { async function removePlayer(teamId: string, rosterId: string) {
setError(null) setError(null)
try { try {
@ -527,13 +467,6 @@ export function TournamentDetail({
</div> </div>
) : ( ) : (
<> <>
<ClassesCard
classes={classes}
courses={courses}
organizationId={organizationId}
onCreate={createClass}
onDelete={deleteClass}
/>
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
{([0, 1] as const).map((slot) => { {([0, 1] as const).map((slot) => {
const team = teams[slot] const team = teams[slot]
@ -608,161 +541,6 @@ function JoinCodeChip({ code }: { code: string }) {
// --- Create team (State A) ------------------------------------------------- // --- Create team (State A) -------------------------------------------------
// --- Konkurranseklasser (2026-08-03) ----------------------------------------
// Fritt navngitte klasser med et valgfritt standardutslag -- se
// tournament_class-tabellen (053_tournament_classes.sql). Kun utslag-
// bekvemmelighet i lagturneringer (ingen egen resultatliste her, bekreftet
// med bruker -- se AssignRoundParticipantControl/LeaderboardTab i
// individual-tournament-detail.tsx for der klasse OGSÅ deler leaderboardet).
function ClassesCard({
classes,
courses,
organizationId,
onCreate,
onDelete,
}: {
classes: ApiTournamentClass[]
courses: ApiCourse[]
organizationId: string
onCreate: (name: string, defaultTeeId: string | null) => void
onDelete: (classId: string) => 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
fetch(`/orgs/${organizationId}/courses/${courseId}/tees`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiTee[]) => {
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="mb-4 flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<h3 className="text-sm font-bold text-foreground">Klasser</h3>
<p className="text-xs text-muted-foreground text-pretty">
Foreslår riktig utslag når en spiller legges til en kamp -- ingen egen resultatliste
her (poeng er knyttet til hele kamper, ikke enkeltspillere).
</p>
</div>
</div>
{classes.length > 0 && (
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
{classes.map((c) => (
<li key={c.id} className="flex items-center gap-3 px-3 py-2.5">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold 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"
onClick={() => onDelete(c.id)}
aria-label={`Fjern klassen ${c.name}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 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.5">
<Label htmlFor="class-name">Klassenavn</Label>
<Input
id="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.5">
<Label htmlFor="class-course">Bane (for standardutslag)</Label>
<select
id="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.5">
<Label htmlFor="class-tee">Standardutslag</Label>
<select
id="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="outline"
disabled={adding || name.trim().length === 0}
className="h-11 shrink-0 rounded-xl font-semibold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til
</Button>
</form>
</section>
)
}
function CreateTeamCard({ function CreateTeamCard({
slot, slot,
onCreate, onCreate,

View file

@ -31,6 +31,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CourseTemplatePicker } from "@/components/course-template-editor"
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) - // --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) -
@ -68,6 +69,25 @@ type ApiCourse = {
source: string source: string
} }
type ApiTee = {
id: string
name: string
}
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
// standardutslag brukt til å forhåndsutfylle riktig utslag når en spiller
// legges til en match (session-blind-draw.tsx). Ren utslag-bekvemmelighet
// her -- INGEN egen resultatliste for lagturneringer (bekreftet med
// bruker: poeng er knyttet til hele kamper, ikke enkeltspillere). Flyttet
// hit fra tournament-detail.tsx (2026-08-04) -- gir ingen mening å velge
// et standardutslag før en bane faktisk finnes i programmet.
type ApiTournamentClass = {
id: string
name: string
default_tee_id: string | null
default_tee_name: string | null
}
type ApiSession = { type ApiSession = {
id: string id: string
sequence: number sequence: number
@ -128,6 +148,7 @@ export function TournamentProgram({
}) { }) {
const [sessions, setSessions] = useState<ApiSession[]>([]) const [sessions, setSessions] = useState<ApiSession[]>([])
const [courses, setCourses] = useState<ApiCourse[]>([]) const [courses, setCourses] = useState<ApiCourse[]>([])
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -135,11 +156,14 @@ export function TournamentProgram({
let cancelled = false let cancelled = false
async function load() { async function load() {
try { try {
const [sessionsRes, coursesRes] = await Promise.all([ const [sessionsRes, coursesRes, classesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, {
credentials: "include", credentials: "include",
}), }),
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }), fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
credentials: "include",
}),
]) ])
if (!sessionsRes.ok || !coursesRes.ok) throw new Error("load failed") if (!sessionsRes.ok || !coursesRes.ok) throw new Error("load failed")
const sessionsData: ApiSession[] = await sessionsRes.json() const sessionsData: ApiSession[] = await sessionsRes.json()
@ -147,6 +171,7 @@ export function TournamentProgram({
if (cancelled) return if (cancelled) return
setSessions(sessionsData) setSessions(sessionsData)
setCourses(coursesData) setCourses(coursesData)
if (classesRes.ok) setClasses(await classesRes.json())
} catch { } catch {
if (!cancelled) setError("Klarte ikke å laste programmet. Prøv å laste siden på nytt.") if (!cancelled) setError("Klarte ikke å laste programmet. Prøv å laste siden på nytt.")
} finally { } finally {
@ -159,6 +184,37 @@ export function TournamentProgram({
} }
}, [organizationId, tournamentId]) }, [organizationId, tournamentId])
async function createClass(name: string, defaultTeeId: string | null) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name, default_tee_id: defaultTeeId }),
})
if (!res.ok) throw new Error(`create class: ${res.status}`)
const created: ApiTournamentClass = await res.json()
setClasses((prev) => [...prev, created])
} catch {
setError("Klarte ikke å opprette klassen. Prøv igjen.")
}
}
async function deleteClass(classId: string) {
setError(null)
try {
const res = await fetch(
`/orgs/${organizationId}/tournaments/${tournamentId}/classes/${classId}`,
{ method: "DELETE", credentials: "include" },
)
if (res.status !== 204) throw new Error(`delete class: ${res.status}`)
setClasses((prev) => prev.filter((c) => c.id !== classId))
} catch {
setError("Klarte ikke å fjerne klassen. Prøv igjen.")
}
}
const coursesById = useMemo(() => { const coursesById = useMemo(() => {
const map = new Map<string, ApiCourse>() const map = new Map<string, ApiCourse>()
for (const c of courses) map.set(c.id, c) for (const c of courses) map.set(c.id, c)
@ -384,6 +440,16 @@ export function TournamentProgram({
onCreate={addSession} onCreate={addSession}
/> />
</div> </div>
<div className="mt-6">
<ClassesCard
classes={classes}
courses={courses}
organizationId={organizationId}
onCreate={createClass}
onDelete={deleteClass}
/>
</div>
</> </>
)} )}
</main> </main>
@ -391,6 +457,163 @@ export function TournamentProgram({
) )
} }
// --- Konkurranseklasser (2026-08-03, flyttet hit 2026-08-04) ---------------
// Fritt navngitte klasser med et valgfritt standardutslag -- se
// tournament_class-tabellen (053_tournament_classes.sql). Kun utslag-
// bekvemmelighet i lagturneringer (ingen egen resultatliste her, bekreftet
// med bruker -- se AssignRoundParticipantControl/LeaderboardTab i
// individual-tournament-detail.tsx for der klasse OGSÅ deler leaderboardet).
// Plassert her, ETTER økt-/baneoppsettet over, siden standardutslaget er
// meningsløst å velge før man vet hvilke utslag banen faktisk har.
function ClassesCard({
classes,
courses,
organizationId,
onCreate,
onDelete,
}: {
classes: ApiTournamentClass[]
courses: ApiCourse[]
organizationId: string
onCreate: (name: string, defaultTeeId: string | null) => void
onDelete: (classId: string) => 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
fetch(`/orgs/${organizationId}/courses/${courseId}/tees`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiTee[]) => {
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-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<h3 className="text-sm font-bold text-foreground">Klasser</h3>
<p className="text-xs text-muted-foreground text-pretty">
Foreslår riktig utslag når en spiller legges til en kamp -- ingen egen resultatliste
her (poeng er knyttet til hele kamper, ikke enkeltspillere).
</p>
</div>
</div>
{classes.length > 0 && (
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
{classes.map((c) => (
<li key={c.id} className="flex items-center gap-3 px-3 py-2.5">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold 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"
onClick={() => onDelete(c.id)}
aria-label={`Fjern klassen ${c.name}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 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.5">
<Label htmlFor="class-name">Klassenavn</Label>
<Input
id="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.5">
<Label htmlFor="class-course">Bane (for standardutslag)</Label>
<select
id="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.5">
<Label htmlFor="class-tee">Standardutslag</Label>
<select
id="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="outline"
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>
)
}
// --- Session card (timeline item) ------------------------------------------ // --- Session card (timeline item) ------------------------------------------
function SessionCard({ function SessionCard({
@ -776,6 +999,7 @@ function EditCourseField({
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [officialSearchOpen, setOfficialSearchOpen] = useState(false) const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
if (!open) { if (!open) {
return ( return (
@ -849,7 +1073,8 @@ function EditCourseField({
)} )}
</ul> </ul>
{!officialSearchOpen ? ( {!officialSearchOpen && !templatePickerOpen ? (
<div className="flex flex-wrap gap-x-4 gap-y-1">
<button <button
type="button" type="button"
onClick={() => setOfficialSearchOpen(true)} onClick={() => setOfficialSearchOpen(true)}
@ -857,7 +1082,15 @@ function EditCourseField({
> >
Hent bane fra teeoff i stedet Hent bane fra teeoff i stedet
</button> </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 <OfficialCourseSearch
organizationId={organizationId} organizationId={organizationId}
onClose={() => setOfficialSearchOpen(false)} onClose={() => setOfficialSearchOpen(false)}
@ -867,6 +1100,17 @@ function EditCourseField({
setOfficialSearchOpen(false) setOfficialSearchOpen(false)
}} }}
/> />
) : (
<CourseTemplatePicker
organizationId={organizationId}
onCreated={(created) => {
const course = { id: created.id, name: created.name, source: "custom" }
onCourseImported(course)
select(course)
setTemplatePickerOpen(false)
}}
onCancel={() => setTemplatePickerOpen(false)}
/>
)} )}
</div> </div>
) )
@ -970,6 +1214,7 @@ function CreateSessionCard({
const [courseQuery, setCourseQuery] = useState("") const [courseQuery, setCourseQuery] = useState("")
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null) const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
const [officialSearchOpen, setOfficialSearchOpen] = useState(false) const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false)
const [useHandicap, setUseHandicap] = useState(true) const [useHandicap, setUseHandicap] = useState(true)
@ -1186,6 +1431,7 @@ function CreateSessionCard({
</ul> </ul>
</div> </div>
)} )}
<div className="flex flex-wrap gap-x-4 gap-y-1">
<button <button
type="button" type="button"
onClick={() => setOfficialSearchOpen(true)} onClick={() => setOfficialSearchOpen(true)}
@ -1193,6 +1439,14 @@ function CreateSessionCard({
> >
Hent bane fra teeoff i stedet Hent bane fra teeoff i stedet
</button> </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 && ( {officialSearchOpen && (
<OfficialCourseSearch <OfficialCourseSearch
organizationId={organizationId} organizationId={organizationId}
@ -1204,6 +1458,17 @@ function CreateSessionCard({
}} }}
/> />
)} )}
{templatePickerOpen && (
<CourseTemplatePicker
organizationId={organizationId}
onCreated={(course) => {
onCourseImported({ id: course.id, name: course.name, source: "custom" })
setSelectedCourseId(course.id)
setTemplatePickerOpen(false)
}}
onCancel={() => setTemplatePickerOpen(false)}
/>
)}
</div> </div>
)} )}
</div> </div>