Update Todos
Undersøk kode-kontekst for alle tre byggeoppgavene
Avklar åpne designspørsmål med bruker
Bygg: flytte spiller mellom lag (backend + frontend)
Bygg: individuell rangering i turnering (backend + frontend)
Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend)
Typesjekket produksjonsbuild av frontend
Scratch-verifiser alt tre + full regresjon
Browser-verifiser alt tre (Chrome DevTools)
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Rull ut mot ekte systemer (med bekreftelse)
Alle tre er live. Oppsummert:
Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match.
Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen.
Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre.
Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
This commit is contained in:
parent
58a1242a9b
commit
d6c1a38318
11 changed files with 1154 additions and 65 deletions
13
034_session_scorecard_invitations.sql
Normal file
13
034_session_scorecard_invitations.sql
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-- =====================================================================
|
||||||
|
-- TeeCup — midlertidige spillere: etter-runde scorekort + innloggings-
|
||||||
|
-- invitasjon, økt-nivå (migrasjon 034, FEATURE_BACKLOG.md "Midlertidige
|
||||||
|
-- spillere + automatisk etter-runde-invitasjon", avklart 2026-07-28)
|
||||||
|
-- =====================================================================
|
||||||
|
-- Dobbel-utsending-sperre: en rad her ER allerede "denne deltakeren i
|
||||||
|
-- denne matchen har fått sin scorekort-e-post" -- samme "tilstedeværelse/
|
||||||
|
-- tidsstempel = skjedd"-mønster som resten av skjemaet (round.started_at
|
||||||
|
-- m.fl.), ikke en egen tabell siden match_participant allerede er nøyaktig
|
||||||
|
-- (økt, spiller)-koblingen vi trenger å spore mot.
|
||||||
|
\set ON_ERROR_STOP on
|
||||||
|
|
||||||
|
ALTER TABLE match_participant ADD COLUMN invitation_sent_at timestamptz;
|
||||||
|
|
@ -3636,15 +3636,16 @@ Disse må avklares før eller under de relevante fasene:
|
||||||
beslektet, men SISTE steg, ikke designet ennå. Ingen migrasjon
|
beslektet, men SISTE steg, ikke designet ennå. Ingen migrasjon
|
||||||
skrevet — ADR-037 er ren struktur-beslutning.
|
skrevet — ADR-037 er ren struktur-beslutning.
|
||||||
7. **Frittstående runder: ekte spillformer** (slagspill/match/skins/
|
7. **Frittstående runder: ekte spillformer** (slagspill/match/skins/
|
||||||
par-lag-konkurranse, reist 2026-07-28) — **AVKLART OG BACKEND BYGGET
|
par-lag-konkurranse, reist 2026-07-28) — **AVKLART, BYGGET (backend +
|
||||||
SAMME DAG, se ADR-039.** `round.play_format` styrer nå faktisk både
|
frontend) OG LIVE SAMME DAG, se ADR-039.** `round.play_format` styrer
|
||||||
HCP-beregning (via en portert versjon av turnering-motoren) og et
|
nå faktisk både HCP-beregning (via en portert versjon av turnering-
|
||||||
nytt `format-result`-lese-endepunkt for scorekort-presentasjon.
|
motoren) og et `format-result`-lese-endepunkt for scorekort-
|
||||||
Fortsatt IKKE utrullet mot ekte systemer, og frontend (sideoppsett/
|
presentasjon, med tilhørende UI for sideoppsett/skins-konfig/
|
||||||
skins-konfig/matchstatus-visning) fortsatt ikke bygget — se
|
matchstatus-visning — se FEATURE_BACKLOG.md/CLAUDE.md for full detalj
|
||||||
FEATURE_BACKLOG.md. "Flere flighter i én frittstående runde" (egen
|
og etterfølgende samme-dags oppfølgingsrunder. "Flere flighter i én
|
||||||
seksjon i FEATURE_BACKLOG.md) forblir et adskilt, ikke-relatert
|
frittstående runde" (egen seksjon i FEATURE_BACKLOG.md) forblir et
|
||||||
spørsmål (grupperer RUNDER, ikke deltakere innad i én runde).
|
adskilt, ikke-relatert spørsmål (grupperer RUNDER, ikke deltakere
|
||||||
|
innad i én runde).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
122
CLAUDE.md
122
CLAUDE.md
|
|
@ -4726,6 +4726,123 @@ Ferdig og verifisert:
|
||||||
produksjonsbuild (samme `Dockerfile` som deployes) kompilerte rent,
|
produksjonsbuild (samme `Dockerfile` som deployes) kompilerte rent,
|
||||||
alle 24 ruter listet uendret.
|
alle 24 ruter listet uendret.
|
||||||
|
|
||||||
|
- **Tre organisator-oppfølgingspunkter fra FEATURE_BACKLOG.md, ALLE
|
||||||
|
BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE (2026-07-28), samme dag:**
|
||||||
|
brukeren ba om at alle tre gjenstående, godt avgrensede oppfølgings-
|
||||||
|
punkter fra en tidligere gjennomgang av `.md`-filene tas i én runde,
|
||||||
|
inkludert oppdatering av de relevante backlog-/beslutningsfilene.
|
||||||
|
1. **Flytte spiller mellom lag:** ny
|
||||||
|
`POST /orgs/{id}/teams/{team_id}/roster/{roster_id}/move`
|
||||||
|
(`{"target_team_id": ...}`, `app/routers/tournaments.py`) —
|
||||||
|
atomisk `UPDATE team_roster SET team_id = ...`, avviser 409
|
||||||
|
`ALREADY_IN_MATCH` hvis spilleren allerede er lagt til i en match
|
||||||
|
(roster-raden er referert av `match_participant.team_roster_id`
|
||||||
|
med `ON DELETE RESTRICT` -- en flytting ville da gjort matchens
|
||||||
|
`team_side` inkonsistent med spillerens faktiske lag), 400 ved
|
||||||
|
flytting til samme lag, 404 ved ukjent mållag/roster-id.
|
||||||
|
Kapteinmerket nullstilles eksplisitt ved flytting (følger ikke med
|
||||||
|
til det nye laget). Frontend: ny "Flytt til {annet lag}"-handling i
|
||||||
|
`TeamPanel` sin per-spiller-meny (`tournament-detail.tsx`) — v1s
|
||||||
|
to-lags-grense (ADR-011) gjør målet entydig, ingen dropdown
|
||||||
|
nødvendig. Ingen migrasjon.
|
||||||
|
2. **Individuell rangering PER ØKT i en org-turnering:** ny
|
||||||
|
`GET /orgs/{id}/sessions/{id}/individual-leaderboard` — bevisst
|
||||||
|
AVGRENSET til én økt (ikke summert på tvers av turneringen, samme
|
||||||
|
avklaring som rundeleaderboardets omfang tidligere), og KUN
|
||||||
|
meningsfull for `scoring_mode='stroke'`-økter i individuell-ball-
|
||||||
|
format (singles/fourball; delt-ball-formater og hole_result-modus
|
||||||
|
avvist med 400, ikke krasj, siden de ikke har noen individuell
|
||||||
|
brutto-score å rangere fra). Gjenbruker `mp.playing_handicap`
|
||||||
|
(allerede beregnet, allowance-justert) + `allocate_over_played_
|
||||||
|
holes` (samme mønster som eksisterende match-play-scoring i
|
||||||
|
`scoring.py`, som fikk sin `_played_hole_numbers` omdøpt til
|
||||||
|
`played_hole_numbers` -- "fjern understrek når et andre bruksted
|
||||||
|
dukker opp"-mønsteret, samme som `SIDE_IS_UNIT` tidligere) — ingen
|
||||||
|
ny regnelogikk. Respekterer samme reveal-gating (`locked_team_ids`/
|
||||||
|
`own_team_ids`, ADR-013/026) som den eksisterende matchlisten.
|
||||||
|
Frontend: ny side `/tournaments/[id]/sessions/[sessionId]/
|
||||||
|
individual-leaderboard` (`session-individual-leaderboard.tsx`,
|
||||||
|
egen enklere lokal variant av `round-leaderboard.tsx` sitt
|
||||||
|
rangerings-/mode-toggle-mønster -- brutto/netto/poeng, delt
|
||||||
|
plassering "T-N"), lenket fra blind draw-skjermen for
|
||||||
|
kvalifiserende økter. Ingen migrasjon.
|
||||||
|
3. **Midlertidige spillere + automatisk etter-runde-invitasjon
|
||||||
|
(økt-nivå):** de tre tidligere åpne spørsmålene avklart eksplisitt
|
||||||
|
(AskUserQuestion) -- nivå ØKT (ikke turnering), dobbel-utsending-
|
||||||
|
sperre JA, locale bevisst alltid `nb`. Ny migrasjon
|
||||||
|
`034_session_scorecard_invitations.sql`
|
||||||
|
(`match_participant.invitation_sent_at`, samme "tidsstempel =
|
||||||
|
skjedd"-mønster som `round.started_at` m.fl.). Ny
|
||||||
|
`POST /orgs/{id}/sessions/{id}/send-scorecard-invitations` —
|
||||||
|
sender KUN til spillere uten konto (`player.user_id IS NULL`) OG
|
||||||
|
med registrert e-post OG uten en tidligere sendt invitasjon for
|
||||||
|
akkurat denne (økt, deltaker)-kombinasjonen; responsen skiller
|
||||||
|
`sent`/`skipped_has_account`/`skipped_no_email`/
|
||||||
|
`skipped_already_sent` for full gjennomsiktighet. Gjenbruker
|
||||||
|
SAMME magic-link-token-mekanisme som vanlig innlogging (ikke bare
|
||||||
|
en "logg inn senere"-henvisning) -- ny `send_session_result_email()`
|
||||||
|
i `app/email.py` (begge nb/en-maler klare, kun `nb` faktisk brukt).
|
||||||
|
E-postens innhold: matchresultat (`status_text`) + individuelt
|
||||||
|
slagtotal når tilgjengelig (individuell-ball-formater -- `SUM
|
||||||
|
gross_strokes` er naturlig `NULL` for delt-ball-formater uten noen
|
||||||
|
egen format-sjekk, siden `match_participant_id` aldri settes i
|
||||||
|
`hole_score` der). Frontend: ny "Send scorekort til alle med
|
||||||
|
e-post"-knapp i blind draw-skjermen (`session-blind-draw.tsx`),
|
||||||
|
synlig når økten har minst én match.
|
||||||
|
**Liten, men reell ryddejobb funnet FØR e-post-laget ble lagt på**
|
||||||
|
(delt med forrige rundes rundevarsel-mønster): dev-log-grenen for den
|
||||||
|
nye invitasjons-e-posten skrev først KUN en oppsummeringstekst, ikke
|
||||||
|
selve token-en -- umulig å teste innloggingslenken i et scratch-/dev-
|
||||||
|
miljø uten SMTP. Rettet til å skrive to linjer: én i SAMME format som
|
||||||
|
`request_magic_link` sin etablerte `[DEV] Magic link for ...`-linje
|
||||||
|
(så eksisterende dev-verktøy som harvester token derfra fungerer
|
||||||
|
uendret), pluss en egen lesbar oppsummeringslinje.
|
||||||
|
**Scratch-verifisert grundig, 54/54 nye sjekker** (isolert
|
||||||
|
`teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API-
|
||||||
|
container, alle 34 migrasjoner kjørt friskt, full org-turnering-
|
||||||
|
scaffold bygget fra bunnen via API -- org/bane/18 hull/tee/turnering/
|
||||||
|
to lag/seks spillere/roster/singles-stroke-økt/to matcher): flytting
|
||||||
|
happy-path + kaptein-nullstilling + alle tre feilveier (409/400/404)
|
||||||
|
bekreftet, individuell rangering bekreftet tom→fylt→korrekt brutto/
|
||||||
|
netto/poeng for et hånd-utregnet 4-spiller-scenario (kryssjekket at
|
||||||
|
netto-til-par alltid ≤ brutto-til-par for alle fire), begge ikke-
|
||||||
|
kvalifiserende økt-typer (hole_result, foursome) avvist rent,
|
||||||
|
invitasjons-utsending bekreftet presist (1 sendt/2 manglet e-post/1
|
||||||
|
hadde allerede konto via en EKTE innlogget "linket" bruker), andre
|
||||||
|
kall bekreftet idempotent (0 nye, riktig `skipped_already_sent`), OG
|
||||||
|
den utstedte lenken bekreftet FAKTISK brukbar (spilleren logget inn
|
||||||
|
med den, kontoen ble koblet til spiller-profilen, samme ADR-017-
|
||||||
|
mekanisme uendret). PLUSS regresjon av scoring.py-omdøpingen
|
||||||
|
(`test_holeless_course_crash.py`, 5/5) og to notifikasjons-/e-post-
|
||||||
|
testsuiter fra forrige runde (28/28, 19/19) — alle fortsatt grønne.
|
||||||
|
`test_isolation.sql` 12/12. Ekte typesjekket produksjonsbuild
|
||||||
|
kompilerte rent, ny rute `/tournaments/[id]/sessions/[sessionId]/
|
||||||
|
individual-leaderboard` listet.
|
||||||
|
**Deretter en FULL, ekte nettleser-gjennomgang** (Chrome DevTools MCP,
|
||||||
|
isolert scratch-backend, ekte innlogging inkl. tvungen 2FA-oppsett for
|
||||||
|
en fersk organisasjonseier): flyttet en spiller mellom lag direkte i
|
||||||
|
UI-et og bekreftet begge lags roster-lister/-antall oppdatert
|
||||||
|
umiddelbart; åpnet individuell rangering og bekreftet alle tre
|
||||||
|
visningsmodiene (Brutto/Netto/Poeng) viste korrekte, ulike tall for de
|
||||||
|
samme to spillerne; trykket "Send scorekort til alle med e-post" og
|
||||||
|
bekreftet resultatteksten "1 invitasjon sendt, 1 mangler registrert
|
||||||
|
e-post" -- trykket samme knapp igjen og bekreftet "0 invitasjoner
|
||||||
|
sendt, 1 allerede sendt tidligere, …" (dobbel-sperren synlig direkte i
|
||||||
|
UI-et, ikke bare i et API-svar). Ingen konsollfeil i noen av de tre
|
||||||
|
rundene.
|
||||||
|
**Rullet ut mot ekte systemer 2026-07-28**, bruker bekreftet
|
||||||
|
eksplisitt: migrasjon 034 kjørt mot ekte `teecup_db`
|
||||||
|
(`invitation_sent_at`-kolonnen bekreftet, `test_isolation.sql`
|
||||||
|
fortsatt 12/12), deretter `docker compose up -d --build teecup_api
|
||||||
|
teecup_frontend`. Begge containere boot-et rent, `/health`/
|
||||||
|
`/dashboard` → 200, `teeoff.no` upåvirket.
|
||||||
|
**Samtidig, ren dokumentasjonshygiene:** et par steder i
|
||||||
|
`FEATURE_BACKLOG.md`/`ARCHITECTURE_DECISIONS.md` hadde blitt hengende
|
||||||
|
etter `CLAUDE.md` (ADR-039-utrulling/frontend og PWA-offline-
|
||||||
|
browsertesting fremstod fortsatt som "ikke gjort" til tross for at
|
||||||
|
begge var fullført i tidligere økter denne uken) — rettet til å
|
||||||
|
stemme med den faktiske, allerede leverte tilstanden.
|
||||||
|
|
||||||
Neste steg:
|
Neste steg:
|
||||||
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
|
||||||
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
|
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
|
||||||
|
|
@ -4952,3 +5069,8 @@ Neste steg:
|
||||||
fremtidig finpuss (ikke bedt om ennå): redigere et sidenavn i
|
fremtidig finpuss (ikke bedt om ennå): redigere et sidenavn i
|
||||||
etterkant (kun opprett/slett finnes i dag), en tydeligere
|
etterkant (kun opprett/slett finnes i dag), en tydeligere
|
||||||
skins-poeng-forklaring i UI-et.
|
skins-poeng-forklaring i UI-et.
|
||||||
|
12. **Ferdig, kun for historikk:** de tre organisator-oppfølgingspunktene
|
||||||
|
(flytte spiller mellom lag, individuell rangering per økt, midlertidige
|
||||||
|
spillere + etter-runde-invitasjon) — alle bygget, scratch-/
|
||||||
|
browserverifisert og live 2026-07-28, se status over. Ingen nye åpne
|
||||||
|
spørsmål igjen fra denne runden.
|
||||||
|
|
|
||||||
|
|
@ -774,12 +774,25 @@ telefonens OS (egen, større runde, se vurderingen over), e-post-fallback
|
||||||
utrullingsplanen (denne runden + rundeleaderboardet under ble bygget
|
utrullingsplanen (denne runden + rundeleaderboardet under ble bygget
|
||||||
sammen).
|
sammen).
|
||||||
|
|
||||||
### Tillegg 2026-07-25: e-post som fallback-kanal, betinget av samtykke
|
### Tillegg 2026-07-25: e-post som fallback-kanal, betinget av samtykke — ✅ BYGGET OG LIVE 2026-07-28
|
||||||
|
|
||||||
Brukeren foreslo at TeeCup i tillegg sender en e-post til mottakeren av en
|
**Bygget rett til den "fremtidig presisering"-varianten under, ikke det
|
||||||
venneforespørsel ("du har fått en forespørsel, åpne appen for å se den")
|
enklere forslaget:** brukeren ba eksplisitt om at mottakeren skal kunne
|
||||||
— MEN kun hvis brukeren har akseptert e-post som kommunikasjonskanal fra
|
velge HVILKE varseltyper som skal gi e-post, ikke én global av/på-bryter
|
||||||
TeeCup.
|
— ny `user_notification_email_pref`-tabell (migrasjon 033, samme
|
||||||
|
"tilstedeværelse = valgt"-mønster som `friend_categorization`/
|
||||||
|
`round_visible_category`, trygg standard = ingen typer valgt). E-post-
|
||||||
|
utsendingen ligger ETT sted (inni `create_notification()` selv,
|
||||||
|
`app/routers/notifications.py`), så den dekker automatisk alle nåværende
|
||||||
|
OG fremtidige varseltyper (`friend`/`round`/`result`/`tournament`) uten
|
||||||
|
at hvert kallsted må huske det selv. Ny `send_notification_email()` i
|
||||||
|
`app/email.py`, ny seksjon "Varsler på e-post" i `/account`. Se
|
||||||
|
CLAUDE.md-status 2026-07-28 for full detalj (19/19 scratch-sjekker).
|
||||||
|
|
||||||
|
Opprinnelig forslag, for historikkens skyld: brukeren foreslo at TeeCup i
|
||||||
|
tillegg sender en e-post til mottakeren av en venneforespørsel ("du har
|
||||||
|
fått en forespørsel, åpne appen for å se den") — MEN kun hvis brukeren
|
||||||
|
har akseptert e-post som kommunikasjonskanal fra TeeCup.
|
||||||
|
|
||||||
**Sjekket eksisterende kode:** det finnes I DAG ingen generell
|
**Sjekket eksisterende kode:** det finnes I DAG ingen generell
|
||||||
kommunikasjons-/varslings-samtykke-flagg på `app_user` — det eneste
|
kommunikasjons-/varslings-samtykke-flagg på `app_user` — det eneste
|
||||||
|
|
@ -872,7 +885,7 @@ V0-prompt da, siden det er en egen, synlig UI-flate.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Leaderboard for runder og turneringer — 🧠 DRØFTET 2026-07-25, IKKE besluttet
|
## Leaderboard for runder og turneringer — ✅ HELT FERDIG (runder 2026-07-26, turnering-individuell-rangering 2026-07-28)
|
||||||
|
|
||||||
Brukeren ba om et leaderboard for pågående og ferdige runder OG
|
Brukeren ba om et leaderboard for pågående og ferdige runder OG
|
||||||
turneringer, usikker på om det bør ligge der man allerede ser rundens
|
turneringer, usikker på om det bør ligge der man allerede ser rundens
|
||||||
|
|
@ -1072,6 +1085,29 @@ fargevalg/layout).
|
||||||
Full verifiserings- og utrullingsdetalj i CLAUDE.md sin statuslogg
|
Full verifiserings- og utrullingsdetalj i CLAUDE.md sin statuslogg
|
||||||
(2026-07-26) -- ikke gjentatt her for å unngå duplisering.
|
(2026-07-26) -- ikke gjentatt her for å unngå duplisering.
|
||||||
|
|
||||||
|
### Oppdatering 2026-07-28: turnering-halvparten av spørsmålet AVKLART OG BYGGET
|
||||||
|
|
||||||
|
Det tidligere åpne spørsmålet ("mener brukeren en individuell rangering
|
||||||
|
INNAD i en turnering, ved siden av det eksisterende lag-poeng-
|
||||||
|
leaderboardet?") ble avklart eksplisitt (AskUserQuestion) — svar: JA, en
|
||||||
|
egen rangering PER ØKT (ikke summert på tvers av turneringen), ved siden
|
||||||
|
av lag-poeng-leaderboardet, ikke i stedet for det. Bygget: ny
|
||||||
|
`GET /orgs/{id}/sessions/{id}/individual-leaderboard`
|
||||||
|
(`app/routers/tournaments.py`) — KUN meningsfull for
|
||||||
|
`scoring_mode='stroke'`-økter i individuell-ball-format (singles/
|
||||||
|
fourball; delt-ball-formater og hole_result-modus avvises tydelig med
|
||||||
|
400, ikke krasj, siden de ikke har noen individuell brutto-score å
|
||||||
|
rangere fra). Gjenbruker `mp.playing_handicap` (allerede beregnet,
|
||||||
|
allowance-justert per format) + `allocate_over_played_holes` (samme
|
||||||
|
mønster som eksisterende match-play-scoring) for netto/poeng — ingen ny
|
||||||
|
regnelogikk. Respekterer samme reveal-gating (blind draw, ADR-013) som
|
||||||
|
matchlisten. Frontend: ny side
|
||||||
|
`/tournaments/[id]/sessions/[sessionId]/individual-leaderboard`
|
||||||
|
(`session-individual-leaderboard.tsx`, gjenbruker rangerings-/
|
||||||
|
mode-toggle-mønsteret fra `round-leaderboard.tsx` i en enklere,
|
||||||
|
sesjonsscopet variant), lenket fra blind draw-skjermen for kvalifiserende
|
||||||
|
økter. Scratch- og browserverifisert 2026-07-28, se CLAUDE.md-status.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Spillerliste-redesign (vertikal, utslag/HCP/rediger inline) — ✅ HELT FERDIG, håndkodet (V0 tom for credits), live 2026-07-26
|
## Spillerliste-redesign (vertikal, utslag/HCP/rediger inline) — ✅ HELT FERDIG, håndkodet (V0 tom for credits), live 2026-07-26
|
||||||
|
|
@ -1990,7 +2026,7 @@ kjørt mot ekte `teecup_db`, begge containere redeployet, `/health`/
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Midlertidige spillere + automatisk etter-runde-invitasjon — 📋 FORESLÅTT 2026-07-20, IKKE bygget ennå
|
## Midlertidige spillere + automatisk etter-runde-invitasjon — ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-07-28 (økt-nivå)
|
||||||
|
|
||||||
Reist samme runde som punktet over, uttalt som punkt 2 (ikke like prioritert
|
Reist samme runde som punktet over, uttalt som punkt 2 (ikke like prioritert
|
||||||
som "Mine runder"-dashbordet, men skal likevel dokumenteres grundig nå).
|
som "Mine runder"-dashbordet, men skal likevel dokumenteres grundig nå).
|
||||||
|
|
@ -2027,16 +2063,34 @@ koblingen skal skje).
|
||||||
magic-link, ingen ny auth-mekanisme nødvendig siden `link_player_by_
|
magic-link, ingen ny auth-mekanisme nødvendig siden `link_player_by_
|
||||||
email()` allerede kobler kontoen automatisk ved første innlogging).
|
email()` allerede kobler kontoen automatisk ved første innlogging).
|
||||||
|
|
||||||
**Åpne spørsmål, trengs FØR bygging:**
|
**Åpne spørsmål — alle avklart 2026-07-28 (AskUserQuestion), deretter bygget:**
|
||||||
- Skal utsendingsknappen ligge på ØKT-nivå (send til alle i denne ene
|
- **Nivå: ØKT** (bekreftet, den anbefalte retningen). Ny
|
||||||
runden) eller TURNERING-nivå (send til alle på tvers av alle økter, når
|
`POST /orgs/{id}/sessions/{id}/send-scorecard-invitations`
|
||||||
hele turneringen er ferdig)? Økt-nivå virker riktigst — en spiller kan
|
(`app/routers/tournaments.py`).
|
||||||
ha spilt kun én av flere økter.
|
- **Dobbel-utsending-sperre: JA.** Ny `match_participant.
|
||||||
- Skal systemet spore "allerede sendt til denne spilleren for denne økten"
|
invitation_sent_at timestamptz` (migrasjon `034`) — samme
|
||||||
for å hindre dobbel utsending ved et nytt klikk (sannsynligvis ja — én
|
"tidsstempel = skjedd"-mønster som `round.started_at` m.fl. Kun
|
||||||
liten ny tabell/kolonne)?
|
spillere UTEN konto (`player.user_id IS NULL`) OG med registrert
|
||||||
- Skal e-posten sendes på spillerens/organisasjonens foretrukne språk
|
e-post OG uten en tidligere sendt invitasjon for akkurat denne
|
||||||
(samme `locale`-mønster som magic-link-e-posten, ADR-015 Beslutning C)?
|
(økt, deltaker)-kombinasjonen kvalifiserer — responsen skiller
|
||||||
|
eksplisitt mellom `sent`/`skipped_has_account`/`skipped_no_email`/
|
||||||
|
`skipped_already_sent` for full gjennomsiktighet i UI-et.
|
||||||
|
- **Locale: bevisst alltid `nb`** (ikke bygget som et valg) — en spiller
|
||||||
|
uten konto har ingen lagret språkpreferanse å lese fra, og dette er en
|
||||||
|
norsk klubb-app. Ny `send_session_result_email()` i `app/email.py`
|
||||||
|
har likevel BEGGE nb/en-maler klare (samme fil-konvensjon som ellers),
|
||||||
|
kun `nb` faktisk brukt i dag.
|
||||||
|
E-posten inneholder et EKTE, ferdig innloggingslenke (samme
|
||||||
|
magic-link-mekanisme som vanlig innlogging, ikke bare en «logg inn
|
||||||
|
senere»-henvisning) + matchresultat (status_text) + individuelt
|
||||||
|
slagtotal når tilgjengelig (individuell-ball-formater).
|
||||||
|
Scratch-verifisert grundig (54/54 sjekker, inkl. at den utstedte
|
||||||
|
lenken faktisk logger spilleren inn og kobler kontoen automatisk til
|
||||||
|
spiller-profilen, ADR-017-mekanismen uendret) OG bekreftet direkte i
|
||||||
|
en ekte innlogget nettleser (Chrome DevTools) — knappen viste korrekt
|
||||||
|
«1 invitasjon sendt, 1 mangler registrert e-post» første gang, «0
|
||||||
|
invitasjoner sendt, 1 allerede sendt tidligere, …» andre gang. Se
|
||||||
|
CLAUDE.md-status 2026-07-28 for full detalj.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -2550,23 +2604,28 @@ kringkasting bekreftet begge veier, ikke bare REST-svar.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Turneringsoppsett: flytte/slette spillere mellom lag — 📋 NOTERT 2026-07-25, IKKE bygget
|
## Turneringsoppsett: flytte spillere mellom lag — ✅ BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE 2026-07-28
|
||||||
|
|
||||||
Reist av brukeren samme runde som dashbord-integreringen. I dag finnes
|
Reist av brukeren 2026-07-25 samme runde som dashbord-integreringen. Løst
|
||||||
`DELETE .../roster/{roster_id}` (fjerner en spiller fra ETT lag) og
|
akkurat slik forrige runde antok tryggest: ny
|
||||||
`PATCH .../roster/{roster_id}` (kun `is_captain`, ADR-023) — men INGEN vei
|
`POST /orgs/{id}/teams/{team_id}/roster/{roster_id}/move`
|
||||||
til å FLYTTE en allerede rostret spiller til et ANNET lag i samme
|
(`{"target_team_id": ...}`, `app/routers/tournaments.py`) — atomisk
|
||||||
turnering i én operasjon. I dag må organisatoren gjøre det som to separate
|
`UPDATE team_roster SET team_id = ...` i én operasjon, ingen
|
||||||
kall (fjern fra lag A, legg til på lag B), og det finnes ingen slik
|
mellomtilstand. Avviser med 409 `ALREADY_IN_MATCH` hvis spilleren
|
||||||
UI-handling i `tournament-detail.tsx` sin `TeamPanel` i det hele tatt —
|
allerede er lagt til i en match (roster-raden er referert av
|
||||||
kun fjerning.
|
`match_participant.team_roster_id` med `ON DELETE RESTRICT` — å flytte
|
||||||
|
den ville gjort matchens `team_side` inkonsistent med spillerens
|
||||||
**Ikke designet i detalj ennå** — rent notert som et reelt hull. Naturlig
|
faktiske lag), avviser 400 ved flytting til samme lag, 404 ved ukjent
|
||||||
retning ved bygging: enten et eget `POST .../roster/{roster_id}/move`
|
mållag/roster-id. Kapteinmerket følger IKKE med til det nye laget
|
||||||
(target-team-id) som gjør begge operasjonene atomisk (unngår en
|
(nullstilles eksplisitt ved flytting). Frontend: ny "Flytt til {annet
|
||||||
mellomtilstand der spilleren midlertidig ikke er rostret noe sted), eller
|
lag}"-handling i `TeamPanel` sin per-spiller-meny
|
||||||
en UI-snarvei som bare kjører de to eksisterende kallene i sekvens — det
|
(`tournament-detail.tsx`) — v1s to-lags-grense (ADR-011) gjør målet
|
||||||
første er tryggere (ingen delvis fullført tilstand ved feil midtveis).
|
entydig, ingen dropdown nødvendig.
|
||||||
|
Scratch-verifisert (happy path, kaptein-nullstilling, 409/400/404 alle
|
||||||
|
bekreftet) OG bekreftet direkte i en ekte innlogget nettleser (Chrome
|
||||||
|
DevTools): flytting oppdaterte begge lags roster-lister og -antall
|
||||||
|
umiddelbart, ingen konsollfeil. Ingen migrasjon. Se CLAUDE.md-status
|
||||||
|
2026-07-28.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -2916,19 +2975,22 @@ hvilke "første handling"-alternativer dashbordet bør vise i fremtiden.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Frittstående runder: ekte spillformer (slagspill/match/skins/par-lag) — ✅ BACKEND BYGGET OG SCRATCH-VERIFISERT (ADR-039), IKKE ENNÅ rullet ut mot ekte systemer, frontend fortsatt ikke bygget
|
## Frittstående runder: ekte spillformer (slagspill/match/skins/par-lag) — ✅ HELT FERDIG, backend + frontend BYGGET, SCRATCH-/BROWSERVERIFISERT OG LIVE (ADR-039)
|
||||||
|
|
||||||
**Oppdatering 2026-07-28 (ADR-039):** design + backend er ferdig samme
|
**Oppdatering 2026-07-28 (ADR-039):** design + backend bygget samme dag
|
||||||
dag som punktet ble reist. Se CLAUDE.md-status 2026-07-28 for full
|
som punktet ble reist, frontend (sideoppsett/skins-konfig/matchstatus-
|
||||||
detalj (migrasjon `031`, nye endepunkter, 96/96 scratch-sjekker). Kort:
|
visning/delt-ball-scorekort) og selve utrullingen mot ekte `teecup_db`/
|
||||||
alle fire load-bærende spørsmål (sider/gruppering, omfang av par-/lag-
|
`teecup_api` fulgte rett etter, samme dag — se CLAUDE.md-status
|
||||||
underformater, skins-regler, HCP-tellestatus for delt-ball) avklart med
|
2026-07-28 for full detalj (migrasjon `031`, nye endepunkter, 96+26
|
||||||
bruker, hele match-play-motoren fra org-turneringer PORTERT uendret
|
backend-scratch-sjekker, full nettleser-verifisering av alle seks
|
||||||
(ingen ny regnelogikk for match/fourball/foursome/greensome/scramble),
|
formater). Kort: alle fire load-bærende spørsmål (sider/gruppering,
|
||||||
kun skins fikk ekte ny motorkode. **Gjenstår:** frontend (ingen skjerm
|
omfang av par-/lag-underformater, skins-regler, HCP-tellestatus for
|
||||||
for sideoppsett/skins-konfig/matchstatus-visning ennå) og selve
|
delt-ball) avklart med bruker, hele match-play-motoren fra
|
||||||
utrullingen mot ekte `teecup_db`/`teecup_api` — begge egne, separate
|
org-turneringer PORTERT uendret (ingen ny regnelogikk for match/
|
||||||
neste steg.
|
fourball/foursome/greensome/scramble), kun skins fikk ekte ny
|
||||||
|
motorkode. Etterfølgende samme-dags oppfølgingsrunder (minimums-
|
||||||
|
spiller-håndhevelse, offline-kø utvidet til frittstående runder,
|
||||||
|
scorekort-bugfikser for spillformater) også alle live — se CLAUDE.md.
|
||||||
|
|
||||||
Opprinnelig reist av brukeren rett etter at ADR-038 (faktisk HCP) ble
|
Opprinnelig reist av brukeren rett etter at ADR-038 (faktisk HCP) ble
|
||||||
rullet ut:
|
rullet ut:
|
||||||
|
|
@ -3164,7 +3226,7 @@ Full design i ARCHITECTURE_DECISIONS.md ADR-028. Kort:
|
||||||
| Service worker: cache app-navigasjon + `/orgs/*`-GET-er | ✅ bygget | `public/sw.js`, nettverk-først/cache-fallback (bevisst IKKE stale-while-revalidate, se ADR-028). `public/offline.html` som siste utvei. |
|
| Service worker: cache app-navigasjon + `/orgs/*`-GET-er | ✅ bygget | `public/sw.js`, nettverk-først/cache-fallback (bevisst IKKE stale-while-revalidate, se ADR-028). `public/offline.html` som siste utvei. |
|
||||||
| Offline scoreregistrering (hole-scores/hole-results) | ✅ bygget | `lib/offline-queue.ts` (IndexedDB-kø) + `components/session-scorecard.tsx`. Synker automatisk ved `window`s `online`-event, pluss manuell "Synkroniser nå"-knapp. Bevisst IKKE Background Sync API (iOS Safari støtter den ikke). |
|
| Offline scoreregistrering (hole-scores/hole-results) | ✅ bygget | `lib/offline-queue.ts` (IndexedDB-kø) + `components/session-scorecard.tsx`. Synker automatisk ved `window`s `online`-event, pluss manuell "Synkroniser nå"-knapp. Bevisst IKKE Background Sync API (iOS Safari støtter den ikke). |
|
||||||
| Andre skrivehandlinger offline (walkover, chat/feed, oppsett) | 💤 bevisst utenfor omfang | Kun de to scoreregistrerings-endepunktene er køet — se ADR-028 Beslutning B for begrunnelse per type. |
|
| Andre skrivehandlinger offline (walkover, chat/feed, oppsett) | 💤 bevisst utenfor omfang | Kun de to scoreregistrerings-endepunktene er køet — se ADR-028 Beslutning B for begrunnelse per type. |
|
||||||
| Faktisk browser-testet (DevTools Offline-modus) | ❌ **FORTSATT IKKE GJORT — OPPFØLGINGSPUNKT** | Kun verifisert med typesjekket build + container-boot/curl, aldri i en ekte nettleser. Ingen nettleserverktøy tilgjengelig i byggeøkten. **Brukeren bør selv åpne et scorekort, skru på Chrome DevTools sin Offline-bryter, registrere et par slag, skru nettet på igjen, og bekrefte at de faktisk synkes** — først da er offline-flyten reelt bevist, ikke bare kodegjennomgått. |
|
| Faktisk browser-testet (DevTools Offline-modus) | ✅ **FERDIG 2026-07-28** | Både turnering-scorekortet (den opprinnelige ADR-028-flyten) og den senere utvidelsen til frittstående runder er nå bevist med ekte Chrome DevTools-nettverksemulering (offline → registrer slag → tilbake online → automatisk synk bekreftet server-side), ikke bare kodegjennomgang. Se CLAUDE.md-status 2026-07-28. |
|
||||||
|
|
||||||
**Rullet ut live 2026-07-19**, bruker bekreftet eksplisitt: `docker compose
|
**Rullet ut live 2026-07-19**, bruker bekreftet eksplisitt: `docker compose
|
||||||
up -d --build teecup_frontend` (ingen migrasjon). Verifisert:
|
up -d --build teecup_frontend` (ingen migrasjon). Verifisert:
|
||||||
|
|
|
||||||
57
app/email.py
57
app/email.py
|
|
@ -263,3 +263,60 @@ async def send_notification_email(to_email: str, message: str, link_path: str, l
|
||||||
link = f"{settings.PUBLIC_BASE_URL}{link_path}"
|
link = f"{settings.PUBLIC_BASE_URL}{link_path}"
|
||||||
body = template["body"].format(message=message, link=link)
|
body = template["body"].format(message=message, link=link)
|
||||||
await to_thread(_send_sync, to_email, template["subject"], body)
|
await to_thread(_send_sync, to_email, template["subject"], body)
|
||||||
|
|
||||||
|
|
||||||
|
_SESSION_INVITATION_TEMPLATES = {
|
||||||
|
"nb": {
|
||||||
|
"subject": "Din runde i {tournament} er registrert i TeeCup",
|
||||||
|
"body": (
|
||||||
|
"Hei {name},\n\n"
|
||||||
|
"{summary}\n\n"
|
||||||
|
"Åpne lenken under innen {minutes} minutter for å logge deg rett inn og se mer, "
|
||||||
|
"eller følge med videre:\n\n"
|
||||||
|
"{link}\n\n"
|
||||||
|
"Fungerer ikke lenken? Lim inn denne koden manuelt i stedet:\n"
|
||||||
|
"{token}\n\n"
|
||||||
|
"Fikk du denne uventet? Arrangøren av turneringen har registrert deg som spiller "
|
||||||
|
"i TeeCup -- se bort fra e-posten hvis dette ikke stemmer.\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"en": {
|
||||||
|
"subject": "Your round in {tournament} has been recorded in TeeCup",
|
||||||
|
"body": (
|
||||||
|
"Hi {name},\n\n"
|
||||||
|
"{summary}\n\n"
|
||||||
|
"Open the link below within {minutes} minutes to log straight in and see more, "
|
||||||
|
"or keep following along:\n\n"
|
||||||
|
"{link}\n\n"
|
||||||
|
"Link not working? Paste this code in manually instead:\n"
|
||||||
|
"{token}\n\n"
|
||||||
|
"Got this unexpectedly? The tournament organizer registered you as a player in "
|
||||||
|
"TeeCup -- ignore this email if that's not right.\n"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_session_result_email(
|
||||||
|
to_email: str, player_name: str, tournament_name: str, summary: str, raw_token: str, locale: str = "nb"
|
||||||
|
) -> None:
|
||||||
|
"""FEATURE_BACKLOG.md "Midlertidige spillere + automatisk etter-runde-
|
||||||
|
invitasjon" (avklart 2026-07-28, økt-nivå) -- en organisator sender
|
||||||
|
dette til spillere UTEN egen konto ennå (player.user_id IS NULL) etter
|
||||||
|
en økt. `summary` er en allerede ferdig, norsk tekst (samme snapshot-
|
||||||
|
prinsipp som varsel-meldinger) som beskriver matchresultatet -- bygget
|
||||||
|
av kalleren (app/routers/tournaments.py), ikke her. Samme ekte
|
||||||
|
klikkbare magic-link-mønster som send_magic_link_email (ikke bare en
|
||||||
|
"logg inn senere"-henvisning som organisasjonsinvitasjonen, siden
|
||||||
|
dette er en engangs, direkte innloggingslenke)."""
|
||||||
|
template = _SESSION_INVITATION_TEMPLATES.get(locale, _SESSION_INVITATION_TEMPLATES["nb"])
|
||||||
|
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
|
||||||
|
subject = template["subject"].format(tournament=tournament_name)
|
||||||
|
body = template["body"].format(
|
||||||
|
name=player_name,
|
||||||
|
summary=summary,
|
||||||
|
minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES,
|
||||||
|
link=link,
|
||||||
|
token=raw_token,
|
||||||
|
)
|
||||||
|
await to_thread(_send_sync, to_email, subject, body)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ router = APIRouter()
|
||||||
_INDIVIDUAL_FORMATS = {"singles", "fourball"}
|
_INDIVIDUAL_FORMATS = {"singles", "fourball"}
|
||||||
|
|
||||||
|
|
||||||
def _played_hole_numbers(hole_config: str) -> list[int]:
|
def played_hole_numbers(hole_config: str) -> list[int]:
|
||||||
if hole_config == "front_9":
|
if hole_config == "front_9":
|
||||||
return list(range(1, 10))
|
return list(range(1, 10))
|
||||||
if hole_config == "back_9":
|
if hole_config == "back_9":
|
||||||
|
|
@ -98,7 +98,7 @@ def _side_net(rows: list, strokes_per_hole: dict, format_: str, hole_number: int
|
||||||
|
|
||||||
|
|
||||||
async def _compute_hole_results(conn, match_id: str, match) -> list[HoleResult]:
|
async def _compute_hole_results(conn, match_id: str, match) -> list[HoleResult]:
|
||||||
played = _played_hole_numbers(match["hole_config"])
|
played = played_hole_numbers(match["hole_config"])
|
||||||
|
|
||||||
if match["scoring_mode"] == "hole_result":
|
if match["scoring_mode"] == "hole_result":
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
|
|
@ -188,7 +188,7 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None:
|
||||||
match_id,
|
match_id,
|
||||||
)
|
)
|
||||||
results = await _compute_hole_results(conn, match_id, match)
|
results = await _compute_hole_results(conn, match_id, match)
|
||||||
played = _played_hole_numbers(match["hole_config"])
|
played = played_hole_numbers(match["hole_config"])
|
||||||
state = compute_match_state(results, total_holes=len(played))
|
state = compute_match_state(results, total_holes=len(played))
|
||||||
|
|
||||||
# ADR-020 Beslutning C: fortegnet av state.lead cachet i egen kolonne
|
# ADR-020 Beslutning C: fortegnet av state.lead cachet i egen kolonne
|
||||||
|
|
@ -382,7 +382,7 @@ async def submit_hole_score(
|
||||||
"Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
|
"Denne økten bruker hull-resultat-modus, ikke slagregistrering.",
|
||||||
)
|
)
|
||||||
|
|
||||||
played = _played_hole_numbers(match["hole_config"])
|
played = played_hole_numbers(match["hole_config"])
|
||||||
if body.hole_number not in played:
|
if body.hole_number not in played:
|
||||||
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
|
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
|
||||||
|
|
||||||
|
|
@ -507,7 +507,7 @@ async def submit_hole_result(
|
||||||
"Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
|
"Denne økten bruker slagregistrering, ikke hull-resultat-modus.",
|
||||||
)
|
)
|
||||||
|
|
||||||
played = _played_hole_numbers(match["hole_config"])
|
played = played_hole_numbers(match["hole_config"])
|
||||||
if body.hole_number not in played:
|
if body.hole_number not in played:
|
||||||
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
|
raise app_error(400, "OUT_OF_SCOPE", "Hullnummeret er utenfor øktens spilte omfang.")
|
||||||
|
|
||||||
|
|
@ -604,7 +604,7 @@ async def fetch_scorecard(conn, match_id: str) -> Scorecard:
|
||||||
)
|
)
|
||||||
hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
|
hole_result_entries = [HoleResultOut(**dict(r)) for r in rows]
|
||||||
|
|
||||||
played = _played_hole_numbers(match["hole_config"])
|
played = played_hole_numbers(match["hole_config"])
|
||||||
holes = [
|
holes = [
|
||||||
ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results)
|
ScorecardHole(hole_number=h, result=_RESULT_LABEL[r]) for h, r in zip(played, results)
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,25 @@ rostret på et lag som ikke finnes ennå).
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import date, datetime
|
import traceback
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
from fastapi import APIRouter, Depends, UploadFile
|
from fastapi import APIRouter, Depends, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from handicap_engine import allocate_over_played_holes
|
||||||
|
|
||||||
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
from ..blind_draw import locked_team_ids
|
from ..blind_draw import locked_team_ids, own_team_ids
|
||||||
|
from ..config import settings
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
|
from ..email import send_session_result_email
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
||||||
|
from .auth import _hash_secret
|
||||||
from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant
|
from ..team_authz import is_org_member, user_is_team_captain, user_is_tournament_participant
|
||||||
from .scoring import apply_concession, recompute_and_cache_match_state
|
from .scoring import apply_concession, played_hole_numbers, recompute_and_cache_match_state
|
||||||
from .. import storage
|
from .. import storage
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -547,6 +553,85 @@ async def update_roster_entry(
|
||||||
return RosterEntry(**dict(row))
|
return RosterEntry(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
class RosterMove(BaseModel):
|
||||||
|
target_team_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}/move",
|
||||||
|
response_model=RosterEntry,
|
||||||
|
)
|
||||||
|
async def move_roster_entry(
|
||||||
|
team_id: str,
|
||||||
|
roster_id: str,
|
||||||
|
body: RosterMove,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> RosterEntry:
|
||||||
|
"""FEATURE_BACKLOG.md "Turneringsoppsett: flytte/slette spillere mellom
|
||||||
|
lag" (notert 2026-07-25) -- atomisk flytting i én operasjon i stedet for
|
||||||
|
to separate DELETE+POST-kall (unngår en mellomtilstand der spilleren
|
||||||
|
midlertidig ikke er rostret noe sted)."""
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
current = await conn.fetchrow(
|
||||||
|
"SELECT tr.id, t.tournament_id::text AS tournament_id "
|
||||||
|
"FROM team_roster tr JOIN team t ON t.id = tr.team_id "
|
||||||
|
"WHERE tr.id = $1 AND tr.team_id = $2",
|
||||||
|
roster_id,
|
||||||
|
team_id,
|
||||||
|
)
|
||||||
|
if current is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.")
|
||||||
|
if body.target_team_id == team_id:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Spilleren er allerede på dette laget.")
|
||||||
|
target_team = await conn.fetchrow(
|
||||||
|
"SELECT id FROM team WHERE id = $1 AND tournament_id = $2",
|
||||||
|
body.target_team_id,
|
||||||
|
current["tournament_id"],
|
||||||
|
)
|
||||||
|
if target_team is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Mållaget finnes ikke i denne turneringen.")
|
||||||
|
|
||||||
|
# Roster-raden er referert av match_participant.team_roster_id (ON
|
||||||
|
# DELETE RESTRICT) -- en spiller som allerede er satt opp i minst én
|
||||||
|
# match kan ikke flyttes trygt (matchens team_side ville da ikke
|
||||||
|
# lenger stemme med spillerens faktiske lag). Krev at spilleren
|
||||||
|
# fjernes fra matchen(e) FØRST, i stedet for å tillate en
|
||||||
|
# inkonsistent mellomtilstand.
|
||||||
|
already_in_match = await conn.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM match_participant WHERE team_roster_id = $1)",
|
||||||
|
roster_id,
|
||||||
|
)
|
||||||
|
if already_in_match:
|
||||||
|
raise app_error(
|
||||||
|
409,
|
||||||
|
"ALREADY_IN_MATCH",
|
||||||
|
"Spilleren er allerede lagt til i en match og kan ikke flyttes -- "
|
||||||
|
"fjern spilleren fra matchen(e) først.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kaptein-merket følger IKKE med til det nye laget -- "kun én
|
||||||
|
# kaptein per lag" (ADR-023) håndheves allerede ELLERS via
|
||||||
|
# update_roster_entry, men en flyttet spiller skal aldri bli
|
||||||
|
# kaptein på et nytt lag uten et eksplisitt valg der.
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
WITH moved AS (
|
||||||
|
UPDATE team_roster
|
||||||
|
SET team_id = $1, is_captain = false
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, player_id, handicap_index_snapshot, is_captain
|
||||||
|
)
|
||||||
|
SELECT moved.id::text, moved.player_id::text,
|
||||||
|
moved.handicap_index_snapshot::float AS handicap_index_snapshot,
|
||||||
|
moved.is_captain, p.display_name
|
||||||
|
FROM moved JOIN player p ON p.id = moved.player_id
|
||||||
|
""",
|
||||||
|
body.target_team_id,
|
||||||
|
roster_id,
|
||||||
|
)
|
||||||
|
return RosterEntry(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
|
"/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}",
|
||||||
status_code=204,
|
status_code=204,
|
||||||
|
|
@ -905,6 +990,141 @@ async def delete_session(
|
||||||
await conn.execute("DELETE FROM session WHERE id = $1", session_id)
|
await conn.execute("DELETE FROM session WHERE id = $1", session_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Midlertidige spillere: etter-runde scorekort + innloggingsinvitasjon,
|
||||||
|
# økt-nivå (FEATURE_BACKLOG.md, avklart 2026-07-28, migrasjon 034).
|
||||||
|
#
|
||||||
|
# Eksplisitt organisator-handling (ikke en automatisk bakgrunnsjobb, se
|
||||||
|
# begrunnelse i backloggen) -- sender KUN til spillere UTEN egen konto ennå
|
||||||
|
# (player.user_id IS NULL) og med en registrert e-post, og KUN én gang per
|
||||||
|
# (økt, deltaker) via match_participant.invitation_sent_at. Locale er
|
||||||
|
# bevisst alltid 'nb' -- en spiller uten konto har ingen lagret
|
||||||
|
# språkpreferanse å lese.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SendInvitationsOut(BaseModel):
|
||||||
|
sent: int
|
||||||
|
skipped_has_account: int
|
||||||
|
skipped_no_email: int
|
||||||
|
skipped_already_sent: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/orgs/{organization_id}/sessions/{session_id}/send-scorecard-invitations",
|
||||||
|
response_model=SendInvitationsOut,
|
||||||
|
)
|
||||||
|
async def send_scorecard_invitations(
|
||||||
|
session_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> SendInvitationsOut:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
session = await conn.fetchrow(
|
||||||
|
"SELECT tournament_id::text AS tournament_id, name FROM session WHERE id = $1",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if session is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
||||||
|
tournament_name = await conn.fetchval(
|
||||||
|
"SELECT name FROM tournament WHERE id = $1", session["tournament_id"]
|
||||||
|
)
|
||||||
|
session_label = (session["name"] or "").strip() or "en økt"
|
||||||
|
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT mp.id::text AS participant_id, mp.invitation_sent_at,
|
||||||
|
p.display_name, p.email, p.user_id::text AS user_id,
|
||||||
|
m.status_text
|
||||||
|
FROM match_participant mp
|
||||||
|
JOIN match m ON m.id = mp.match_id
|
||||||
|
JOIN team_roster tr ON tr.id = mp.team_roster_id
|
||||||
|
JOIN player p ON p.id = tr.player_id
|
||||||
|
WHERE m.session_id = $1
|
||||||
|
""",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
skipped_has_account = 0
|
||||||
|
skipped_no_email = 0
|
||||||
|
skipped_already_sent = 0
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
if r["user_id"] is not None:
|
||||||
|
skipped_has_account += 1
|
||||||
|
continue
|
||||||
|
if not r["email"]:
|
||||||
|
skipped_no_email += 1
|
||||||
|
continue
|
||||||
|
if r["invitation_sent_at"] is not None:
|
||||||
|
skipped_already_sent += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Kun meningsfull for individuell-ball-formater (singles/
|
||||||
|
# fourball) -- for delt-ball-formater er match_participant_id
|
||||||
|
# ALDRI satt i hole_score (ADR/scoring.py), så SUM blir naturlig
|
||||||
|
# NULL der uten noen egen format-sjekk her.
|
||||||
|
gross = await conn.fetchval(
|
||||||
|
"SELECT SUM(gross_strokes) FROM hole_score WHERE match_participant_id = $1",
|
||||||
|
r["participant_id"],
|
||||||
|
)
|
||||||
|
if r["status_text"]:
|
||||||
|
summary = f"Du spilte i {session_label} i {tournament_name} -- resultatet ble {r['status_text']}."
|
||||||
|
else:
|
||||||
|
summary = f"Du var satt opp for {session_label} i {tournament_name}."
|
||||||
|
if gross is not None:
|
||||||
|
summary += f" Du hadde {gross} slag totalt."
|
||||||
|
|
||||||
|
email = r["email"].strip().lower()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
raw_token = secrets.token_urlsafe(32)
|
||||||
|
expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES)
|
||||||
|
# Samme "ugyldiggjør eldre uforbrukte lenker"-mønster som
|
||||||
|
# request_magic_link (routers/auth.py) -- begrenser vinduet en
|
||||||
|
# eldre, lekket lenke kan misbrukes i.
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL",
|
||||||
|
email,
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO magic_link_token (email, token_hash, expires_at, locale) VALUES ($1, $2, $3, 'nb')",
|
||||||
|
email,
|
||||||
|
_hash_secret(raw_token),
|
||||||
|
expires_at,
|
||||||
|
)
|
||||||
|
if settings.SMTP_CONFIGURED:
|
||||||
|
try:
|
||||||
|
await send_session_result_email(
|
||||||
|
email, r["display_name"], tournament_name, summary, raw_token, "nb"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Se app/email.py sitt mønster -- driftsfeil i
|
||||||
|
# utsendingen skal aldri hindre resten av utsendingen
|
||||||
|
# (token-raden består uansett, lenken virker fortsatt
|
||||||
|
# hvis spilleren ber om en ny normalt).
|
||||||
|
traceback.print_exc()
|
||||||
|
elif settings.DEV_LOG_MAGIC_LINKS:
|
||||||
|
# Samme "[DEV] Magic link for ..."-linjeformat som
|
||||||
|
# request_magic_link (routers/auth.py) FØRST -- slik at
|
||||||
|
# eksisterende dev-/test-verktøy som harvester token derfra
|
||||||
|
# fungerer uendret her også -- pluss en egen, lesbar
|
||||||
|
# oppsummeringslinje.
|
||||||
|
print(f"[DEV] Magic link for {email} (nb): {raw_token}", flush=True)
|
||||||
|
print(f"[DEV] Scorekort-invitasjon til {email}: {summary}", flush=True)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE match_participant SET invitation_sent_at = now() WHERE id = $1",
|
||||||
|
r["participant_id"],
|
||||||
|
)
|
||||||
|
sent += 1
|
||||||
|
|
||||||
|
return SendInvitationsOut(
|
||||||
|
sent=sent,
|
||||||
|
skipped_has_account=skipped_has_account,
|
||||||
|
skipped_no_email=skipped_no_email,
|
||||||
|
skipped_already_sent=skipped_already_sent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden.
|
# Sponsorer (ADR-018) -- navn+lenke+logo, alle aktive fra MinIO-runden.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -1221,3 +1441,155 @@ async def get_leaderboard(
|
||||||
) -> Leaderboard:
|
) -> Leaderboard:
|
||||||
async with org_connection(organization_id) as conn:
|
async with org_connection(organization_id) as conn:
|
||||||
return await fetch_leaderboard(conn, tournament_id)
|
return await fetch_leaderboard(conn, tournament_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Individuell rangering PER ØKT (FEATURE_BACKLOG.md "Leaderboard for runder
|
||||||
|
# og turneringer", 2026-07-25/28 oppfølging) -- ved siden av, ikke i stedet
|
||||||
|
# for, lag-poeng-leaderboardet over. Bevisst AVGRENSET til én økt (ikke
|
||||||
|
# summert på tvers av turneringen, se avklaring i CLAUDE.md) -- og KUN
|
||||||
|
# meningsfullt for scoring_mode='stroke'-økter i individuell-ball-format
|
||||||
|
# (singles/fourball, se scoring.py sin _INDIVIDUAL_FORMATS) siden delt-ball-
|
||||||
|
# formater og hole_result-modus ikke har noen individuell brutto-score.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class IndividualLeaderboardEntry(BaseModel):
|
||||||
|
participant_id: str
|
||||||
|
display_name: str
|
||||||
|
team_id: str
|
||||||
|
team_name: str
|
||||||
|
team_color: str | None
|
||||||
|
thru: int
|
||||||
|
gross_score: int | None
|
||||||
|
gross_to_par: int | None
|
||||||
|
net_score: int | None
|
||||||
|
net_to_par: int | None
|
||||||
|
total_points: int | None
|
||||||
|
|
||||||
|
|
||||||
|
class IndividualLeaderboard(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
format: str
|
||||||
|
entries: list[IndividualLeaderboardEntry]
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_individual_leaderboard(
|
||||||
|
conn, organization_id: str, session_id: str, viewer_user_id: str | None
|
||||||
|
) -> IndividualLeaderboard:
|
||||||
|
session = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT tournament_id::text AS tournament_id, format, scoring_mode,
|
||||||
|
hole_config::text AS hole_config, course_id::text AS course_id
|
||||||
|
FROM session WHERE id = $1
|
||||||
|
""",
|
||||||
|
session_id,
|
||||||
|
)
|
||||||
|
if session is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
|
||||||
|
if session["scoring_mode"] != "stroke" or session["format"] not in ("singles", "fourball"):
|
||||||
|
raise app_error(
|
||||||
|
400,
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
"Individuell rangering er kun tilgjengelig for slagspill-økter i format singles eller fourball.",
|
||||||
|
)
|
||||||
|
|
||||||
|
hole_rows = await conn.fetch(
|
||||||
|
"SELECT hole_number, par, stroke_index FROM hole WHERE course_id = $1 ORDER BY hole_number",
|
||||||
|
session["course_id"],
|
||||||
|
)
|
||||||
|
if len(hole_rows) != 18:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Banen mangler registrerte hull.")
|
||||||
|
par_by_hole = {r["hole_number"]: r["par"] for r in hole_rows}
|
||||||
|
all_18_si = [r["stroke_index"] for r in hole_rows]
|
||||||
|
played = played_hole_numbers(session["hole_config"])
|
||||||
|
|
||||||
|
# Samme reveal-respekterende synlighet som fetch_matches (ADR-013/026):
|
||||||
|
# egen side FØR reveal, hele feltet ETTER.
|
||||||
|
locked = await locked_team_ids(conn, session_id)
|
||||||
|
revealed = len(locked) >= 2
|
||||||
|
own = (
|
||||||
|
set()
|
||||||
|
if revealed
|
||||||
|
else await own_team_ids(conn, organization_id, session["tournament_id"], viewer_user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
participant_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT mp.id::text AS participant_id, mp.playing_handicap,
|
||||||
|
tr.team_id::text AS team_id, t.name AS team_name, t.color AS team_color,
|
||||||
|
p.display_name
|
||||||
|
FROM match_participant mp
|
||||||
|
JOIN match m ON m.id = mp.match_id
|
||||||
|
JOIN team_roster tr ON tr.id = mp.team_roster_id
|
||||||
|
JOIN team t ON t.id = tr.team_id
|
||||||
|
JOIN player p ON p.id = tr.player_id
|
||||||
|
WHERE m.session_id = $1 AND ($2 OR tr.team_id::text = ANY($3::text[]))
|
||||||
|
ORDER BY p.display_name
|
||||||
|
""",
|
||||||
|
session_id,
|
||||||
|
revealed,
|
||||||
|
list(own),
|
||||||
|
)
|
||||||
|
|
||||||
|
entries: list[IndividualLeaderboardEntry] = []
|
||||||
|
for r in participant_rows:
|
||||||
|
score_rows = await conn.fetch(
|
||||||
|
"SELECT hole_number, gross_strokes FROM hole_score "
|
||||||
|
"WHERE match_participant_id = $1 ORDER BY hole_number",
|
||||||
|
r["participant_id"],
|
||||||
|
)
|
||||||
|
gross_score = gross_to_par = net_score = net_to_par = total_points = None
|
||||||
|
if score_rows:
|
||||||
|
gross_score = sum(s["gross_strokes"] for s in score_rows)
|
||||||
|
par_total = sum(par_by_hole[s["hole_number"]] for s in score_rows)
|
||||||
|
gross_to_par = gross_score - par_total
|
||||||
|
|
||||||
|
if r["playing_handicap"] is not None:
|
||||||
|
strokes_list = allocate_over_played_holes(r["playing_handicap"], all_18_si, played)
|
||||||
|
strokes_by_hole = dict(zip(played, strokes_list))
|
||||||
|
strokes_received_total = sum(strokes_by_hole[s["hole_number"]] for s in score_rows)
|
||||||
|
net_score = gross_score - strokes_received_total
|
||||||
|
net_to_par = gross_to_par - strokes_received_total
|
||||||
|
# Samme stableford-formel som round-leaderboardets
|
||||||
|
# total_points (rounds.py) -- 2 poeng for netto par, +/-1
|
||||||
|
# per slag, gulvet på 0.
|
||||||
|
total_points = sum(
|
||||||
|
max(
|
||||||
|
0,
|
||||||
|
par_by_hole[s["hole_number"]]
|
||||||
|
- (s["gross_strokes"] - strokes_by_hole[s["hole_number"]])
|
||||||
|
+ 2,
|
||||||
|
)
|
||||||
|
for s in score_rows
|
||||||
|
)
|
||||||
|
|
||||||
|
entries.append(
|
||||||
|
IndividualLeaderboardEntry(
|
||||||
|
participant_id=r["participant_id"],
|
||||||
|
display_name=r["display_name"],
|
||||||
|
team_id=r["team_id"],
|
||||||
|
team_name=r["team_name"],
|
||||||
|
team_color=r["team_color"],
|
||||||
|
thru=len(score_rows),
|
||||||
|
gross_score=gross_score,
|
||||||
|
gross_to_par=gross_to_par,
|
||||||
|
net_score=net_score,
|
||||||
|
net_to_par=net_to_par,
|
||||||
|
total_points=total_points,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return IndividualLeaderboard(session_id=session_id, format=session["format"], entries=entries)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/orgs/{organization_id}/sessions/{session_id}/individual-leaderboard",
|
||||||
|
response_model=IndividualLeaderboard,
|
||||||
|
)
|
||||||
|
async def get_individual_leaderboard(
|
||||||
|
session_id: str,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> IndividualLeaderboard:
|
||||||
|
async with org_connection(organization_id) as conn:
|
||||||
|
return await fetch_individual_leaderboard(conn, organization_id, session_id, user.user_id)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { SessionIndividualLeaderboard } from "@/components/session-individual-leaderboard"
|
||||||
|
|
||||||
|
export default async function SessionIndividualLeaderboardPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string; sessionId: string }>
|
||||||
|
searchParams: Promise<{ org?: string; name?: string }>
|
||||||
|
}) {
|
||||||
|
const { id, sessionId } = await params
|
||||||
|
const { org, name } = await searchParams
|
||||||
|
const tournamentName = name?.trim() || "Turnering"
|
||||||
|
|
||||||
|
// Samme grunn som de andre turnering-undersidene: API-et krever
|
||||||
|
// organization_id på alle økt-/scoring-endepunkter (RLS, se ADR-001/003).
|
||||||
|
if (!org) {
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SessionIndividualLeaderboard
|
||||||
|
organizationId={org}
|
||||||
|
tournamentId={id}
|
||||||
|
sessionId={sessionId}
|
||||||
|
tournamentName={tournamentName}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import type React from "react"
|
import type React from "react"
|
||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Clock, EyeOff, Lock, PartyPopper, Plus, Users, X } from "lucide-react"
|
import { ArrowLeft, Clock, EyeOff, Lock, Mail, PartyPopper, Plus, Trophy, Users, X } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
@ -337,6 +337,18 @@ export function SessionBlindDraw({
|
||||||
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
|
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(session.format === "singles" || session.format === "fourball") && session.scoring_mode === "stroke" && (
|
||||||
|
<Link
|
||||||
|
href={`/tournaments/${tournamentId}/sessions/${sessionId}/individual-leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||||
|
className="mb-4 flex w-fit items-center gap-1.5 rounded-xl border border-border bg-card px-3 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-accent/60"
|
||||||
|
>
|
||||||
|
<Trophy aria-hidden="true" className="size-4 text-primary" />
|
||||||
|
Individuell rangering
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{matches.length > 0 && <SendInvitationsButton organizationId={organizationId} sessionId={sessionId} />}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||||
{error}
|
{error}
|
||||||
|
|
@ -802,6 +814,72 @@ function RevealSide({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Midlertidige spillere: etter-runde-invitasjon (migrasjon 034) --------
|
||||||
|
// Eksplisitt organisator-knapp, økt-nivå (FEATURE_BACKLOG.md, avklart
|
||||||
|
// 2026-07-28) -- sender scorekort + innloggingslenke KUN til spillere uten
|
||||||
|
// egen konto ennå, KUN én gang per (økt, deltaker), håndtert av backend.
|
||||||
|
|
||||||
|
type SendInvitationsResult = {
|
||||||
|
sent: number
|
||||||
|
skipped_has_account: number
|
||||||
|
skipped_no_email: number
|
||||||
|
skipped_already_sent: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendInvitationsButton({ organizationId, sessionId }: { organizationId: string; sessionId: string }) {
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [result, setResult] = useState<SendInvitationsResult | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Send scorekort og innloggingslenke til alle spillere med registrert e-post i denne økten som ikke har konto ennå?",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
setSending(true)
|
||||||
|
setError(null)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/send-scorecard-invitations`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`send: ${res.status}`)
|
||||||
|
setResult(await res.json())
|
||||||
|
} catch {
|
||||||
|
setError("Klarte ikke å sende invitasjoner. Prøv igjen.")
|
||||||
|
} finally {
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex flex-col gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending}
|
||||||
|
className="h-10 w-fit rounded-xl font-semibold"
|
||||||
|
>
|
||||||
|
<Mail aria-hidden="true" className="size-4" />
|
||||||
|
{sending ? "Sender …" : "Send scorekort til alle med e-post"}
|
||||||
|
</Button>
|
||||||
|
{result && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{result.sent} {result.sent === 1 ? "invitasjon sendt" : "invitasjoner sendt"}
|
||||||
|
{result.skipped_already_sent > 0 && `, ${result.skipped_already_sent} allerede sendt tidligere`}
|
||||||
|
{result.skipped_has_account > 0 && `, ${result.skipped_has_account} har allerede konto`}
|
||||||
|
{result.skipped_no_email > 0 && `, ${result.skipped_no_email} mangler registrert e-post`}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Small helpers ---------------------------------------------------------
|
// --- Small helpers ---------------------------------------------------------
|
||||||
|
|
||||||
function MetaBadge({ children }: { children: React.ReactNode }) {
|
function MetaBadge({ children }: { children: React.ReactNode }) {
|
||||||
|
|
|
||||||
306
frontend/components/session-individual-leaderboard.tsx
Normal file
306
frontend/components/session-individual-leaderboard.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
// Individuell rangering PER ØKT i en org-turnering (FEATURE_BACKLOG.md
|
||||||
|
// "Leaderboard for runder og turneringer", avklart 2026-07-28 -- omfang
|
||||||
|
// bevisst AVGRENSET til én økt, ikke summert på tvers av turneringen, se
|
||||||
|
// CLAUDE.md for begrunnelse). Ved siden av, ikke i stedet for, det
|
||||||
|
// eksisterende lag-poeng-leaderboardet (tournament-leaderboard.tsx) -- KUN
|
||||||
|
// meningsfullt for scoring_mode='stroke'-økter i format singles/fourball,
|
||||||
|
// backend avviser (400) ellers.
|
||||||
|
//
|
||||||
|
// Gjenbruker samme rangering/mode-toggle/verdi-merke-mønster som
|
||||||
|
// round-leaderboard.tsx (brutto/netto/poeng, "form + farge"-språket, delt
|
||||||
|
// plassering "T-N") -- egen, enklere lokal versjon her siden denne
|
||||||
|
// leaderboardet ikke har noen hull-for-hull-data å utvide til (backend
|
||||||
|
// returnerer kun aggregerte tall per deltaker, ikke per hull).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { ArrowLeft, Trophy } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type ApiEntry = {
|
||||||
|
participant_id: string
|
||||||
|
display_name: string
|
||||||
|
team_id: string
|
||||||
|
team_name: string
|
||||||
|
team_color: string | null
|
||||||
|
thru: number
|
||||||
|
gross_score: number | null
|
||||||
|
gross_to_par: number | null
|
||||||
|
net_score: number | null
|
||||||
|
net_to_par: number | null
|
||||||
|
total_points: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type ApiLeaderboard = {
|
||||||
|
session_id: string
|
||||||
|
format: string
|
||||||
|
entries: ApiEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORMAT_LABELS: Record<string, string> = {
|
||||||
|
singles: "Singles",
|
||||||
|
fourball: "Fourball",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mode = "gross" | "net" | "points"
|
||||||
|
|
||||||
|
type RankedEntry = ApiEntry & { rank: number | null; tied: boolean }
|
||||||
|
|
||||||
|
function valueFor(entry: ApiEntry, mode: Mode): number | null {
|
||||||
|
if (mode === "gross") return entry.gross_to_par
|
||||||
|
if (mode === "net") return entry.net_to_par
|
||||||
|
return entry.total_points
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankEntries(entries: ApiEntry[], mode: Mode): RankedEntry[] {
|
||||||
|
const withValue = entries.filter((e) => valueFor(e, mode) !== null)
|
||||||
|
const withoutValue = entries.filter((e) => valueFor(e, mode) === null)
|
||||||
|
const direction = mode === "points" ? -1 : 1
|
||||||
|
withValue.sort((a, b) => direction * ((valueFor(a, mode) as number) - (valueFor(b, mode) as number)))
|
||||||
|
|
||||||
|
const ranked: RankedEntry[] = []
|
||||||
|
let rank = 0
|
||||||
|
let prevValue: number | null = null
|
||||||
|
for (let i = 0; i < withValue.length; i++) {
|
||||||
|
const e = withValue[i]
|
||||||
|
const v = valueFor(e, mode) as number
|
||||||
|
if (v !== prevValue) {
|
||||||
|
rank = i + 1
|
||||||
|
prevValue = v
|
||||||
|
}
|
||||||
|
const tied = withValue.filter((o) => valueFor(o, mode) === v).length > 1
|
||||||
|
ranked.push({ ...e, rank, tied })
|
||||||
|
}
|
||||||
|
for (const e of withoutValue) {
|
||||||
|
ranked.push({ ...e, rank: null, tied: false })
|
||||||
|
}
|
||||||
|
return ranked
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToPar(value: number): string {
|
||||||
|
if (value === 0) return "E"
|
||||||
|
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToParMark({ value, emphasize }: { value: number; emphasize: boolean }) {
|
||||||
|
if (value === 0) {
|
||||||
|
return <span className="text-lg font-extrabold tabular-nums text-foreground">E</span>
|
||||||
|
}
|
||||||
|
const under = value < 0
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 min-w-10 shrink-0 items-center justify-center px-2 text-base font-extrabold tabular-nums",
|
||||||
|
under ? "rounded-full" : "rounded-[6px]",
|
||||||
|
emphasize
|
||||||
|
? under
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-brand-orange text-brand-orange-foreground"
|
||||||
|
: under
|
||||||
|
? "border-2 border-primary bg-primary/10 text-primary"
|
||||||
|
: "border-2 border-brand-orange bg-brand-orange/10 text-brand-orange",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatToPar(value)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PointsMark({ value, emphasize }: { value: number; emphasize: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 min-w-10 shrink-0 items-center justify-center rounded-full px-2 text-base font-extrabold tabular-nums",
|
||||||
|
emphasize ? "bg-primary text-primary-foreground" : "border-2 border-primary bg-primary/10 text-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ValueMark({ mode, value, emphasize }: { mode: Mode; value: number; emphasize: boolean }) {
|
||||||
|
return mode === "points" ? <PointsMark value={value} emphasize={emphasize} /> : <ToParMark value={value} emphasize={emphasize} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function RankBadge({ rank, tied, isLeader }: { rank: number | null; tied: boolean; isLeader: boolean }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-10 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
|
||||||
|
isLeader ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{rank !== null ? (tied ? `T-${rank}` : `#${rank}`) : "–"}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeToggle({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
|
||||||
|
const options: { value: Mode; label: string }[] = [
|
||||||
|
{ value: "gross", label: "Brutto" },
|
||||||
|
{ value: "net", label: "Netto" },
|
||||||
|
{ value: "points", label: "Poeng" },
|
||||||
|
]
|
||||||
|
return (
|
||||||
|
<div role="tablist" aria-label="Brutto, netto eller poeng" className="inline-flex gap-1 rounded-full bg-muted p-1">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === opt.value}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"min-h-9 rounded-full px-3.5 text-sm font-bold transition-colors",
|
||||||
|
mode === opt.value ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionIndividualLeaderboard({
|
||||||
|
organizationId,
|
||||||
|
tournamentId,
|
||||||
|
sessionId,
|
||||||
|
tournamentName,
|
||||||
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
tournamentId: string
|
||||||
|
sessionId: string
|
||||||
|
tournamentName: string
|
||||||
|
}) {
|
||||||
|
const [data, setData] = useState<ApiLeaderboard | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [mode, setMode] = useState<Mode>("gross")
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/individual-leaderboard`, {
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (res.status === 400) {
|
||||||
|
if (!cancelled)
|
||||||
|
setError("Denne økten støtter ikke individuell rangering -- kun slagspill i format singles eller fourball.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (res.status === 404) {
|
||||||
|
if (!cancelled) setError("Økten finnes ikke.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
if (!cancelled) setError("Klarte ikke å hente rangeringen. Prøv igjen om litt.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parsed: ApiLeaderboard = await res.json()
|
||||||
|
if (!cancelled) setData(parsed)
|
||||||
|
}
|
||||||
|
void load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [organizationId, sessionId])
|
||||||
|
|
||||||
|
const backHref = `/tournaments/${tournamentId}/sessions/${sessionId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
||||||
|
<p className="text-base font-medium text-destructive">{error}</p>
|
||||||
|
<Link href={backHref} className="text-base font-semibold text-primary underline underline-offset-2">
|
||||||
|
Tilbake til økten
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
|
||||||
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranked = rankEntries(data.entries, mode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-[100dvh] bg-background">
|
||||||
|
<header className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex min-h-14 max-w-xl items-center gap-2 px-4 py-2">
|
||||||
|
<Link
|
||||||
|
href={backHref}
|
||||||
|
className="flex min-h-11 items-center gap-1.5 rounded-xl pr-3 text-base font-bold text-foreground transition-colors hover:text-primary"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" className="size-6" />
|
||||||
|
Individuell rangering
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto flex max-w-xl flex-col gap-4 px-4 py-5 pb-16">
|
||||||
|
<div className="flex flex-col gap-1 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
||||||
|
<span className="text-xl font-extrabold tracking-tight text-foreground text-balance">{tournamentName}</span>
|
||||||
|
<span className="text-sm font-semibold text-muted-foreground">{FORMAT_LABELS[data.format] ?? data.format}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.entries.length === 0 ? (
|
||||||
|
<p className="rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||||||
|
Ingen deltakere å rangere ennå -- enten er ingen matcher satt opp, eller oppstillingen er ikke avslørt.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Stilling</h2>
|
||||||
|
<ModeToggle mode={mode} onChange={setMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
{ranked.map((entry) => {
|
||||||
|
const value = valueFor(entry, mode)
|
||||||
|
const isLeader = entry.rank === 1
|
||||||
|
return (
|
||||||
|
<li key={entry.participant_id} className="flex items-center gap-3 p-3 sm:p-4">
|
||||||
|
<RankBadge rank={entry.rank} tied={entry.tied} isLeader={isLeader} />
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="size-2.5 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: entry.team_color ?? "var(--muted-foreground)" }}
|
||||||
|
/>
|
||||||
|
<span className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span className="truncate text-base font-bold text-foreground">{entry.display_name}</span>
|
||||||
|
<span className="truncate text-xs font-semibold text-muted-foreground">
|
||||||
|
{entry.team_name} · {entry.thru > 0 ? `${entry.thru} hull spilt` : "Ikke startet"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{isLeader && entry.rank === 1 && <Trophy aria-hidden="true" className="size-4 shrink-0 text-primary" />}
|
||||||
|
{value !== null ? (
|
||||||
|
<ValueMark mode={mode} value={value} emphasize={isLeader} />
|
||||||
|
) : (
|
||||||
|
<span className="shrink-0 text-sm font-semibold text-muted-foreground">–</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{mode !== "gross" && ranked.some((e) => e.rank === null) && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Spillere uten {mode === "net" ? "netto-tall" : "poengsum"} (ingen handicap beregnet ennå) vises uten
|
||||||
|
rangering.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowLeftRight,
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
Flag,
|
Flag,
|
||||||
|
|
@ -262,6 +263,37 @@ export function TournamentDetail({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function movePlayer(teamId: string, rosterId: string, targetTeamId: string) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}/move`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ target_team_id: targetTeamId }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
throw new Error(body?.detail?.message ?? `move roster: ${res.status}`)
|
||||||
|
}
|
||||||
|
const moved: ApiRosterEntry = await res.json()
|
||||||
|
setTeams(
|
||||||
|
(prev) =>
|
||||||
|
prev.map((t) => {
|
||||||
|
if (!t) return t
|
||||||
|
if (t.id === teamId) return { ...t, roster: t.roster.filter((r) => r.id !== rosterId) }
|
||||||
|
if (t.id === targetTeamId) return { ...t, roster: [...t.roster, moved] }
|
||||||
|
return t
|
||||||
|
}) as [Team | null, Team | null],
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Klarte ikke å flytte spilleren. Prøv igjen.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateStatus(newStatus: TournamentStatus) {
|
async function updateStatus(newStatus: TournamentStatus) {
|
||||||
setError(null)
|
setError(null)
|
||||||
const previous = status
|
const previous = status
|
||||||
|
|
@ -398,6 +430,7 @@ export function TournamentDetail({
|
||||||
findPlayerTeam={findPlayerTeam}
|
findPlayerTeam={findPlayerTeam}
|
||||||
onToggleCaptain={toggleCaptain}
|
onToggleCaptain={toggleCaptain}
|
||||||
onRemovePlayer={removePlayer}
|
onRemovePlayer={removePlayer}
|
||||||
|
onMovePlayer={movePlayer}
|
||||||
onAddExisting={addExistingPlayer}
|
onAddExisting={addExistingPlayer}
|
||||||
onAddNew={addNewPlayer}
|
onAddNew={addNewPlayer}
|
||||||
onUpdatePlayer={updatePlayer}
|
onUpdatePlayer={updatePlayer}
|
||||||
|
|
@ -548,6 +581,7 @@ function TeamPanel({
|
||||||
findPlayerTeam,
|
findPlayerTeam,
|
||||||
onToggleCaptain,
|
onToggleCaptain,
|
||||||
onRemovePlayer,
|
onRemovePlayer,
|
||||||
|
onMovePlayer,
|
||||||
onAddExisting,
|
onAddExisting,
|
||||||
onAddNew,
|
onAddNew,
|
||||||
onUpdatePlayer,
|
onUpdatePlayer,
|
||||||
|
|
@ -560,6 +594,7 @@ function TeamPanel({
|
||||||
findPlayerTeam: (playerId: string) => Team | null
|
findPlayerTeam: (playerId: string) => Team | null
|
||||||
onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void
|
onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void
|
||||||
onRemovePlayer: (teamId: string, rosterId: string) => void
|
onRemovePlayer: (teamId: string, rosterId: string) => void
|
||||||
|
onMovePlayer: (teamId: string, rosterId: string, targetTeamId: string) => void
|
||||||
onAddExisting: (teamId: string, playerId: string) => void
|
onAddExisting: (teamId: string, playerId: string) => void
|
||||||
onAddNew: (teamId: string, name: string, handicap?: number) => void
|
onAddNew: (teamId: string, name: string, handicap?: number) => void
|
||||||
onUpdatePlayer: (
|
onUpdatePlayer: (
|
||||||
|
|
@ -719,6 +754,15 @@ function TeamPanel({
|
||||||
<Star aria-hidden="true" className="size-4" />
|
<Star aria-hidden="true" className="size-4" />
|
||||||
{entry.is_captain ? "Fjern som kaptein" : "Gjør til kaptein"}
|
{entry.is_captain ? "Fjern som kaptein" : "Gjør til kaptein"}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
{otherTeam && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onMovePlayer(team.id, entry.id, otherTeam.id)}
|
||||||
|
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
<ArrowLeftRight aria-hidden="true" className="size-4" />
|
||||||
|
Flytt til {otherTeam.name}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setConfirmingId(entry.id)}
|
onClick={() => setConfirmingId(entry.id)}
|
||||||
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive"
|
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue