Fiks turneringsoppsett-rekkefølge, koble til TeeOff-import, og bygg delt bane-mal-bibliotek
Klasser vises nå etter bane-/rundeoppsett (var meningsløst å velge standardutslag før banen fantes). TeeOff-import koblet inn i individuelle turneringer. Ny mal-basert baneoppretting i turneringsmodulen (fra bunnen av / TeeOff / offentlig custom-bane), med personal_course gjenbrukt som et delt, plattform-omfattende bane-bibliotek på tvers av org- og single-runde-modulen (ADR-042, migrasjon 054). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
828b508c53
commit
aee1662de2
12 changed files with 2382 additions and 301 deletions
23
054_personal_course_provenance.sql
Normal file
23
054_personal_course_provenance.sql
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
-- Bane-mal-bibliotek, del II (2026-08-04) -- brukeren oppdaget at
|
||||||
|
-- turneringsmodulen manglet TeeOff-import for individuelle turneringer OG at
|
||||||
|
-- "opprett manuell bane" der bare var et bart navnefelt (ingen hull/utslag-
|
||||||
|
-- skjema fantes). Løsningen: la BEGGE moduler (turnering + single-runde)
|
||||||
|
-- tilby "bruk en eksisterende bane som mal" -- enten fra TeeOff eller fra en
|
||||||
|
-- annen bruker/organisasjons offentlige custom-bane -- som forhåndsutfyller
|
||||||
|
-- et redigerbart hull/utslag-skjema før lagring.
|
||||||
|
--
|
||||||
|
-- personal_course (020_personal_rounds.sql) er, til tross for navnet,
|
||||||
|
-- ALLEREDE et globalt, plattform-omfattende bibliotek -- GET /personal-
|
||||||
|
-- courses søker på tvers av ALLE brukeres baner uten eier- eller org-
|
||||||
|
-- filtrering (rounds.py:142-149). Bekreftet med bruker: dette biblioteket
|
||||||
|
-- er nettopp det "offentlige" stedet begge moduler skal dele -- ingen ny
|
||||||
|
-- tabell trengs, kun denne ene kolonnen for opphav/attribusjon når en bane
|
||||||
|
-- er en redigert kopi ("fork") av en annen.
|
||||||
|
--
|
||||||
|
-- Eierskaps-håndhevelse for de nye endre-/dupliser-/slette-endepunktene
|
||||||
|
-- (rounds.py) skjer i APPLIKASJONSLAGET (created_by_user_id ==
|
||||||
|
-- innlogget bruker), IKKE via RLS -- personal_course har aldri vært
|
||||||
|
-- org-scopet eller RLS-dekket (bevisst, siden opprettelsen i migrasjon 020),
|
||||||
|
-- og det er ingen grunn til å endre det prinsippet nå.
|
||||||
|
ALTER TABLE personal_course
|
||||||
|
ADD COLUMN forked_from_id uuid REFERENCES personal_course (id) ON DELETE SET NULL;
|
||||||
|
|
@ -3899,6 +3899,85 @@ for-tall-bekreftet leaderboard-rangering per klasse i ekte nettleser).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-042: Delt, plattform-omfattende bane-mal-bibliotek (`personal_course`)
|
||||||
|
|
||||||
|
**Kontekst:** Brukeren oppdaget at turneringsmodulens "opprett manuell
|
||||||
|
bane" i praksis var ubrukelig (kun et navnefelt, ingen vei til hull/
|
||||||
|
utslag), og ba om at BÅDE turneringsmodulen og single-runde-modulen skal
|
||||||
|
tilby "bruk en eksisterende bane (TeeOff eller andres custom-bane) som
|
||||||
|
mal" ved manuell baneoppretting. Et oppfølgingsspørsmål presiserte at
|
||||||
|
disse custom-banene "bør lagres, og de må være offentlige" — synlige og
|
||||||
|
gjenbrukbare av andre.
|
||||||
|
|
||||||
|
**Beslutning A — Gjenbruk `personal_course`, ikke en ny tabell.**
|
||||||
|
`personal_course` (020_personal_rounds.sql, opprinnelig bygget for
|
||||||
|
frittstående runder) er, til tross for navnet og til tross for at ingen
|
||||||
|
tidligere runde eksplisitt utnyttet det, ALLEREDE en global,
|
||||||
|
plattform-omfattende katalog — `GET /personal-courses` søker på tvers av
|
||||||
|
ALLE brukeres baner uten eier- eller organisasjonsfiltrering, siden
|
||||||
|
tabellen (bevisst, fra migrasjon 020) aldri har vært RLS-/org-scopet.
|
||||||
|
Fremfor å bygge en ny `community_course`-tabell (vurdert og forkastet)
|
||||||
|
gjenbrukes denne eksisterende, allerede-globale tabellen direkte som det
|
||||||
|
delte mal-biblioteket — eneste tilføyelse er én kolonne,
|
||||||
|
`forked_from_id` (migrasjon 054, selvreferende FK, `ON DELETE SET NULL`)
|
||||||
|
for proveniens/attribusjon. Dette er også den ENESTE farbare veien for
|
||||||
|
at single-runde-modulen (som ikke har noe organisasjons-begrep i det
|
||||||
|
hele tatt) kan dele samme bibliotek som org-turneringsmodulen.
|
||||||
|
|
||||||
|
**Beslutning B — "Offentlig" betyr hele plattformen, ikke bare egen
|
||||||
|
organisasjon.** Bekreftet eksplisitt med bruker (`AskUserQuestion`) etter
|
||||||
|
at jeg flagget spenningen: skal en publisert custom-bane være synlig for
|
||||||
|
ALLE organisasjoner + alle frittstående brukere, eller kun innad i én
|
||||||
|
organisasjon? Svaret var plattform-omfattende — organisasjonsgrensen
|
||||||
|
gjelder domenedata (turneringer, spillere, resultater), ikke dette
|
||||||
|
delte, lavsensitive bane-referansebiblioteket. En org-`course`-rad
|
||||||
|
(brukt til faktisk spill i en turnering) forblir like fullt org-scopet
|
||||||
|
og RLS-beskyttet som før — publisering til `personal_course` skjer som
|
||||||
|
en SEPARAT, samtidig innsetting i samme transaksjon (`courses.py`), ikke
|
||||||
|
en endring av `course`-tabellens egen skoping.
|
||||||
|
|
||||||
|
**Beslutning C — Eierskap håndheves i applikasjonslaget, ikke RLS.**
|
||||||
|
Siden `personal_course` bevisst aldri har vært org-scopet, finnes det
|
||||||
|
ingen `app.current_org`-kontekst å håndheve mot. De nye
|
||||||
|
endre-/dupliser-/slette-endepunktene (`rounds.py`) sjekker eksplisitt
|
||||||
|
`created_by_user_id == innlogget bruker` i handler-koden — samme
|
||||||
|
`plain_connection()`-uten-RLS-mønster som resten av `personal_course`/
|
||||||
|
`round`-familien allerede bruker (ADR-033 Beslutning A).
|
||||||
|
|
||||||
|
**Beslutning D — Rediger forker automatisk hvis du ikke eier raden;
|
||||||
|
Dupliser er en egen, eksplisitt handling.** `PATCH /personal-courses/{id}`:
|
||||||
|
eier den innloggede brukeren raden, oppdateres den i sted; eier
|
||||||
|
brukeren IKKE raden, opprettes automatisk en NY rad (eid av innlogget
|
||||||
|
bruker, `forked_from_id` satt til originalen) — originalen selv røres
|
||||||
|
aldri. Ett endepunkt dekker begge casene uten at frontend selv må
|
||||||
|
forgrene på eierskap. `POST .../duplicate` er en SEPARAT, eksplisitt
|
||||||
|
handling tilgjengelig uansett eierskap på kilden — dekker f.eks. å lage
|
||||||
|
en variant av DIN EGEN bane uten å miste originalen, noe fork-ved-
|
||||||
|
redigering alene ikke gjør (den trigges kun når raden IKKE er din).
|
||||||
|
Bekreftet eksplisitt med bruker at begge mekanismene skulle beholdes
|
||||||
|
side om side.
|
||||||
|
|
||||||
|
**Beslutning E — Mal-bruk er en engangs-kopi, ingen vedvarende kobling.**
|
||||||
|
Når en org-`course` opprettes med en `personal_course` (eller en
|
||||||
|
TeeOff-bane) som utgangspunkt, kopieres dataene inn i den nye raden ved
|
||||||
|
opprettelsestidspunktet — ingen fremmednøkkel eller synk-mekanisme
|
||||||
|
knytter dem sammen etterpå. Samme filosofi som offisiell TeeOff-import
|
||||||
|
allerede etablerte (ADR-019): en mal er et utgangspunkt å redigere fritt
|
||||||
|
fra, ikke en levende referanse. Konsekvens: sletting av en
|
||||||
|
`personal_course`-mal påvirker ALDRI en org-`course`-rad som tidligere
|
||||||
|
ble opprettet fra den.
|
||||||
|
|
||||||
|
**Konsekvens/bevisst utenfor omfang:** ingen moderasjon/vetting av
|
||||||
|
offentlige baner (tillitsbasert, matcher appens øvrige
|
||||||
|
lukkede-plattform-antagelser); ingen privat/kun-min-org-synlighet per
|
||||||
|
bane (alt publisert via denne veien er plattform-offentlig, ingen
|
||||||
|
per-rad-innstilling bygget). Se CHANGELOG.md 2026-08-04 for full
|
||||||
|
bygge-/verifiseringsdetalj (34/34 håndregnede API-sjekker, ekte
|
||||||
|
nettleser-verifisering på tvers av to brukere/to organisasjoner).
|
||||||
|
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Åpne spørsmål (ikke besluttet ennå)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
181
CHANGELOG.md
181
CHANGELOG.md
|
|
@ -7207,3 +7207,184 @@ Neste steg:
|
||||||
teecup_api teecup_frontend`, begge containere boot-et rent,
|
teecup_api teecup_frontend`, begge containere boot-et rent,
|
||||||
`/health`/`/dashboard` → 200. V0-zip-en slettet etter merge, per
|
`/health`/`/dashboard` → 200. V0-zip-en slettet etter merge, per
|
||||||
etablert rutine.
|
etablert rutine.
|
||||||
|
|
||||||
|
19. **Baneoppsett i turneringer: rekkefølge, TeeOff-import og delt
|
||||||
|
bane-mal-bibliotek — BYGGET OG SCRATCH-VERIFISERT GRUNDIG 2026-08-04,
|
||||||
|
migrasjon 054, IKKE ENNÅ RULLET UT MOT ekte `teecup_db`.** Brukeren
|
||||||
|
oppdaget, mens hen brukte den live turneringsmodulen, at "Klasser"
|
||||||
|
(med sin Standardutslag-velger, ADR-041) vises FØR banen/runden i det
|
||||||
|
hele tatt er satt opp -- meningsløst å velge standardutslag for en
|
||||||
|
klasse før man vet hvilke utslag banen har. Bemerket samtidig at
|
||||||
|
TeeOff-henting av baner ikke så ut til å være tilgjengelig i
|
||||||
|
turneringsmodulen, og ba om en helt ny funksjon: la BÅDE
|
||||||
|
turneringsmodulen OG single-runde-modulen tilby "bruk en eksisterende
|
||||||
|
bane som mal" (fra TeeOff, eller fra en annens custom-bane) når man
|
||||||
|
oppretter en manuell bane.
|
||||||
|
|
||||||
|
**Del I -- rekkefølge + TeeOff-import-gap (ingen skjemaendring):**
|
||||||
|
Explore-agent bekreftet klagen presist og avdekket at det var verre
|
||||||
|
for lagturneringer enn antatt: for individuelle turneringer lå
|
||||||
|
`ClassesCard` FØR `RoundsCard` i samme Oppsett-fane
|
||||||
|
(`individual-tournament-detail.tsx`); for lagturneringer lå Klasser
|
||||||
|
på selve FØRSTE fanen ("Lag og spillere"), mens bane-/rundeoppsett
|
||||||
|
krevde en hel sidenavigering til "Program". TeeOff offisiell-import
|
||||||
|
(ADR-019, ferdig bygget) var KUN koblet til lagturnerings-økt-UI-et,
|
||||||
|
fullstendig fraværende fra den individuelle turneringsflyten.
|
||||||
|
Fikset: `ClassesCard` flyttet til å rendres ETTER `RoundsCard` i
|
||||||
|
individuelle turneringer; Klasser-kortet flyttet fysisk fra
|
||||||
|
`tournament-detail.tsx` ("Lag og spillere") til `tournament-program.tsx`
|
||||||
|
("Program"), rett etter økt-/rundeoppsettet -- `classes`-state (brukt
|
||||||
|
av `TeamPanel` til roster-klassevisning) ble værende i
|
||||||
|
`tournament-detail.tsx`, men create/delete-handlerne og selve
|
||||||
|
`ClassesCard`-komponentdefinisjonen flyttet med til `tournament-program.tsx`.
|
||||||
|
`OfficialCourseSearch`-komponentmønsteret (allerede i
|
||||||
|
`tournament-program.tsx`) kopiert inn i `individual-tournament-detail.tsx`
|
||||||
|
(samme duplisering-mellom-turneringstype-filer-konvensjon som
|
||||||
|
`ClassesCard` allerede fulgte), koblet til `NewRoundForm` sitt
|
||||||
|
eksisterende "+ Ny bane"-felt.
|
||||||
|
|
||||||
|
**Det største funnet (Explore-agent, avdekket FØR bygging):**
|
||||||
|
turneringsmodulens "opprett manuell bane" var i praksis ubrukelig --
|
||||||
|
KUN et navnefelt (`POST /orgs/{id}/courses` tok bare imot `{name}`),
|
||||||
|
ingen vei til hull/par/hcp-indeks/utslag i det hele tatt. To
|
||||||
|
sub-ressurs-endepunkter for å legge til dette i etterkant fantes
|
||||||
|
(`courses.py` sine `create_tee`/`create_holes`), men hadde ALDRI fått
|
||||||
|
noe frontend-kallsted -- funksjonen ble aldri fullført.
|
||||||
|
|
||||||
|
**Del II -- mal-basert baneoppretting + delt offentlig bane-bibliotek.**
|
||||||
|
Et nøkkelfunn forenklet løsningen betraktelig: `personal_course`
|
||||||
|
(020_personal_rounds.sql, "frittstående runder") er, til tross for
|
||||||
|
navnet, ALLEREDE et globalt, plattform-omfattende bibliotek -- `GET
|
||||||
|
/personal-courses` søker på tvers av ALLE brukeres baner uten eier-
|
||||||
|
eller org-filtrering, og `POST /personal-courses` tar allerede imot
|
||||||
|
full hull-/utslagdata i ett atomisk kall. Ingen ny tabell trengtes --
|
||||||
|
kun én kolonne (`forked_from_id`, migrasjon 054) for
|
||||||
|
proveniens/attribusjon.
|
||||||
|
Tre `AskUserQuestion`-runder avklarte omfanget presist FØR bygging
|
||||||
|
(samme disiplin som ADR-037/039/041): (1) ren reorder, ingen
|
||||||
|
blokkering; (2) et helt NYTT, kompakt hull-/utslag-editorskjema for
|
||||||
|
turneringsmodulen (speiler single-runde-modulens `OwnCreateStep` i
|
||||||
|
funksjon, egen stil), med "bruk som mal"-vei fra BÅDE TeeOff og
|
||||||
|
offentlig custom-bane; (3) alle tre moduler (individuell,
|
||||||
|
lagturnering, frittstående runde) skal ha funksjonen.
|
||||||
|
Et oppfølgingsspørsmål fra brukeren ("disse custom banene bør lagres,
|
||||||
|
og de må være offentlige... er det noe jeg ikke har tenkt på?") ble
|
||||||
|
tatt til en fjerde avklaringsrunde: (a) "offentlig" betyr HELE
|
||||||
|
TeeCup-plattformen (ikke bare egen org) -- eneste måte
|
||||||
|
single-runde-modulen faktisk kan dele samme mal-bibliotek som
|
||||||
|
turneringsmodulen, siden den ikke har noe org-begrep; (b) "Dupliser"
|
||||||
|
beholdes som egen, eksplisitt handling i tillegg til at redigering av
|
||||||
|
en ANNENS bane automatisk forker en kopi (redigering av DIN EGEN bane
|
||||||
|
endrer den i stedet, ingen fork).
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `054_personal_course_provenance.sql`: `personal_course.forked_from_id`
|
||||||
|
(selvreferende FK, `ON DELETE SET NULL`). Ingen RLS-endring --
|
||||||
|
tabellen har aldri vært org-scopet (bevisst siden migrasjon 020).
|
||||||
|
- `rounds.py`: `PersonalCourseOut`/`PersonalCourseDetail` utvidet med
|
||||||
|
`is_mine`/`created_by_display_name` (attribusjon i mal-søket, FULLT
|
||||||
|
navn per navneformat-regelen -- dette er en administrasjonsvisning,
|
||||||
|
ikke direkte adressering) og (kun Detail) `holes`/`full_tees`/
|
||||||
|
`forked_from_id` (forhåndsutfylling). Nye endepunkter: `PATCH
|
||||||
|
/personal-courses/{id}` (eier: oppdaterer i sted; IKKE eier: forker
|
||||||
|
automatisk -- ett endepunkt dekker begge casene uten at frontend må
|
||||||
|
forgrene på eierskap), `POST .../duplicate` (eksplisitt, uavhengig
|
||||||
|
av eierskap), `DELETE ...` (kun eier, 403 ellers; fanger opp
|
||||||
|
`asyncpg.ForeignKeyViolationError` fra `round.personal_course_id`
|
||||||
|
sin allerede-eksisterende `NO ACTION`-FK og gir en vennlig 409
|
||||||
|
"IN_USE" -- IKKE via den delte `translate_db_errors()`, som ville
|
||||||
|
gitt feil retning/melding for akkurat denne casen). `GET
|
||||||
|
/personal-courses` fikk en `mine=true`-parameter (for
|
||||||
|
"Mine baner"-administrasjonen, unngår den vanlige 20-treffs-
|
||||||
|
begrensningen på navnesøket).
|
||||||
|
- `courses.py`: `POST /orgs/{id}/courses` utvidet til valgfritt å ta
|
||||||
|
imot samme `holes`/`tees`-form som `PersonalCourseCreate` --
|
||||||
|
populert setter den BÅDE org-`course`-raden (med hull/utslag,
|
||||||
|
samme SQL-mønster som de eksisterende men ubrukte sub-ressurs-
|
||||||
|
endepunktene) OG en tilsvarende `personal_course`-rad (eid av
|
||||||
|
innlogget bruker, "offentliggjøringen" brukeren ba om) i SAMME
|
||||||
|
transaksjon -- ingen vedvarende kobling mellom de to radene etterpå
|
||||||
|
(samme "engangs-kopi"-filosofi som offisiell TeeOff-import,
|
||||||
|
ADR-019). `official-import`-endepunktet selv er UENDRET.
|
||||||
|
- `rounds.py` sin `GET /rounds/official-search/{slug}` (personlig
|
||||||
|
rundemodul) utvidet med fulle `holes`/`full_tees` PER TeeOff-bane
|
||||||
|
(kun populert når teeoff-dataene er komplette nok -- samme
|
||||||
|
fullstendighetskrav som ADR-019-importen allerede håndhever) --
|
||||||
|
nødvendig fordi frittstående runder ALDRI persisterer teeoff-baner
|
||||||
|
(ADR-033 Beslutning C, live oppslag), så "bruk som mal" der må få
|
||||||
|
dataene tilbake i selve søkesvaret i stedet for å runde-trippe
|
||||||
|
gjennom en lagret rad slik org-siden kan.
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Ny delt fil `course-template-editor.tsx`: `CourseTemplateEditor`
|
||||||
|
(kompakt hull-/utslag-skjema, forhåndsutfyllbar via `initial`-prop,
|
||||||
|
ren UI + lokal validering -- kalleren avgjør hvor data persisteres,
|
||||||
|
matcher prosjektets etablerte adaptermønster) + `CourseTemplatePicker`
|
||||||
|
(velger: "Fra bunnen av" / "TeeOff-bane som mal" / "Offentlig bane
|
||||||
|
som mal" -- sistnevnte med BÅDE "Bruk direkte" og "Tilpass før
|
||||||
|
bruk"). Brukt fra `tournament-program.tsx` (både ny økt OG endre
|
||||||
|
eksisterende økts bane) og `individual-tournament-detail.tsx`
|
||||||
|
(`NewRoundForm`).
|
||||||
|
- `new-round.tsx`: `OwnCreateStep` utvidet med valgfrie
|
||||||
|
`initial`/`forkedFromId`-props (samme prefyll-mekanisme). Nye
|
||||||
|
"Bruk som mal for egen bane"-knapper i BÅDE `CourseList` (TeeOff-
|
||||||
|
baner, kun synlig når komplette maldata finnes) og `OwnSearch`
|
||||||
|
(offentlige custom-baner, nå med attribusjon "Opprettet av X" /
|
||||||
|
"Opprettet av deg" -- samme navneformat-regel som backend).
|
||||||
|
- `account-settings.tsx`: ny "Mine baner"-seksjon (`MyCoursesSection`)
|
||||||
|
-- liste over EGNE `personal_course`-rader (`?mine=true`), med
|
||||||
|
Rediger (gjenbruker `CourseTemplateEditor`, PATCH), Dupliser
|
||||||
|
(POST duplicate) og Slett (DELETE, med vennlig 409/i-bruk-melding)
|
||||||
|
per rad. Andres baner vises IKKE her -- kun i mal-søket i de
|
||||||
|
respektive modulene.
|
||||||
|
|
||||||
|
**Reell bug funnet og rettet UNDER scratch-verifisering** (ikke
|
||||||
|
antatt riktig fra koden alene): `CourseTemplateEditor` ble først
|
||||||
|
bygget med sitt eget `<form onSubmit>` -- men komponenten rendres
|
||||||
|
INNI et allerede eksisterende `<form>` (`NewRoundForm`/
|
||||||
|
`CreateSessionCard`), og nestede `<form>`-elementer er ugyldig HTML.
|
||||||
|
"Opprett bane"-knappen submittet i praksis det YTRE runde-/økt-
|
||||||
|
skjemaet i stedet, en ekte side-navigasjon som vasket bort
|
||||||
|
`?org=...`-parameteren fra URL-en -- funnet ved en ekte klikk-
|
||||||
|
gjennom-test i nettleser (fetch-kallet nådde aldri serveren, bekreftet
|
||||||
|
ved å sjekke databasen). Samme feilklasse som `OfficialCourseSearch`
|
||||||
|
allerede hadde en kommentar om fra en tidligere runde. Rettet: `<form>`
|
||||||
|
→ `<div>`, `type="submit"` → `type="button"` med eksplisitt
|
||||||
|
`onClick`-håndtering.
|
||||||
|
|
||||||
|
**Scratch-verifisert grundig** (isolert `teecup_app_scratch`-rolle +
|
||||||
|
isolert scratch-MinIO + engangs API-/frontend-container, ekte
|
||||||
|
nettleser-innlogging som TO forskjellige brukere på tvers av TO
|
||||||
|
forskjellige organisasjoner):
|
||||||
|
- API-nivå: eget Python-testskript, 34/34 håndregnede sjekker bestått
|
||||||
|
(publisering-ved-opprettelse, kryss-bruker/kryss-org synlighet,
|
||||||
|
attribusjon, fork-ved-ikke-eier-redigering med verifisert uendret
|
||||||
|
original, eier-redigering-i-sted, eksplisitt duplisering,
|
||||||
|
eierskaps-gatede 403-er, og slette-blokkert-mens-i-bruk med riktig
|
||||||
|
409/IN_USE-kode).
|
||||||
|
- Ekte nettleser: rekkefølge-fiksen bekreftet i begge turneringstyper;
|
||||||
|
"Opprett bane med hull/utslag" fra bunnen av OG fra TeeOff-mal
|
||||||
|
(ekte Bergen Golfklubb-data, redigert par på hull 1 før lagring,
|
||||||
|
bekreftet BÅDE org-`course`- og `personal_course`-raden ble
|
||||||
|
opprettet riktig i databasen); den nye offentlige banen dukket
|
||||||
|
umiddelbart opp i single-runde-modulens banesøk MED riktig
|
||||||
|
attribusjon; "bruk som mal"-broen fra der forhåndsutfylte
|
||||||
|
`OwnCreateStep` korrekt fra en ANNEN brukers bane og forket en ny,
|
||||||
|
egen-eid rad ved lagring (bekreftet i databasen: ny rad eid av
|
||||||
|
innlogget bruker, `forked_from_id` pekende på originalen, originalen
|
||||||
|
selv uendret); "Mine baner" i kontoinnstillinger bekreftet å vise
|
||||||
|
KUN egne baner, Rediger (i sted, verifisert i databasen), Dupliser
|
||||||
|
og Slett (inkl. `confirm()`-dialoghåndtering) alle fungerende.
|
||||||
|
- `test_isolation.sql` 12/12 bestått (additiv migrasjon, ingen
|
||||||
|
RLS-endring). Ekte typesjekket produksjonsbuild (`docker build`,
|
||||||
|
samme steg som `Dockerfile` faktisk bruker) kjørt flere ganger
|
||||||
|
gjennom byggerunden, null TypeScript-feil.
|
||||||
|
Scratch-miljøet (database, rolle, MinIO, containere, images,
|
||||||
|
midlertidige hemmeligheter) fullstendig ryddet opp etter
|
||||||
|
verifisering.
|
||||||
|
**Rullet ut live 2026-08-04**, bruker bekreftet eksplisitt ("Ja
|
||||||
|
takk"): migrasjon 054 kjørt mot ekte `teecup_db` (kun
|
||||||
|
`forked_from_id`-kolonnen, bekreftet tom/nullable, ingen
|
||||||
|
databrudd), `docker compose up -d --build teecup_api
|
||||||
|
teecup_frontend`, begge containere boot-et rent, `/health` og
|
||||||
|
`/dashboard` → 200 over https.
|
||||||
|
|
|
||||||
|
|
@ -4050,6 +4050,50 @@ og 1920px storskjerm. Full detalj i CHANGELOG.md 2026-08-04.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Baneoppsett i turneringer: rekkefølge, TeeOff-import og delt bane-mal-bibliotek — ✅ HELT FERDIG, BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-08-04 (ADR-042, migrasjon 054)
|
||||||
|
|
||||||
|
Brukeren oppdaget at "Klasser" (med Standardutslag-velgeren, ADR-041)
|
||||||
|
vises FØR bane-/rundeoppsett i turneringsmodulen — meningsløst å velge
|
||||||
|
utslag for en klasse før man vet hvilke utslag banen har. Samtidig
|
||||||
|
bemerket hen at TeeOff-baneimport ikke var tilgjengelig i
|
||||||
|
turneringsmodulen, og ba om en ny funksjon: "bruk en eksisterende
|
||||||
|
TeeOff-bane som mal" ved manuell baneoppretting, i BÅDE
|
||||||
|
turneringsmodulen og single-runde-modulen.
|
||||||
|
|
||||||
|
**Del I (rekkefølge + TeeOff-import-gap):** `ClassesCard` flyttet til å
|
||||||
|
vises etter runde-/baneoppsett i begge turneringstyper (individuelle:
|
||||||
|
etter `RoundsCard` i samme fane; lagturneringer: flyttet fysisk fra
|
||||||
|
"Lag og spillere"-siden til "Program"-siden). TeeOff-import
|
||||||
|
(`OfficialCourseSearch`-mønsteret) koblet inn i den individuelle
|
||||||
|
turneringsflyten, som manglet det helt fra før.
|
||||||
|
|
||||||
|
**Del II (mal-basert baneoppretting + delt bibliotek, ADR-042):** avdekket
|
||||||
|
under planlegging at turneringsmodulens "opprett manuell bane" i praksis
|
||||||
|
var ubrukelig (kun et navnefelt, ingen hull-/utslag-skjema fantes noe
|
||||||
|
sted der). Bygget: et nytt, kompakt hull-/utslag-editorskjema for
|
||||||
|
turneringsmodulen; en malvelger (fra bunnen av / TeeOff-bane som mal /
|
||||||
|
offentlig custom-bane som mal); gjenbruk av `personal_course`
|
||||||
|
(020_personal_rounds.sql) som et allerede-globalt, plattform-omfattende
|
||||||
|
bane-bibliotek brukerens egen oppfølging ba om ("disse banene bør lagres
|
||||||
|
og være offentlige") — se ADR-042 for de fem arkitektoniske
|
||||||
|
beslutningene (delt tabell fremfor ny, plattform- IKKE org-omfattende
|
||||||
|
synlighet, applikasjonslags-eierskap, fork-ved-fremmed-redigering +
|
||||||
|
egen dupliser-handling, engangs-kopi ingen vedvarende kobling). Ny
|
||||||
|
"Mine baner"-administrasjon i kontoinnstillinger (rediger/dupliser/
|
||||||
|
slett egne publiserte baner).
|
||||||
|
|
||||||
|
Reell nestet-`<form>`-HTML-bug funnet og rettet UNDER scratch-
|
||||||
|
verifisering (ikke antatt riktig fra koden alene) — se CHANGELOG.md
|
||||||
|
2026-08-04 for full detalj. 34/34 håndregnede API-sjekker + grundig
|
||||||
|
ekte nettleser-verifisering (to brukere, to organisasjoner) bestått,
|
||||||
|
`test_isolation.sql` 12/12 uendret.
|
||||||
|
|
||||||
|
**Bevisst utenfor omfang:** ingen moderasjon/vetting av offentlige
|
||||||
|
baner; ingen privat/kun-min-org-synlighet per bane (alt publisert via
|
||||||
|
denne veien er plattform-offentlig).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Bevisst endret fra opprinnelige (Gemini-)råd
|
## Bevisst endret fra opprinnelige (Gemini-)råd
|
||||||
|
|
||||||
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
- 🔀 **Banedata:** API mot teeoff (ADR-004), IKKE direkte delt database. Direkte
|
||||||
|
|
|
||||||
|
|
@ -23,10 +23,6 @@ from pydantic import BaseModel, Field
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
class CourseCreate(BaseModel):
|
|
||||||
name: str = Field(min_length=1, max_length=200)
|
|
||||||
|
|
||||||
|
|
||||||
class Course(BaseModel):
|
class Course(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
|
@ -45,24 +41,6 @@ async def list_courses(
|
||||||
return [Course(**dict(r)) for r in rows]
|
return [Course(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/orgs/{organization_id}/courses", response_model=Course, status_code=201)
|
|
||||||
async def create_course(
|
|
||||||
body: CourseCreate,
|
|
||||||
organization_id: str = Depends(get_authorized_org),
|
|
||||||
) -> Course:
|
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
|
||||||
row = await conn.fetchrow(
|
|
||||||
f"""
|
|
||||||
INSERT INTO course (organization_id, name, source)
|
|
||||||
VALUES ($1, $2, 'custom')
|
|
||||||
RETURNING {_COURSE_COLUMNS}
|
|
||||||
""",
|
|
||||||
organization_id,
|
|
||||||
body.name,
|
|
||||||
)
|
|
||||||
return Course(**dict(row))
|
|
||||||
|
|
||||||
|
|
||||||
# --- Tee-er (ADR-013-relatert: match_participant.tee_id krever en ekte tee) --
|
# --- Tee-er (ADR-013-relatert: match_participant.tee_id krever en ekte tee) --
|
||||||
#
|
#
|
||||||
# Manglet helt frem til nå -- verken en liste-vei (selv offisielt importerte
|
# Manglet helt frem til nå -- verken en liste-vei (selv offisielt importerte
|
||||||
|
|
@ -282,6 +260,147 @@ async def create_holes(
|
||||||
return sorted((Hole(**dict(r)) for r in rows), key=lambda h: h.hole_number)
|
return sorted((Hole(**dict(r)) for r in rows), key=lambda h: h.hole_number)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Manuell baneoppretting med fullt hull-/utslagoppsett (2026-08-04) ------
|
||||||
|
#
|
||||||
|
# `create_course` over tok frem til nå KUN imot et navn -- ingen vei til
|
||||||
|
# hull/utslag fantes for en manuelt opprettet org-bane (kun de to ubrukte
|
||||||
|
# sub-ressurs-endepunktene over, `create_tee`/`create_holes`, som ALDRI fikk
|
||||||
|
# noe frontend-kallsted). Brukeren oppdaget dette hullet: "opprett manuell
|
||||||
|
# bane" i turneringsmodulen var i praksis ubrukelig uten et sted å taste inn
|
||||||
|
# hull/par/hcp-indeks/utslag etterpå.
|
||||||
|
#
|
||||||
|
# Denne utvidede varianten dekker begge deler i ÉN innsending (speiler
|
||||||
|
# `PersonalCourseCreate` i rounds.py sin form), OG publiserer samtidig en
|
||||||
|
# kopi til det delte, plattform-omfattende bane-biblioteket
|
||||||
|
# (`personal_course`, 020_personal_rounds.sql) -- brukeren bekreftet
|
||||||
|
# eksplisitt at custom-baner opprettet via den nye mal-baserte flyten skal
|
||||||
|
# "lagres og være offentlige", slik at samme bane kan brukes som mal av
|
||||||
|
# andre organisasjoner OG i single-runde-modulen. `personal_course` har
|
||||||
|
# ingen RLS/organization_id (bevisst globalt siden migrasjon 020) -- å skrive
|
||||||
|
# til den fra en org-scopet tilkobling er trygt, ingen policy er i veien.
|
||||||
|
|
||||||
|
|
||||||
|
class CourseCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=200)
|
||||||
|
# Enten BEGGE er med, eller INGEN -- ufullstendig oppsett (kun holes,
|
||||||
|
# ikke tees) gir en tydelig 400 heller enn en course-rad som stille
|
||||||
|
# mangler halve dataene.
|
||||||
|
holes: list[HoleCreate] | None = None
|
||||||
|
tees: list[TeeCreate] | None = None
|
||||||
|
# Satt når banen opprettes med en offentlig custom-bane (personal_course)
|
||||||
|
# som mal -- ren attribusjon på den NYE personal_course-publiseringen
|
||||||
|
# under, ingen vedvarende kobling på selve org-bane-raden (samme
|
||||||
|
# "engangs-kopi"-filosofi som offisiell TeeOff-import, ADR-019).
|
||||||
|
template_personal_course_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/orgs/{organization_id}/courses", response_model=Course, status_code=201)
|
||||||
|
async def create_course(
|
||||||
|
body: CourseCreate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> Course:
|
||||||
|
if (body.holes is None) != (body.tees is None):
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Hull og utslag må enten begge være med, eller ingen av dem.")
|
||||||
|
if body.holes is not None and body.tees is not None:
|
||||||
|
numbers = sorted(h.hole_number for h in body.holes)
|
||||||
|
if len(body.holes) != 18 or numbers != list(range(1, 19)):
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Må oppgi nøyaktig 18 hull, nummerert 1-18.")
|
||||||
|
for t in body.tees:
|
||||||
|
genders = [r.gender for r in t.ratings]
|
||||||
|
if len(genders) != len(set(genders)):
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED", "Kan ikke oppgi to ratinger for samme kjønn på ett utslag."
|
||||||
|
)
|
||||||
|
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
async with conn.transaction():
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
f"""
|
||||||
|
INSERT INTO course (organization_id, name, source)
|
||||||
|
VALUES ($1, $2, 'custom')
|
||||||
|
RETURNING {_COURSE_COLUMNS}
|
||||||
|
""",
|
||||||
|
organization_id,
|
||||||
|
body.name,
|
||||||
|
)
|
||||||
|
if body.holes is not None and body.tees is not None:
|
||||||
|
for h in body.holes:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO hole (organization_id, course_id, hole_number, par, stroke_index)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
""",
|
||||||
|
organization_id,
|
||||||
|
row["id"],
|
||||||
|
h.hole_number,
|
||||||
|
h.par,
|
||||||
|
h.stroke_index,
|
||||||
|
)
|
||||||
|
for t in body.tees:
|
||||||
|
tee_row = await conn.fetchrow(
|
||||||
|
"INSERT INTO tee (organization_id, course_id, name) VALUES ($1, $2, $3) RETURNING id",
|
||||||
|
organization_id,
|
||||||
|
row["id"],
|
||||||
|
t.name,
|
||||||
|
)
|
||||||
|
for r in t.ratings:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO tee_rating
|
||||||
|
(organization_id, tee_id, scope, gender, course_rating, slope_rating, par)
|
||||||
|
VALUES ($1, $2, 'full_18', $3, $4, $5, $6)
|
||||||
|
""",
|
||||||
|
organization_id,
|
||||||
|
tee_row["id"],
|
||||||
|
r.gender,
|
||||||
|
r.course_rating,
|
||||||
|
r.slope_rating,
|
||||||
|
r.par,
|
||||||
|
)
|
||||||
|
|
||||||
|
pc_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
INSERT INTO personal_course (name, created_by_user_id, forked_from_id)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id
|
||||||
|
""",
|
||||||
|
body.name,
|
||||||
|
user.user_id,
|
||||||
|
body.template_personal_course_id,
|
||||||
|
)
|
||||||
|
for h in body.holes:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO personal_course_hole (personal_course_id, hole_number, par, stroke_index)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
""",
|
||||||
|
pc_row["id"],
|
||||||
|
h.hole_number,
|
||||||
|
h.par,
|
||||||
|
h.stroke_index,
|
||||||
|
)
|
||||||
|
for t in body.tees:
|
||||||
|
pc_tee_row = await conn.fetchrow(
|
||||||
|
"INSERT INTO personal_course_tee (personal_course_id, name) VALUES ($1, $2) RETURNING id",
|
||||||
|
pc_row["id"],
|
||||||
|
t.name,
|
||||||
|
)
|
||||||
|
for r in t.ratings:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO personal_course_tee_rating
|
||||||
|
(personal_course_tee_id, gender, course_rating, slope_rating, par)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
""",
|
||||||
|
pc_tee_row["id"],
|
||||||
|
r.gender,
|
||||||
|
r.course_rating,
|
||||||
|
r.slope_rating,
|
||||||
|
r.par,
|
||||||
|
)
|
||||||
|
return Course(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
# --- Offisiell banedata fra teeoff (ADR-019) ---------------------------------
|
# --- Offisiell banedata fra teeoff (ADR-019) ---------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import uuid
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
import asyncpg
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
|
||||||
from pydantic import BaseModel, EmailStr, Field
|
from pydantic import BaseModel, EmailStr, Field
|
||||||
|
|
||||||
|
|
@ -121,11 +122,18 @@ class PersonalCourseCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18)
|
holes: list[PersonalCourseHoleIn] = Field(min_length=18, max_length=18)
|
||||||
tees: list[PersonalCourseTeeIn] = Field(min_length=1)
|
tees: list[PersonalCourseTeeIn] = Field(min_length=1)
|
||||||
|
# Satt når banen opprettes med en annen offentlig custom-bane som
|
||||||
|
# utgangspunkt/mal (turneringsmodul + single-runde-modul, 2026-08-04) --
|
||||||
|
# ren attribusjon/proveniens, INGEN vedvarende kobling (samme "engangs-
|
||||||
|
# kopi"-filosofi som offisiell TeeOff-import, ADR-019).
|
||||||
|
forked_from_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class PersonalCourseOut(BaseModel):
|
class PersonalCourseOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
|
is_mine: bool
|
||||||
|
created_by_display_name: str
|
||||||
|
|
||||||
|
|
||||||
class TeeOption(BaseModel):
|
class TeeOption(BaseModel):
|
||||||
|
|
@ -137,18 +145,53 @@ class PersonalCourseDetail(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
tees: list[TeeOption]
|
tees: list[TeeOption]
|
||||||
|
# Full hull-/utslagdata -- kun brukt til å forhåndsutfylle et redigerbart
|
||||||
|
# baneoppsett-skjema når denne banen brukes som MAL (2026-08-04). `tees`
|
||||||
|
# over (kun navn+kjønn) er uendret -- eksisterende kallsteder som kun
|
||||||
|
# velger EN allerede ferdig bane til en runde bruker fortsatt den formen.
|
||||||
|
holes: list[PersonalCourseHoleIn]
|
||||||
|
full_tees: list[PersonalCourseTeeIn]
|
||||||
|
is_mine: bool
|
||||||
|
created_by_display_name: str
|
||||||
|
forked_from_id: str | None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/personal-courses", response_model=list[PersonalCourseOut])
|
@router.get("/personal-courses", response_model=list[PersonalCourseOut])
|
||||||
async def search_personal_courses(
|
async def search_personal_courses(
|
||||||
q: str = "",
|
q: str = "",
|
||||||
|
# "Mine baner"-administrasjon (account-settings.tsx, 2026-08-04) --
|
||||||
|
# begrenser til EGNE baner uten å være underlagt den vanlige LIMIT 20-
|
||||||
|
# navnesøket sitt treffbilde. Ikke kombinert med `q` -- eier-visningen
|
||||||
|
# trenger ikke tekstsøk, kun en fullstendig liste over egne baner.
|
||||||
|
mine: bool = False,
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> list[PersonalCourseOut]:
|
) -> list[PersonalCourseOut]:
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
rows = await conn.fetch(
|
if mine:
|
||||||
"SELECT id::text AS id, name FROM personal_course WHERE name ILIKE $1 ORDER BY name LIMIT 20",
|
rows = await conn.fetch(
|
||||||
f"%{q.strip()}%",
|
"""
|
||||||
)
|
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]
|
return [PersonalCourseOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -158,12 +201,23 @@ async def get_personal_course(
|
||||||
user: CurrentUser = Depends(get_current_user),
|
user: CurrentUser = Depends(get_current_user),
|
||||||
) -> PersonalCourseDetail:
|
) -> PersonalCourseDetail:
|
||||||
async with plain_connection() as conn:
|
async with plain_connection() as conn:
|
||||||
course_row = await conn.fetchrow("SELECT id::text AS id, name FROM personal_course WHERE id = $1", personal_course_id)
|
course_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT pc.id::text AS id, pc.name, pc.forked_from_id::text AS forked_from_id,
|
||||||
|
(pc.created_by_user_id::text = $2) AS is_mine,
|
||||||
|
au.display_name AS created_by_display_name
|
||||||
|
FROM personal_course pc
|
||||||
|
JOIN app_user au ON au.id = pc.created_by_user_id
|
||||||
|
WHERE pc.id = $1
|
||||||
|
""",
|
||||||
|
personal_course_id,
|
||||||
|
user.user_id,
|
||||||
|
)
|
||||||
if course_row is None:
|
if course_row is None:
|
||||||
raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Den egendefinerte banen finnes ikke.")
|
||||||
tee_rows = await conn.fetch(
|
tee_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT t.name AS tee_name, r.gender
|
SELECT t.name AS tee_name, r.gender, r.course_rating, r.slope_rating, r.par
|
||||||
FROM personal_course_tee t
|
FROM personal_course_tee t
|
||||||
JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id
|
JOIN personal_course_tee_rating r ON r.personal_course_tee_id = t.id
|
||||||
WHERE t.personal_course_id = $1
|
WHERE t.personal_course_id = $1
|
||||||
|
|
@ -171,16 +225,74 @@ async def get_personal_course(
|
||||||
""",
|
""",
|
||||||
personal_course_id,
|
personal_course_id,
|
||||||
)
|
)
|
||||||
|
hole_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT hole_number, par, stroke_index FROM personal_course_hole
|
||||||
|
WHERE personal_course_id = $1 ORDER BY hole_number
|
||||||
|
""",
|
||||||
|
personal_course_id,
|
||||||
|
)
|
||||||
tees: dict[str, list[str]] = {}
|
tees: dict[str, list[str]] = {}
|
||||||
|
full_tees: dict[str, list[PersonalCourseTeeRatingIn]] = {}
|
||||||
for r in tee_rows:
|
for r in tee_rows:
|
||||||
tees.setdefault(r["tee_name"], []).append(r["gender"])
|
tees.setdefault(r["tee_name"], []).append(r["gender"])
|
||||||
|
full_tees.setdefault(r["tee_name"], []).append(
|
||||||
|
PersonalCourseTeeRatingIn(
|
||||||
|
gender=r["gender"], course_rating=r["course_rating"], slope_rating=r["slope_rating"], par=r["par"]
|
||||||
|
)
|
||||||
|
)
|
||||||
return PersonalCourseDetail(
|
return PersonalCourseDetail(
|
||||||
id=course_row["id"],
|
id=course_row["id"],
|
||||||
name=course_row["name"],
|
name=course_row["name"],
|
||||||
tees=[TeeOption(name=name, genders=genders) for name, genders in tees.items()],
|
tees=[TeeOption(name=name, genders=genders) for name, genders in tees.items()],
|
||||||
|
holes=[PersonalCourseHoleIn(**dict(h)) for h in hole_rows],
|
||||||
|
full_tees=[PersonalCourseTeeIn(name=name, ratings=ratings) for name, ratings in full_tees.items()],
|
||||||
|
is_mine=course_row["is_mine"],
|
||||||
|
created_by_display_name=course_row["created_by_display_name"],
|
||||||
|
forked_from_id=course_row["forked_from_id"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _replace_personal_course_contents(
|
||||||
|
conn, personal_course_id: str, name: str, holes: list[PersonalCourseHoleIn], tees: list[PersonalCourseTeeIn]
|
||||||
|
) -> None:
|
||||||
|
"""Sletter og setter inn hull/utslag på nytt -- trygt siden datamengden er
|
||||||
|
liten og fast (18 hull, 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)
|
@router.post("/personal-courses", response_model=PersonalCourseOut, status_code=201)
|
||||||
async def create_personal_course(
|
async def create_personal_course(
|
||||||
body: PersonalCourseCreate,
|
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.")
|
raise app_error(400, "VALIDATION_FAILED", "Alle 18 hullnumre og alle 18 stroke-indekser må være unike, 1-18.")
|
||||||
|
|
||||||
async with plain_connection() as conn, translate_db_errors():
|
async with plain_connection() as conn, translate_db_errors():
|
||||||
|
own_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
|
||||||
async with conn.transaction():
|
async with conn.transaction():
|
||||||
course_row = await conn.fetchrow(
|
course_row = await conn.fetchrow(
|
||||||
"INSERT INTO personal_course (name, created_by_user_id) VALUES ($1, $2) RETURNING id::text AS id, name",
|
"""
|
||||||
|
INSERT INTO personal_course (name, created_by_user_id, forked_from_id)
|
||||||
|
VALUES ($1, $2, $3) RETURNING id::text AS id, name
|
||||||
|
""",
|
||||||
body.name.strip(),
|
body.name.strip(),
|
||||||
user.user_id,
|
user.user_id,
|
||||||
|
body.forked_from_id,
|
||||||
)
|
)
|
||||||
for h in body.holes:
|
for h in body.holes:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
|
|
@ -225,7 +342,153 @@ async def create_personal_course(
|
||||||
r.slope_rating,
|
r.slope_rating,
|
||||||
r.par,
|
r.par,
|
||||||
)
|
)
|
||||||
return PersonalCourseOut(**dict(course_row))
|
return PersonalCourseOut(id=course_row["id"], name=course_row["name"], is_mine=True, created_by_display_name=own_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/personal-courses/{personal_course_id}", response_model=PersonalCourseOut)
|
||||||
|
async def update_personal_course(
|
||||||
|
personal_course_id: str,
|
||||||
|
body: PersonalCourseCreate,
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> PersonalCourseOut:
|
||||||
|
"""Eier: oppdaterer banen i sted. IKKE eier: forker automatisk -- en NY
|
||||||
|
rad opprettes, eid av innlogget bruker, med `forked_from_id` satt til
|
||||||
|
originalen -- originalen selv røres ikke. Ett endepunkt dekker begge
|
||||||
|
"rediger min egen"/"lag min redigerbare kopi av andres"-casene, se
|
||||||
|
plan 2026-08-04 -- unngår at frontend selv 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
|
name: str
|
||||||
is_main_course: bool
|
is_main_course: bool
|
||||||
tees: list[TeeOption]
|
tees: list[TeeOption]
|
||||||
|
# Fullt hull-/utslagoppsett -- KUN populert når teeoff har komplette data
|
||||||
|
# (alle 18 hull med par+hcp-indeks, minst én tee med rating). Brukt til å
|
||||||
|
# forhåndsutfylle et redigerbart baneoppsett-skjema når spilleren velger
|
||||||
|
# "bruk som mal" i stedet for direkte spill på banen (2026-08-04, samme
|
||||||
|
# mønster som org-siden sin `official-import` allerede parser -- her
|
||||||
|
# returneres dataene i stedet for å skrives, siden frittstående runder
|
||||||
|
# aldri persisterer teeoff-baner, ADR-033 Beslutning C). Tom liste = ikke
|
||||||
|
# komplett nok til å brukes som mal ennå.
|
||||||
|
holes: list[PersonalCourseHoleIn]
|
||||||
|
full_tees: list[PersonalCourseTeeIn]
|
||||||
|
|
||||||
|
|
||||||
class OfficialFacilityDetail(BaseModel):
|
class OfficialFacilityDetail(BaseModel):
|
||||||
|
|
@ -321,23 +594,54 @@ async def get_official_facility_for_round(
|
||||||
|
|
||||||
courses: list[OfficialCourseOption] = []
|
courses: list[OfficialCourseOption] = []
|
||||||
for c in facility.get("courses", []):
|
for c in facility.get("courses", []):
|
||||||
if len(c.get("holes") or []) != 18:
|
raw_holes = c.get("holes") or []
|
||||||
|
if len(raw_holes) != 18:
|
||||||
continue
|
continue
|
||||||
tees: list[TeeOption] = []
|
tees: list[TeeOption] = []
|
||||||
|
full_tees: list[PersonalCourseTeeIn] = []
|
||||||
for t in c.get("tees") or []:
|
for t in c.get("tees") or []:
|
||||||
genders: list[str] = []
|
genders: list[str] = []
|
||||||
|
ratings: list[PersonalCourseTeeRatingIn] = []
|
||||||
|
par_total = sum(h["par"] for h in raw_holes if h.get("par") is not None)
|
||||||
if t.get("cr_men") is not None and t.get("slope_men") is not None:
|
if t.get("cr_men") is not None and t.get("slope_men") is not None:
|
||||||
genders.append("m")
|
genders.append("m")
|
||||||
|
ratings.append(
|
||||||
|
PersonalCourseTeeRatingIn(
|
||||||
|
gender="m", course_rating=float(t["cr_men"]), slope_rating=int(t["slope_men"]), par=par_total
|
||||||
|
)
|
||||||
|
)
|
||||||
if t.get("cr_women") is not None and t.get("slope_women") is not None:
|
if t.get("cr_women") is not None and t.get("slope_women") is not None:
|
||||||
genders.append("f")
|
genders.append("f")
|
||||||
|
ratings.append(
|
||||||
|
PersonalCourseTeeRatingIn(
|
||||||
|
gender="f", course_rating=float(t["cr_women"]), slope_rating=int(t["slope_women"]), par=par_total
|
||||||
|
)
|
||||||
|
)
|
||||||
if genders:
|
if genders:
|
||||||
tees.append(TeeOption(name=t.get("name") or "Tee", genders=genders))
|
tees.append(TeeOption(name=t.get("name") or "Tee", genders=genders))
|
||||||
|
full_tees.append(PersonalCourseTeeIn(name=t.get("name") or "Tee", ratings=ratings))
|
||||||
|
|
||||||
|
# Malbruk krever komplette data (samme fullstendighetskrav som
|
||||||
|
# org-siden sin ADR-019-import) -- ellers tomme lister, kursets
|
||||||
|
# øvrige felt (navn/tees for direktevalg) er fortsatt fylt ut.
|
||||||
|
holes_complete = all(h.get("par") is not None and h.get("hcp_index") is not None for h in raw_holes)
|
||||||
|
template_holes = (
|
||||||
|
[
|
||||||
|
PersonalCourseHoleIn(hole_number=h["hole_number"], par=h["par"], stroke_index=h["hcp_index"])
|
||||||
|
for h in raw_holes
|
||||||
|
]
|
||||||
|
if holes_complete and full_tees
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
courses.append(
|
courses.append(
|
||||||
OfficialCourseOption(
|
OfficialCourseOption(
|
||||||
teeoff_course_id=c["id"],
|
teeoff_course_id=c["id"],
|
||||||
name=c["name"],
|
name=c["name"],
|
||||||
is_main_course=bool(c.get("is_main_course")),
|
is_main_course=bool(c.get("is_main_course")),
|
||||||
tees=tees,
|
tees=tees,
|
||||||
|
holes=template_holes,
|
||||||
|
full_tees=full_tees if template_holes else [],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses)
|
return OfficialFacilityDetail(slug=facility["slug"], name=facility["name"], courses=courses)
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,13 @@ import {
|
||||||
Bell,
|
Bell,
|
||||||
BellRing,
|
BellRing,
|
||||||
Camera,
|
Camera,
|
||||||
|
Copy,
|
||||||
|
Flag,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Lock,
|
Lock,
|
||||||
LogOut,
|
LogOut,
|
||||||
Mail,
|
Mail,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
|
|
@ -31,6 +34,7 @@ import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Wordmark } from "@/components/wordmark"
|
import { Wordmark } from "@/components/wordmark"
|
||||||
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
|
import { TwoFactorSetupForm } from "@/components/two-factor-flow"
|
||||||
|
import { CourseTemplateEditor, type CourseTemplateValues } from "@/components/course-template-editor"
|
||||||
|
|
||||||
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
|
// ADR-031: personlig profil, ETT sett per konto (app_user), atskilt fra de
|
||||||
// org-scopede `player`-radene organisatorer administrerer.
|
// org-scopede `player`-radene organisatorer administrerer.
|
||||||
|
|
@ -284,6 +288,8 @@ export function AccountSettings() {
|
||||||
|
|
||||||
<PushNotificationSection />
|
<PushNotificationSection />
|
||||||
|
|
||||||
|
<MyCoursesSection />
|
||||||
|
|
||||||
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
|
|
@ -1425,6 +1431,177 @@ function PushNotificationSection() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Mine baner (2026-08-04) -------------------------------------------------
|
||||||
|
// Administrasjon av `personal_course`-rader brukeren eier -- det delte,
|
||||||
|
// plattform-omfattende bane-biblioteket som både turneringsmodulen og
|
||||||
|
// single-runde-modulen nå bruker som mal-kilde. Redigering skjer i sted
|
||||||
|
// (PATCH, samme eier -- ingen fork utløses); dupliser/slett er egne,
|
||||||
|
// eksplisitte handlinger. Andres baner vises IKKE her -- kun i mal-søket
|
||||||
|
// i de respektive modulene (denne siden er ren eierskapsadministrasjon).
|
||||||
|
|
||||||
|
type ApiMyCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
|
||||||
|
type ApiMyCourseDetail = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
holes: CourseTemplateValues["holes"]
|
||||||
|
full_tees: CourseTemplateValues["tees"]
|
||||||
|
}
|
||||||
|
|
||||||
|
function MyCoursesSection() {
|
||||||
|
const [courses, setCourses] = useState<ApiMyCourse[] | null>(null)
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editingInitial, setEditingInitial] = useState<CourseTemplateValues | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch("/personal-courses?mine=true", { credentials: "include" })
|
||||||
|
if (res.ok) setCourses(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function startEdit(id: string) {
|
||||||
|
setError(null)
|
||||||
|
const res = await fetch(`/personal-courses/${id}`, { credentials: "include" })
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("Klarte ikke å hente banedetaljer.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const detail: ApiMyCourseDetail = await res.json()
|
||||||
|
setEditingInitial({ name: detail.name, holes: detail.holes, tees: detail.full_tees })
|
||||||
|
setEditingId(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(values: CourseTemplateValues) {
|
||||||
|
if (!editingId) return
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/personal-courses/${editingId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify(values),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`update: ${res.status}`)
|
||||||
|
setEditingId(null)
|
||||||
|
setEditingInitial(null)
|
||||||
|
await load()
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å lagre endringene.")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function duplicate(id: string) {
|
||||||
|
setError(null)
|
||||||
|
const res = await fetch(`/personal-courses/${id}/duplicate`, { method: "POST", credentials: "include" })
|
||||||
|
if (!res.ok) {
|
||||||
|
setError("Klarte ikke å duplisere banen.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: string) {
|
||||||
|
if (!confirm("Slette denne banen? Dette kan ikke angres.")) return
|
||||||
|
setError(null)
|
||||||
|
const res = await fetch(`/personal-courses/${id}`, { method: "DELETE", credentials: "include" })
|
||||||
|
if (res.status === 204) {
|
||||||
|
await load()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
setError(
|
||||||
|
body?.detail?.code === "IN_USE"
|
||||||
|
? "Denne banen er i bruk i én eller flere runder og kan ikke slettes."
|
||||||
|
: "Klarte ikke å slette banen.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
|
<Flag aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Mine baner</h2>
|
||||||
|
<p className="text-xs text-muted-foreground text-pretty">
|
||||||
|
Egendefinerte baner du har opprettet -- offentlig synlige og brukbare som mal av andre,
|
||||||
|
i både turneringsmodulen og single-runde-modulen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{editingId ? (
|
||||||
|
<CourseTemplateEditor
|
||||||
|
initial={editingInitial ?? undefined}
|
||||||
|
submitLabel="Lagre endringer"
|
||||||
|
submitting={submitting}
|
||||||
|
error={null}
|
||||||
|
onSubmit={saveEdit}
|
||||||
|
onCancel={() => {
|
||||||
|
setEditingId(null)
|
||||||
|
setEditingInitial(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : courses === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Laster…</p>
|
||||||
|
) : courses.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Ingen egne baner ennå -- opprett en via "Egen bane" i ny-runde-veiviseren, eller "Opprett
|
||||||
|
bane med hull/utslag" i en turnering.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
|
||||||
|
{courses.map((c) => (
|
||||||
|
<li key={c.id} className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{c.name}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => startEdit(c.id)}
|
||||||
|
aria-label={`Rediger ${c.name}`}
|
||||||
|
className="size-9 rounded-lg text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => duplicate(c.id)}
|
||||||
|
aria-label={`Dupliser ${c.name}`}
|
||||||
|
className="size-9 rounded-lg text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Copy aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => remove(c.id)}
|
||||||
|
aria-label={`Slett ${c.name}`}
|
||||||
|
className="size-9 rounded-lg text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function NotificationEmailPrefsSection() {
|
function NotificationEmailPrefsSection() {
|
||||||
const [types, setTypes] = useState<string[]>([])
|
const [types, setTypes] = useState<string[]>([])
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
|
|
||||||
698
frontend/components/course-template-editor.tsx
Normal file
698
frontend/components/course-template-editor.tsx
Normal file
|
|
@ -0,0 +1,698 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Kompakt hull-/utslag-editor for org-turneringsmodulen (2026-08-04) --
|
||||||
|
// "opprett manuell bane" der var frem til nå KUN et navnefelt (ingen vei til
|
||||||
|
// hull/par/hcp-indeks/utslag fantes i det hele tatt, POST /orgs/{id}/courses
|
||||||
|
// tok bare imot `{name}`). Speiler single-runde-modulens `OwnCreateStep`
|
||||||
|
// (new-round.tsx) i FUNKSJON, men i et kompakt, kort-innebygd formspråk som
|
||||||
|
// matcher resten av turneringsmodulens skjemaer (h-10/h-11, text-sm) i
|
||||||
|
// stedet for enkeltrunde-modulens store veiviser-stil.
|
||||||
|
//
|
||||||
|
// Ren UI + lokal validering -- kalleren avgjør hvor dataene faktisk
|
||||||
|
// persisteres (org-`course` via POST /orgs/{id}/courses, som nå valgfritt
|
||||||
|
// tar imot samme `holes`/`tees`-form i tillegg til navn), matcher
|
||||||
|
// prosjektets etablerte "hold komponenten dum"-adaptermønster.
|
||||||
|
//
|
||||||
|
// `CourseTemplatePicker` under er selve inngangspunktet brukt fra
|
||||||
|
// tournament-program.tsx/individual-tournament-detail.tsx: tilbyr "fra
|
||||||
|
// bunnen av", "TeeOff-bane som mal" og "offentlig custom-bane som mal"
|
||||||
|
// (søk mot GET /personal-courses -- allerede, litt overraskende, et
|
||||||
|
// PLATTFORM-OMFATTENDE bibliotek uansett org/bruker, se rounds.py).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Plus, Trash2, X } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
export type ApiCourseHole = { hole_number: number; par: number; stroke_index: number }
|
||||||
|
export type ApiCourseTeeRating = { gender: "m" | "f"; course_rating: number; slope_rating: number; par: number }
|
||||||
|
export type ApiCourseTee = { name: string; ratings: ApiCourseTeeRating[] }
|
||||||
|
export type CourseTemplateValues = { name: string; holes: ApiCourseHole[]; tees: ApiCourseTee[] }
|
||||||
|
export type ApiCourseSummary = { id: string; name: string }
|
||||||
|
|
||||||
|
function blankHoles(): ApiCourseHole[] {
|
||||||
|
return Array.from({ length: 18 }, (_, i) => ({ hole_number: i + 1, par: 4, stroke_index: i + 1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankTees(): ApiCourseTee[] {
|
||||||
|
return [{ name: "", ratings: [{ gender: "m", course_rating: 70, slope_rating: 113, par: 72 }] }]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TeeRow = {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
men: boolean
|
||||||
|
women: boolean
|
||||||
|
menRating: { courseRating: string; slope: string; par: string }
|
||||||
|
womenRating: { courseRating: string; slope: string; par: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
function teesToRows(tees: ApiCourseTee[]): TeeRow[] {
|
||||||
|
return tees.map((t, i) => {
|
||||||
|
const m = t.ratings.find((r) => r.gender === "m")
|
||||||
|
const f = t.ratings.find((r) => r.gender === "f")
|
||||||
|
return {
|
||||||
|
key: `${i}-${t.name}`,
|
||||||
|
name: t.name,
|
||||||
|
men: !!m,
|
||||||
|
women: !!f,
|
||||||
|
menRating: m
|
||||||
|
? { courseRating: String(m.course_rating), slope: String(m.slope_rating), par: String(m.par) }
|
||||||
|
: { courseRating: "", slope: "", par: "" },
|
||||||
|
womenRating: f
|
||||||
|
? { courseRating: String(f.course_rating), slope: String(f.slope_rating), par: String(f.par) }
|
||||||
|
: { courseRating: "", slope: "", par: "" },
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kompakt skjema: 18-hulls par/stroke-indeks-rutenett + utslag med rating
|
||||||
|
* per kjønn. Forhåndsutfylt fra `initial` når banen opprettes som mal-basert
|
||||||
|
* (fra TeeOff eller en offentlig custom-bane). */
|
||||||
|
export function CourseTemplateEditor({
|
||||||
|
initial,
|
||||||
|
submitLabel,
|
||||||
|
submitting,
|
||||||
|
error,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
initial?: CourseTemplateValues
|
||||||
|
submitLabel: string
|
||||||
|
submitting: boolean
|
||||||
|
error: string | null
|
||||||
|
onSubmit: (values: CourseTemplateValues) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(initial?.name ?? "")
|
||||||
|
const [holes, setHoles] = useState<ApiCourseHole[]>(initial?.holes ?? blankHoles())
|
||||||
|
const [tees, setTees] = useState<TeeRow[]>(teesToRows(initial?.tees ?? blankTees()))
|
||||||
|
|
||||||
|
function updateHole(index: number, field: "par" | "stroke_index", value: number) {
|
||||||
|
setHoles((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h)))
|
||||||
|
}
|
||||||
|
function updateTee(key: string, patch: Partial<TeeRow>) {
|
||||||
|
setTees((prev) => prev.map((t) => (t.key === key ? { ...t, ...patch } : t)))
|
||||||
|
}
|
||||||
|
function addTee() {
|
||||||
|
setTees((prev) => [
|
||||||
|
...prev,
|
||||||
|
{ key: `new-${Date.now()}`, name: "", men: false, women: false, menRating: { courseRating: "", slope: "", par: "" }, womenRating: { courseRating: "", slope: "", par: "" } },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
function removeTee(key: string) {
|
||||||
|
setTees((prev) => (prev.length > 1 ? prev.filter((t) => t.key !== key) : prev))
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexesValid = new Set(holes.map((h) => h.stroke_index)).size === 18
|
||||||
|
const teesValid = tees.every(
|
||||||
|
(t) =>
|
||||||
|
t.name.trim() !== "" &&
|
||||||
|
(t.men || t.women) &&
|
||||||
|
(!t.men || (t.menRating.courseRating.trim() && t.menRating.slope.trim() && t.menRating.par.trim())) &&
|
||||||
|
(!t.women || (t.womenRating.courseRating.trim() && t.womenRating.slope.trim() && t.womenRating.par.trim())),
|
||||||
|
)
|
||||||
|
const canSubmit = name.trim() !== "" && indexesValid && teesValid && !submitting
|
||||||
|
|
||||||
|
// Bevisst IKKE et <form onSubmit>: denne komponenten rendres ofte inne i
|
||||||
|
// et YTRE skjema (NewRoundForm/CreateSessionCard), og nestede <form>-
|
||||||
|
// elementer er ugyldig HTML -- funnet ved en ekte scratch-verifisering
|
||||||
|
// 2026-08-04 (submit-knappen submittet i praksis det ytre runde-/økt-
|
||||||
|
// skjemaet i stedet, en reell side-navigasjon som vasket bort ?org=...
|
||||||
|
// fra URL-en). Samme feilklasse/fiks som OfficialCourseSearch fra før.
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!canSubmit) return
|
||||||
|
const parseDecimal = (v: string) => Number(v.replace(",", "."))
|
||||||
|
onSubmit({
|
||||||
|
name: name.trim(),
|
||||||
|
holes,
|
||||||
|
tees: tees.map((t) => ({
|
||||||
|
name: t.name.trim(),
|
||||||
|
ratings: [
|
||||||
|
...(t.men
|
||||||
|
? [{ gender: "m" as const, course_rating: parseDecimal(t.menRating.courseRating), slope_rating: Number(t.menRating.slope), par: Number(t.menRating.par) }]
|
||||||
|
: []),
|
||||||
|
...(t.women
|
||||||
|
? [{ gender: "f" as const, course_rating: parseDecimal(t.womenRating.courseRating), slope_rating: Number(t.womenRating.slope), par: Number(t.womenRating.par) }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-background p-4">
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="ct-name" className="text-xs font-semibold">
|
||||||
|
Navn på banen
|
||||||
|
</Label>
|
||||||
|
<Input id="ct-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="F.eks. Hjemmebanen" className="h-10 rounded-xl text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-xs font-semibold">Hull -- par og stroke-indeks</span>
|
||||||
|
{!indexesValid && (
|
||||||
|
<p role="alert" className="text-xs font-medium text-destructive">
|
||||||
|
Hver stroke-indeks (1-18) må brukes nøyaktig én gang.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="max-h-72 overflow-y-auto rounded-xl border border-border">
|
||||||
|
<div className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 border-b border-border bg-muted/60 px-3 py-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
<span className="w-12">Hull</span>
|
||||||
|
<span>Par</span>
|
||||||
|
<span>SI</span>
|
||||||
|
</div>
|
||||||
|
{holes.map((h, index) => (
|
||||||
|
<div key={h.hole_number} className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 border-b border-border px-3 py-1.5 last:border-b-0">
|
||||||
|
<span className="w-12 text-sm font-semibold text-foreground">{h.hole_number}</span>
|
||||||
|
<select
|
||||||
|
aria-label={`Par for hull ${h.hole_number}`}
|
||||||
|
value={h.par}
|
||||||
|
onChange={(e) => updateHole(index, "par", Number(e.target.value))}
|
||||||
|
className="h-9 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
|
||||||
|
>
|
||||||
|
{[3, 4, 5, 6].map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
aria-label={`Stroke-indeks for hull ${h.hole_number}`}
|
||||||
|
value={h.stroke_index}
|
||||||
|
onChange={(e) => updateHole(index, "stroke_index", Number(e.target.value))}
|
||||||
|
className="h-9 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 18 }, (_, i) => i + 1).map((si) => (
|
||||||
|
<option key={si} value={si}>
|
||||||
|
{si}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<span className="text-xs font-semibold">Utslag</span>
|
||||||
|
{tees.map((t, index) => (
|
||||||
|
<div key={t.key} className="flex flex-col gap-3 rounded-xl border border-border bg-card p-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
<Label htmlFor={`ct-tee-${t.key}`} className="text-xs font-semibold">
|
||||||
|
Navn på utslag {index + 1}
|
||||||
|
</Label>
|
||||||
|
<Input id={`ct-tee-${t.key}`} value={t.name} onChange={(e) => updateTee(t.key, { name: e.target.value })} placeholder="F.eks. Gul" className="h-10 rounded-xl text-sm" />
|
||||||
|
</div>
|
||||||
|
{tees.length > 1 && (
|
||||||
|
<Button type="button" variant="ghost" onClick={() => removeTee(t.key)} className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold text-muted-foreground">
|
||||||
|
<Trash2 aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<RatingRow
|
||||||
|
label="Herre"
|
||||||
|
enabled={t.men}
|
||||||
|
rating={t.menRating}
|
||||||
|
onToggle={(men) => updateTee(t.key, { men })}
|
||||||
|
onChange={(menRating) => updateTee(t.key, { menRating })}
|
||||||
|
idPrefix={`ct-men-${t.key}`}
|
||||||
|
/>
|
||||||
|
<RatingRow
|
||||||
|
label="Dame"
|
||||||
|
enabled={t.women}
|
||||||
|
rating={t.womenRating}
|
||||||
|
onToggle={(women) => updateTee(t.key, { women })}
|
||||||
|
onChange={(womenRating) => updateTee(t.key, { womenRating })}
|
||||||
|
idPrefix={`ct-women-${t.key}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button type="button" variant="outline" onClick={addTee} className="h-10 self-start rounded-xl text-xs font-bold">
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Legg til utslag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" onClick={handleSubmit} disabled={!canSubmit} className="h-11 flex-1 rounded-xl text-sm font-bold">
|
||||||
|
{submitting ? "Lagrer…" : submitLabel}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={onCancel} className="h-11 rounded-xl text-sm font-bold text-muted-foreground">
|
||||||
|
Avbryt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RatingRow({
|
||||||
|
label,
|
||||||
|
enabled,
|
||||||
|
rating,
|
||||||
|
onToggle,
|
||||||
|
onChange,
|
||||||
|
idPrefix,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
enabled: boolean
|
||||||
|
rating: { courseRating: string; slope: string; par: string }
|
||||||
|
onToggle: (enabled: boolean) => void
|
||||||
|
onChange: (rating: { courseRating: string; slope: string; par: string }) => void
|
||||||
|
idPrefix: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-border bg-background/60 p-2.5">
|
||||||
|
<label className="flex items-center gap-2 text-xs font-semibold text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => onToggle(e.target.checked)}
|
||||||
|
className="size-4 shrink-0 accent-primary"
|
||||||
|
/>
|
||||||
|
{label}-rating
|
||||||
|
</label>
|
||||||
|
{enabled && (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<Input
|
||||||
|
aria-label={`${label} course rating`}
|
||||||
|
inputMode="decimal"
|
||||||
|
value={rating.courseRating}
|
||||||
|
onChange={(e) => onChange({ ...rating, courseRating: e.target.value })}
|
||||||
|
placeholder="CR"
|
||||||
|
className="h-9 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
aria-label={`${label} slope`}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={rating.slope}
|
||||||
|
onChange={(e) => onChange({ ...rating, slope: e.target.value })}
|
||||||
|
placeholder="Slope"
|
||||||
|
className="h-9 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
aria-label={`${label} par`}
|
||||||
|
inputMode="numeric"
|
||||||
|
value={rating.par}
|
||||||
|
onChange={(e) => onChange({ ...rating, par: e.target.value })}
|
||||||
|
placeholder="Par"
|
||||||
|
className="h-9 rounded-lg text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mal-velger: "fra bunnen av" / "TeeOff-bane som mal" / "offentlig custom-
|
||||||
|
// bane som mal". Brukt som selve "opprett bane"-inngangen i org-
|
||||||
|
// turneringsmodulen (erstatter det tidligere bare-navnefeltet).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type ApiPublicCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
|
||||||
|
type ApiPublicCourseDetail = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
holes: ApiCourseHole[]
|
||||||
|
full_tees: ApiCourseTee[]
|
||||||
|
}
|
||||||
|
type ApiOfficialFacility = { slug: string; name: string; city: string | null; county: string | null }
|
||||||
|
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean }
|
||||||
|
type ApiOfficialTeeOption = { name: string; genders: ("m" | "f")[] }
|
||||||
|
type ApiOfficialCourseDetail = { id: number; name: string; holes: { hole_number: number; par: number; stroke_index: number }[]; tees: (ApiOfficialTeeOption & { ratings?: ApiCourseTeeRating[] })[] }
|
||||||
|
|
||||||
|
type PickerStep =
|
||||||
|
| { kind: "choose" }
|
||||||
|
| { kind: "official-search" }
|
||||||
|
| { kind: "official-facility"; facility: ApiOfficialFacility }
|
||||||
|
| { kind: "public-search" }
|
||||||
|
| { kind: "editor"; initial?: CourseTemplateValues; templatePersonalCourseId?: string }
|
||||||
|
|
||||||
|
export function CourseTemplatePicker({
|
||||||
|
organizationId,
|
||||||
|
onCreated,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
onCreated: (course: ApiCourseSummary) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [step, setStep] = useState<PickerStep>({ kind: "choose" })
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function submitCourse(values: CourseTemplateValues, templatePersonalCourseId?: string) {
|
||||||
|
setSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/courses`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ ...values, template_personal_course_id: templatePersonalCourseId ?? null }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`create course: ${res.status}`)
|
||||||
|
const created: ApiCourseSummary = await res.json()
|
||||||
|
onCreated(created)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.kind === "choose") {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-background p-4">
|
||||||
|
<span className="text-xs font-semibold">Opprett manuell bane</span>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
|
<Button type="button" variant="outline" onClick={() => setStep({ kind: "editor" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
|
||||||
|
Fra bunnen av
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setStep({ kind: "official-search" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
|
||||||
|
TeeOff-bane som mal
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setStep({ kind: "public-search" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
|
||||||
|
Offentlig bane som mal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button type="button" variant="ghost" onClick={onCancel} className="h-9 self-start rounded-xl text-xs font-semibold text-muted-foreground">
|
||||||
|
Avbryt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.kind === "official-search") {
|
||||||
|
return (
|
||||||
|
<OfficialFacilitySearch
|
||||||
|
organizationId={organizationId}
|
||||||
|
onPickFacility={(facility) => setStep({ kind: "official-facility", facility })}
|
||||||
|
onClose={() => setStep({ kind: "choose" })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.kind === "official-facility") {
|
||||||
|
return (
|
||||||
|
<OfficialCourseList
|
||||||
|
organizationId={organizationId}
|
||||||
|
facility={step.facility}
|
||||||
|
onBack={() => setStep({ kind: "official-search" })}
|
||||||
|
onPicked={(values) => setStep({ kind: "editor", initial: values })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.kind === "public-search") {
|
||||||
|
return (
|
||||||
|
<PublicCourseSearch
|
||||||
|
onPickDirect={(values, sourceId) => submitCourse(values, sourceId)}
|
||||||
|
onPickCustomize={(values, sourceId) => setStep({ kind: "editor", initial: values, templatePersonalCourseId: sourceId })}
|
||||||
|
onClose={() => setStep({ kind: "choose" })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CourseTemplateEditor
|
||||||
|
initial={step.initial}
|
||||||
|
submitLabel="Opprett bane"
|
||||||
|
submitting={submitting}
|
||||||
|
error={error}
|
||||||
|
onSubmit={(values) => submitCourse(values, step.templatePersonalCourseId)}
|
||||||
|
onCancel={() => setStep({ kind: "choose" })}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OfficialFacilitySearch({
|
||||||
|
organizationId,
|
||||||
|
onPickFacility,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
onPickFacility: (facility: ApiOfficialFacility) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
setSearching(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/courses/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
|
||||||
|
if (!res.ok) throw new Error(`search: ${res.status}`)
|
||||||
|
setFacilities(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
|
||||||
|
setFacilities([])
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold text-foreground">TeeOff-bane som mal</span>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Søk anleggsnavn…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
runSearch()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-11 flex-1 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={() => runSearch()} disabled={searching} className="h-11 shrink-0 rounded-xl font-bold">
|
||||||
|
{searching ? "Søker…" : "Søk"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{facilities && (
|
||||||
|
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
||||||
|
{facilities.map((f) => (
|
||||||
|
<li key={f.slug} className="border-b border-border last:border-b-0">
|
||||||
|
<button type="button" onClick={() => onPickFacility(f)} className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60">
|
||||||
|
<span className="text-sm font-semibold text-foreground">{f.name}</span>
|
||||||
|
{(f.city || f.county) && <span className="text-xs text-muted-foreground">{[f.city, f.county].filter(Boolean).join(", ")}</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{facilities.length === 0 && <li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function OfficialCourseList({
|
||||||
|
organizationId,
|
||||||
|
facility,
|
||||||
|
onBack,
|
||||||
|
onPicked,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
facility: ApiOfficialFacility
|
||||||
|
onBack: () => void
|
||||||
|
onPicked: (values: CourseTemplateValues) => void
|
||||||
|
}) {
|
||||||
|
const [courses, setCourses] = useState<ApiOfficialCourseOption[] | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [loadingCourseId, setLoadingCourseId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : { courses: [] }))
|
||||||
|
.then((detail: { courses: ApiOfficialCourseOption[] }) => {
|
||||||
|
if (!cancelled) setCourses(detail.courses)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setError("Klarte ikke å hente baner for dette anlegget.")
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [organizationId, facility.slug])
|
||||||
|
|
||||||
|
async function pick(course: ApiOfficialCourseOption) {
|
||||||
|
// Henter full bane-/utslagdata via samme offisiell-import-endepunkt som
|
||||||
|
// "vanlig" import bruker -- men i STEDET for å lagre importen som en
|
||||||
|
// ferdig org-bane, brukes svaret KUN til å forhåndsutfylle det
|
||||||
|
// redigerbare skjemaet (ingen kobling til TeeOff bevares etterpå, se
|
||||||
|
// migrasjon 054 sin kommentar om "engangs-kopi, ingen vedvarende
|
||||||
|
// kobling"). Trygt å kalle -- import er idempotent (courses.py).
|
||||||
|
setLoadingCourseId(course.teeoff_course_id)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/courses/official-import`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ facility_slug: facility.slug, teeoff_course_id: course.teeoff_course_id }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`import: ${res.status}`)
|
||||||
|
const imported: { id: string; name: string } = await res.json()
|
||||||
|
const [holesRes, teesRes] = await Promise.all([
|
||||||
|
fetch(`/orgs/${organizationId}/courses/${imported.id}/holes`, { credentials: "include" }),
|
||||||
|
fetch(`/orgs/${organizationId}/courses/${imported.id}/tees`, { credentials: "include" }),
|
||||||
|
])
|
||||||
|
const holes: ApiCourseHole[] = holesRes.ok ? await holesRes.json() : []
|
||||||
|
const teesRaw: { name: string; ratings: ApiCourseTeeRating[] }[] = teesRes.ok ? await teesRes.json() : []
|
||||||
|
onPicked({ name: imported.name, holes, tees: teesRaw.map((t) => ({ name: t.name, ratings: t.ratings })) })
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å hente banedata fra teeoff. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setLoadingCourseId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
|
||||||
|
<button type="button" onClick={onBack} className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
← {facility.name}
|
||||||
|
</button>
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
{courses === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Laster baner…</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
|
||||||
|
{courses.map((c) => (
|
||||||
|
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loadingCourseId !== null}
|
||||||
|
onClick={() => pick(c)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
{loadingCourseId === c.teeoff_course_id && <span className="text-xs font-normal text-muted-foreground">Henter…</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{courses.length === 0 && <li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen 18-hulls baner hos dette anlegget ennå.</li>}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PublicCourseSearch({
|
||||||
|
onPickDirect,
|
||||||
|
onPickCustomize,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
onPickDirect: (values: CourseTemplateValues, sourcePersonalCourseId: string) => void
|
||||||
|
onPickCustomize: (values: CourseTemplateValues, sourcePersonalCourseId: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [results, setResults] = useState<ApiPublicCourse[]>([])
|
||||||
|
const [loadingId, setLoadingId] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fetch(`/personal-courses?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((data: ApiPublicCourse[]) => {
|
||||||
|
if (!cancelled) setResults(data)
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}, 250)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
async function fetchDetail(course: ApiPublicCourse): Promise<CourseTemplateValues | null> {
|
||||||
|
setLoadingId(course.id)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
|
||||||
|
if (!res.ok) throw new Error(`detail: ${res.status}`)
|
||||||
|
const detail: ApiPublicCourseDetail = await res.json()
|
||||||
|
return { name: detail.name, holes: detail.holes, tees: detail.full_tees }
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å hente banedetaljer.")
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
setLoadingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold text-foreground">Offentlig bane som mal</span>
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
<Input autoFocus value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Søk etter banenavn…" className="h-11 rounded-xl text-sm" />
|
||||||
|
<ul className="flex max-h-72 flex-col gap-2 overflow-auto">
|
||||||
|
{results.map((c) => (
|
||||||
|
<li key={c.id} className="flex flex-col gap-2 rounded-xl border border-border p-3">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-semibold text-foreground">{c.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{c.is_mine ? "Opprettet av deg" : `Opprettet av ${c.created_by_display_name}`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loadingId !== null}
|
||||||
|
onClick={async () => {
|
||||||
|
const values = await fetchDetail(c)
|
||||||
|
if (values) onPickDirect(values, c.id)
|
||||||
|
}}
|
||||||
|
className="h-9 flex-1 rounded-lg text-xs font-bold"
|
||||||
|
>
|
||||||
|
Bruk direkte
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={loadingId !== null}
|
||||||
|
onClick={async () => {
|
||||||
|
const values = await fetchDetail(c)
|
||||||
|
if (values) onPickCustomize(values, c.id)
|
||||||
|
}}
|
||||||
|
className="h-9 flex-1 rounded-lg text-xs font-bold"
|
||||||
|
>
|
||||||
|
Tilpass før bruk
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{results.length === 0 && (
|
||||||
|
<li className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||||
|
{query.trim() ? `Ingen offentlige baner matcher «${query}».` : "Skriv for å søke."}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,7 @@ import {
|
||||||
import { TournamentPresentationPanel } from "@/components/tournament-presentation"
|
import { TournamentPresentationPanel } from "@/components/tournament-presentation"
|
||||||
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
|
import { StrokePlayLeaderboard, type LeaderboardRow as StrokePlayRow } from "@/components/stroke-play-leaderboard"
|
||||||
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
|
import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge"
|
||||||
|
import { CourseTemplatePicker } from "@/components/course-template-editor"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
// --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) --
|
// --- Types (matcher app/routers/individual_tournaments.py/tournaments.py) --
|
||||||
|
|
@ -444,6 +445,10 @@ export function IndividualTournamentDetail({
|
||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCourseImported(course: ApiCourse) {
|
||||||
|
setCourses((prev) => [...prev, course].sort((a, b) => a.name.localeCompare(b.name)))
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background">
|
||||||
|
|
@ -551,6 +556,7 @@ export function IndividualTournamentDetail({
|
||||||
onAddRound={addRound}
|
onAddRound={addRound}
|
||||||
onDeleteRound={deleteRound}
|
onDeleteRound={deleteRound}
|
||||||
onAddCourse={addCourse}
|
onAddCourse={addCourse}
|
||||||
|
onCourseImported={handleCourseImported}
|
||||||
onCreateClass={createClass}
|
onCreateClass={createClass}
|
||||||
onDeleteClass={deleteClass}
|
onDeleteClass={deleteClass}
|
||||||
onSetParticipantClass={setParticipantClass}
|
onSetParticipantClass={setParticipantClass}
|
||||||
|
|
@ -620,6 +626,7 @@ function SetupTab({
|
||||||
onAddRound,
|
onAddRound,
|
||||||
onDeleteRound,
|
onDeleteRound,
|
||||||
onAddCourse,
|
onAddCourse,
|
||||||
|
onCourseImported,
|
||||||
onCreateClass,
|
onCreateClass,
|
||||||
onDeleteClass,
|
onDeleteClass,
|
||||||
onSetParticipantClass,
|
onSetParticipantClass,
|
||||||
|
|
@ -640,6 +647,7 @@ function SetupTab({
|
||||||
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
||||||
onDeleteRound: (id: string) => Promise<void>
|
onDeleteRound: (id: string) => Promise<void>
|
||||||
onAddCourse: (name: string) => Promise<ApiCourse | null>
|
onAddCourse: (name: string) => Promise<ApiCourse | null>
|
||||||
|
onCourseImported: (course: ApiCourse) => void
|
||||||
onCreateClass: (name: string, defaultTeeId: string | null) => Promise<void>
|
onCreateClass: (name: string, defaultTeeId: string | null) => Promise<void>
|
||||||
onDeleteClass: (id: string) => Promise<void>
|
onDeleteClass: (id: string) => Promise<void>
|
||||||
onSetParticipantClass: (participantId: string, classId: string | null) => Promise<void>
|
onSetParticipantClass: (participantId: string, classId: string | null) => Promise<void>
|
||||||
|
|
@ -700,6 +708,21 @@ function SetupTab({
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<RoundsCard
|
||||||
|
base={base}
|
||||||
|
organizationId={organizationId}
|
||||||
|
rounds={rounds}
|
||||||
|
courses={courses}
|
||||||
|
participants={participants}
|
||||||
|
classes={classes}
|
||||||
|
scoringMethod={tournament.scoring_method}
|
||||||
|
onAddRound={onAddRound}
|
||||||
|
onDeleteRound={onDeleteRound}
|
||||||
|
onAddCourse={onAddCourse}
|
||||||
|
onCourseImported={onCourseImported}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
|
||||||
<ClassesCard
|
<ClassesCard
|
||||||
classes={classes}
|
classes={classes}
|
||||||
courses={courses}
|
courses={courses}
|
||||||
|
|
@ -717,20 +740,6 @@ function SetupTab({
|
||||||
onRemoveParticipant={onRemoveParticipant}
|
onRemoveParticipant={onRemoveParticipant}
|
||||||
onSetClass={onSetParticipantClass}
|
onSetClass={onSetParticipantClass}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RoundsCard
|
|
||||||
base={base}
|
|
||||||
organizationId={organizationId}
|
|
||||||
rounds={rounds}
|
|
||||||
courses={courses}
|
|
||||||
participants={participants}
|
|
||||||
classes={classes}
|
|
||||||
scoringMethod={tournament.scoring_method}
|
|
||||||
onAddRound={onAddRound}
|
|
||||||
onDeleteRound={onDeleteRound}
|
|
||||||
onAddCourse={onAddCourse}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1150,6 +1159,7 @@ function RoundsCard({
|
||||||
onAddRound,
|
onAddRound,
|
||||||
onDeleteRound,
|
onDeleteRound,
|
||||||
onAddCourse,
|
onAddCourse,
|
||||||
|
onCourseImported,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
base: string
|
base: string
|
||||||
|
|
@ -1162,6 +1172,7 @@ function RoundsCard({
|
||||||
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
||||||
onDeleteRound: (id: string) => Promise<void>
|
onDeleteRound: (id: string) => Promise<void>
|
||||||
onAddCourse: (name: string) => Promise<ApiCourse | null>
|
onAddCourse: (name: string) => Promise<ApiCourse | null>
|
||||||
|
onCourseImported: (course: ApiCourse) => void
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
|
|
@ -1210,7 +1221,9 @@ function RoundsCard({
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<NewRoundForm
|
<NewRoundForm
|
||||||
|
organizationId={organizationId}
|
||||||
courses={courses}
|
courses={courses}
|
||||||
|
onCourseImported={onCourseImported}
|
||||||
onCreate={async (name, courseId, holeConfig) => {
|
onCreate={async (name, courseId, holeConfig) => {
|
||||||
await onAddRound(name, courseId, holeConfig)
|
await onAddRound(name, courseId, holeConfig)
|
||||||
setCreating(false)
|
setCreating(false)
|
||||||
|
|
@ -1224,14 +1237,18 @@ function RoundsCard({
|
||||||
}
|
}
|
||||||
|
|
||||||
function NewRoundForm({
|
function NewRoundForm({
|
||||||
|
organizationId,
|
||||||
courses,
|
courses,
|
||||||
onCreate,
|
onCreate,
|
||||||
onCreateCourse,
|
onCreateCourse,
|
||||||
|
onCourseImported,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
|
organizationId: string
|
||||||
courses: ApiCourse[]
|
courses: ApiCourse[]
|
||||||
onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
onCreate: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
||||||
onCreateCourse: (name: string) => Promise<ApiCourse | null>
|
onCreateCourse: (name: string) => Promise<ApiCourse | null>
|
||||||
|
onCourseImported: (course: ApiCourse) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}) {
|
}) {
|
||||||
const [name, setName] = useState("")
|
const [name, setName] = useState("")
|
||||||
|
|
@ -1239,6 +1256,8 @@ function NewRoundForm({
|
||||||
const [holeConfig, setHoleConfig] = useState<ApiRound["hole_config"]>("full_18")
|
const [holeConfig, setHoleConfig] = useState<ApiRound["hole_config"]>("full_18")
|
||||||
const [newCourseName, setNewCourseName] = useState("")
|
const [newCourseName, setNewCourseName] = useState("")
|
||||||
const [addingCourse, setAddingCourse] = useState(false)
|
const [addingCourse, setAddingCourse] = useState(false)
|
||||||
|
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
||||||
|
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
async function handleCreateCourse() {
|
async function handleCreateCourse() {
|
||||||
|
|
@ -1334,6 +1353,46 @@ function NewRoundForm({
|
||||||
Banen må ha registrert hull (par/hcp-indeks) og utslag før scoring kan
|
Banen må ha registrert hull (par/hcp-indeks) og utslag før scoring kan
|
||||||
begynne -- sett opp dette under organisasjonens baner om det mangler.
|
begynne -- sett opp dette under organisasjonens baner om det mangler.
|
||||||
</p>
|
</p>
|
||||||
|
{!officialSearchOpen && !templatePickerOpen ? (
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOfficialSearchOpen(true)}
|
||||||
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Hent bane fra teeoff i stedet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTemplatePickerOpen(true)}
|
||||||
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Opprett bane med hull/utslag i stedet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : officialSearchOpen ? (
|
||||||
|
<OfficialCourseSearch
|
||||||
|
organizationId={organizationId}
|
||||||
|
onClose={() => setOfficialSearchOpen(false)}
|
||||||
|
onImported={(course) => {
|
||||||
|
onCourseImported(course)
|
||||||
|
setCourseId(course.id)
|
||||||
|
setAddingCourse(false)
|
||||||
|
setOfficialSearchOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CourseTemplatePicker
|
||||||
|
organizationId={organizationId}
|
||||||
|
onCreated={(created) => {
|
||||||
|
onCourseImported({ id: created.id, name: created.name, source: "custom" })
|
||||||
|
setCourseId(created.id)
|
||||||
|
setAddingCourse(false)
|
||||||
|
setTemplatePickerOpen(false)
|
||||||
|
}}
|
||||||
|
onCancel={() => setTemplatePickerOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|
@ -2279,3 +2338,223 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- TeeOff-baneimport (2026-08-04) -----------------------------------------
|
||||||
|
// Samme mønster/endepunkter som tournament-program.tsx sin OfficialCourse
|
||||||
|
// Search (ADR-019) -- kopiert hit i stedet for delt via import, samme
|
||||||
|
// duplisering-mellom-de-to-turneringstype-filene-konvensjon som ClassesCard
|
||||||
|
// allerede fulgte. Var tidligere KUN tilgjengelig for lagturneringer; denne
|
||||||
|
// runden fikset gapet der individuelle turneringer manglet TeeOff-import helt.
|
||||||
|
|
||||||
|
type ApiOfficialFacility = {
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
city: string | null
|
||||||
|
county: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiOfficialCourseOption = {
|
||||||
|
teeoff_course_id: number
|
||||||
|
name: string
|
||||||
|
is_main_course: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function OfficialCourseSearch({
|
||||||
|
organizationId,
|
||||||
|
onClose,
|
||||||
|
onImported,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
onClose: () => void
|
||||||
|
onImported: (course: ApiCourse) => void
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = useState("")
|
||||||
|
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
|
||||||
|
const [selectedFacility, setSelectedFacility] = useState<ApiOfficialFacility | null>(null)
|
||||||
|
const [courseOptions, setCourseOptions] = useState<ApiOfficialCourseOption[] | null>(null)
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function runSearch() {
|
||||||
|
setSearching(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/orgs/${organizationId}/courses/official-search?q=${encodeURIComponent(query.trim())}`,
|
||||||
|
{ credentials: "include" },
|
||||||
|
)
|
||||||
|
if (!res.ok) throw new Error(`search: ${res.status}`)
|
||||||
|
setFacilities(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
|
||||||
|
setFacilities([])
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pickFacility(facility: ApiOfficialFacility) {
|
||||||
|
setSelectedFacility(facility)
|
||||||
|
setError(null)
|
||||||
|
setCourseOptions(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
|
||||||
|
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
|
||||||
|
setCourseOptions(detail.courses)
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å hente baner for dette anlegget.")
|
||||||
|
setCourseOptions([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importCourse(course: ApiOfficialCourseOption) {
|
||||||
|
if (!selectedFacility) return
|
||||||
|
setImporting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/courses/official-import`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({
|
||||||
|
facility_slug: selectedFacility.slug,
|
||||||
|
teeoff_course_id: course.teeoff_course_id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
const code = body?.detail?.code
|
||||||
|
if (code === "EXTERNAL_DATA_INCOMPLETE") {
|
||||||
|
setError("Denne banen mangler nok data i teeoff til å importeres ennå.")
|
||||||
|
} else if (code === "DUPLICATE") {
|
||||||
|
setError("Denne banen er allerede importert til organisasjonen.")
|
||||||
|
} else {
|
||||||
|
setError("Klarte ikke å importere banen. Prøv igjen.")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onImported(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å importere banen. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setImporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold text-foreground">Hent bane fra teeoff</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="size-8 rounded-lg text-muted-foreground"
|
||||||
|
aria-label="Lukk"
|
||||||
|
>
|
||||||
|
<X aria-hidden="true" className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{!selectedFacility ? (
|
||||||
|
<>
|
||||||
|
{/* Bevisst IKKE et eget <form> her -- denne komponenten rendres inne
|
||||||
|
i NewRoundForm sitt <form onSubmit={handleSubmit}>, og nestede
|
||||||
|
<form>-elementer er ugyldig HTML (samme feilrapport-funn som i
|
||||||
|
tournament-program.tsx sin variant -- "Søk"-knappen ville ellers
|
||||||
|
i praksis submitte det YTRE runde-skjemaet). */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
placeholder="Søk anleggsnavn…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault()
|
||||||
|
runSearch()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="h-11 flex-1 rounded-xl text-base"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => runSearch()}
|
||||||
|
disabled={searching}
|
||||||
|
className="h-11 shrink-0 rounded-xl font-bold"
|
||||||
|
>
|
||||||
|
{searching ? "Søker…" : "Søk"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{facilities && (
|
||||||
|
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
||||||
|
{facilities.map((f) => (
|
||||||
|
<li key={f.slug} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickFacility(f)}
|
||||||
|
className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-foreground">{f.name}</span>
|
||||||
|
{(f.city || f.county) && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{[f.city, f.county].filter(Boolean).join(", ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{facilities.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedFacility(null)
|
||||||
|
setCourseOptions(null)
|
||||||
|
}}
|
||||||
|
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
← {selectedFacility.name}
|
||||||
|
</button>
|
||||||
|
{courseOptions === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Laster baner…</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
|
||||||
|
{courseOptions.map((c) => (
|
||||||
|
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={importing}
|
||||||
|
onClick={() => importCourse(c)}
|
||||||
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{c.name}
|
||||||
|
{importing && <span className="text-xs font-normal text-muted-foreground">Importerer…</span>}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{courseOptions.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
|
||||||
|
Ingen 18-hulls baner å importere hos dette anlegget ennå.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import { Label } from "@/components/ui/label"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Wordmark } from "@/components/wordmark"
|
import { Wordmark } from "@/components/wordmark"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import type { ApiCourseHole, ApiCourseTee, CourseTemplateValues } from "@/components/course-template-editor"
|
||||||
|
|
||||||
// --- Types -------------------------------------------------------------------
|
// --- Types -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -48,7 +49,15 @@ type ApiGender = "m" | "f"
|
||||||
// ikke utslagslisten filtreres riktig per spillers kjønn (samme sjekk
|
// ikke utslagslisten filtreres riktig per spillers kjønn (samme sjekk
|
||||||
// backend selv håndhever ved innsending).
|
// backend selv håndhever ved innsending).
|
||||||
type Tee = { id: string; name: string; genders: ApiGender[] }
|
type Tee = { id: string; name: string; genders: ApiGender[] }
|
||||||
type Course = { id: string; name: string; tees: Tee[] }
|
type Course = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
tees: Tee[]
|
||||||
|
// Fullt hull-/utslagoppsett for "bruk som mal"-broen (2026-08-04) --
|
||||||
|
// fraværende/tom når kilden (teeoff) ikke har komplette nok data ennå.
|
||||||
|
templateHoles?: ApiCourseHole[]
|
||||||
|
templateTees?: ApiCourseTee[]
|
||||||
|
}
|
||||||
type OfficialClub = { id: string; name: string; location: string; courses: Course[] }
|
type OfficialClub = { id: string; name: string; location: string; courses: Course[] }
|
||||||
|
|
||||||
// Hvilken bane-kilde+id som faktisk skal sendes til /rounds -- Course over
|
// Hvilken bane-kilde+id som faktisk skal sendes til /rounds -- Course over
|
||||||
|
|
@ -226,9 +235,22 @@ type ApiOfficialCourseOption = {
|
||||||
name: string
|
name: string
|
||||||
is_main_course: boolean
|
is_main_course: boolean
|
||||||
tees: ApiTeeOption[]
|
tees: ApiTeeOption[]
|
||||||
|
// Fullt hull-/utslagoppsett -- tom liste når teeoff ikke har komplette nok
|
||||||
|
// data til å brukes som mal (2026-08-04, "bruk denne banen som mal"-bro).
|
||||||
|
holes: ApiCourseHole[]
|
||||||
|
full_tees: ApiCourseTee[]
|
||||||
|
}
|
||||||
|
type ApiPersonalCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
|
||||||
|
type ApiPersonalCourseDetail = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
tees: ApiTeeOption[]
|
||||||
|
holes: ApiCourseHole[]
|
||||||
|
full_tees: ApiCourseTee[]
|
||||||
|
is_mine: boolean
|
||||||
|
created_by_display_name: string
|
||||||
|
forked_from_id: string | null
|
||||||
}
|
}
|
||||||
type ApiPersonalCourse = { id: string; name: string }
|
|
||||||
type ApiPersonalCourseDetail = { id: string; name: string; tees: ApiTeeOption[] }
|
|
||||||
type PersonMatch = {
|
type PersonMatch = {
|
||||||
id: string
|
id: string
|
||||||
first_name: string
|
first_name: string
|
||||||
|
|
@ -1019,6 +1041,9 @@ function Step1(props: {
|
||||||
}) {
|
}) {
|
||||||
const { s1Sub, setS1Sub, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props
|
const { s1Sub, setS1Sub, selectedClub, setSelectedClub, setCourseSource, course, chooseCourse, ownGender } = props
|
||||||
const [resolvingClub, setResolvingClub] = useState(false)
|
const [resolvingClub, setResolvingClub] = useState(false)
|
||||||
|
// "Bruk som mal"-broen (2026-08-04): forhåndsutfylling for own-create,
|
||||||
|
// satt fra enten TeeOff-baneutvalget eller det offentlige bane-søket.
|
||||||
|
const [templatePrefill, setTemplatePrefill] = useState<{ values: CourseTemplateValues; forkedFromId?: string } | null>(null)
|
||||||
|
|
||||||
if (s1Sub === "source") {
|
if (s1Sub === "source") {
|
||||||
return (
|
return (
|
||||||
|
|
@ -1062,7 +1087,13 @@ function Step1(props: {
|
||||||
id: facility.slug,
|
id: facility.slug,
|
||||||
name: detail.name,
|
name: detail.name,
|
||||||
location: [facility.city, facility.county].filter(Boolean).join(", "),
|
location: [facility.city, facility.county].filter(Boolean).join(", "),
|
||||||
courses: detail.courses.map((c) => ({ id: String(c.teeoff_course_id), name: c.name, tees: apiTeesToTees(c.tees) })),
|
courses: detail.courses.map((c) => ({
|
||||||
|
id: String(c.teeoff_course_id),
|
||||||
|
name: c.name,
|
||||||
|
tees: apiTeesToTees(c.tees),
|
||||||
|
templateHoles: c.holes,
|
||||||
|
templateTees: c.full_tees,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
setSelectedClub(club)
|
setSelectedClub(club)
|
||||||
setS1Sub("official-courses")
|
setS1Sub("official-courses")
|
||||||
|
|
@ -1083,6 +1114,11 @@ function Step1(props: {
|
||||||
description="Velg hvilken bane du skal spille."
|
description="Velg hvilken bane du skal spille."
|
||||||
courses={selectedClub.courses}
|
courses={selectedClub.courses}
|
||||||
onSelect={(c) => chooseCourse(c, { source: "teeoff", facilitySlug: selectedClub.id, teeoffCourseId: Number(c.id) }, "official")}
|
onSelect={(c) => chooseCourse(c, { source: "teeoff", facilitySlug: selectedClub.id, teeoffCourseId: Number(c.id) }, "official")}
|
||||||
|
onUseAsTemplate={(c) => {
|
||||||
|
setTemplatePrefill({ values: { name: c.name, holes: c.templateHoles ?? [], tees: c.templateTees ?? [] } })
|
||||||
|
setCourseSource("own")
|
||||||
|
setS1Sub("own-create")
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1100,7 +1136,20 @@ function Step1(props: {
|
||||||
"own",
|
"own",
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
onCreate={() => setS1Sub("own-create")}
|
onUseAsTemplate={async (picked) => {
|
||||||
|
const res = await fetch(`/personal-courses/${picked.id}`, { credentials: "include" })
|
||||||
|
if (!res.ok) return
|
||||||
|
const detail: ApiPersonalCourseDetail = await res.json()
|
||||||
|
setTemplatePrefill({
|
||||||
|
values: { name: detail.name, holes: detail.holes, tees: detail.full_tees },
|
||||||
|
forkedFromId: detail.id,
|
||||||
|
})
|
||||||
|
setS1Sub("own-create")
|
||||||
|
}}
|
||||||
|
onCreate={() => {
|
||||||
|
setTemplatePrefill(null)
|
||||||
|
setS1Sub("own-create")
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1108,8 +1157,13 @@ function Step1(props: {
|
||||||
if (s1Sub === "own-create") {
|
if (s1Sub === "own-create") {
|
||||||
return (
|
return (
|
||||||
<OwnCreateStep
|
<OwnCreateStep
|
||||||
|
initial={templatePrefill?.values}
|
||||||
|
forkedFromId={templatePrefill?.forkedFromId}
|
||||||
onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")}
|
onCreated={(created) => chooseCourse(created, { source: "custom", personalCourseId: created.id }, "own")}
|
||||||
onCancel={() => setS1Sub("own-search")}
|
onCancel={() => {
|
||||||
|
setTemplatePrefill(null)
|
||||||
|
setS1Sub("own-search")
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1274,17 +1328,29 @@ function ClubRow({ name, location, distance, disabled, onClick }: { name: string
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CourseList({ title, description, courses, onSelect }: { title: string; description: string; courses: Course[]; onSelect: (c: Course) => void }) {
|
function CourseList({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
courses,
|
||||||
|
onSelect,
|
||||||
|
onUseAsTemplate,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
courses: Course[]
|
||||||
|
onSelect: (c: Course) => void
|
||||||
|
onUseAsTemplate?: (c: Course) => void
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<StepHeader title={title} description={description} />
|
<StepHeader title={title} description={description} />
|
||||||
<ul className="flex flex-col gap-3">
|
<ul className="flex flex-col gap-3">
|
||||||
{courses.map((course) => (
|
{courses.map((course) => (
|
||||||
<li key={course.id}>
|
<li key={course.id} className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(course)}
|
onClick={() => onSelect(course)}
|
||||||
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
className="flex w-full items-center gap-4 text-left transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
>
|
>
|
||||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||||
<Flag aria-hidden="true" className="size-6 text-muted-foreground" />
|
<Flag aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||||
|
|
@ -1294,6 +1360,16 @@ function CourseList({ title, description, courses, onSelect }: { title: string;
|
||||||
<span className="text-base text-muted-foreground">{course.tees.length} utslag</span>
|
<span className="text-base text-muted-foreground">{course.tees.length} utslag</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{onUseAsTemplate && course.templateHoles && course.templateHoles.length > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onUseAsTemplate(course)}
|
||||||
|
className="h-10 self-start rounded-xl text-sm font-bold"
|
||||||
|
>
|
||||||
|
Bruk som mal for egen bane
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{courses.length === 0 && (
|
{courses.length === 0 && (
|
||||||
|
|
@ -1306,7 +1382,15 @@ function CourseList({ title, description, courses, onSelect }: { title: string;
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function OwnSearch({ onSelect, onCreate }: { onSelect: (c: ApiPersonalCourse) => void; onCreate: () => void }) {
|
function OwnSearch({
|
||||||
|
onSelect,
|
||||||
|
onUseAsTemplate,
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
onSelect: (c: ApiPersonalCourse) => void
|
||||||
|
onUseAsTemplate: (c: ApiPersonalCourse) => void
|
||||||
|
onCreate: () => void
|
||||||
|
}) {
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [results, setResults] = useState<ApiPersonalCourse[]>([])
|
const [results, setResults] = useState<ApiPersonalCourse[]>([])
|
||||||
|
|
||||||
|
|
@ -1345,19 +1429,30 @@ function OwnSearch({ onSelect, onCreate }: { onSelect: (c: ApiPersonalCourse) =>
|
||||||
{results.length > 0 ? (
|
{results.length > 0 ? (
|
||||||
<ul className="flex flex-col gap-3">
|
<ul className="flex flex-col gap-3">
|
||||||
{results.map((course) => (
|
{results.map((course) => (
|
||||||
<li key={course.id}>
|
<li key={course.id} className="flex flex-col gap-2 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(course)}
|
onClick={() => onSelect(course)}
|
||||||
className="flex w-full items-center gap-4 rounded-2xl border border-border bg-card p-5 text-left shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
className="flex w-full items-center gap-4 text-left transition-colors hover:opacity-80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
>
|
>
|
||||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
|
||||||
<Flag aria-hidden="true" className="size-6 text-muted-foreground" />
|
<Flag aria-hidden="true" className="size-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 flex-col">
|
<div className="flex min-w-0 flex-col">
|
||||||
<span className="text-lg font-bold text-foreground">{course.name}</span>
|
<span className="text-lg font-bold text-foreground">{course.name}</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{course.is_mine ? "Opprettet av deg" : `Opprettet av ${course.created_by_display_name}`}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onUseAsTemplate(course)}
|
||||||
|
className="h-10 self-start rounded-xl text-sm font-bold"
|
||||||
|
>
|
||||||
|
Bruk som mal for egen bane
|
||||||
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
@ -1386,14 +1481,52 @@ function emptyHoles() {
|
||||||
return Array.from({ length: 18 }, (_, i) => ({ hole: i + 1, par: "4", strokeIndex: String(i + 1) }))
|
return Array.from({ length: 18 }, (_, i) => ({ hole: i + 1, par: "4", strokeIndex: String(i + 1) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Bruk som mal"-broen (2026-08-04): konverterer fullt hull-/utslagoppsett
|
||||||
|
// (fra en TeeOff-bane eller en offentlig custom-bane) til det redigerbare
|
||||||
|
// lokale string-baserte skjema-formatet -- gjenbruker samme string-felt-
|
||||||
|
// tilnærming som resten av OwnCreateStep (tillater midlertidig ugyldig/
|
||||||
|
// tom input mens brukeren redigerer).
|
||||||
|
function templateToHoles(holes: ApiCourseHole[]) {
|
||||||
|
return holes
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.hole_number - b.hole_number)
|
||||||
|
.map((h) => ({ hole: h.hole_number, par: String(h.par), strokeIndex: String(h.stroke_index) }))
|
||||||
|
}
|
||||||
|
function templateToTees(tees: ApiCourseTee[]): CreateTee[] {
|
||||||
|
return tees.map((t, i) => {
|
||||||
|
const m = t.ratings.find((r) => r.gender === "m")
|
||||||
|
const f = t.ratings.find((r) => r.gender === "f")
|
||||||
|
return {
|
||||||
|
id: `tpl-${i}-${t.name}`,
|
||||||
|
name: t.name,
|
||||||
|
men: !!m,
|
||||||
|
women: !!f,
|
||||||
|
menRating: m ? { courseRating: String(m.course_rating), slope: String(m.slope_rating), par: String(m.par) } : undefined,
|
||||||
|
womenRating: f ? { courseRating: String(f.course_rating), slope: String(f.slope_rating), par: String(f.par) } : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function parseDecimal(value: string): number {
|
function parseDecimal(value: string): number {
|
||||||
return Number(value.replace(",", "."))
|
return Number(value.replace(",", "."))
|
||||||
}
|
}
|
||||||
|
|
||||||
function OwnCreateStep({ onCreated, onCancel }: { onCreated: (course: Course) => void; onCancel: () => void }) {
|
function OwnCreateStep({
|
||||||
const [name, setName] = useState("")
|
initial,
|
||||||
const [holes, setHoles] = useState(emptyHoles)
|
forkedFromId,
|
||||||
const [tees, setTees] = useState<CreateTee[]>([{ id: `nt-${Date.now()}`, name: "", men: true, women: false }])
|
onCreated,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
initial?: CourseTemplateValues
|
||||||
|
forkedFromId?: string
|
||||||
|
onCreated: (course: Course) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(initial?.name ?? "")
|
||||||
|
const [holes, setHoles] = useState(() => (initial ? templateToHoles(initial.holes) : emptyHoles()))
|
||||||
|
const [tees, setTees] = useState<CreateTee[]>(() =>
|
||||||
|
initial ? templateToTees(initial.tees) : [{ id: `nt-${Date.now()}`, name: "", men: true, women: false }],
|
||||||
|
)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -1440,6 +1573,7 @@ function OwnCreateStep({ onCreated, onCancel }: { onCreated: (course: Course) =>
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
|
forked_from_id: forkedFromId ?? null,
|
||||||
}
|
}
|
||||||
const res = await fetch("/personal-courses", {
|
const res = await fetch("/personal-courses", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -70,16 +70,6 @@ type ApiTournamentClass = {
|
||||||
default_tee_name: string | null
|
default_tee_name: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiCourse = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiTee = {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Team colors er DATA brukt på scorekort senere -- bevisst atskilt fra
|
// Team colors er DATA brukt på scorekort senere -- bevisst atskilt fra
|
||||||
// app-ens merkevare-grønn/oransje.
|
// app-ens merkevare-grønn/oransje.
|
||||||
const TEAM_COLORS: { value: string; label: string }[] = [
|
const TEAM_COLORS: { value: string; label: string }[] = [
|
||||||
|
|
@ -107,7 +97,6 @@ export function TournamentDetail({
|
||||||
const [joinCode, setJoinCode] = useState<string | null>(null)
|
const [joinCode, setJoinCode] = useState<string | null>(null)
|
||||||
const [status, setStatus] = useState<TournamentStatus | null>(null)
|
const [status, setStatus] = useState<TournamentStatus | null>(null)
|
||||||
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
|
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
|
||||||
const [courses, setCourses] = useState<ApiCourse[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -115,7 +104,7 @@ export function TournamentDetail({
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [teamsRes, poolRes, tournamentsRes, classesRes, coursesRes] = await Promise.all([
|
const [teamsRes, poolRes, tournamentsRes, classesRes] = await Promise.all([
|
||||||
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
}),
|
}),
|
||||||
|
|
@ -124,7 +113,6 @@ export function TournamentDetail({
|
||||||
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
}),
|
}),
|
||||||
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
|
|
||||||
])
|
])
|
||||||
if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed")
|
if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed")
|
||||||
const teamsData: { id: string; name: string; color: string | null }[] =
|
const teamsData: { id: string; name: string; color: string | null }[] =
|
||||||
|
|
@ -143,7 +131,6 @@ export function TournamentDetail({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (classesRes.ok) setClasses(await classesRes.json())
|
if (classesRes.ok) setClasses(await classesRes.json())
|
||||||
if (coursesRes.ok) setCourses(await coursesRes.json())
|
|
||||||
|
|
||||||
const withRosters = await Promise.all(
|
const withRosters = await Promise.all(
|
||||||
teamsData.map(async (t) => {
|
teamsData.map(async (t) => {
|
||||||
|
|
@ -300,53 +287,6 @@ export function TournamentDetail({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createClass(name: string, defaultTeeId: string | null) {
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ name, default_tee_id: defaultTeeId }),
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`create class: ${res.status}`)
|
|
||||||
const created: ApiTournamentClass = await res.json()
|
|
||||||
setClasses((prev) => [...prev, created])
|
|
||||||
} catch {
|
|
||||||
setError("Klarte ikke å opprette klassen. Prøv igjen.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteClass(classId: string) {
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch(
|
|
||||||
`/orgs/${organizationId}/tournaments/${tournamentId}/classes/${classId}`,
|
|
||||||
{ method: "DELETE", credentials: "include" },
|
|
||||||
)
|
|
||||||
if (res.status !== 204) throw new Error(`delete class: ${res.status}`)
|
|
||||||
setClasses((prev) => prev.filter((c) => c.id !== classId))
|
|
||||||
// Roster-rader som pekte på klassen mister den lokalt også (backend
|
|
||||||
// satte allerede class_id=NULL via ON DELETE SET NULL) -- unngår en
|
|
||||||
// hengende, ugyldig klasse-referanse i UI-et inntil neste full reload.
|
|
||||||
setTeams(
|
|
||||||
(prev) =>
|
|
||||||
prev.map((t) =>
|
|
||||||
t
|
|
||||||
? {
|
|
||||||
...t,
|
|
||||||
roster: t.roster.map((r) =>
|
|
||||||
r.class_id === classId ? { ...r, class_id: null, class_name: null } : r,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: t,
|
|
||||||
) as [Team | null, Team | null],
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
setError("Klarte ikke å fjerne klassen. Prøv igjen.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removePlayer(teamId: string, rosterId: string) {
|
async function removePlayer(teamId: string, rosterId: string) {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -527,13 +467,6 @@ export function TournamentDetail({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ClassesCard
|
|
||||||
classes={classes}
|
|
||||||
courses={courses}
|
|
||||||
organizationId={organizationId}
|
|
||||||
onCreate={createClass}
|
|
||||||
onDelete={deleteClass}
|
|
||||||
/>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{([0, 1] as const).map((slot) => {
|
{([0, 1] as const).map((slot) => {
|
||||||
const team = teams[slot]
|
const team = teams[slot]
|
||||||
|
|
@ -608,161 +541,6 @@ function JoinCodeChip({ code }: { code: string }) {
|
||||||
|
|
||||||
// --- Create team (State A) -------------------------------------------------
|
// --- Create team (State A) -------------------------------------------------
|
||||||
|
|
||||||
// --- Konkurranseklasser (2026-08-03) ----------------------------------------
|
|
||||||
// Fritt navngitte klasser med et valgfritt standardutslag -- se
|
|
||||||
// tournament_class-tabellen (053_tournament_classes.sql). Kun utslag-
|
|
||||||
// bekvemmelighet i lagturneringer (ingen egen resultatliste her, bekreftet
|
|
||||||
// med bruker -- se AssignRoundParticipantControl/LeaderboardTab i
|
|
||||||
// individual-tournament-detail.tsx for der klasse OGSÅ deler leaderboardet).
|
|
||||||
|
|
||||||
function ClassesCard({
|
|
||||||
classes,
|
|
||||||
courses,
|
|
||||||
organizationId,
|
|
||||||
onCreate,
|
|
||||||
onDelete,
|
|
||||||
}: {
|
|
||||||
classes: ApiTournamentClass[]
|
|
||||||
courses: ApiCourse[]
|
|
||||||
organizationId: string
|
|
||||||
onCreate: (name: string, defaultTeeId: string | null) => void
|
|
||||||
onDelete: (classId: string) => void
|
|
||||||
}) {
|
|
||||||
const [name, setName] = useState("")
|
|
||||||
const [courseId, setCourseId] = useState("")
|
|
||||||
const [teeId, setTeeId] = useState("")
|
|
||||||
const [tees, setTees] = useState<ApiTee[]>([])
|
|
||||||
const [adding, setAdding] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!courseId) {
|
|
||||||
setTees([])
|
|
||||||
setTeeId("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let cancelled = false
|
|
||||||
fetch(`/orgs/${organizationId}/courses/${courseId}/tees`, { credentials: "include" })
|
|
||||||
.then((res) => (res.ok ? res.json() : []))
|
|
||||||
.then((data: ApiTee[]) => {
|
|
||||||
if (!cancelled) {
|
|
||||||
setTees(data)
|
|
||||||
setTeeId(data[0]?.id ?? "")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [organizationId, courseId])
|
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (name.trim().length === 0 || adding) return
|
|
||||||
setAdding(true)
|
|
||||||
await onCreate(name.trim(), teeId || null)
|
|
||||||
setAdding(false)
|
|
||||||
setName("")
|
|
||||||
setCourseId("")
|
|
||||||
setTeeId("")
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="mb-4 flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
|
||||||
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<h3 className="text-sm font-bold text-foreground">Klasser</h3>
|
|
||||||
<p className="text-xs text-muted-foreground text-pretty">
|
|
||||||
Foreslår riktig utslag når en spiller legges til en kamp -- ingen egen resultatliste
|
|
||||||
her (poeng er knyttet til hele kamper, ikke enkeltspillere).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{classes.length > 0 && (
|
|
||||||
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
|
|
||||||
{classes.map((c) => (
|
|
||||||
<li key={c.id} className="flex items-center gap-3 px-3 py-2.5">
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
|
||||||
<span className="truncate text-sm font-semibold text-foreground">{c.name}</span>
|
|
||||||
{c.default_tee_name && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Standardutslag: {c.default_tee_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onDelete(c.id)}
|
|
||||||
aria-label={`Fjern klassen ${c.name}`}
|
|
||||||
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 aria-hidden="true" className="size-4" />
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
|
||||||
<Label htmlFor="class-name">Klassenavn</Label>
|
|
||||||
<Input
|
|
||||||
id="class-name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="F.eks. Damer"
|
|
||||||
className="h-11"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
|
||||||
<Label htmlFor="class-course">Bane (for standardutslag)</Label>
|
|
||||||
<select
|
|
||||||
id="class-course"
|
|
||||||
value={courseId}
|
|
||||||
onChange={(e) => setCourseId(e.target.value)}
|
|
||||||
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
|
|
||||||
>
|
|
||||||
<option value="">Ingen standardutslag</option>
|
|
||||||
{courses.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{tees.length > 0 && (
|
|
||||||
<div className="flex flex-1 flex-col gap-1.5">
|
|
||||||
<Label htmlFor="class-tee">Standardutslag</Label>
|
|
||||||
<select
|
|
||||||
id="class-tee"
|
|
||||||
value={teeId}
|
|
||||||
onChange={(e) => setTeeId(e.target.value)}
|
|
||||||
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
|
|
||||||
>
|
|
||||||
{tees.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{t.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="outline"
|
|
||||||
disabled={adding || name.trim().length === 0}
|
|
||||||
className="h-11 shrink-0 rounded-xl font-semibold"
|
|
||||||
>
|
|
||||||
<Plus aria-hidden="true" className="size-4" />
|
|
||||||
Legg til
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CreateTeamCard({
|
function CreateTeamCard({
|
||||||
slot,
|
slot,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { CourseTemplatePicker } from "@/components/course-template-editor"
|
||||||
|
|
||||||
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) -
|
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) -
|
||||||
|
|
||||||
|
|
@ -68,6 +69,25 @@ type ApiCourse = {
|
||||||
source: string
|
source: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ApiTee = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
|
||||||
|
// standardutslag brukt til å forhåndsutfylle riktig utslag når en spiller
|
||||||
|
// legges til en match (session-blind-draw.tsx). Ren utslag-bekvemmelighet
|
||||||
|
// her -- INGEN egen resultatliste for lagturneringer (bekreftet med
|
||||||
|
// bruker: poeng er knyttet til hele kamper, ikke enkeltspillere). Flyttet
|
||||||
|
// hit fra tournament-detail.tsx (2026-08-04) -- gir ingen mening å velge
|
||||||
|
// et standardutslag før en bane faktisk finnes i programmet.
|
||||||
|
type ApiTournamentClass = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
default_tee_id: string | null
|
||||||
|
default_tee_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
type ApiSession = {
|
type ApiSession = {
|
||||||
id: string
|
id: string
|
||||||
sequence: number
|
sequence: number
|
||||||
|
|
@ -128,6 +148,7 @@ export function TournamentProgram({
|
||||||
}) {
|
}) {
|
||||||
const [sessions, setSessions] = useState<ApiSession[]>([])
|
const [sessions, setSessions] = useState<ApiSession[]>([])
|
||||||
const [courses, setCourses] = useState<ApiCourse[]>([])
|
const [courses, setCourses] = useState<ApiCourse[]>([])
|
||||||
|
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
@ -135,11 +156,14 @@ export function TournamentProgram({
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [sessionsRes, coursesRes] = await Promise.all([
|
const [sessionsRes, coursesRes, classesRes] = await Promise.all([
|
||||||
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, {
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
}),
|
}),
|
||||||
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
|
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
|
||||||
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
|
||||||
|
credentials: "include",
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
if (!sessionsRes.ok || !coursesRes.ok) throw new Error("load failed")
|
if (!sessionsRes.ok || !coursesRes.ok) throw new Error("load failed")
|
||||||
const sessionsData: ApiSession[] = await sessionsRes.json()
|
const sessionsData: ApiSession[] = await sessionsRes.json()
|
||||||
|
|
@ -147,6 +171,7 @@ export function TournamentProgram({
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
setSessions(sessionsData)
|
setSessions(sessionsData)
|
||||||
setCourses(coursesData)
|
setCourses(coursesData)
|
||||||
|
if (classesRes.ok) setClasses(await classesRes.json())
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("Klarte ikke å laste programmet. Prøv å laste siden på nytt.")
|
if (!cancelled) setError("Klarte ikke å laste programmet. Prøv å laste siden på nytt.")
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -159,6 +184,37 @@ export function TournamentProgram({
|
||||||
}
|
}
|
||||||
}, [organizationId, tournamentId])
|
}, [organizationId, tournamentId])
|
||||||
|
|
||||||
|
async function createClass(name: string, defaultTeeId: string | null) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ name, default_tee_id: defaultTeeId }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`create class: ${res.status}`)
|
||||||
|
const created: ApiTournamentClass = await res.json()
|
||||||
|
setClasses((prev) => [...prev, created])
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å opprette klassen. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteClass(classId: string) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/orgs/${organizationId}/tournaments/${tournamentId}/classes/${classId}`,
|
||||||
|
{ method: "DELETE", credentials: "include" },
|
||||||
|
)
|
||||||
|
if (res.status !== 204) throw new Error(`delete class: ${res.status}`)
|
||||||
|
setClasses((prev) => prev.filter((c) => c.id !== classId))
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å fjerne klassen. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const coursesById = useMemo(() => {
|
const coursesById = useMemo(() => {
|
||||||
const map = new Map<string, ApiCourse>()
|
const map = new Map<string, ApiCourse>()
|
||||||
for (const c of courses) map.set(c.id, c)
|
for (const c of courses) map.set(c.id, c)
|
||||||
|
|
@ -384,6 +440,16 @@ export function TournamentProgram({
|
||||||
onCreate={addSession}
|
onCreate={addSession}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<ClassesCard
|
||||||
|
classes={classes}
|
||||||
|
courses={courses}
|
||||||
|
organizationId={organizationId}
|
||||||
|
onCreate={createClass}
|
||||||
|
onDelete={deleteClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -391,6 +457,163 @@ export function TournamentProgram({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Konkurranseklasser (2026-08-03, flyttet hit 2026-08-04) ---------------
|
||||||
|
// Fritt navngitte klasser med et valgfritt standardutslag -- se
|
||||||
|
// tournament_class-tabellen (053_tournament_classes.sql). Kun utslag-
|
||||||
|
// bekvemmelighet i lagturneringer (ingen egen resultatliste her, bekreftet
|
||||||
|
// med bruker -- se AssignRoundParticipantControl/LeaderboardTab i
|
||||||
|
// individual-tournament-detail.tsx for der klasse OGSÅ deler leaderboardet).
|
||||||
|
// Plassert her, ETTER økt-/baneoppsettet over, siden standardutslaget er
|
||||||
|
// meningsløst å velge før man vet hvilke utslag banen faktisk har.
|
||||||
|
|
||||||
|
function ClassesCard({
|
||||||
|
classes,
|
||||||
|
courses,
|
||||||
|
organizationId,
|
||||||
|
onCreate,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
classes: ApiTournamentClass[]
|
||||||
|
courses: ApiCourse[]
|
||||||
|
organizationId: string
|
||||||
|
onCreate: (name: string, defaultTeeId: string | null) => void
|
||||||
|
onDelete: (classId: string) => void
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [courseId, setCourseId] = useState("")
|
||||||
|
const [teeId, setTeeId] = useState("")
|
||||||
|
const [tees, setTees] = useState<ApiTee[]>([])
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!courseId) {
|
||||||
|
setTees([])
|
||||||
|
setTeeId("")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let cancelled = false
|
||||||
|
fetch(`/orgs/${organizationId}/courses/${courseId}/tees`, { credentials: "include" })
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((data: ApiTee[]) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setTees(data)
|
||||||
|
setTeeId(data[0]?.id ?? "")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [organizationId, courseId])
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (name.trim().length === 0 || adding) return
|
||||||
|
setAdding(true)
|
||||||
|
await onCreate(name.trim(), teeId || null)
|
||||||
|
setAdding(false)
|
||||||
|
setName("")
|
||||||
|
setCourseId("")
|
||||||
|
setTeeId("")
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
||||||
|
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h3 className="text-sm font-bold text-foreground">Klasser</h3>
|
||||||
|
<p className="text-xs text-muted-foreground text-pretty">
|
||||||
|
Foreslår riktig utslag når en spiller legges til en kamp -- ingen egen resultatliste
|
||||||
|
her (poeng er knyttet til hele kamper, ikke enkeltspillere).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{classes.length > 0 && (
|
||||||
|
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
|
||||||
|
{classes.map((c) => (
|
||||||
|
<li key={c.id} className="flex items-center gap-3 px-3 py-2.5">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-sm font-semibold text-foreground">{c.name}</span>
|
||||||
|
{c.default_tee_name && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Standardutslag: {c.default_tee_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(c.id)}
|
||||||
|
aria-label={`Fjern klassen ${c.name}`}
|
||||||
|
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" className="size-4" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
<Label htmlFor="class-name">Klassenavn</Label>
|
||||||
|
<Input
|
||||||
|
id="class-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="F.eks. Damer"
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
<Label htmlFor="class-course">Bane (for standardutslag)</Label>
|
||||||
|
<select
|
||||||
|
id="class-course"
|
||||||
|
value={courseId}
|
||||||
|
onChange={(e) => setCourseId(e.target.value)}
|
||||||
|
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
|
||||||
|
>
|
||||||
|
<option value="">Ingen standardutslag</option>
|
||||||
|
{courses.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{tees.length > 0 && (
|
||||||
|
<div className="flex flex-1 flex-col gap-1.5">
|
||||||
|
<Label htmlFor="class-tee">Standardutslag</Label>
|
||||||
|
<select
|
||||||
|
id="class-tee"
|
||||||
|
value={teeId}
|
||||||
|
onChange={(e) => setTeeId(e.target.value)}
|
||||||
|
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
|
||||||
|
>
|
||||||
|
{tees.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="outline"
|
||||||
|
disabled={adding || name.trim().length === 0}
|
||||||
|
className="h-11 shrink-0 rounded-xl font-semibold"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="size-4" />
|
||||||
|
Legg til
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Session card (timeline item) ------------------------------------------
|
// --- Session card (timeline item) ------------------------------------------
|
||||||
|
|
||||||
function SessionCard({
|
function SessionCard({
|
||||||
|
|
@ -776,6 +999,7 @@ function EditCourseField({
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState("")
|
const [query, setQuery] = useState("")
|
||||||
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
||||||
|
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
||||||
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -849,15 +1073,24 @@ function EditCourseField({
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{!officialSearchOpen ? (
|
{!officialSearchOpen && !templatePickerOpen ? (
|
||||||
<button
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setOfficialSearchOpen(true)}
|
type="button"
|
||||||
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
onClick={() => setOfficialSearchOpen(true)}
|
||||||
>
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
Hent bane fra teeoff i stedet
|
>
|
||||||
</button>
|
Hent bane fra teeoff i stedet
|
||||||
) : (
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTemplatePickerOpen(true)}
|
||||||
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Opprett bane med hull/utslag i stedet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : officialSearchOpen ? (
|
||||||
<OfficialCourseSearch
|
<OfficialCourseSearch
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
onClose={() => setOfficialSearchOpen(false)}
|
onClose={() => setOfficialSearchOpen(false)}
|
||||||
|
|
@ -867,6 +1100,17 @@ function EditCourseField({
|
||||||
setOfficialSearchOpen(false)
|
setOfficialSearchOpen(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<CourseTemplatePicker
|
||||||
|
organizationId={organizationId}
|
||||||
|
onCreated={(created) => {
|
||||||
|
const course = { id: created.id, name: created.name, source: "custom" }
|
||||||
|
onCourseImported(course)
|
||||||
|
select(course)
|
||||||
|
setTemplatePickerOpen(false)
|
||||||
|
}}
|
||||||
|
onCancel={() => setTemplatePickerOpen(false)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -970,6 +1214,7 @@ function CreateSessionCard({
|
||||||
const [courseQuery, setCourseQuery] = useState("")
|
const [courseQuery, setCourseQuery] = useState("")
|
||||||
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
|
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
|
||||||
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
||||||
|
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
||||||
|
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||||
const [useHandicap, setUseHandicap] = useState(true)
|
const [useHandicap, setUseHandicap] = useState(true)
|
||||||
|
|
@ -1186,13 +1431,22 @@ function CreateSessionCard({
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setOfficialSearchOpen(true)}
|
type="button"
|
||||||
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
onClick={() => setOfficialSearchOpen(true)}
|
||||||
>
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
Hent bane fra teeoff i stedet
|
>
|
||||||
</button>
|
Hent bane fra teeoff i stedet
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTemplatePickerOpen(true)}
|
||||||
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Opprett bane med hull/utslag i stedet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{officialSearchOpen && (
|
{officialSearchOpen && (
|
||||||
<OfficialCourseSearch
|
<OfficialCourseSearch
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
|
|
@ -1204,6 +1458,17 @@ function CreateSessionCard({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{templatePickerOpen && (
|
||||||
|
<CourseTemplatePicker
|
||||||
|
organizationId={organizationId}
|
||||||
|
onCreated={(course) => {
|
||||||
|
onCourseImported({ id: course.id, name: course.name, source: "custom" })
|
||||||
|
setSelectedCourseId(course.id)
|
||||||
|
setTemplatePickerOpen(false)
|
||||||
|
}}
|
||||||
|
onCancel={() => setTemplatePickerOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue