diff --git a/054_personal_course_provenance.sql b/054_personal_course_provenance.sql new file mode 100644 index 0000000..85b8960 --- /dev/null +++ b/054_personal_course_provenance.sql @@ -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; diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 39f7198..f88ac71 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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å) Disse må avklares før eller under de relevante fasene: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3973f7f..ee93ee4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7207,3 +7207,184 @@ Neste steg: teecup_api teecup_frontend`, begge containere boot-et rent, `/health`/`/dashboard` → 200. V0-zip-en slettet etter merge, per 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 `
` -- men komponenten rendres + INNI et allerede eksisterende `` (`NewRoundForm`/ + `CreateSessionCard`), og nestede ``-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: `` + → `
`, `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. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 4489d42..6ccb238 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -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-``-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 - 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte diff --git a/app/routers/courses.py b/app/routers/courses.py index 7a5bf77..695488a 100644 --- a/app/routers/courses.py +++ b/app/routers/courses.py @@ -23,10 +23,6 @@ from pydantic import BaseModel, Field router = APIRouter() -class CourseCreate(BaseModel): - name: str = Field(min_length=1, max_length=200) - - class Course(BaseModel): id: str name: str @@ -45,24 +41,6 @@ async def list_courses( 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) -- # # 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) +# --- 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) --------------------------------- diff --git a/app/routers/rounds.py b/app/routers/rounds.py index 400cd11..7614f59 100644 --- a/app/routers/rounds.py +++ b/app/routers/rounds.py @@ -40,6 +40,7 @@ import uuid from datetime import date, datetime, timedelta, timezone from typing import Literal +import asyncpg from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect from pydantic import BaseModel, EmailStr, Field @@ -121,11 +122,18 @@ class PersonalCourseCreate(BaseModel): name: str holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18) 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): id: str name: str + is_mine: bool + created_by_display_name: str class TeeOption(BaseModel): @@ -137,18 +145,53 @@ class PersonalCourseDetail(BaseModel): id: str name: str 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]) async def search_personal_courses( 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), ) -> list[PersonalCourseOut]: async with plain_connection() as conn: - rows = await conn.fetch( - "SELECT id::text AS id, name FROM personal_course WHERE name ILIKE $1 ORDER BY name LIMIT 20", - f"%{q.strip()}%", - ) + if mine: + rows = await conn.fetch( + """ + 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()}%", + user.user_id, + ) return [PersonalCourseOut(**dict(r)) for r in rows] @@ -158,12 +201,23 @@ async def get_personal_course( user: CurrentUser = Depends(get_current_user), ) -> PersonalCourseDetail: 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: raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.") 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 JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id WHERE t.personal_course_id = $1 @@ -171,16 +225,74 @@ async def get_personal_course( """, 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]] = {} + full_tees: dict[str, list[PersonalCourseTeeRatingIn]] = {} for r in tee_rows: 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( id=course_row["id"], name=course_row["name"], 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, få 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, + ) + + @router.post("/personal-courses", response_model=PersonalCourseOut, status_code=201) async def create_personal_course( body: PersonalCourseCreate, @@ -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.") 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(): 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(), user.user_id, + body.forked_from_id, ) for h in body.holes: await conn.execute( @@ -225,7 +342,153 @@ async def create_personal_course( r.slope_rating, 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 må forgrene på 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 på 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 is_main_course: bool 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): @@ -321,23 +594,54 @@ async def get_official_facility_for_round( courses: list[OfficialCourseOption] = [] 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 tees: list[TeeOption] = [] + full_tees: list[PersonalCourseTeeIn] = [] for t in c.get("tees") or []: 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: 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: 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: 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( OfficialCourseOption( teeoff_course_id=c["id"], name=c["name"], is_main_course=bool(c.get("is_main_course")), tees=tees, + holes=template_holes, + full_tees=full_tees if template_holes else [], ) ) return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses) diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx index ac2928d..6c71191 100644 --- a/frontend/components/account-settings.tsx +++ b/frontend/components/account-settings.tsx @@ -14,10 +14,13 @@ import { Bell, BellRing, Camera, + Copy, + Flag, KeyRound, Lock, LogOut, Mail, + Pencil, Plus, ShieldCheck, ShieldOff, @@ -31,6 +34,7 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Wordmark } from "@/components/wordmark" 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 // org-scopede `player`-radene organisatorer administrerer. @@ -284,6 +288,8 @@ export function AccountSettings() { + +
@@ -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(null) + const [editingId, setEditingId] = useState(null) + const [editingInitial, setEditingInitial] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(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 ( +
+
+
+
+
+

Mine baner

+

+ Egendefinerte baner du har opprettet -- offentlig synlige og brukbare som mal av andre, + i både turneringsmodulen og single-runde-modulen. +

+
+
+ + {error &&

{error}

} + + {editingId ? ( + { + setEditingId(null) + setEditingInitial(null) + }} + /> + ) : courses === null ? ( +

Laster…

+ ) : courses.length === 0 ? ( +

+ Ingen egne baner ennå -- opprett en via "Egen bane" i ny-runde-veiviseren, eller "Opprett + bane med hull/utslag" i en turnering. +

+ ) : ( +
    + {courses.map((c) => ( +
  • + {c.name} + + + +
  • + ))} +
+ )} +
+ ) +} + function NotificationEmailPrefsSection() { const [types, setTypes] = useState([]) const [loaded, setLoaded] = useState(false) diff --git a/frontend/components/course-template-editor.tsx b/frontend/components/course-template-editor.tsx new file mode 100644 index 0000000..5093953 --- /dev/null +++ b/frontend/components/course-template-editor.tsx @@ -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(initial?.holes ?? blankHoles()) + const [tees, setTees] = useState(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) { + 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 : denne komponenten rendres ofte inne i + // et YTRE skjema (NewRoundForm/CreateSessionCard), og nestede - + // 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 ( +
+ {error &&

{error}

} + +
+ + setName(e.target.value)} placeholder="F.eks. Hjemmebanen" className="h-10 rounded-xl text-sm" /> +
+ +
+ Hull -- par og stroke-indeks + {!indexesValid && ( +

+ Hver stroke-indeks (1-18) må brukes nøyaktig én gang. +

+ )} +
+
+ Hull + Par + SI +
+ {holes.map((h, index) => ( +
+ {h.hole_number} + + +
+ ))} +
+
+ +
+ Utslag + {tees.map((t, index) => ( +
+
+
+ + updateTee(t.key, { name: e.target.value })} placeholder="F.eks. Gul" className="h-10 rounded-xl text-sm" /> +
+ {tees.length > 1 && ( + + )} +
+ updateTee(t.key, { men })} + onChange={(menRating) => updateTee(t.key, { menRating })} + idPrefix={`ct-men-${t.key}`} + /> + updateTee(t.key, { women })} + onChange={(womenRating) => updateTee(t.key, { womenRating })} + idPrefix={`ct-women-${t.key}`} + /> +
+ ))} + +
+ +
+ + +
+
+ ) +} + +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 ( +
+ + {enabled && ( +
+ onChange({ ...rating, courseRating: e.target.value })} + placeholder="CR" + className="h-9 rounded-lg text-sm" + /> + onChange({ ...rating, slope: e.target.value })} + placeholder="Slope" + className="h-9 rounded-lg text-sm" + /> + onChange({ ...rating, par: e.target.value })} + placeholder="Par" + className="h-9 rounded-lg text-sm" + /> +
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// 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({ kind: "choose" }) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(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 ( +
+ Opprett manuell bane +
+ + + +
+ +
+ ) + } + + if (step.kind === "official-search") { + return ( + setStep({ kind: "official-facility", facility })} + onClose={() => setStep({ kind: "choose" })} + /> + ) + } + + if (step.kind === "official-facility") { + return ( + setStep({ kind: "official-search" })} + onPicked={(values) => setStep({ kind: "editor", initial: values })} + /> + ) + } + + if (step.kind === "public-search") { + return ( + submitCourse(values, sourceId)} + onPickCustomize={(values, sourceId) => setStep({ kind: "editor", initial: values, templatePersonalCourseId: sourceId })} + onClose={() => setStep({ kind: "choose" })} + /> + ) + } + + return ( + 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(null) + const [searching, setSearching] = useState(false) + const [error, setError] = useState(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 ( +
+
+ TeeOff-bane som mal + +
+ {error &&

{error}

} +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + runSearch() + } + }} + className="h-11 flex-1 rounded-xl text-base" + /> + +
+ {facilities && ( +
    + {facilities.map((f) => ( +
  • + +
  • + ))} + {facilities.length === 0 &&
  • Ingen treff.
  • } +
+ )} +
+ ) +} + +function OfficialCourseList({ + organizationId, + facility, + onBack, + onPicked, +}: { + organizationId: string + facility: ApiOfficialFacility + onBack: () => void + onPicked: (values: CourseTemplateValues) => void +}) { + const [courses, setCourses] = useState(null) + const [error, setError] = useState(null) + const [loadingCourseId, setLoadingCourseId] = useState(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 ( +
+ + {error &&

{error}

} + {courses === null ? ( +

Laster baner…

+ ) : ( +
    + {courses.map((c) => ( +
  • + +
  • + ))} + {courses.length === 0 &&
  • Ingen 18-hulls baner hos dette anlegget ennå.
  • } +
+ )} +
+ ) +} + +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([]) + const [loadingId, setLoadingId] = useState(null) + const [error, setError] = useState(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 { + 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 ( +
+
+ Offentlig bane som mal + +
+ {error &&

{error}

} + setQuery(e.target.value)} placeholder="Søk etter banenavn…" className="h-11 rounded-xl text-sm" /> +
    + {results.map((c) => ( +
  • +
    + {c.name} + {c.is_mine ? "Opprettet av deg" : `Opprettet av ${c.created_by_display_name}`} +
    +
    + + +
    +
  • + ))} + {results.length === 0 && ( +
  • + {query.trim() ? `Ingen offentlige baner matcher «${query}».` : "Skriv for å søke."} +
  • + )} +
+
+ ) +} diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 56fe4e7..a6f3535 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -41,6 +41,7 @@ import { import { TournamentPresentationPanel } from "@/components/tournament-presentation" import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard" import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge" +import { CourseTemplatePicker } from "@/components/course-template-editor" import { cn } from "@/lib/utils" // --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) -- @@ -444,6 +445,10 @@ export function IndividualTournamentDetail({ return created } + function handleCourseImported(course: ApiCourse) { + setCourses((prev) => [...prev, course].sort((a, b) => a.name.localeCompare(b.name))) + } + if (loading) { return (
@@ -551,6 +556,7 @@ export function IndividualTournamentDetail({ onAddRound={addRound} onDeleteRound={deleteRound} onAddCourse={addCourse} + onCourseImported={handleCourseImported} onCreateClass={createClass} onDeleteClass={deleteClass} onSetParticipantClass={setParticipantClass} @@ -620,6 +626,7 @@ function SetupTab({ onAddRound, onDeleteRound, onAddCourse, + onCourseImported, onCreateClass, onDeleteClass, onSetParticipantClass, @@ -640,6 +647,7 @@ function SetupTab({ onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise onDeleteRound: (id: string) => Promise onAddCourse: (name: string) => Promise + onCourseImported: (course: ApiCourse) => void onCreateClass: (name: string, defaultTeeId: string | null) => Promise onDeleteClass: (id: string) => Promise onSetParticipantClass: (participantId: string, classId: string | null) => Promise @@ -700,6 +708,21 @@ function SetupTab({ )}
+ + - -
) } @@ -1150,6 +1159,7 @@ function RoundsCard({ onAddRound, onDeleteRound, onAddCourse, + onCourseImported, onError, }: { base: string @@ -1162,6 +1172,7 @@ function RoundsCard({ onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise onDeleteRound: (id: string) => Promise onAddCourse: (name: string) => Promise + onCourseImported: (course: ApiCourse) => void onError: (message: string) => void }) { const [creating, setCreating] = useState(false) @@ -1210,7 +1221,9 @@ function RoundsCard({ ) : ( { await onAddRound(name, courseId, holeConfig) setCreating(false) @@ -1224,14 +1237,18 @@ function RoundsCard({ } function NewRoundForm({ + organizationId, courses, onCreate, onCreateCourse, + onCourseImported, onCancel, }: { + organizationId: string courses: ApiCourse[] onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise onCreateCourse: (name: string) => Promise + onCourseImported: (course: ApiCourse) => void onCancel: () => void }) { const [name, setName] = useState("") @@ -1239,6 +1256,8 @@ function NewRoundForm({ const [holeConfig, setHoleConfig] = useState("full_18") const [newCourseName, setNewCourseName] = useState("") const [addingCourse, setAddingCourse] = useState(false) + const [officialSearchOpen, setOfficialSearchOpen] = useState(false) + const [templatePickerOpen, setTemplatePickerOpen] = useState(false) const [submitting, setSubmitting] = useState(false) async function handleCreateCourse() { @@ -1334,6 +1353,46 @@ function NewRoundForm({ Banen må ha registrert hull (par/hcp-indeks) og utslag før scoring kan begynne -- sett opp dette under organisasjonens baner om det mangler.

+ {!officialSearchOpen && !templatePickerOpen ? ( +
+ + +
+ ) : officialSearchOpen ? ( + setOfficialSearchOpen(false)} + onImported={(course) => { + onCourseImported(course) + setCourseId(course.id) + setAddingCourse(false) + setOfficialSearchOpen(false) + }} + /> + ) : ( + { + onCourseImported({ id: created.id, name: created.name, source: "custom" }) + setCourseId(created.id) + setAddingCourse(false) + setTemplatePickerOpen(false) + }} + onCancel={() => setTemplatePickerOpen(false)} + /> + )}
@@ -2279,3 +2338,223 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
) } + +// --- 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(null) + const [selectedFacility, setSelectedFacility] = useState(null) + const [courseOptions, setCourseOptions] = useState(null) + const [searching, setSearching] = useState(false) + const [importing, setImporting] = useState(false) + const [error, setError] = useState(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 ( +
+
+ Hent bane fra teeoff + +
+ + {error &&

{error}

} + + {!selectedFacility ? ( + <> + {/* Bevisst IKKE et eget her -- denne komponenten rendres inne + i NewRoundForm sitt , og nestede + -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). */} +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + runSearch() + } + }} + className="h-11 flex-1 rounded-xl text-base" + /> + +
+ {facilities && ( +
    + {facilities.map((f) => ( +
  • + +
  • + ))} + {facilities.length === 0 && ( +
  • Ingen treff.
  • + )} +
+ )} + + ) : ( + <> + + {courseOptions === null ? ( +

Laster baner…

+ ) : ( +
    + {courseOptions.map((c) => ( +
  • + +
  • + ))} + {courseOptions.length === 0 && ( +
  • + Ingen 18-hulls baner å importere hos dette anlegget ennå. +
  • + )} +
+ )} + + )} +
+ ) +} diff --git a/frontend/components/new-round.tsx b/frontend/components/new-round.tsx index f8c4213..263e4e0 100644 --- a/frontend/components/new-round.tsx +++ b/frontend/components/new-round.tsx @@ -39,6 +39,7 @@ import { Label } from "@/components/ui/label" import { Switch } from "@/components/ui/switch" import { Wordmark } from "@/components/wordmark" import { cn } from "@/lib/utils" +import type { ApiCourseHole, ApiCourseTee, CourseTemplateValues } from "@/components/course-template-editor" // --- Types ------------------------------------------------------------------- @@ -48,7 +49,15 @@ type ApiGender = "m" | "f" // ikke utslagslisten filtreres riktig per spillers kjønn (samme sjekk // backend selv håndhever ved innsending). 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[] } // Hvilken bane-kilde+id som faktisk skal sendes til /rounds -- Course over @@ -226,9 +235,22 @@ type ApiOfficialCourseOption = { name: string is_main_course: boolean 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 = { id: string first_name: string @@ -1019,6 +1041,9 @@ function Step1(props: { }) { const { s1Sub, setS1Sub, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props 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") { return ( @@ -1062,7 +1087,13 @@ function Step1(props: { id: facility.slug, name: detail.name, 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) setS1Sub("official-courses") @@ -1083,6 +1114,11 @@ function Step1(props: { description="Velg hvilken bane du skal spille." courses={selectedClub.courses} 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", ) }} - 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") { return ( 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 (
    {courses.map((course) => ( -
  • +
  • + {onUseAsTemplate && course.templateHoles && course.templateHoles.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 [results, setResults] = useState([]) @@ -1345,19 +1429,30 @@ function OwnSearch({ onSelect, onCreate }: { onSelect: (c: ApiPersonalCourse) => {results.length > 0 ? (
      {results.map((course) => ( -
    • +
    • +
    • ))}
    @@ -1386,14 +1481,52 @@ function emptyHoles() { 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 { return Number(value.replace(",", ".")) } -function OwnCreateStep({ onCreated, onCancel }: { onCreated: (course: Course) => void; onCancel: () => void }) { - const [name, setName] = useState("") - const [holes, setHoles] = useState(emptyHoles) - const [tees, setTees] = useState([{ id: `nt-${Date.now()}`, name: "", men: true, women: false }]) +function OwnCreateStep({ + initial, + forkedFromId, + 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(() => + initial ? templateToTees(initial.tees) : [{ id: `nt-${Date.now()}`, name: "", men: true, women: false }], + ) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) @@ -1440,6 +1573,7 @@ function OwnCreateStep({ onCreated, onCancel }: { onCreated: (course: Course) => : []), ], })), + forked_from_id: forkedFromId ?? null, } const res = await fetch("/personal-courses", { method: "POST", diff --git a/frontend/components/tournament-detail.tsx b/frontend/components/tournament-detail.tsx index 979c92d..e351ea7 100644 --- a/frontend/components/tournament-detail.tsx +++ b/frontend/components/tournament-detail.tsx @@ -70,16 +70,6 @@ type ApiTournamentClass = { 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 // app-ens merkevare-grønn/oransje. const TEAM_COLORS: { value: string; label: string }[] = [ @@ -107,7 +97,6 @@ export function TournamentDetail({ const [joinCode, setJoinCode] = useState(null) const [status, setStatus] = useState(null) const [classes, setClasses] = useState([]) - const [courses, setCourses] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -115,7 +104,7 @@ export function TournamentDetail({ let cancelled = false async function load() { try { - const [teamsRes, poolRes, tournamentsRes, classesRes, coursesRes] = await Promise.all([ + const [teamsRes, poolRes, tournamentsRes, classesRes] = await Promise.all([ fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include", }), @@ -124,7 +113,6 @@ export function TournamentDetail({ fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, { credentials: "include", }), - fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }), ]) if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed") const teamsData: { id: string; name: string; color: string | null }[] = @@ -143,7 +131,6 @@ export function TournamentDetail({ } } if (classesRes.ok) setClasses(await classesRes.json()) - if (coursesRes.ok) setCourses(await coursesRes.json()) const withRosters = await Promise.all( 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) { setError(null) try { @@ -527,13 +467,6 @@ export function TournamentDetail({
) : ( <> -
{([0, 1] as const).map((slot) => { const team = teams[slot] @@ -608,161 +541,6 @@ function JoinCodeChip({ code }: { code: string }) { // --- 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([]) - 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 ( -
-
-
-
-
-

Klasser

-

- Foreslår riktig utslag når en spiller legges til en kamp -- ingen egen resultatliste - her (poeng er knyttet til hele kamper, ikke enkeltspillere). -

-
-
- - {classes.length > 0 && ( -
    - {classes.map((c) => ( -
  • -
    - {c.name} - {c.default_tee_name && ( - - Standardutslag: {c.default_tee_name} - - )} -
    - -
  • - ))} -
- )} - - -
- - setName(e.target.value)} - placeholder="F.eks. Damer" - className="h-11" - /> -
-
- - -
- {tees.length > 0 && ( -
- - -
- )} - - -
- ) -} - function CreateTeamCard({ slot, onCreate, diff --git a/frontend/components/tournament-program.tsx b/frontend/components/tournament-program.tsx index 6f57864..07bbba8 100644 --- a/frontend/components/tournament-program.tsx +++ b/frontend/components/tournament-program.tsx @@ -31,6 +31,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { cn } from "@/lib/utils" +import { CourseTemplatePicker } from "@/components/course-template-editor" // --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) - @@ -68,6 +69,25 @@ type ApiCourse = { 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 = { id: string sequence: number @@ -128,6 +148,7 @@ export function TournamentProgram({ }) { const [sessions, setSessions] = useState([]) const [courses, setCourses] = useState([]) + const [classes, setClasses] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -135,11 +156,14 @@ export function TournamentProgram({ let cancelled = false async function load() { try { - const [sessionsRes, coursesRes] = await Promise.all([ + const [sessionsRes, coursesRes, classesRes] = await Promise.all([ fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { 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") const sessionsData: ApiSession[] = await sessionsRes.json() @@ -147,6 +171,7 @@ export function TournamentProgram({ if (cancelled) return setSessions(sessionsData) setCourses(coursesData) + if (classesRes.ok) setClasses(await classesRes.json()) } catch { if (!cancelled) setError("Klarte ikke å laste programmet. Prøv å laste siden på nytt.") } finally { @@ -159,6 +184,37 @@ export function TournamentProgram({ } }, [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 map = new Map() for (const c of courses) map.set(c.id, c) @@ -384,6 +440,16 @@ export function TournamentProgram({ onCreate={addSession} />
+ +
+ +
)} @@ -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([]) + 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 ( +
+
+
+
+
+

Klasser

+

+ Foreslår riktig utslag når en spiller legges til en kamp -- ingen egen resultatliste + her (poeng er knyttet til hele kamper, ikke enkeltspillere). +

+
+
+ + {classes.length > 0 && ( +
    + {classes.map((c) => ( +
  • +
    + {c.name} + {c.default_tee_name && ( + + Standardutslag: {c.default_tee_name} + + )} +
    + +
  • + ))} +
+ )} + +
+
+ + setName(e.target.value)} + placeholder="F.eks. Damer" + className="h-11" + /> +
+
+ + +
+ {tees.length > 0 && ( +
+ + +
+ )} + +
+
+ ) +} + // --- Session card (timeline item) ------------------------------------------ function SessionCard({ @@ -776,6 +999,7 @@ function EditCourseField({ const [open, setOpen] = useState(false) const [query, setQuery] = useState("") const [officialSearchOpen, setOfficialSearchOpen] = useState(false) + const [templatePickerOpen, setTemplatePickerOpen] = useState(false) if (!open) { return ( @@ -849,15 +1073,24 @@ function EditCourseField({ )} - {!officialSearchOpen ? ( - - ) : ( + {!officialSearchOpen && !templatePickerOpen ? ( +
+ + +
+ ) : officialSearchOpen ? ( setOfficialSearchOpen(false)} @@ -867,6 +1100,17 @@ function EditCourseField({ setOfficialSearchOpen(false) }} /> + ) : ( + { + const course = { id: created.id, name: created.name, source: "custom" } + onCourseImported(course) + select(course) + setTemplatePickerOpen(false) + }} + onCancel={() => setTemplatePickerOpen(false)} + /> )} ) @@ -970,6 +1214,7 @@ function CreateSessionCard({ const [courseQuery, setCourseQuery] = useState("") const [selectedCourseId, setSelectedCourseId] = useState(null) const [officialSearchOpen, setOfficialSearchOpen] = useState(false) + const [templatePickerOpen, setTemplatePickerOpen] = useState(false) const [advancedOpen, setAdvancedOpen] = useState(false) const [useHandicap, setUseHandicap] = useState(true) @@ -1186,13 +1431,22 @@ function CreateSessionCard({ )} - +
+ + +
{officialSearchOpen && ( )} + {templatePickerOpen && ( + { + onCourseImported({ id: course.id, name: course.name, source: "custom" }) + setSelectedCourseId(course.id) + setTemplatePickerOpen(false) + }} + onCancel={() => setTemplatePickerOpen(false)} + /> + )} )}