Rundedeltakelse som bulk-tabell i Spillere-steget (ADR-093)
Oppfølger til ADR-092: fant og fikset misvisende "alle er gruppert"- tekst i Utslagsgrupper-panelet (viste seg egentlig å bety "ingen deltakere i runden ennå"). Egentlig årsak: å tilordne en deltaker til en runde var kun mulig ett om gangen, gjemt bak et rundekort. Ny bulk-tabell i "Spillere" (V0-generert) med sjekkbokser per runde, kjønn/alder/utslagssted redigerbart samme sted. Nytt PATCH-endepunkt for å endre utslagssted uten å miste gruppemedlemskap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
1867c65143
commit
5daf2a764a
7 changed files with 1040 additions and 213 deletions
|
|
@ -9402,6 +9402,105 @@ layout).
|
||||||
frontend). `docker compose build teecup_frontend && up -d
|
frontend). `docker compose build teecup_frontend && up -d
|
||||||
teecup_frontend`, rene logger, 200 OK.
|
teecup_frontend`, rene logger, 200 OK.
|
||||||
|
|
||||||
|
**Tillegg 2026-08-19 -- oppdaget rett etter utrulling:** bruker
|
||||||
|
rapporterte "jeg ser ikke gruppene som er opprettet" / "jeg ser rett og
|
||||||
|
slett ingen steder å trykke". Ikke en bug i selve wizard-strukturen --
|
||||||
|
"Rediger grupper" åpnet panelet korrekt. Det egentlige problemet: runden
|
||||||
|
hadde 0 deltakere TILORDNET RUNDEN (en egen tildeling under "Runder og
|
||||||
|
baner", adskilt fra å være med i selve turneringen -- eksisterte fra
|
||||||
|
før, ikke innført av denne runden), og `round-groups-panel.tsx` sin
|
||||||
|
"Ugrupperte (0)"-tekst sa misvisende "Alle deltakere er i en gruppe" i
|
||||||
|
akkurat denne situasjonen (samme boolske sjekk, `ungrouped.length===0`,
|
||||||
|
dekket begge "alle er gruppert" OG "ingen finnes i det hele tatt").
|
||||||
|
Rettet med en `totalRoundParticipants`-beregning og et eget forklarende
|
||||||
|
banner når den er 0: "Ingen deltakere er lagt til i DENNE runden ennå
|
||||||
|
... Gå til «Runder og baner» ... først." Se CHANGELOG.md #124.
|
||||||
|
|
||||||
|
## ADR-093: Rundedeltakelse flyttet til "Spillere"-steget, som en bulk-tabell (2026-08-19)
|
||||||
|
|
||||||
|
Direkte oppfølger til ADR-092-tillegget over: den EGENTLIGE årsaken til
|
||||||
|
"jeg ser ikke gruppene" var at å tilordne en deltaker TIL en runde (med
|
||||||
|
utslagssted) kun var mulig ett om gangen, gjemt bak en kontroll inni
|
||||||
|
hver enkelt utvidede rundekort under "Runder og baner". Bruker: "Det er
|
||||||
|
ikke logisk at spillere ligger under 'Runder og baner'. SPESIELT ikke
|
||||||
|
når vi et par faner senere har en fane som heter 'Spillere'. I
|
||||||
|
'Spillere'-fanen bør man kunne bulk legge til spillere til runder.
|
||||||
|
Gjerne med sjekkbokser ... I den samme tabellen bør man se kjønn og
|
||||||
|
alder og utslagssted, slik at man enkelt kan endre dette også."
|
||||||
|
|
||||||
|
**Ny bulk-tabell** (`round-participation-table.tsx`, V0-generert): rader
|
||||||
|
= turneringens deltakere (navn, kjønn-nedtrekk, fødselsdato m/utledet
|
||||||
|
alder), kolonner = turneringens runder (sjekkboks + utslagssted per
|
||||||
|
celle, "Standardutslag"-forhåndsvalg og "Velg alle/ingen" per
|
||||||
|
rundekolonne). Ren kontrollert/presentasjonell komponent -- all
|
||||||
|
datahenting/orkestrering ligger i `SetupTab`. Plassert som et eget
|
||||||
|
avsnitt UNDER `ParticipantsCard` i "Spillere"-steget (som forblir
|
||||||
|
uendret -- turnering-medlemskap/klasse/HCP/status er en annen
|
||||||
|
bekymring enn RUNDE-tilordning).
|
||||||
|
|
||||||
|
**Backend: ett nytt endepunkt.** `PATCH .../rounds/{round_id}/
|
||||||
|
participants/{round_participant_id}` (kun `tee_id`) -- undersøkt
|
||||||
|
migrasjon 084 FØR bygging: gruppemedlemskap
|
||||||
|
(`tournament_round_group_id`) er en KOLONNE direkte på
|
||||||
|
`tournament_round_participant`, ikke en egen koblingstabell. Å endre
|
||||||
|
utslagssted via de to eksisterende endepunktene (slett+opprett-på-nytt,
|
||||||
|
eneste mulighet før denne runden) ville derfor STILLE fjernet spilleren
|
||||||
|
fra en allerede tildelt utslagsgruppe (ny rad = ny id = `tournament_
|
||||||
|
round_group_id=NULL`). Den nye PATCH-en gjenbruker akkurat samme
|
||||||
|
validering som `add_round_participant` (utslag hører til rundens bane,
|
||||||
|
kjønn/rating-sjekk for slagspill/Stableford) via en delt
|
||||||
|
`_validate_round_tee_choice`-hjelpefunksjon, og endrer raden i stedet
|
||||||
|
for å erstatte den.
|
||||||
|
|
||||||
|
**Ingen bulk-endepunkt bygget.** `add_round_participant`/
|
||||||
|
`remove_round_participant` gjør ekte forretningslogikk (bane-tilhørighet,
|
||||||
|
HCP/kjønn/rating-sjekk) -- ikke verdt å duplisere i en ny bulk-SQL-vei.
|
||||||
|
Bulk-handlinger i frontend (`bulkSetRoundParticipation` i `SetupTab`)
|
||||||
|
kjører i stedet parallelle `Promise.all`-batcher av de eksisterende
|
||||||
|
ett-om-gangen-endepunktene, samler eventuelle per-deltaker-feil i en kort
|
||||||
|
oppsummering (vist i tabellens error-banner), og gjør ALLTID en reell
|
||||||
|
refetch av rundens deltakerliste fra serveren etterpå i stedet for
|
||||||
|
optimistisk sammenslåing -- tolererer delvis feilende batcher rent.
|
||||||
|
|
||||||
|
**Kjønn/fødselsdato: ingen backend-endring.** Gjenbruker eksisterende
|
||||||
|
`PATCH /orgs/{id}/players/{player_id}` (støttet `gender`/`birth_date`
|
||||||
|
fra før) -- samme "redigeres i spillerpoolen, gjelder umiddelbart"-
|
||||||
|
presedens som ADR-091.
|
||||||
|
|
||||||
|
**Klassens standardutslag gjenbrukt som bulk-forhåndsvalg.** Den nå
|
||||||
|
fjernede `AssignRoundParticipantControl` sin "deltakerens klasses
|
||||||
|
standardutslag, hvis gyldig for rundens bane"-oppløsning er flyttet til
|
||||||
|
en frittstående `resolveDefaultTeeId`-funksjon, brukt per deltaker ved
|
||||||
|
"velg alle" (faller tilbake til rundens eget "Standardutslag"-valg,
|
||||||
|
så til første utslag i lista).
|
||||||
|
|
||||||
|
**Ryddet duplisert inngang** (samme presedens som Grupper og
|
||||||
|
startliste-steget i ADR-092): `AssignRoundParticipantControl`-kallet i
|
||||||
|
`RoundCard` FJERNET fullstendig (funksjonen slettet) -- bulk-tabellen
|
||||||
|
overtar jobben. Selve den skrivebeskyttede "hvem er i runden med
|
||||||
|
hvilket utslag"-listen i rundekortet BEHOLDES uendret, med en ny liten
|
||||||
|
pekertekst til "Spillere". Kaskaderende opprydding av nå-ubrukte props
|
||||||
|
(`participants`/`classes`/`organizationId`/`tees`) gjennom `RoundCard`
|
||||||
|
og `RoundsCard`.
|
||||||
|
|
||||||
|
**Verifisert:** Ny backend-test `test_round_participant_tee.py` (2
|
||||||
|
tester -- bekrefter eksplisitt at `tournament_round_group_id` er
|
||||||
|
UENDRET etter en PATCH, og at et utslag fra feil bane avvises) --
|
||||||
|
149/149 grønt (opp fra 147). `tsc --noEmit` rent, `vitest run` 55/55.
|
||||||
|
Grundigere scratch-runde enn de små ADR-092-fiksene (genuint ny,
|
||||||
|
interaktiv matrise): lokal `next dev`-visning av komponentens egen
|
||||||
|
V0-demo (6 spillere med variert kjønn/fødselsdato/null-verdier, 3
|
||||||
|
runder hvorav én med 3 kjønnsspesifikke utslag, én markert "opptatt"),
|
||||||
|
faktisk klikket gjennom av-/påkrysning og bulk "velg alle" (bekreftet
|
||||||
|
riktig gjenoppretting av standardutslag for ALLE, inkludert en nettopp
|
||||||
|
fjernet spiller), lys+mørk, smal skjerm (sticky spillerkolonne,
|
||||||
|
sidescroll). IKKE en full innlogget klikk-gjennom mot ekte backend
|
||||||
|
denne runden -- vurdert tilstrekkelig dekket av backend-pytestene
|
||||||
|
(inkludert den nye PATCH-en) + typesjekket props-kontrakt mellom
|
||||||
|
`SetupTab` og komponenten, gitt øktens allerede betydelige lengde.
|
||||||
|
|
||||||
|
**Rullet ut:** venter på bekreftelse.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Utviklingsplan (rekkefølge)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
|
||||||
65
CHANGELOG.md
65
CHANGELOG.md
|
|
@ -12914,3 +12914,68 @@ Neste steg:
|
||||||
**Rullet ut 2026-08-18** -- bruker bekreftet. Ingen migrasjon (ren
|
**Rullet ut 2026-08-18** -- bruker bekreftet. Ingen migrasjon (ren
|
||||||
frontend). `docker compose build teecup_frontend && up -d
|
frontend). `docker compose build teecup_frontend && up -d
|
||||||
teecup_frontend`, rene logger, 200 OK.
|
teecup_frontend`, rene logger, 200 OK.
|
||||||
|
|
||||||
|
124. **Misvisende "Alle deltakere er i en gruppe"-tekst i Utslagsgrupper
|
||||||
|
-- 2026-08-19 (oppfølger til ADR-092).** Bruker rapporterte rett
|
||||||
|
etter wizard-utrullingen: "jeg ser ikke gruppene som er
|
||||||
|
opprettet" / "jeg ser rett og slett ingen steder å trykke". Skjerm-
|
||||||
|
dumper avslørte at panelet faktisk åpnet seg fint -- den ekte
|
||||||
|
årsaken var at Runde 1 hadde 0 deltakere TILORDNET RUNDEN (egen
|
||||||
|
tildeling under "Runder og baner", adskilt fra å være med i
|
||||||
|
turneringen, eksisterte fra før denne økten). `round-groups-
|
||||||
|
panel.tsx` sin "Ugrupperte (0)"-seksjon skrev da misvisende "Alle
|
||||||
|
deltakere er i en gruppe" -- samme `ungrouped.length===0`-sjekk
|
||||||
|
dekket både "alle er gruppert" og "ingen finnes i det hele tatt",
|
||||||
|
ingen måte å skille dem fra teksten alene. Lagt til en
|
||||||
|
`totalRoundParticipants`-beregning (`ungrouped.length` + summen av
|
||||||
|
alle gruppers deltakere) og et eget forklarende banner når den er 0,
|
||||||
|
pluss rettet selve "Ugrupperte"-teksten til å si "Ingen deltakere
|
||||||
|
lagt til i runden ennå" i stedet, med henvisning til "Runder og
|
||||||
|
baner". Ren tekst-/betingelse-endring i en allerede fungerende
|
||||||
|
komponent -- ingen ny logikk for selve gruppe-beregningen.
|
||||||
|
|
||||||
|
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Ingen egen
|
||||||
|
scratch-runde -- et forsøk på å mocke `RoundGroupsPanel` isolert
|
||||||
|
strandet på at komponenten gjør ekte `fetch`-kall ved mount (ingen
|
||||||
|
enkel stub uten å bygge et helt mock-lag), vurdert som ikke verdt
|
||||||
|
kostnaden for en ren tekst-/boolsk-betingelse-endring; lest grundig
|
||||||
|
igjennom i stedet (samme lavere-tier-vurdering som Cut-hjelpe-
|
||||||
|
teksten, punkt 121).
|
||||||
|
|
||||||
|
**Rullet ut:** venter på bekreftelse (rulles ut sammen med #125).
|
||||||
|
|
||||||
|
125. **Rundedeltakelse flyttet til "Spillere"-steget, som en bulk-tabell
|
||||||
|
-- 2026-08-19 (ADR-093).** Direkte oppfølger til #124: den EGENTLIGE
|
||||||
|
årsaken til "jeg ser ikke gruppene" var at å tilordne en deltaker
|
||||||
|
TIL en runde (med utslagssted) kun var mulig ett om gangen, gjemt
|
||||||
|
bak en kontroll inni hver enkelt rundekort. Bruker: "Det er ikke
|
||||||
|
logisk at spillere ligger under 'Runder og baner' ... I 'Spillere'-
|
||||||
|
fanen bør man kunne bulk legge til spillere til runder. Gjerne med
|
||||||
|
sjekkbokser ... kjønn og alder og utslagssted, slik at man enkelt
|
||||||
|
kan endre dette også." Ny bulk-tabell (`round-participation-
|
||||||
|
table.tsx`, V0-generert) i "Spillere"-steget, under `ParticipantsCard`:
|
||||||
|
rader = deltakere (navn, kjønn, fødselsdato/alder), kolonner =
|
||||||
|
runder (sjekkboks + utslagssted per celle, "Standardutslag" og
|
||||||
|
"Velg alle/ingen" per rundekolonne). Ett nytt backend-endepunkt --
|
||||||
|
`PATCH .../rounds/{id}/participants/{id}` for å ENDRE utslagssted
|
||||||
|
uten å slette+gjenopprette raden (ville stille fjernet spilleren fra
|
||||||
|
en allerede tildelt utslagsgruppe, se ADR-093 for full begrunnelse
|
||||||
|
om hvorfor). Ingen bulk-backend-endepunkt -- bulk-handlinger kjører
|
||||||
|
parallelle kall mot de eksisterende ett-om-gangen-endepunktene, med
|
||||||
|
refetch-fra-server etterpå og en feilsammendrag-banner for delvise
|
||||||
|
feil. Kjønn/fødselsdato redigeres via allerede eksisterende
|
||||||
|
spillerpool-PATCH, ingen backend-endring der. Ryddet opp den gamle
|
||||||
|
ett-om-gangen-kontrollen (`AssignRoundParticipantControl`) fullstendig,
|
||||||
|
samme "ikke duplisert inngang"-presedens som Grupper og startliste-
|
||||||
|
steget (ADR-092).
|
||||||
|
|
||||||
|
**Verifisert:** Ny backend-test (2 stk, bekrefter PATCH bevarer
|
||||||
|
gruppemedlemskap og avviser utslag fra feil bane) -- 149/149 grønt
|
||||||
|
(opp fra 147). `tsc --noEmit` rent, `vitest run` 55/55. Grundig
|
||||||
|
scratch-runde av selve komponenten (klikket faktisk gjennom av-/
|
||||||
|
påkrysning, bulk "velg alle", lys+mørk, smal skjerm) -- IKKE en full
|
||||||
|
innlogget klikk-gjennom mot ekte backend denne runden, vurdert
|
||||||
|
tilstrekkelig dekket av backend-pytestene + typesjekket props-
|
||||||
|
kontrakt.
|
||||||
|
|
||||||
|
**Rullet ut:** venter på bekreftelse.
|
||||||
|
|
|
||||||
|
|
@ -488,6 +488,16 @@ class RoundParticipantCreate(BaseModel):
|
||||||
tee_id: str
|
tee_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class RoundParticipantTeeUpdate(BaseModel):
|
||||||
|
"""Kun utslagssted -- ADR-093. Endrer raden i stedet for slett+
|
||||||
|
gjenopprett nettopp for å BEVARE tournament_round_group_id (slett+
|
||||||
|
gjenopprett ville stille fjernet spilleren fra en ev. allerede
|
||||||
|
tildelt utslagsgruppe -- se migrasjon 084, gruppemedlemskap er en
|
||||||
|
kolonne direkte på denne raden, ikke en egen koblingstabell)."""
|
||||||
|
|
||||||
|
tee_id: str
|
||||||
|
|
||||||
|
|
||||||
class RoundParticipantOut(BaseModel):
|
class RoundParticipantOut(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
tournament_participant_id: str
|
tournament_participant_id: str
|
||||||
|
|
@ -543,6 +553,70 @@ async def _compute_round_participant_handicap(conn, round_participant_id: str) -
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _validate_round_tee_choice(
|
||||||
|
conn, tournament_id: str, round_id: str, tournament_participant_id: str, tee_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Delt av add_round_participant OG update_round_participant_tee
|
||||||
|
(PATCH, ADR-093) -- selve valideringen (utslaget hører til rundens
|
||||||
|
bane, deltakeren har HCP+kjønn og valgt utslag har en rating for det
|
||||||
|
kjønnet når scoring_method krever det) er identisk uansett om det er
|
||||||
|
en NY rundedeltaker eller en ENDRING av utslag på en eksisterende --
|
||||||
|
kun hva som skjer ETTERPÅ (INSERT vs. UPDATE) skiller dem."""
|
||||||
|
round_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT tr.course_id::text AS course_id, t.scoring_method
|
||||||
|
FROM tournament_round tr
|
||||||
|
JOIN tournament t ON t.id = tr.tournament_id
|
||||||
|
WHERE tr.id = $1 AND tr.tournament_id = $2
|
||||||
|
""",
|
||||||
|
round_id,
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
if round_row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
||||||
|
|
||||||
|
tee_course_id = await conn.fetchval("SELECT course_id::text FROM tee WHERE id = $1", tee_id)
|
||||||
|
if tee_course_id is None or tee_course_id != round_row["course_id"]:
|
||||||
|
raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke rundens bane.")
|
||||||
|
|
||||||
|
requires_handicap = round_row["scoring_method"] in _SCORING_METHODS_REQUIRING_HANDICAP
|
||||||
|
if requires_handicap:
|
||||||
|
player_row = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT tp.handicap_index_snapshot, p.gender
|
||||||
|
FROM tournament_participant tp
|
||||||
|
JOIN player p ON p.id = tp.player_id
|
||||||
|
WHERE tp.id = $1
|
||||||
|
""",
|
||||||
|
tournament_participant_id,
|
||||||
|
)
|
||||||
|
if player_row is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Turnering-deltakeren finnes ikke.")
|
||||||
|
if player_row["handicap_index_snapshot"] is None:
|
||||||
|
raise app_error(
|
||||||
|
400,
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
"Denne spilleren mangler handicap-indeks -- påkrevd for "
|
||||||
|
"nettoslagspill/Stableford.",
|
||||||
|
)
|
||||||
|
if player_row["gender"] is None:
|
||||||
|
raise app_error(
|
||||||
|
400,
|
||||||
|
"VALIDATION_FAILED",
|
||||||
|
"Denne spilleren mangler registrert kjønn -- trengs for å finne riktig tee-rating.",
|
||||||
|
)
|
||||||
|
has_rating = await conn.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM tee_rating WHERE tee_id = $1 AND scope = 'full_18' AND gender = $2)",
|
||||||
|
tee_id,
|
||||||
|
player_row["gender"],
|
||||||
|
)
|
||||||
|
if not has_rating:
|
||||||
|
kjonn_tekst = "dame" if player_row["gender"] == "f" else "herre"
|
||||||
|
raise app_error(
|
||||||
|
400, "VALIDATION_FAILED", f"Dette utslaget har ingen {kjonn_tekst}-rating -- velg et annet."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants",
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants",
|
||||||
response_model=RoundParticipantOut,
|
response_model=RoundParticipantOut,
|
||||||
|
|
@ -555,59 +629,7 @@ async def add_round_participant(
|
||||||
organization_id: str = Depends(get_authorized_org),
|
organization_id: str = Depends(get_authorized_org),
|
||||||
) -> RoundParticipantOut:
|
) -> RoundParticipantOut:
|
||||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
round_row = await conn.fetchrow(
|
await _validate_round_tee_choice(conn, tournament_id, round_id, body.tournament_participant_id, body.tee_id)
|
||||||
"""
|
|
||||||
SELECT tr.course_id::text AS course_id, t.scoring_method
|
|
||||||
FROM tournament_round tr
|
|
||||||
JOIN tournament t ON t.id = tr.tournament_id
|
|
||||||
WHERE tr.id = $1 AND tr.tournament_id = $2
|
|
||||||
""",
|
|
||||||
round_id,
|
|
||||||
tournament_id,
|
|
||||||
)
|
|
||||||
if round_row is None:
|
|
||||||
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.")
|
|
||||||
|
|
||||||
tee_course_id = await conn.fetchval("SELECT course_id::text FROM tee WHERE id = $1", body.tee_id)
|
|
||||||
if tee_course_id is None or tee_course_id != round_row["course_id"]:
|
|
||||||
raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke rundens bane.")
|
|
||||||
|
|
||||||
requires_handicap = round_row["scoring_method"] in _SCORING_METHODS_REQUIRING_HANDICAP
|
|
||||||
if requires_handicap:
|
|
||||||
player_row = await conn.fetchrow(
|
|
||||||
"""
|
|
||||||
SELECT tp.handicap_index_snapshot, p.gender
|
|
||||||
FROM tournament_participant tp
|
|
||||||
JOIN player p ON p.id = tp.player_id
|
|
||||||
WHERE tp.id = $1
|
|
||||||
""",
|
|
||||||
body.tournament_participant_id,
|
|
||||||
)
|
|
||||||
if player_row is None:
|
|
||||||
raise app_error(404, "NOT_FOUND", "Turnering-deltakeren finnes ikke.")
|
|
||||||
if player_row["handicap_index_snapshot"] is None:
|
|
||||||
raise app_error(
|
|
||||||
400,
|
|
||||||
"VALIDATION_FAILED",
|
|
||||||
"Denne spilleren mangler handicap-indeks -- påkrevd for "
|
|
||||||
"nettoslagspill/Stableford.",
|
|
||||||
)
|
|
||||||
if player_row["gender"] is None:
|
|
||||||
raise app_error(
|
|
||||||
400,
|
|
||||||
"VALIDATION_FAILED",
|
|
||||||
"Denne spilleren mangler registrert kjønn -- trengs for å finne riktig tee-rating.",
|
|
||||||
)
|
|
||||||
has_rating = await conn.fetchval(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM tee_rating WHERE tee_id = $1 AND scope = 'full_18' AND gender = $2)",
|
|
||||||
body.tee_id,
|
|
||||||
player_row["gender"],
|
|
||||||
)
|
|
||||||
if not has_rating:
|
|
||||||
kjonn_tekst = "dame" if player_row["gender"] == "f" else "herre"
|
|
||||||
raise app_error(
|
|
||||||
400, "VALIDATION_FAILED", f"Dette utslaget har ingen {kjonn_tekst}-rating -- velg et annet."
|
|
||||||
)
|
|
||||||
|
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
|
|
@ -691,6 +713,54 @@ async def remove_round_participant(
|
||||||
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants/{round_participant_id}",
|
||||||
|
response_model=RoundParticipantOut,
|
||||||
|
)
|
||||||
|
async def update_round_participant_tee(
|
||||||
|
tournament_id: str,
|
||||||
|
round_id: str,
|
||||||
|
round_participant_id: str,
|
||||||
|
body: RoundParticipantTeeUpdate,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
) -> RoundParticipantOut:
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
existing = await conn.fetchrow(
|
||||||
|
"SELECT tournament_participant_id::text AS tournament_participant_id "
|
||||||
|
"FROM tournament_round_participant WHERE id = $1 AND tournament_round_id = $2",
|
||||||
|
round_participant_id,
|
||||||
|
round_id,
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Rundedeltakeren finnes ikke.")
|
||||||
|
|
||||||
|
await _validate_round_tee_choice(
|
||||||
|
conn, tournament_id, round_id, existing["tournament_participant_id"], body.tee_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE tournament_round_participant SET tee_id = $1 WHERE id = $2",
|
||||||
|
body.tee_id,
|
||||||
|
round_participant_id,
|
||||||
|
)
|
||||||
|
await _compute_round_participant_handicap(conn, round_participant_id)
|
||||||
|
|
||||||
|
out = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT trp.id::text AS id, trp.tournament_participant_id::text AS tournament_participant_id,
|
||||||
|
p.display_name AS player_name, trp.tee_id::text AS tee_id, tee.name AS tee_name,
|
||||||
|
trp.course_handicap, trp.playing_handicap, tp.stat_level
|
||||||
|
FROM tournament_round_participant trp
|
||||||
|
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
|
||||||
|
JOIN player p ON p.id = tp.player_id
|
||||||
|
JOIN tee ON tee.id = trp.tee_id
|
||||||
|
WHERE trp.id = $1
|
||||||
|
""",
|
||||||
|
round_participant_id,
|
||||||
|
)
|
||||||
|
return RoundParticipantOut(**dict(out))
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
# Utslagsgrupper (migrasjon 084, 2026-08-18) -- hvem spiller med hvem,
|
# Utslagsgrupper (migrasjon 084, 2026-08-18) -- hvem spiller med hvem,
|
||||||
# klokka når. Bruker: "Jeg må jo kunne bulksjekke hvem som skal spille
|
# klokka når. Bruker: "Jeg må jo kunne bulksjekke hvem som skal spille
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import { Label } from "@/components/ui/label"
|
||||||
import { PlayerImportPanel } from "@/components/player-import-panel"
|
import { PlayerImportPanel } from "@/components/player-import-panel"
|
||||||
import { RoundGroupsPanel } from "@/components/round-groups-panel"
|
import { RoundGroupsPanel } from "@/components/round-groups-panel"
|
||||||
import { RoundGroupsStepList, type RoundSummary } from "@/components/round-groups-step-list"
|
import { RoundGroupsStepList, type RoundSummary } from "@/components/round-groups-step-list"
|
||||||
|
import { RoundParticipationTable } from "@/components/round-participation-table"
|
||||||
import { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card"
|
import { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card"
|
||||||
import { TournamentSetupNav, type SetupStep } from "@/components/tournament-setup-nav"
|
import { TournamentSetupNav, type SetupStep } from "@/components/tournament-setup-nav"
|
||||||
import {
|
import {
|
||||||
|
|
@ -165,8 +166,9 @@ type ApiParticipant = {
|
||||||
|
|
||||||
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
|
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
|
||||||
// standardutslag. Her (individuelle turneringer) brukes klasse til BÅDE
|
// standardutslag. Her (individuelle turneringer) brukes klasse til BÅDE
|
||||||
// utslag-forhåndsutfylling (AssignRoundParticipantControl) OG en egen
|
// utslag-forhåndsutfylling (rundedeltakelse-tabellen, ADR-093 -- flyttet
|
||||||
// resultatliste-seksjon per klasse (LeaderboardTab) -- se
|
// dit fra den nå fjernede AssignRoundParticipantControl 2026-08-19) OG en
|
||||||
|
// egen resultatliste-seksjon per klasse (LeaderboardTab) -- se
|
||||||
// 053_tournament_classes.sql for full begrunnelse og forskjellen fra
|
// 053_tournament_classes.sql for full begrunnelse og forskjellen fra
|
||||||
// lagturneringer (tournament-detail.tsx), hvor klasse KUN styrer utslag.
|
// lagturneringer (tournament-detail.tsx), hvor klasse KUN styrer utslag.
|
||||||
type ApiTournamentClass = {
|
type ApiTournamentClass = {
|
||||||
|
|
@ -1215,6 +1217,188 @@ function SetupTab({
|
||||||
}
|
}
|
||||||
}, [organizationId])
|
}, [organizationId])
|
||||||
|
|
||||||
|
// Rundedeltakelse-tabellen (ADR-093, "Spillere"-steget) -- lat innlasting,
|
||||||
|
// KUN når steget faktisk vises (ikke eagerly ved sideinnlasting), av alle
|
||||||
|
// rundenes rundedeltakere OG alle unike baners utslag samtidig. Flere
|
||||||
|
// runder kan dele bane -- dedupliserer på course_id for å ikke hente
|
||||||
|
// samme banes utslag flere ganger.
|
||||||
|
const [roundParticipantsByRound, setRoundParticipantsByRound] = useState<Record<
|
||||||
|
string,
|
||||||
|
ApiRoundParticipant[]
|
||||||
|
> | null>(null)
|
||||||
|
const [teesByCourse, setTeesByCourse] = useState<Record<string, ApiTee[]>>({})
|
||||||
|
const [busyRoundIds, setBusyRoundIds] = useState<string[]>([])
|
||||||
|
const [participationErrors, setParticipationErrors] = useState<string[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== "players" || roundParticipantsByRound !== null || rounds.length === 0) return
|
||||||
|
let cancelled = false
|
||||||
|
async function load() {
|
||||||
|
const uniqueCourseIds = Array.from(new Set(rounds.map((r) => r.course_id)))
|
||||||
|
const [rpLists, teeLists] = await Promise.all([
|
||||||
|
Promise.all(rounds.map((r) => getJson<ApiRoundParticipant[]>(`${base}/rounds/${r.id}/participants`))),
|
||||||
|
Promise.all(
|
||||||
|
uniqueCourseIds.map((cid) => getJson<ApiTee[]>(`/orgs/${organizationId}/courses/${cid}/tees`)),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
if (cancelled) return
|
||||||
|
const byRound: Record<string, ApiRoundParticipant[]> = {}
|
||||||
|
rounds.forEach((r, i) => {
|
||||||
|
byRound[r.id] = rpLists[i] ?? []
|
||||||
|
})
|
||||||
|
const byCourse: Record<string, ApiTee[]> = {}
|
||||||
|
uniqueCourseIds.forEach((cid, i) => {
|
||||||
|
byCourse[cid] = teeLists[i] ?? []
|
||||||
|
})
|
||||||
|
setRoundParticipantsByRound(byRound)
|
||||||
|
setTeesByCourse(byCourse)
|
||||||
|
}
|
||||||
|
load()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [step, roundParticipantsByRound, rounds, base, organizationId])
|
||||||
|
|
||||||
|
async function refreshRoundParticipants(roundId: string) {
|
||||||
|
const data = await getJson<ApiRoundParticipant[]>(`${base}/rounds/${roundId}/participants`)
|
||||||
|
setRoundParticipantsByRound((prev) => ({ ...(prev ?? {}), [roundId]: data ?? [] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRoundParticipant(
|
||||||
|
roundId: string,
|
||||||
|
tournamentParticipantId: string,
|
||||||
|
checked: boolean,
|
||||||
|
teeId: string,
|
||||||
|
) {
|
||||||
|
setParticipationErrors([])
|
||||||
|
if (checked) {
|
||||||
|
const res = await fetch(`${base}/rounds/${roundId}/participants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ tournament_participant_id: tournamentParticipantId, tee_id: teeId }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
onError(await errorMessage(res, "Klarte ikke å legge til deltakeren i runden."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const created: ApiRoundParticipant = await res.json()
|
||||||
|
setRoundParticipantsByRound((prev) => ({
|
||||||
|
...(prev ?? {}),
|
||||||
|
[roundId]: [...(prev?.[roundId] ?? []), created],
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
const existing = (roundParticipantsByRound?.[roundId] ?? []).find(
|
||||||
|
(rp) => rp.tournament_participant_id === tournamentParticipantId,
|
||||||
|
)
|
||||||
|
if (!existing) return
|
||||||
|
const res = await fetch(`${base}/rounds/${roundId}/participants/${existing.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
onError("Klarte ikke å fjerne deltakeren fra runden.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setRoundParticipantsByRound((prev) => ({
|
||||||
|
...(prev ?? {}),
|
||||||
|
[roundId]: (prev?.[roundId] ?? []).filter((rp) => rp.id !== existing.id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkSetRoundParticipation(roundId: string, checked: boolean, defaultTeeId: string) {
|
||||||
|
setBusyRoundIds((prev) => [...prev, roundId])
|
||||||
|
setParticipationErrors([])
|
||||||
|
const assignedIds = new Set((roundParticipantsByRound?.[roundId] ?? []).map((rp) => rp.tournament_participant_id))
|
||||||
|
const targets = checked
|
||||||
|
? participants.filter((p) => !assignedIds.has(p.id))
|
||||||
|
: (roundParticipantsByRound?.[roundId] ?? [])
|
||||||
|
|
||||||
|
const failures: string[] = []
|
||||||
|
if (checked) {
|
||||||
|
await Promise.all(
|
||||||
|
(targets as ApiParticipant[]).map(async (p) => {
|
||||||
|
const teeId = resolveDefaultTeeId(p, classes, teesByCourse[rounds.find((r) => r.id === roundId)?.course_id ?? ""] ?? [], defaultTeeId)
|
||||||
|
if (!teeId) {
|
||||||
|
failures.push(`${p.player_name}: fant ikke noe gyldig utslag.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await fetch(`${base}/rounds/${roundId}/participants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ tournament_participant_id: p.id, tee_id: teeId }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
failures.push(`${p.player_name}: ${await errorMessage(res, "ukjent feil")}`)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
await Promise.all(
|
||||||
|
(targets as ApiRoundParticipant[]).map(async (rp) => {
|
||||||
|
const res = await fetch(`${base}/rounds/${roundId}/participants/${rp.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
if (!res.ok) failures.push(`${rp.player_name}: klarte ikke å fjerne.`)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await refreshRoundParticipants(roundId)
|
||||||
|
setBusyRoundIds((prev) => prev.filter((id) => id !== roundId))
|
||||||
|
if (failures.length > 0) {
|
||||||
|
setParticipationErrors([`${failures.length} av ${targets.length} feilet:`, ...failures])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeRoundParticipantTee(roundId: string, roundParticipantId: string, teeId: string) {
|
||||||
|
const res = await fetch(`${base}/rounds/${roundId}/participants/${roundParticipantId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ tee_id: teeId }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
onError(await errorMessage(res, "Klarte ikke å endre utslagssted."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const updated: ApiRoundParticipant = await res.json()
|
||||||
|
setRoundParticipantsByRound((prev) => ({
|
||||||
|
...(prev ?? {}),
|
||||||
|
[roundId]: (prev?.[roundId] ?? []).map((rp) => (rp.id === updated.id ? updated : rp)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePlayerGender(playerId: string, gender: "m" | "f" | "x") {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/players/${playerId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ gender }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
onError(await errorMessage(res, "Klarte ikke å endre kjønn."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPool((prev) => prev.map((p) => (p.id === playerId ? { ...p, gender } : p)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePlayerBirthDate(playerId: string, birthDate: string) {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/players/${playerId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ birth_date: birthDate }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
onError(await errorMessage(res, "Klarte ikke å endre fødselsdato."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setPool((prev) => prev.map((p) => (p.id === playerId ? { ...p, birth_date: birthDate } : p)))
|
||||||
|
}
|
||||||
|
|
||||||
const settingsValues: TournamentSettingsValues = {
|
const settingsValues: TournamentSettingsValues = {
|
||||||
name: tournament.name,
|
name: tournament.name,
|
||||||
startDate: tournament.start_date,
|
startDate: tournament.start_date,
|
||||||
|
|
@ -1250,8 +1434,6 @@ function SetupTab({
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
rounds={rounds}
|
rounds={rounds}
|
||||||
courses={courses}
|
courses={courses}
|
||||||
participants={participants}
|
|
||||||
classes={classes}
|
|
||||||
scoringMethod={tournament.scoring_method}
|
scoringMethod={tournament.scoring_method}
|
||||||
onAddRound={onAddRound}
|
onAddRound={onAddRound}
|
||||||
onDeleteRound={onDeleteRound}
|
onDeleteRound={onDeleteRound}
|
||||||
|
|
@ -1272,23 +1454,84 @@ function SetupTab({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "players" && (
|
{step === "players" && (
|
||||||
<ParticipantsCard
|
<div className="flex flex-col gap-6">
|
||||||
organizationId={organizationId}
|
<ParticipantsCard
|
||||||
tournamentId={tournament.id}
|
organizationId={organizationId}
|
||||||
participants={participants}
|
tournamentId={tournament.id}
|
||||||
pool={pool}
|
participants={participants}
|
||||||
classes={classes}
|
pool={pool}
|
||||||
onAddParticipant={onAddParticipant}
|
classes={classes}
|
||||||
onAddNewPlayer={onAddNewPlayer}
|
onAddParticipant={onAddParticipant}
|
||||||
onImportComplete={async () => {
|
onAddNewPlayer={onAddNewPlayer}
|
||||||
await Promise.all([refreshPool(), onImportComplete()])
|
onImportComplete={async () => {
|
||||||
}}
|
await Promise.all([refreshPool(), onImportComplete()])
|
||||||
onRemoveParticipant={onRemoveParticipant}
|
}}
|
||||||
onSetClass={onSetParticipantClass}
|
onRemoveParticipant={onRemoveParticipant}
|
||||||
onSetStatLevel={onSetParticipantStatLevel}
|
onSetClass={onSetParticipantClass}
|
||||||
onSetHandicap={onSetParticipantHandicap}
|
onSetStatLevel={onSetParticipantStatLevel}
|
||||||
onSetStatus={onSetParticipantStatus}
|
onSetHandicap={onSetParticipantHandicap}
|
||||||
/>
|
onSetStatus={onSetParticipantStatus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h2 className="text-base font-bold text-foreground">Rundedeltakelse</h2>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Hvem spiller hvilke runder, fra hvilket utslagssted -- for hele turneringen på én gang.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{rounds.length === 0 ? (
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Ingen runder ennå -- legg til runder under «Runder og baner» først.
|
||||||
|
</p>
|
||||||
|
) : roundParticipantsByRound === null ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Laster…</p>
|
||||||
|
) : (
|
||||||
|
<RoundParticipationTable
|
||||||
|
players={participants.map((p) => {
|
||||||
|
const player = pool.find((pl) => pl.id === p.player_id)
|
||||||
|
return {
|
||||||
|
participantId: p.id,
|
||||||
|
playerId: p.player_id,
|
||||||
|
name: p.player_name,
|
||||||
|
gender: player?.gender ?? null,
|
||||||
|
birthDate: player?.birth_date ?? null,
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
rounds={rounds.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.name || `Runde ${r.sequence}`,
|
||||||
|
tees: teesByCourse[r.course_id] ?? [],
|
||||||
|
}))}
|
||||||
|
cells={Object.fromEntries(
|
||||||
|
rounds.map((r) => [
|
||||||
|
r.id,
|
||||||
|
Object.fromEntries(
|
||||||
|
participants.map((p) => {
|
||||||
|
const rp = (roundParticipantsByRound[r.id] ?? []).find(
|
||||||
|
(x) => x.tournament_participant_id === p.id,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
p.id,
|
||||||
|
rp
|
||||||
|
? { assigned: true, teeId: rp.tee_id, teeName: rp.tee_name }
|
||||||
|
: { assigned: false, teeId: null, teeName: null },
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
)}
|
||||||
|
onToggle={toggleRoundParticipant}
|
||||||
|
onBulkToggle={bulkSetRoundParticipation}
|
||||||
|
onChangeTee={changeRoundParticipantTee}
|
||||||
|
onChangeGender={changePlayerGender}
|
||||||
|
onChangeBirthDate={changePlayerBirthDate}
|
||||||
|
busyRoundIds={busyRoundIds}
|
||||||
|
errors={participationErrors}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === "groups" && (
|
{step === "groups" && (
|
||||||
|
|
@ -2370,8 +2613,6 @@ function RoundsCard({
|
||||||
organizationId,
|
organizationId,
|
||||||
rounds,
|
rounds,
|
||||||
courses,
|
courses,
|
||||||
participants,
|
|
||||||
classes,
|
|
||||||
scoringMethod,
|
scoringMethod,
|
||||||
onAddRound,
|
onAddRound,
|
||||||
onDeleteRound,
|
onDeleteRound,
|
||||||
|
|
@ -2383,8 +2624,6 @@ function RoundsCard({
|
||||||
organizationId: string
|
organizationId: string
|
||||||
rounds: ApiRound[]
|
rounds: ApiRound[]
|
||||||
courses: ApiCourse[]
|
courses: ApiCourse[]
|
||||||
participants: ApiParticipant[]
|
|
||||||
classes: ApiTournamentClass[]
|
|
||||||
scoringMethod: string | null
|
scoringMethod: string | null
|
||||||
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>
|
||||||
|
|
@ -2415,10 +2654,7 @@ function RoundsCard({
|
||||||
<RoundCard
|
<RoundCard
|
||||||
key={r.id}
|
key={r.id}
|
||||||
base={base}
|
base={base}
|
||||||
organizationId={organizationId}
|
|
||||||
round={r}
|
round={r}
|
||||||
participants={participants}
|
|
||||||
classes={classes}
|
|
||||||
scoringMethod={scoringMethod}
|
scoringMethod={scoringMethod}
|
||||||
onDelete={() => onDeleteRound(r.id)}
|
onDelete={() => onDeleteRound(r.id)}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
|
|
@ -2644,35 +2880,24 @@ function NewRoundForm({
|
||||||
|
|
||||||
function RoundCard({
|
function RoundCard({
|
||||||
base,
|
base,
|
||||||
organizationId,
|
|
||||||
round,
|
round,
|
||||||
participants,
|
|
||||||
classes,
|
|
||||||
scoringMethod,
|
scoringMethod,
|
||||||
onDelete,
|
onDelete,
|
||||||
onError,
|
onError,
|
||||||
}: {
|
}: {
|
||||||
base: string
|
base: string
|
||||||
organizationId: string
|
|
||||||
round: ApiRound
|
round: ApiRound
|
||||||
participants: ApiParticipant[]
|
|
||||||
classes: ApiTournamentClass[]
|
|
||||||
scoringMethod: string | null
|
scoringMethod: string | null
|
||||||
onDelete: () => Promise<void>
|
onDelete: () => Promise<void>
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null)
|
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null)
|
||||||
const [tees, setTees] = useState<ApiTee[]>([])
|
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
|
|
||||||
async function loadRoundParticipants() {
|
async function loadRoundParticipants() {
|
||||||
const [rpData, teeData] = await Promise.all([
|
const rpData = await getJson<ApiRoundParticipant[]>(`${base}/rounds/${round.id}/participants`)
|
||||||
getJson<ApiRoundParticipant[]>(`${base}/rounds/${round.id}/participants`),
|
|
||||||
getJson<ApiTee[]>(`/orgs/${organizationId}/courses/${round.course_id}/tees`),
|
|
||||||
])
|
|
||||||
setRoundParticipants(rpData ?? [])
|
setRoundParticipants(rpData ?? [])
|
||||||
setTees(teeData ?? [])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -2682,22 +2907,6 @@ function RoundCard({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [expanded])
|
}, [expanded])
|
||||||
|
|
||||||
async function addRoundParticipant(tournamentParticipantId: string, teeId: string) {
|
|
||||||
onError("")
|
|
||||||
const res = await fetch(`${base}/rounds/${round.id}/participants`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ tournament_participant_id: tournamentParticipantId, tee_id: teeId }),
|
|
||||||
})
|
|
||||||
if (!res.ok) {
|
|
||||||
onError(await errorMessage(res, "Klarte ikke å legge til deltakeren i runden."))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const created: ApiRoundParticipant = await res.json()
|
|
||||||
setRoundParticipants((prev) => [...(prev ?? []), created])
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeRoundParticipant(id: string) {
|
async function removeRoundParticipant(id: string) {
|
||||||
const res = await fetch(`${base}/rounds/${round.id}/participants/${id}`, {
|
const res = await fetch(`${base}/rounds/${round.id}/participants/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
|
|
@ -2710,9 +2919,6 @@ function RoundCard({
|
||||||
setRoundParticipants((prev) => (prev ?? []).filter((rp) => rp.id !== id))
|
setRoundParticipants((prev) => (prev ?? []).filter((rp) => rp.id !== id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignedIds = new Set((roundParticipants ?? []).map((rp) => rp.tournament_participant_id))
|
|
||||||
const unassigned = participants.filter((p) => !assignedIds.has(p.id))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-xl border border-border">
|
<div className="overflow-hidden rounded-xl border border-border">
|
||||||
<div className="flex items-center gap-3 bg-background px-4 py-3">
|
<div className="flex items-center gap-3 bg-background px-4 py-3">
|
||||||
|
|
@ -2805,20 +3011,9 @@ function RoundCard({
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{unassigned.length > 0 && tees.length > 0 && (
|
<p className="text-xs text-muted-foreground">
|
||||||
<AssignRoundParticipantControl
|
Legg til eller fjern deltakere fra runden under «Spillere».
|
||||||
candidates={unassigned}
|
</p>
|
||||||
tees={tees}
|
|
||||||
classes={classes}
|
|
||||||
onAssign={addRoundParticipant}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{unassigned.length > 0 && tees.length === 0 && (
|
|
||||||
<p className="text-xs font-medium text-brand-orange">
|
|
||||||
Banen «{round.course_name}» har ingen registrerte utslag ennå -- kan ikke legge til
|
|
||||||
deltakere før det er på plass.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -2827,92 +3022,25 @@ function RoundCard({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssignRoundParticipantControl({
|
// Klassens standardutslag brukes som forhåndsvalg ved bulk-tilordning i
|
||||||
candidates,
|
// rundedeltakelse-tabellen (ADR-093) -- samme oppløsning som den nå
|
||||||
tees,
|
// fjernede AssignRoundParticipantControl brukte for ett-om-gangen-
|
||||||
classes,
|
// tilordning: deltakerens klasses standardutslag, KUN hvis det faktisk
|
||||||
onAssign,
|
// finnes på den aktuelle rundens bane (klassens standardutslag kan
|
||||||
}: {
|
// tilhøre en annen bane). Faller tilbake til rundens eget valgte
|
||||||
candidates: ApiParticipant[]
|
// "Standardutslag" (lokal UI-state i round-participation-table.tsx),
|
||||||
tees: ApiTee[]
|
// og til slutt til det første utslaget i lista.
|
||||||
classes: ApiTournamentClass[]
|
function resolveDefaultTeeId(
|
||||||
onAssign: (tournamentParticipantId: string, teeId: string) => Promise<void>
|
participant: ApiParticipant,
|
||||||
}) {
|
classes: ApiTournamentClass[],
|
||||||
const [participantId, setParticipantId] = useState(candidates[0]?.id ?? "")
|
tees: ApiTee[],
|
||||||
const [teeId, setTeeId] = useState(tees[0]?.id ?? "")
|
roundDefaultTeeId: string | null,
|
||||||
const [submitting, setSubmitting] = useState(false)
|
): string | null {
|
||||||
|
const cls = participant.class_id ? classes.find((c) => c.id === participant.class_id) : null
|
||||||
useEffect(() => {
|
const classTee = cls?.default_tee_id ? tees.find((t) => t.id === cls.default_tee_id) : null
|
||||||
if (!candidates.some((c) => c.id === participantId)) setParticipantId(candidates[0]?.id ?? "")
|
if (classTee) return classTee.id
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
if (roundDefaultTeeId && tees.some((t) => t.id === roundDefaultTeeId)) return roundDefaultTeeId
|
||||||
}, [candidates])
|
return tees[0]?.id ?? null
|
||||||
|
|
||||||
// Konkurranseklasser (2026-08-03) -- forhåndsvelg deltakerens klasses
|
|
||||||
// standardutslag, kun hvis det utslaget faktisk finnes på DENNE rundens
|
|
||||||
// bane (klassens standardutslag kan tilhøre en annen bane). Fritt
|
|
||||||
// overstyrbart under.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!participantId) return
|
|
||||||
const candidate = candidates.find((c) => c.id === participantId)
|
|
||||||
const cls = candidate?.class_id ? classes.find((c) => c.id === candidate.class_id) : null
|
|
||||||
const defaultTee = cls?.default_tee_id ? tees.find((t) => t.id === cls.default_tee_id) : null
|
|
||||||
if (defaultTee) setTeeId(defaultTee.id)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [participantId])
|
|
||||||
|
|
||||||
async function handleAssign() {
|
|
||||||
if (!participantId || !teeId || submitting) return
|
|
||||||
setSubmitting(true)
|
|
||||||
await onAssign(participantId, teeId)
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 rounded-lg border border-dashed border-border p-2.5 sm:flex-row sm:items-end">
|
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
|
||||||
<Label htmlFor="assign-participant" className="text-xs font-semibold text-muted-foreground">
|
|
||||||
Deltaker
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="assign-participant"
|
|
||||||
value={participantId}
|
|
||||||
onChange={(e) => setParticipantId(e.target.value)}
|
|
||||||
className="h-10 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
|
|
||||||
>
|
|
||||||
{candidates.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.player_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-1 flex-col gap-1">
|
|
||||||
<Label htmlFor="assign-tee" className="text-xs font-semibold text-muted-foreground">
|
|
||||||
Utslag
|
|
||||||
</Label>
|
|
||||||
<select
|
|
||||||
id="assign-tee"
|
|
||||||
value={teeId}
|
|
||||||
onChange={(e) => setTeeId(e.target.value)}
|
|
||||||
className="h-10 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
|
|
||||||
>
|
|
||||||
{tees.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
{t.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAssign}
|
|
||||||
disabled={submitting}
|
|
||||||
className="h-10 shrink-0 rounded-lg text-xs font-bold"
|
|
||||||
>
|
|
||||||
Legg til
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,13 @@ export function RoundGroupsPanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSequences = groups.map((g) => g.sequence).sort((a, b) => a - b)
|
const allSequences = groups.map((g) => g.sequence).sort((a, b) => a - b)
|
||||||
|
// Skiller "ingen deltakere i runden i det hele tatt" fra "alle er
|
||||||
|
// gruppert" -- begge ga tidligere identisk ungrouped.length===0, som
|
||||||
|
// gjorde "Alle deltakere er i en gruppe" misvisende når det egentlig
|
||||||
|
// betydde at ingen var lagt til runden ennå (egen tildeling under
|
||||||
|
// "Runder og baner", adskilt fra å være med i selve turneringen).
|
||||||
|
const totalRoundParticipants =
|
||||||
|
ungrouped.length + groups.reduce((sum, g) => sum + g.participants.length, 0)
|
||||||
|
|
||||||
function GroupSelect({ participantId, currentSequence }: { participantId: string; currentSequence: number | "ungrouped" }) {
|
function GroupSelect({ participantId, currentSequence }: { participantId: string; currentSequence: number | "ungrouped" }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -335,6 +342,13 @@ export function RoundGroupsPanel({
|
||||||
Foreslå en fordeling, juster hvem som spiller sammen, lagre når du er fornøyd.
|
Foreslå en fordeling, juster hvem som spiller sammen, lagre når du er fornøyd.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{totalRoundParticipants === 0 && (
|
||||||
|
<p className="w-full rounded-xl border border-dashed border-border bg-background px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||||
|
Ingen deltakere er lagt til i DENNE runden ennå -- det er et eget steg fra å
|
||||||
|
være med i turneringen. Gå til "Runder og baner", utvid runden og legg til
|
||||||
|
deltakere der, så dukker de opp her.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<label className="flex flex-col gap-1.5">
|
<label className="flex flex-col gap-1.5">
|
||||||
<span className="text-xs font-bold text-foreground">Gruppestørrelse</span>
|
<span className="text-xs font-bold text-foreground">Gruppestørrelse</span>
|
||||||
|
|
@ -416,7 +430,11 @@ export function RoundGroupsPanel({
|
||||||
Ugrupperte ({ungrouped.length})
|
Ugrupperte ({ungrouped.length})
|
||||||
</h3>
|
</h3>
|
||||||
{ungrouped.length === 0 ? (
|
{ungrouped.length === 0 ? (
|
||||||
<p className="text-xs font-medium text-muted-foreground">Alle deltakere er i en gruppe.</p>
|
<p className="text-xs font-medium text-muted-foreground">
|
||||||
|
{totalRoundParticipants === 0
|
||||||
|
? "Ingen deltakere lagt til i runden ennå."
|
||||||
|
: "Alle deltakere er i en gruppe."}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-2">
|
<ul className="flex flex-col gap-2">
|
||||||
{ungrouped.map((p) => (
|
{ungrouped.map((p) => (
|
||||||
|
|
|
||||||
345
frontend/components/round-participation-table.tsx
Normal file
345
frontend/components/round-participation-table.tsx
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { Loader2, TriangleAlert } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// V0-generert (ADR-093, 2026-08-19) -- ren kontrollert/presentasjonell
|
||||||
|
// komponent, ingen egne fetch-kall. SetupTab (individual-tournament-
|
||||||
|
// detail.tsx) eier all datahenting/orkestrering og sender ferdig
|
||||||
|
// sammenslått data + callback-props inn.
|
||||||
|
|
||||||
|
export type RoundParticipationPlayer = {
|
||||||
|
participantId: string
|
||||||
|
playerId: string
|
||||||
|
name: string
|
||||||
|
gender: "m" | "f" | "x" | null
|
||||||
|
birthDate: string | null // ISO "YYYY-MM-DD", null if unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoundParticipationRound = {
|
||||||
|
id: string
|
||||||
|
label: string // e.g. "Runde 1"
|
||||||
|
tees: { id: string; name: string }[] // this round's course's available tees
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoundParticipationCell = {
|
||||||
|
assigned: boolean
|
||||||
|
teeId: string | null // null when not assigned
|
||||||
|
teeName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoundParticipationTableProps = {
|
||||||
|
players: RoundParticipationPlayer[]
|
||||||
|
rounds: RoundParticipationRound[]
|
||||||
|
// cells[roundId][participantId]
|
||||||
|
cells: Record<string, Record<string, RoundParticipationCell>>
|
||||||
|
onToggle: (roundId: string, participantId: string, checked: boolean, teeId: string) => void
|
||||||
|
onBulkToggle: (roundId: string, checked: boolean, teeId: string) => void
|
||||||
|
onChangeTee: (roundId: string, participantId: string, teeId: string) => void
|
||||||
|
onChangeGender: (playerId: string, gender: "m" | "f" | "x") => void
|
||||||
|
onChangeBirthDate: (playerId: string, birthDate: string) => void
|
||||||
|
busyRoundIds: string[]
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard age-from-birthdate: subtract years, then step back one if this
|
||||||
|
// year's birthday hasn't happened yet.
|
||||||
|
function computeAge(birthDate: string): number {
|
||||||
|
const birth = new Date(birthDate)
|
||||||
|
const now = new Date()
|
||||||
|
let age = now.getFullYear() - birth.getFullYear()
|
||||||
|
const monthDiff = now.getMonth() - birth.getMonth()
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate())) {
|
||||||
|
age -= 1
|
||||||
|
}
|
||||||
|
return age
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectClass =
|
||||||
|
"h-10 rounded-lg border border-border bg-background px-2 text-sm font-medium text-foreground transition-all duration-200 ease-in-out hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
|
||||||
|
export function RoundParticipationTable({
|
||||||
|
players,
|
||||||
|
rounds,
|
||||||
|
cells,
|
||||||
|
onToggle,
|
||||||
|
onBulkToggle,
|
||||||
|
onChangeTee,
|
||||||
|
onChangeGender,
|
||||||
|
onChangeBirthDate,
|
||||||
|
busyRoundIds,
|
||||||
|
errors,
|
||||||
|
}: RoundParticipationTableProps) {
|
||||||
|
// Local-only convenience state: each round column's "Standardutslag"
|
||||||
|
// selection, used to pre-fill the teeId when checking a box. Resets to each
|
||||||
|
// round's first tee on mount / when the round set changes.
|
||||||
|
const [defaultTees, setDefaultTees] = useState<Record<string, string>>(() =>
|
||||||
|
Object.fromEntries(rounds.map((r) => [r.id, r.tees[0]?.id ?? ""])),
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDefaultTees((prev) => {
|
||||||
|
const next: Record<string, string> = {}
|
||||||
|
for (const r of rounds) {
|
||||||
|
// keep an existing valid selection, otherwise fall back to first tee
|
||||||
|
const existing = prev[r.id]
|
||||||
|
const stillValid = existing && r.tees.some((t) => t.id === existing)
|
||||||
|
next[r.id] = stillValid ? existing : (r.tees[0]?.id ?? "")
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rounds.map((r) => r.id + ":" + r.tees.map((t) => t.id).join(",")).join("|")])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="flex flex-col gap-1.5 rounded-2xl border border-destructive/30 bg-destructive/10 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 font-bold text-destructive">
|
||||||
|
<TriangleAlert aria-hidden="true" className="size-5 shrink-0" />
|
||||||
|
<span>Noen endringer feilet</span>
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-1 pl-7 text-sm text-destructive">
|
||||||
|
{errors.map((msg, i) => (
|
||||||
|
<li key={i} className="text-pretty leading-relaxed">
|
||||||
|
{msg}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-2xl border border-border bg-card shadow-md shadow-black/8">
|
||||||
|
<table className="w-full border-collapse text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="divide-x divide-border border-b border-border">
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="sticky left-0 z-20 min-w-48 bg-card px-4 py-3 align-bottom text-sm font-bold text-foreground"
|
||||||
|
>
|
||||||
|
Spiller
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="min-w-32 bg-card px-3 py-3 align-bottom text-sm font-bold text-foreground">
|
||||||
|
Kjønn
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="min-w-56 bg-card px-3 py-3 align-bottom text-sm font-bold text-foreground">
|
||||||
|
Alder
|
||||||
|
</th>
|
||||||
|
{rounds.map((round) => {
|
||||||
|
const busy = busyRoundIds.includes(round.id)
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={round.id}
|
||||||
|
scope="col"
|
||||||
|
className="min-w-56 bg-card px-3 py-3 align-bottom"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-bold text-foreground">{round.label}</span>
|
||||||
|
{busy && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs font-semibold text-muted-foreground">
|
||||||
|
<Loader2 aria-hidden="true" className="size-3.5 animate-spin" />
|
||||||
|
Oppdaterer
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">Standardutslag</span>
|
||||||
|
<select
|
||||||
|
value={defaultTees[round.id] ?? ""}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDefaultTees((prev) => ({ ...prev, [round.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
className={selectClass}
|
||||||
|
aria-label={`Standardutslag for ${round.label}`}
|
||||||
|
>
|
||||||
|
{round.tees.map((tee) => (
|
||||||
|
<option key={tee.id} value={tee.id}>
|
||||||
|
{tee.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<BulkToggle
|
||||||
|
round={round}
|
||||||
|
players={players}
|
||||||
|
cells={cells}
|
||||||
|
busy={busy}
|
||||||
|
onBulkToggle={(checked) =>
|
||||||
|
onBulkToggle(round.id, checked, defaultTees[round.id] ?? "")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border">
|
||||||
|
{players.map((player) => {
|
||||||
|
const age = player.birthDate ? computeAge(player.birthDate) : null
|
||||||
|
return (
|
||||||
|
<tr key={player.participantId} className="divide-x divide-border">
|
||||||
|
<th
|
||||||
|
scope="row"
|
||||||
|
className="sticky left-0 z-10 bg-card px-4 py-3 text-left font-bold text-foreground"
|
||||||
|
>
|
||||||
|
{player.name}
|
||||||
|
</th>
|
||||||
|
|
||||||
|
{/* Kjønn */}
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<select
|
||||||
|
value={player.gender ?? ""}
|
||||||
|
onChange={(e) => onChangeGender(player.playerId, e.target.value as "m" | "f" | "x")}
|
||||||
|
className={cn(selectClass, "w-full", player.gender === null && "text-muted-foreground")}
|
||||||
|
aria-label={`Kjønn for ${player.name}`}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Ikke satt
|
||||||
|
</option>
|
||||||
|
<option value="m">Mann</option>
|
||||||
|
<option value="f">Kvinne</option>
|
||||||
|
<option value="x">Annet</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Alder */}
|
||||||
|
<td className="px-3 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={player.birthDate ?? ""}
|
||||||
|
onChange={(e) => onChangeBirthDate(player.playerId, e.target.value)}
|
||||||
|
className={cn(selectClass, "w-36")}
|
||||||
|
aria-label={`Fødselsdato for ${player.name}`}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-sm tabular-nums text-muted-foreground">
|
||||||
|
{age !== null ? `${age} år` : "Ukjent"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Round columns */}
|
||||||
|
{rounds.map((round) => {
|
||||||
|
const busy = busyRoundIds.includes(round.id)
|
||||||
|
const cell = cells[round.id]?.[player.participantId]
|
||||||
|
const assigned = cell?.assigned ?? false
|
||||||
|
const cellId = `cell-${round.id}-${player.participantId}`
|
||||||
|
return (
|
||||||
|
<td key={round.id} className="px-3 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
htmlFor={cellId}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-lg px-2 transition-all duration-200 ease-in-out hover:bg-accent",
|
||||||
|
busy && "pointer-events-none opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={cellId}
|
||||||
|
type="checkbox"
|
||||||
|
checked={assigned}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) =>
|
||||||
|
onToggle(
|
||||||
|
round.id,
|
||||||
|
player.participantId,
|
||||||
|
e.target.checked,
|
||||||
|
e.target.checked ? (defaultTees[round.id] ?? "") : (cell?.teeId ?? ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="size-5 shrink-0 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{assigned ? "Spiller" : "Ikke satt"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{assigned && (
|
||||||
|
<select
|
||||||
|
value={cell?.teeId ?? ""}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => onChangeTee(round.id, player.participantId, e.target.value)}
|
||||||
|
className={selectClass}
|
||||||
|
aria-label={`Utslagssted for ${player.name} i ${round.label}`}
|
||||||
|
>
|
||||||
|
{round.tees.map((tee) => (
|
||||||
|
<option key={tee.id} value={tee.id}>
|
||||||
|
{tee.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BulkToggle({
|
||||||
|
round,
|
||||||
|
players,
|
||||||
|
cells,
|
||||||
|
busy,
|
||||||
|
onBulkToggle,
|
||||||
|
}: {
|
||||||
|
round: RoundParticipationRound
|
||||||
|
players: RoundParticipationPlayer[]
|
||||||
|
cells: Record<string, Record<string, RoundParticipationCell>>
|
||||||
|
busy: boolean
|
||||||
|
onBulkToggle: (checked: boolean) => void
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLInputElement>(null)
|
||||||
|
const assignedCount = players.reduce(
|
||||||
|
(acc, p) => acc + (cells[round.id]?.[p.participantId]?.assigned ? 1 : 0),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
const total = players.length
|
||||||
|
const allAssigned = total > 0 && assignedCount === total
|
||||||
|
const someAssigned = assignedCount > 0 && assignedCount < total
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.indeterminate = someAssigned
|
||||||
|
}, [someAssigned])
|
||||||
|
|
||||||
|
const id = `bulk-${round.id}`
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-lg px-2 transition-all duration-200 ease-in-out hover:bg-accent",
|
||||||
|
busy && "pointer-events-none opacity-50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
ref={ref}
|
||||||
|
type="checkbox"
|
||||||
|
checked={allAssigned}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={() => onBulkToggle(!allAssigned)}
|
||||||
|
className="size-5 shrink-0 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-semibold text-foreground">
|
||||||
|
{allAssigned ? "Velg ingen" : "Velg alle"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
102
tests/test_round_participant_tee.py
Normal file
102
tests/test_round_participant_tee.py
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
"""
|
||||||
|
PATCH for å endre utslagssted på en allerede-tilordnet rundedeltaker
|
||||||
|
(ADR-093, 2026-08-19). Bygget som en direkte oppfølger til bulk-
|
||||||
|
rundedeltakelse-tabellen i "Spillere"-steget -- eneste grunn PATCH-en
|
||||||
|
finnes i det hele tatt (fremfor slett+opprett-på-nytt, som allerede var
|
||||||
|
mulig med de to eksisterende endepunktene) er at slett+opprett ville
|
||||||
|
STILLE fjernet spilleren fra en allerede tildelt utslagsgruppe
|
||||||
|
(migrasjon 084: tournament_round_group_id er en kolonne direkte på
|
||||||
|
tournament_round_participant, ikke en egen koblingstabell -- en ny rad
|
||||||
|
får alltid tournament_round_group_id=NULL).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.routers.individual_tournaments import (
|
||||||
|
RoundGroupIn,
|
||||||
|
RoundParticipantTeeUpdate,
|
||||||
|
SaveRoundGroupsIn,
|
||||||
|
save_round_groups,
|
||||||
|
update_round_participant_tee,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.conftest import (
|
||||||
|
create_org,
|
||||||
|
create_course,
|
||||||
|
create_player,
|
||||||
|
create_tee,
|
||||||
|
create_tournament,
|
||||||
|
create_tournament_participant,
|
||||||
|
create_tournament_round,
|
||||||
|
create_tournament_round_participant,
|
||||||
|
)
|
||||||
|
|
||||||
|
import app.db as app_db
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup(org_id):
|
||||||
|
tournament_id = await create_tournament(org_id, name="Utslagssted-turnering")
|
||||||
|
course_id = await create_course(org_id, name="Utslagssted Links")
|
||||||
|
tee_a = await create_tee(org_id, course_id, name="Gul")
|
||||||
|
tee_b = await create_tee(org_id, course_id, name="Rød")
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE tournament SET format_type = 'individual', scoring_method = 'stroke_gross' WHERE id = $1",
|
||||||
|
tournament_id,
|
||||||
|
)
|
||||||
|
round_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
|
||||||
|
player_id = await create_player(org_id, display_name="Spiller Ett")
|
||||||
|
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
|
||||||
|
rp_id = await create_tournament_round_participant(org_id, round_id, tp_id, tee_a)
|
||||||
|
return tournament_id, round_id, rp_id, tee_a, tee_b
|
||||||
|
|
||||||
|
|
||||||
|
async def test_changing_tee_preserves_group_membership(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
tournament_id, round_id, rp_id, tee_a, tee_b = await _setup(org_id)
|
||||||
|
|
||||||
|
saved = await save_round_groups(
|
||||||
|
tournament_id, round_id,
|
||||||
|
SaveRoundGroupsIn(groups=[RoundGroupIn(sequence=1, round_participant_ids=[rp_id])]),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
assert len(saved.groups[0].participants) == 1
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
group_id_before = await conn.fetchval(
|
||||||
|
"SELECT tournament_round_group_id FROM tournament_round_participant WHERE id = $1", rp_id
|
||||||
|
)
|
||||||
|
assert group_id_before is not None
|
||||||
|
|
||||||
|
updated = await update_round_participant_tee(
|
||||||
|
tournament_id, round_id, rp_id, RoundParticipantTeeUpdate(tee_id=tee_b), organization_id=org_id
|
||||||
|
)
|
||||||
|
assert updated.tee_id == tee_b
|
||||||
|
assert updated.id == rp_id # samme rad, ikke slett+gjenopprett
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
group_id_after = await conn.fetchval(
|
||||||
|
"SELECT tournament_round_group_id FROM tournament_round_participant WHERE id = $1", rp_id
|
||||||
|
)
|
||||||
|
assert group_id_after == group_id_before # fortsatt i samme gruppe
|
||||||
|
|
||||||
|
|
||||||
|
async def test_changing_tee_to_one_outside_course_is_rejected(pool):
|
||||||
|
org_id = await create_org()
|
||||||
|
tournament_id, round_id, rp_id, tee_a, _tee_b = await _setup(org_id)
|
||||||
|
other_course_id = await create_course(org_id, name="En annen bane")
|
||||||
|
foreign_tee_id = await create_tee(org_id, other_course_id, name="Blå")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await update_round_participant_tee(
|
||||||
|
tournament_id, round_id, rp_id,
|
||||||
|
RoundParticipantTeeUpdate(tee_id=foreign_tee_id),
|
||||||
|
organization_id=org_id,
|
||||||
|
)
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert exc_info.value.detail["code"] == "OUT_OF_SCOPE"
|
||||||
|
|
||||||
|
async with app_db.org_connection(org_id) as conn:
|
||||||
|
tee_after = await conn.fetchval("SELECT tee_id FROM tournament_round_participant WHERE id = $1", rp_id)
|
||||||
|
assert str(tee_after) == tee_a # uendret etter avvist forsøk
|
||||||
Loading…
Reference in a new issue