diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 217804b..b6e45c3 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -9402,6 +9402,105 @@ layout). frontend). `docker compose build teecup_frontend && up -d 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50c9914..7e2f5a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12914,3 +12914,68 @@ Neste steg: **Rullet ut 2026-08-18** -- bruker bekreftet. Ingen migrasjon (ren frontend). `docker compose build teecup_frontend && up -d 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. diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index 90fdc66..f99afc4 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -488,6 +488,16 @@ class RoundParticipantCreate(BaseModel): 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): 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( "/orgs/{organization_id}/tournaments/{tournament_id}/rounds/{round_id}/participants", response_model=RoundParticipantOut, @@ -555,59 +629,7 @@ async def add_round_participant( organization_id: str = Depends(get_authorized_org), ) -> RoundParticipantOut: async with org_connection(organization_id) as conn, translate_db_errors(): - 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", 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." - ) + await _validate_round_tee_choice(conn, tournament_id, round_id, body.tournament_participant_id, body.tee_id) row = await conn.fetchrow( """ @@ -691,6 +713,54 @@ async def remove_round_participant( 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, # klokka når. Bruker: "Jeg må jo kunne bulksjekke hvem som skal spille diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 72ad10a..b30ef18 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -47,6 +47,7 @@ import { Label } from "@/components/ui/label" import { PlayerImportPanel } from "@/components/player-import-panel" import { RoundGroupsPanel } from "@/components/round-groups-panel" 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 { TournamentSetupNav, type SetupStep } from "@/components/tournament-setup-nav" import { @@ -165,8 +166,9 @@ type ApiParticipant = { // Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt // standardutslag. Her (individuelle turneringer) brukes klasse til BÅDE -// utslag-forhåndsutfylling (AssignRoundParticipantControl) OG en egen -// resultatliste-seksjon per klasse (LeaderboardTab) -- se +// utslag-forhåndsutfylling (rundedeltakelse-tabellen, ADR-093 -- flyttet +// 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 // lagturneringer (tournament-detail.tsx), hvor klasse KUN styrer utslag. type ApiTournamentClass = { @@ -1215,6 +1217,188 @@ function SetupTab({ } }, [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 | null>(null) + const [teesByCourse, setTeesByCourse] = useState>({}) + const [busyRoundIds, setBusyRoundIds] = useState([]) + const [participationErrors, setParticipationErrors] = useState([]) + + 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(`${base}/rounds/${r.id}/participants`))), + Promise.all( + uniqueCourseIds.map((cid) => getJson(`/orgs/${organizationId}/courses/${cid}/tees`)), + ), + ]) + if (cancelled) return + const byRound: Record = {} + rounds.forEach((r, i) => { + byRound[r.id] = rpLists[i] ?? [] + }) + const byCourse: Record = {} + 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(`${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 = { name: tournament.name, startDate: tournament.start_date, @@ -1250,8 +1434,6 @@ function SetupTab({ organizationId={organizationId} rounds={rounds} courses={courses} - participants={participants} - classes={classes} scoringMethod={tournament.scoring_method} onAddRound={onAddRound} onDeleteRound={onDeleteRound} @@ -1272,23 +1454,84 @@ function SetupTab({ )} {step === "players" && ( - { - await Promise.all([refreshPool(), onImportComplete()]) - }} - onRemoveParticipant={onRemoveParticipant} - onSetClass={onSetParticipantClass} - onSetStatLevel={onSetParticipantStatLevel} - onSetHandicap={onSetParticipantHandicap} - onSetStatus={onSetParticipantStatus} - /> +
+ { + await Promise.all([refreshPool(), onImportComplete()]) + }} + onRemoveParticipant={onRemoveParticipant} + onSetClass={onSetParticipantClass} + onSetStatLevel={onSetParticipantStatLevel} + onSetHandicap={onSetParticipantHandicap} + onSetStatus={onSetParticipantStatus} + /> + +
+
+

Rundedeltakelse

+

+ Hvem spiller hvilke runder, fra hvilket utslagssted -- for hele turneringen på én gang. +

+
+ {rounds.length === 0 ? ( +

+ Ingen runder ennå -- legg til runder under «Runder og baner» først. +

+ ) : roundParticipantsByRound === null ? ( +

Laster…

+ ) : ( + { + 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} + /> + )} +
+
)} {step === "groups" && ( @@ -2370,8 +2613,6 @@ function RoundsCard({ organizationId, rounds, courses, - participants, - classes, scoringMethod, onAddRound, onDeleteRound, @@ -2383,8 +2624,6 @@ function RoundsCard({ organizationId: string rounds: ApiRound[] courses: ApiCourse[] - participants: ApiParticipant[] - classes: ApiTournamentClass[] scoringMethod: string | null onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise onDeleteRound: (id: string) => Promise @@ -2415,10 +2654,7 @@ function RoundsCard({ onDeleteRound(r.id)} onError={onError} @@ -2644,35 +2880,24 @@ function NewRoundForm({ function RoundCard({ base, - organizationId, round, - participants, - classes, scoringMethod, onDelete, onError, }: { base: string - organizationId: string round: ApiRound - participants: ApiParticipant[] - classes: ApiTournamentClass[] scoringMethod: string | null onDelete: () => Promise onError: (message: string) => void }) { const [expanded, setExpanded] = useState(false) const [roundParticipants, setRoundParticipants] = useState(null) - const [tees, setTees] = useState([]) const [confirmingDelete, setConfirmingDelete] = useState(false) async function loadRoundParticipants() { - const [rpData, teeData] = await Promise.all([ - getJson(`${base}/rounds/${round.id}/participants`), - getJson(`/orgs/${organizationId}/courses/${round.course_id}/tees`), - ]) + const rpData = await getJson(`${base}/rounds/${round.id}/participants`) setRoundParticipants(rpData ?? []) - setTees(teeData ?? []) } useEffect(() => { @@ -2682,22 +2907,6 @@ function RoundCard({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [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) { const res = await fetch(`${base}/rounds/${round.id}/participants/${id}`, { method: "DELETE", @@ -2710,9 +2919,6 @@ function RoundCard({ 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 (
@@ -2805,20 +3011,9 @@ function RoundCard({ ))} - {unassigned.length > 0 && tees.length > 0 && ( - - )} - {unassigned.length > 0 && tees.length === 0 && ( -

- Banen «{round.course_name}» har ingen registrerte utslag ennå -- kan ikke legge til - deltakere før det er på plass. -

- )} +

+ Legg til eller fjern deltakere fra runden under «Spillere». +

)}
@@ -2827,92 +3022,25 @@ function RoundCard({ ) } -function AssignRoundParticipantControl({ - candidates, - tees, - classes, - onAssign, -}: { - candidates: ApiParticipant[] - tees: ApiTee[] - classes: ApiTournamentClass[] - onAssign: (tournamentParticipantId: string, teeId: string) => Promise -}) { - const [participantId, setParticipantId] = useState(candidates[0]?.id ?? "") - const [teeId, setTeeId] = useState(tees[0]?.id ?? "") - const [submitting, setSubmitting] = useState(false) - - useEffect(() => { - if (!candidates.some((c) => c.id === participantId)) setParticipantId(candidates[0]?.id ?? "") - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [candidates]) - - // 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 ( -
-
- - -
-
- - -
- -
- ) +// Klassens standardutslag brukes som forhåndsvalg ved bulk-tilordning i +// rundedeltakelse-tabellen (ADR-093) -- samme oppløsning som den nå +// fjernede AssignRoundParticipantControl brukte for ett-om-gangen- +// tilordning: deltakerens klasses standardutslag, KUN hvis det faktisk +// finnes på den aktuelle rundens bane (klassens standardutslag kan +// tilhøre en annen bane). Faller tilbake til rundens eget valgte +// "Standardutslag" (lokal UI-state i round-participation-table.tsx), +// og til slutt til det første utslaget i lista. +function resolveDefaultTeeId( + participant: ApiParticipant, + classes: ApiTournamentClass[], + tees: ApiTee[], + roundDefaultTeeId: string | null, +): string | null { + const cls = participant.class_id ? classes.find((c) => c.id === participant.class_id) : null + const classTee = cls?.default_tee_id ? tees.find((t) => t.id === cls.default_tee_id) : null + if (classTee) return classTee.id + if (roundDefaultTeeId && tees.some((t) => t.id === roundDefaultTeeId)) return roundDefaultTeeId + return tees[0]?.id ?? null } // ========================================================================= diff --git a/frontend/components/round-groups-panel.tsx b/frontend/components/round-groups-panel.tsx index 7e7b5e3..bf96928 100644 --- a/frontend/components/round-groups-panel.tsx +++ b/frontend/components/round-groups-panel.tsx @@ -237,6 +237,13 @@ export function RoundGroupsPanel({ } 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" }) { return ( @@ -335,6 +342,13 @@ export function RoundGroupsPanel({ Foreslå en fordeling, juster hvem som spiller sammen, lagre når du er fornøyd.

+ {totalRoundParticipants === 0 && ( +

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

+ )}