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
|
||||
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)
|
||||
|
|
|
|||
65
CHANGELOG.md
65
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.
|
||||
|
|
|
|||
|
|
@ -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,18 +553,15 @@ async def _compute_round_participant_handicap(conn, round_participant_id: str) -
|
|||
)
|
||||
|
||||
|
||||
@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():
|
||||
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
|
||||
|
|
@ -568,7 +575,7 @@ async def add_round_participant(
|
|||
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)
|
||||
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.")
|
||||
|
||||
|
|
@ -581,7 +588,7 @@ async def add_round_participant(
|
|||
JOIN player p ON p.id = tp.player_id
|
||||
WHERE tp.id = $1
|
||||
""",
|
||||
body.tournament_participant_id,
|
||||
tournament_participant_id,
|
||||
)
|
||||
if player_row is None:
|
||||
raise app_error(404, "NOT_FOUND", "Turnering-deltakeren finnes ikke.")
|
||||
|
|
@ -600,7 +607,7 @@ async def add_round_participant(
|
|||
)
|
||||
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,
|
||||
tee_id,
|
||||
player_row["gender"],
|
||||
)
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
@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(
|
||||
"""
|
||||
WITH inserted AS (
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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 = {
|
||||
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,6 +1454,7 @@ function SetupTab({
|
|||
)}
|
||||
|
||||
{step === "players" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
<ParticipantsCard
|
||||
organizationId={organizationId}
|
||||
tournamentId={tournament.id}
|
||||
|
|
@ -1289,6 +1472,66 @@ function SetupTab({
|
|||
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" && (
|
||||
|
|
@ -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<void>
|
||||
onDeleteRound: (id: string) => Promise<void>
|
||||
|
|
@ -2415,10 +2654,7 @@ function RoundsCard({
|
|||
<RoundCard
|
||||
key={r.id}
|
||||
base={base}
|
||||
organizationId={organizationId}
|
||||
round={r}
|
||||
participants={participants}
|
||||
classes={classes}
|
||||
scoringMethod={scoringMethod}
|
||||
onDelete={() => 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<void>
|
||||
onError: (message: string) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null)
|
||||
const [tees, setTees] = useState<ApiTee[]>([])
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
|
||||
async function loadRoundParticipants() {
|
||||
const [rpData, teeData] = await Promise.all([
|
||||
getJson<ApiRoundParticipant[]>(`${base}/rounds/${round.id}/participants`),
|
||||
getJson<ApiTee[]>(`/orgs/${organizationId}/courses/${round.course_id}/tees`),
|
||||
])
|
||||
const rpData = await getJson<ApiRoundParticipant[]>(`${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 (
|
||||
<div className="overflow-hidden rounded-xl border border-border">
|
||||
<div className="flex items-center gap-3 bg-background px-4 py-3">
|
||||
|
|
@ -2805,20 +3011,9 @@ function RoundCard({
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{unassigned.length > 0 && tees.length > 0 && (
|
||||
<AssignRoundParticipantControl
|
||||
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 på plass.
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Legg til eller fjern deltakere fra runden under «Spillere».
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -2827,92 +3022,25 @@ function RoundCard({
|
|||
)
|
||||
}
|
||||
|
||||
function AssignRoundParticipantControl({
|
||||
candidates,
|
||||
tees,
|
||||
classes,
|
||||
onAssign,
|
||||
}: {
|
||||
candidates: ApiParticipant[]
|
||||
tees: ApiTee[]
|
||||
classes: ApiTournamentClass[]
|
||||
onAssign: (tournamentParticipantId: string, teeId: string) => Promise<void>
|
||||
}) {
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
// 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
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
</p>
|
||||
</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">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-bold text-foreground">Gruppestørrelse</span>
|
||||
|
|
@ -416,7 +430,11 @@ export function RoundGroupsPanel({
|
|||
Ugrupperte ({ungrouped.length})
|
||||
</h3>
|
||||
{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">
|
||||
{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