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:
Erol Haagenrud 2026-08-19 06:21:58 +02:00
parent 1867c65143
commit 5daf2a764a
7 changed files with 1040 additions and 213 deletions

View file

@ -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)

View file

@ -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.

View file

@ -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 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,18 +553,15 @@ async def _compute_round_participant_handicap(conn, round_participant_id: str) -
) )
@router.post( async def _validate_round_tee_choice(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants", conn, tournament_id: str, round_id: str, tournament_participant_id: str, tee_id: str
response_model=RoundParticipantOut, ) -> None:
status_code=201, """Delt av add_round_participant OG update_round_participant_tee
) (PATCH, ADR-093) -- selve valideringen (utslaget hører til rundens
async def add_round_participant( bane, deltakeren har HCP+kjønn og valgt utslag har en rating for det
tournament_id: str, kjønnet når scoring_method krever det) er identisk uansett om det er
round_id: str, en NY rundedeltaker eller en ENDRING av utslag en eksisterende --
body: RoundParticipantCreate, kun hva som skjer ETTERPÅ (INSERT vs. UPDATE) skiller dem."""
organization_id: str = Depends(get_authorized_org),
) -> RoundParticipantOut:
async with org_connection(organization_id) as conn, translate_db_errors():
round_row = await conn.fetchrow( round_row = await conn.fetchrow(
""" """
SELECT tr.course_id::text AS course_id, t.scoring_method SELECT tr.course_id::text AS course_id, t.scoring_method
@ -568,7 +575,7 @@ async def add_round_participant(
if round_row is None: if round_row is None:
raise app_error(404, "NOT_FOUND", "Runden finnes ikke.") 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) 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"]: 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.") raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke rundens bane.")
@ -581,7 +588,7 @@ async def add_round_participant(
JOIN player p ON p.id = tp.player_id JOIN player p ON p.id = tp.player_id
WHERE tp.id = $1 WHERE tp.id = $1
""", """,
body.tournament_participant_id, tournament_participant_id,
) )
if player_row is None: if player_row is None:
raise app_error(404, "NOT_FOUND", "Turnering-deltakeren finnes ikke.") raise app_error(404, "NOT_FOUND", "Turnering-deltakeren finnes ikke.")
@ -600,7 +607,7 @@ async def add_round_participant(
) )
has_rating = await conn.fetchval( has_rating = await conn.fetchval(
"SELECT EXISTS(SELECT 1 FROM tee_rating WHERE tee_id = $1 AND scope = 'full_18' AND gender = $2)", "SELECT EXISTS(SELECT 1 FROM tee_rating WHERE tee_id = $1 AND scope = 'full_18' AND gender = $2)",
body.tee_id, tee_id,
player_row["gender"], player_row["gender"],
) )
if not has_rating: if not has_rating:
@ -609,6 +616,21 @@ async def add_round_participant(
400, "VALIDATION_FAILED", f"Dette utslaget har ingen {kjonn_tekst}-rating -- velg et annet." 400, "VALIDATION_FAILED", f"Dette utslaget har ingen {kjonn_tekst}-rating -- velg et annet."
) )
@router.post(
"/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants",
response_model=RoundParticipantOut,
status_code=201,
)
async def add_round_participant(
tournament_id: str,
round_id: str,
body: RoundParticipantCreate,
organization_id: str = Depends(get_authorized_org),
) -> RoundParticipantOut:
async with org_connection(organization_id) as conn, translate_db_errors():
await _validate_round_tee_choice(conn, tournament_id, round_id, body.tournament_participant_id, body.tee_id)
row = await conn.fetchrow( row = await conn.fetchrow(
""" """
WITH inserted AS ( WITH inserted AS (
@ -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

View file

@ -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,6 +1454,7 @@ function SetupTab({
)} )}
{step === "players" && ( {step === "players" && (
<div className="flex flex-col gap-6">
<ParticipantsCard <ParticipantsCard
organizationId={organizationId} organizationId={organizationId}
tournamentId={tournament.id} tournamentId={tournament.id}
@ -1289,6 +1472,66 @@ function SetupTab({
onSetHandicap={onSetParticipantHandicap} onSetHandicap={onSetParticipantHandicap}
onSetStatus={onSetParticipantStatus} 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 é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}
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 plass.
</p> </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>
)
} }
// ========================================================================= // =========================================================================

View file

@ -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. til "Runder og baner", utvid runden og legg til
deltakere der, 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) => (

View 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>
)
}

View file

@ -0,0 +1,102 @@
"""
PATCH for å endre utslagssted 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--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
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