From 7bda1bf789454aeeb14282a47860ef2181e98a1b Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Tue, 4 Aug 2026 03:44:16 +0200 Subject: [PATCH] =?UTF-8?q?Alt=20er=20bygget=20og=20grundig=20scratch-veri?= =?UTF-8?q?fisert=20=E2=80=94=20b=C3=A5de=20API-niv=C3=A5=20og=20ekte=20ne?= =?UTF-8?q?ttleser-klikk,=20p=C3=A5=20begge=20turneringstyper.=20F=C3=B8r?= =?UTF-8?q?=20jeg=20ruller=20ut=20mot=20ekte=20teecup=5Fdb,=20her=20er=20p?= =?UTF-8?q?lanen:?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hva som skjer: Kjør migrasjon 053_tournament_classes.sql mot ekte teecup_db — ny tournament_class-tabell + nullable class_id-kolonne på team_roster og tournament_participant (rent additivt, ingen eksisterende data røres). docker compose up -d --build teecup_api teecup_frontend — begge containere bygges og redeployes. Hva som er verifisert: Klasser kan opprettes/slettes for begge turneringstyper, med valgfritt standardutslag (kaskaderende bane→utslag-valg) I lagturneringer: klasse foreslår riktig utslag automatisk når en spiller legges til en kamp (bekreftet: Kari→44, Ola→56), ingen endring i selve kampresultatet I individuelle turneringer: samme utslag-forslag ved runde-oppsett, PLUSS leaderboardet deler seg nå i egne seksjoner per klasse med riktig rangering innad i hver (bekreftet: Damer viste Kari #1/Siv #2, Herrer viste Ola #1 — matcher håndregnet slagsum) Sletter man en klasse, mister spillerne bare merkelappen (ikke fjernet fra laget/turneringen) Ingen klasser opprettet = identisk visning som før (bakoverkompatibelt) 98/98 enhetstester og 12/12 isolasjonstester uendret, ekte typesjekket produksjonsbuild --- 053_tournament_classes.sql | 41 ++ app/routers/individual_tournaments.py | 99 ++++- app/routers/tournaments.py | 220 +++++++++- .../individual-tournament-detail.tsx | 398 ++++++++++++++++-- frontend/components/session-blind-draw.tsx | 35 +- frontend/components/tournament-detail.tsx | 350 +++++++++++++-- 6 files changed, 1038 insertions(+), 105 deletions(-) create mode 100644 053_tournament_classes.sql diff --git a/053_tournament_classes.sql b/053_tournament_classes.sql new file mode 100644 index 0000000..be63f6d --- /dev/null +++ b/053_tournament_classes.sql @@ -0,0 +1,41 @@ +-- Konkurranseklasser (2026-08-03) -- organisator kan opprette fritt navngitte +-- klasser (f.eks. "Damer", "Herrer A", "Junior") med et valgfritt +-- standardutslag, og knytte deltakere til en klasse. Delt mellom begge +-- turneringstyper (samme `tournament`-tabell, ADR-037s format_type-mønster) +-- -- én tabell dekker begge, i stedet for to duplikat-tabeller. +-- +-- Brukt til (bekreftet med bruker FØR bygging): +-- 1. Forhåndsutfylle riktig utslag når en spiller legges til en match/runde +-- (BÅDE lagturneringer og individuelle turneringer) -- ren frontend- +-- bekvemmelighet, tee_id forblir required og eksplisitt i API-kontrakten. +-- 2. Egen resultatliste per klasse -- KUN i individuelle turneringer +-- (ADR-037, flatt felt). I lagturneringer (ADR-011, to lag) er poeng +-- knyttet til hele kamper, ikke enkeltspillere -- klasse er der bevisst +-- KUN et utslag-forslag, ingen egen leaderboard-splitting. +CREATE TABLE tournament_class ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + tournament_id uuid NOT NULL, + name text NOT NULL, + default_tee_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (organization_id, tournament_id) REFERENCES tournament (organization_id, id) ON DELETE CASCADE, + FOREIGN KEY (default_tee_id) REFERENCES tee (id) ON DELETE RESTRICT, + UNIQUE (tournament_id, name) +); +CREATE INDEX ON tournament_class (organization_id, tournament_id); + +-- Nullable -- en klasse er valgfri, og sletter man en klasse skal ikke +-- roster-/deltaker-radene forsvinne (samme SET NULL-prinsipp som +-- tournament_round_bbb_hole, migrasjon 044). +ALTER TABLE team_roster + ADD COLUMN class_id uuid REFERENCES tournament_class (id) ON DELETE SET NULL; +ALTER TABLE tournament_participant + ADD COLUMN class_id uuid REFERENCES tournament_class (id) ON DELETE SET NULL; + +-- Row-Level Security (samme org_isolation-policy-mønster som migrasjon 040). +ALTER TABLE tournament_class ENABLE ROW LEVEL SECURITY; +ALTER TABLE tournament_class FORCE ROW LEVEL SECURITY; +CREATE POLICY org_isolation ON tournament_class + USING (organization_id = current_setting('app.current_org', true)::uuid) + WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid); diff --git a/app/routers/individual_tournaments.py b/app/routers/individual_tournaments.py index eb91bb8..b7a250a 100644 --- a/app/routers/individual_tournaments.py +++ b/app/routers/individual_tournaments.py @@ -202,6 +202,15 @@ async def delete_round( class TournamentParticipantCreate(BaseModel): player_id: str + # Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql. + class_id: str | None = None + + +class TournamentParticipantUpdate(BaseModel): + """Eneste redigerbare felt i dag er klassetilhørighet -- exclude_unset, + samme PATCH-semantikk som resten av appen.""" + + class_id: str | None = None class TournamentParticipantOut(BaseModel): @@ -209,6 +218,16 @@ class TournamentParticipantOut(BaseModel): player_id: str player_name: str handicap_index_snapshot: float | None + class_id: str | None + class_name: str | None + + +_TOURNAMENT_PARTICIPANT_COLUMNS = """ + tp.id::text AS id, tp.player_id::text AS player_id, + p.display_name AS player_name, + tp.handicap_index_snapshot::float AS handicap_index_snapshot, + tp.class_id::text AS class_id, tc.name AS class_name +""" @router.post( @@ -228,22 +247,25 @@ async def add_tournament_participant( if player is None: raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.") row = await conn.fetchrow( - """ - INSERT INTO tournament_participant (organization_id, tournament_id, player_id, handicap_index_snapshot) - VALUES ($1, $2, $3, $4) - RETURNING id::text + f""" + WITH inserted AS ( + INSERT INTO tournament_participant + (organization_id, tournament_id, player_id, handicap_index_snapshot, class_id) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, player_id, handicap_index_snapshot, class_id + ) + SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} + FROM inserted tp + JOIN player p ON p.id = tp.player_id + LEFT JOIN tournament_class tc ON tc.id = tp.class_id """, organization_id, tournament_id, body.player_id, player["handicap_index"], + body.class_id, ) - return TournamentParticipantOut( - id=row["id"], - player_id=body.player_id, - player_name=player["display_name"], - handicap_index_snapshot=player["handicap_index"], - ) + return TournamentParticipantOut(**dict(row)) @router.get( @@ -259,12 +281,11 @@ async def list_tournament_participants( if exists is None: raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") rows = await conn.fetch( - """ - SELECT tp.id::text AS id, tp.player_id::text AS player_id, - p.display_name AS player_name, - tp.handicap_index_snapshot::float AS handicap_index_snapshot + f""" + SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} FROM tournament_participant tp JOIN player p ON p.id = tp.player_id + LEFT JOIN tournament_class tc ON tc.id = tp.class_id WHERE tp.tournament_id = $1 ORDER BY p.display_name """, @@ -273,6 +294,45 @@ async def list_tournament_participants( return [TournamentParticipantOut(**dict(r)) for r in rows] +@router.patch( + "/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}", + response_model=TournamentParticipantOut, +) +async def update_tournament_participant( + tournament_id: str, + participant_id: str, + body: TournamentParticipantUpdate, + organization_id: str = Depends(get_authorized_org), +) -> TournamentParticipantOut: + updates = body.model_dump(exclude_unset=True) + if not updates: + raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") + + set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)] + values = list(updates.values()) + values.append(participant_id) + values.append(tournament_id) + + async with org_connection(organization_id) as conn, translate_db_errors(): + row = await conn.fetchrow( + f""" + WITH updated AS ( + UPDATE tournament_participant SET {', '.join(set_clauses)} + WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)} + RETURNING id, player_id, handicap_index_snapshot, class_id + ) + SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} + FROM updated tp + JOIN player p ON p.id = tp.player_id + LEFT JOIN tournament_class tc ON tc.id = tp.class_id + """, + *values, + ) + if row is None: + raise app_error(404, "NOT_FOUND", "Deltakeren finnes ikke.") + return TournamentParticipantOut(**dict(row)) + + @router.delete( "/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}", status_code=204, @@ -973,6 +1033,11 @@ class LeaderboardEntry(BaseModel): stableford_total: int | None copenhagen_total: int | None bbb_total: int | None + # Konkurranseklasse (2026-08-03) -- frontend grupperer den allerede + # sorterte listen under på class_id (stabil gruppering bevarer riktig + # rangering per klasse). Sorteringen under er UENDRET -- ren tillegg. + class_id: str | None + class_name: str | None @router.get( @@ -1004,12 +1069,14 @@ async def individual_leaderboard( SUM(trs.net_total)::int AS net_total, SUM(trs.stableford_points)::int AS stableford_total, SUM(trs.copenhagen_points)::int AS copenhagen_total, - SUM(trs.bbb_points)::int AS bbb_total + SUM(trs.bbb_points)::int AS bbb_total, + tc.id::text AS class_id, tc.name AS class_name FROM tournament_participant tp JOIN player p ON p.id = tp.player_id LEFT JOIN tournament_round_score trs ON trs.tournament_participant_id = tp.id + LEFT JOIN tournament_class tc ON tc.id = tp.class_id WHERE tp.tournament_id = $1 - GROUP BY tp.id, p.display_name + GROUP BY tp.id, p.display_name, tc.id, tc.name """, tournament_id, ) diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 9ba19c6..bddbdc1 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -454,6 +454,8 @@ class RosterEntryCreate(BaseModel): is_captain: bool = False # None => fryses fra player.handicap_index ved innsetting (ADR-007). handicap_index_snapshot: float | None = None + # Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql. + class_id: str | None = None class RosterEntry(BaseModel): @@ -462,6 +464,16 @@ class RosterEntry(BaseModel): display_name: str handicap_index_snapshot: float | None is_captain: bool + class_id: str | None + class_name: str | None + + +_ROSTER_COLUMNS = """ + tr.id::text, tr.player_id::text, + tr.handicap_index_snapshot::float AS handicap_index_snapshot, + tr.is_captain, p.display_name, + tr.class_id::text AS class_id, tc.name AS class_name +""" @router.get("/orgs/{organization_id}/teams/{team_id}/roster", response_model=list[RosterEntry]) @@ -471,12 +483,11 @@ async def list_roster( ) -> list[RosterEntry]: async with org_connection(organization_id) as conn: rows = await conn.fetch( - """ - SELECT tr.id::text, tr.player_id::text, - tr.handicap_index_snapshot::float AS handicap_index_snapshot, - tr.is_captain, p.display_name + f""" + SELECT {_ROSTER_COLUMNS} FROM team_roster tr JOIN player p ON p.id = tr.player_id + LEFT JOIN tournament_class tc ON tc.id = tr.class_id WHERE tr.team_id = $1 ORDER BY p.display_name """, @@ -543,26 +554,37 @@ async def add_roster_entry( """ WITH inserted AS ( INSERT INTO team_roster - (organization_id, team_id, player_id, handicap_index_snapshot, is_captain) - VALUES ($1, $2, $3, $4, $5) - RETURNING id, player_id, handicap_index_snapshot, is_captain + (organization_id, team_id, player_id, handicap_index_snapshot, is_captain, class_id) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, player_id, handicap_index_snapshot, is_captain, class_id ) SELECT inserted.id::text, inserted.player_id::text, inserted.handicap_index_snapshot::float AS handicap_index_snapshot, - inserted.is_captain, p.display_name - FROM inserted JOIN player p ON p.id = inserted.player_id + inserted.is_captain, p.display_name, + inserted.class_id::text AS class_id, tc.name AS class_name + FROM inserted + JOIN player p ON p.id = inserted.player_id + LEFT JOIN tournament_class tc ON tc.id = inserted.class_id """, organization_id, team_id, body.player_id, snapshot, body.is_captain, + body.class_id, ) return RosterEntry(**dict(row)) class RosterEntryUpdate(BaseModel): - is_captain: bool + """Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som + TournamentUpdate/SponsorUpdate. `class_id` lagt til 2026-08-03 (bredere + enn tidligere, da feltet KUN var is_captain: bool påkrevd) -- utvidet + slik at klasse kan settes/endres uten å tvinge et samtidig + kaptein-valg.""" + + is_captain: bool | None = None + class_id: str | None = None @router.patch( @@ -575,6 +597,10 @@ async def update_roster_entry( body: RosterEntryUpdate, organization_id: str = Depends(get_authorized_org), ) -> RosterEntry: + updates = body.model_dump(exclude_unset=True) + if not updates: + raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") + # 2026-07-19 (Brukerroller-runden, ADR-023): kaptein er nå en reell # autorisasjonsrolle (se app/team_authz.py sin user_is_team_captain), så # "kun én kaptein per lag" håndheves eksplisitt her -- å sette en NY @@ -582,28 +608,34 @@ async def update_roster_entry( # SAMME lag, i samme transaksjon (org_connection åpner allerede én). # Å fjerne kapteinmerket (is_captain=false) rører ingen andre rader. async with org_connection(organization_id) as conn: - if body.is_captain: + if updates.get("is_captain"): await conn.execute( "UPDATE team_roster SET is_captain = false WHERE team_id = $1 AND id <> $2", team_id, roster_id, ) + + set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)] + values = list(updates.values()) + values.append(roster_id) + values.append(team_id) + row = await conn.fetchrow( - """ + f""" WITH updated AS ( - UPDATE team_roster - SET is_captain = $1 - WHERE id = $2 AND team_id = $3 - RETURNING id, player_id, handicap_index_snapshot, is_captain + UPDATE team_roster SET {', '.join(set_clauses)} + WHERE id = ${len(values) - 1} AND team_id = ${len(values)} + RETURNING id, player_id, handicap_index_snapshot, is_captain, class_id ) SELECT updated.id::text, updated.player_id::text, updated.handicap_index_snapshot::float AS handicap_index_snapshot, - updated.is_captain, p.display_name - FROM updated JOIN player p ON p.id = updated.player_id + updated.is_captain, p.display_name, + updated.class_id::text AS class_id, tc.name AS class_name + FROM updated + JOIN player p ON p.id = updated.player_id + LEFT JOIN tournament_class tc ON tc.id = updated.class_id """, - body.is_captain, - roster_id, - team_id, + *values, ) if row is None: raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.") @@ -1356,6 +1388,152 @@ async def delete_sponsor( raise app_error(404, "NOT_FOUND", "Sponsoren finnes ikke.") +# --------------------------------------------------------------------------- +# Konkurranseklasser (2026-08-03) -- fritt navngitte, valgfritt standard- +# utslag. Delt mellom lag- og individuelle turneringer (samme `tournament`- +# tabell). Brukes til å forhåndsutfylle riktig utslag når en deltaker legges +# til en match (her)/runde (individual_tournaments.py), og -- KUN for +# individuelle turneringer -- til å dele opp leaderboardet. Se +# 053_tournament_classes.sql for full begrunnelse. +# --------------------------------------------------------------------------- + +class TournamentClassCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + default_tee_id: str | None = None + + +class TournamentClassUpdate(BaseModel): + """Alle felt valgfrie -- exclude_unset, samme PATCH-semantikk som + TournamentUpdate/SponsorUpdate.""" + + name: str | None = Field(default=None, min_length=1, max_length=200) + default_tee_id: str | None = None + + +_CLASS_COLUMNS = """ + tc.id::text, tc.name, tc.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name +""" + + +class TournamentClass(BaseModel): + id: str + name: str + default_tee_id: str | None + default_tee_name: str | None + + +@router.get( + "/orgs/{organization_id}/tournaments/{tournament_id}/classes", + response_model=list[TournamentClass], +) +async def list_tournament_classes( + tournament_id: str, + organization_id: str = Depends(get_authorized_org), +) -> list[TournamentClass]: + async with org_connection(organization_id) as conn: + rows = await conn.fetch( + f""" + SELECT {_CLASS_COLUMNS} + FROM tournament_class tc + LEFT JOIN tee ON tee.id = tc.default_tee_id + WHERE tc.tournament_id = $1 + ORDER BY tc.created_at + """, + tournament_id, + ) + return [TournamentClass(**dict(r)) for r in rows] + + +@router.post( + "/orgs/{organization_id}/tournaments/{tournament_id}/classes", + response_model=TournamentClass, + status_code=201, +) +async def create_tournament_class( + tournament_id: str, + body: TournamentClassCreate, + organization_id: str = Depends(get_authorized_org), +) -> TournamentClass: + async with org_connection(organization_id) as conn, translate_db_errors(): + tournament = await conn.fetchval("SELECT id FROM tournament WHERE id = $1", tournament_id) + if tournament is None: + raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") + row = await conn.fetchrow( + f""" + WITH inserted AS ( + INSERT INTO tournament_class (organization_id, tournament_id, name, default_tee_id) + VALUES ($1, $2, $3, $4) + RETURNING id, name, default_tee_id + ) + SELECT inserted.id::text, inserted.name, + inserted.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name + FROM inserted LEFT JOIN tee ON tee.id = inserted.default_tee_id + """, + organization_id, + tournament_id, + body.name, + body.default_tee_id, + ) + return TournamentClass(**dict(row)) + + +@router.patch( + "/orgs/{organization_id}/tournaments/{tournament_id}/classes/{class_id}", + response_model=TournamentClass, +) +async def update_tournament_class( + tournament_id: str, + class_id: str, + body: TournamentClassUpdate, + organization_id: str = Depends(get_authorized_org), +) -> TournamentClass: + updates = body.model_dump(exclude_unset=True) + if not updates: + raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.") + + set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)] + values = list(updates.values()) + values.append(class_id) + values.append(tournament_id) + + async with org_connection(organization_id) as conn, translate_db_errors(): + row = await conn.fetchrow( + f""" + WITH updated AS ( + UPDATE tournament_class SET {', '.join(set_clauses)} + WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)} + RETURNING id, name, default_tee_id + ) + SELECT updated.id::text, updated.name, + updated.default_tee_id::text AS default_tee_id, tee.name AS default_tee_name + FROM updated LEFT JOIN tee ON tee.id = updated.default_tee_id + """, + *values, + ) + if row is None: + raise app_error(404, "NOT_FOUND", "Klassen finnes ikke.") + return TournamentClass(**dict(row)) + + +@router.delete( + "/orgs/{organization_id}/tournaments/{tournament_id}/classes/{class_id}", + status_code=204, +) +async def delete_tournament_class( + tournament_id: str, + class_id: str, + organization_id: str = Depends(get_authorized_org), +) -> None: + async with org_connection(organization_id) as conn: + deleted = await conn.fetchval( + "DELETE FROM tournament_class WHERE id = $1 AND tournament_id = $2 RETURNING id", + class_id, + tournament_id, + ) + if deleted is None: + raise app_error(404, "NOT_FOUND", "Klassen finnes ikke.") + + # --------------------------------------------------------------------------- # Leaderboard (turnering-total, summert på tvers av alle økter/matcher) # diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index 0f25056..aef0647 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -66,6 +66,21 @@ type ApiParticipant = { player_id: string player_name: string handicap_index_snapshot: number | null + class_id: string | null + class_name: string | null +} + +// 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 +// 053_tournament_classes.sql for full begrunnelse og forskjellen fra +// lagturneringer (tournament-detail.tsx), hvor klasse KUN styrer utslag. +type ApiTournamentClass = { + id: string + name: string + default_tee_id: string | null + default_tee_name: string | null } type ApiRound = { @@ -108,6 +123,8 @@ type ApiLeaderboardEntry = { stableford_total: number | null copenhagen_total: number | null bbb_total: number | null + class_id: string | null + class_name: string | null } type ApiFlagResult = { @@ -180,6 +197,7 @@ export function IndividualTournamentDetail({ const [participants, setParticipants] = useState([]) const [rounds, setRounds] = useState([]) const [courses, setCourses] = useState([]) + const [classes, setClasses] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [tab, setTab] = useState<"setup" | "score" | "leaderboard" | "presentation">("setup") @@ -190,11 +208,12 @@ export function IndividualTournamentDetail({ let cancelled = false async function load() { try { - const [tournamentsData, participantsData, roundsData, coursesData] = await Promise.all([ + const [tournamentsData, participantsData, roundsData, coursesData, classesData] = await Promise.all([ getJson(`/orgs/${organizationId}/tournaments`), getJson(`${base}/participants`), getJson(`${base}/rounds`), getJson(`/orgs/${organizationId}/courses`), + getJson(`${base}/classes`), ]) if (cancelled) return const mine = tournamentsData?.find((t) => t.id === tournamentId) ?? null @@ -206,6 +225,7 @@ export function IndividualTournamentDetail({ setParticipants(participantsData ?? []) setRounds(roundsData ?? []) setCourses(coursesData ?? []) + setClasses(classesData ?? []) } catch { if (!cancelled) setError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.") } finally { @@ -317,6 +337,51 @@ export function IndividualTournamentDetail({ setParticipants((prev) => prev.filter((p) => p.id !== participantId)) } + async function setParticipantClass(participantId: string, classId: string | null) { + setError(null) + const res = await fetch(`${base}/participants/${participantId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ class_id: classId }), + }) + if (!res.ok) { + setError(await errorMessage(res, "Klarte ikke å endre klasse.")) + return + } + const updated: ApiParticipant = await res.json() + setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p))) + } + + async function createClass(name: string, defaultTeeId: string | null) { + setError(null) + const res = await fetch(`${base}/classes`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ name, default_tee_id: defaultTeeId }), + }) + if (!res.ok) { + setError(await errorMessage(res, "Klarte ikke å opprette klassen.")) + return + } + const created: ApiTournamentClass = await res.json() + setClasses((prev) => [...prev, created]) + } + + async function deleteClass(classId: string) { + setError(null) + const res = await fetch(`${base}/classes/${classId}`, { method: "DELETE", credentials: "include" }) + if (res.status !== 204) { + setError(await errorMessage(res, "Klarte ikke å fjerne klassen.")) + return + } + setClasses((prev) => prev.filter((c) => c.id !== classId)) + setParticipants((prev) => + prev.map((p) => (p.class_id === classId ? { ...p, class_id: null, class_name: null } : p)), + ) + } + async function addRound(name: string, courseId: string, holeConfig: ApiRound["hole_config"]) { setError(null) const res = await fetch(`${base}/rounds`, { @@ -462,6 +527,7 @@ export function IndividualTournamentDetail({ participants={participants} rounds={rounds} courses={courses} + classes={classes} onUpdateScoringMethod={updateScoringMethod} onUpdateBbbSweepBonus={updateBbbSweepBonus} onAddParticipant={addParticipant} @@ -470,6 +536,9 @@ export function IndividualTournamentDetail({ onAddRound={addRound} onDeleteRound={deleteRound} onAddCourse={addCourse} + onCreateClass={createClass} + onDeleteClass={deleteClass} + onSetParticipantClass={setParticipantClass} onError={setError} /> )} @@ -527,6 +596,7 @@ function SetupTab({ participants, rounds, courses, + classes, onUpdateScoringMethod, onUpdateBbbSweepBonus, onAddParticipant, @@ -535,6 +605,9 @@ function SetupTab({ onAddRound, onDeleteRound, onAddCourse, + onCreateClass, + onDeleteClass, + onSetParticipantClass, onError, }: { base: string @@ -543,6 +616,7 @@ function SetupTab({ participants: ApiParticipant[] rounds: ApiRound[] courses: ApiCourse[] + classes: ApiTournamentClass[] onUpdateScoringMethod: (method: string) => void onUpdateBbbSweepBonus: (enabled: boolean) => void onAddParticipant: (playerId: string) => Promise @@ -551,6 +625,9 @@ function SetupTab({ onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise onDeleteRound: (id: string) => Promise onAddCourse: (name: string) => Promise + onCreateClass: (name: string, defaultTeeId: string | null) => Promise + onDeleteClass: (id: string) => Promise + onSetParticipantClass: (participantId: string, classId: string | null) => Promise onError: (message: string) => void }) { const [pool, setPool] = useState([]) @@ -608,12 +685,22 @@ function SetupTab({ )} + + Promise + onDelete: (classId: string) => Promise +}) { + const [name, setName] = useState("") + const [courseId, setCourseId] = useState("") + const [teeId, setTeeId] = useState("") + const [tees, setTees] = useState([]) + const [adding, setAdding] = useState(false) + + useEffect(() => { + if (!courseId) { + setTees([]) + setTeeId("") + return + } + let cancelled = false + getJson(`/orgs/${organizationId}/courses/${courseId}/tees`).then((data) => { + if (!cancelled) { + setTees(data ?? []) + setTeeId(data?.[0]?.id ?? "") + } + }) + return () => { + cancelled = true + } + }, [organizationId, courseId]) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (name.trim().length === 0 || adding) return + setAdding(true) + await onCreate(name.trim(), teeId || null) + setAdding(false) + setName("") + setCourseId("") + setTeeId("") + } + + return ( +
+
+

Klasser

+ + {classes.length} + +
+

+ Foreslår riktig utslag ved oppsett av en runde, og gir hver klasse sin egen seksjon på + leaderboardet. +

+ + {classes.length > 0 && ( +
    + {classes.map((c) => ( +
  • +
    + {c.name} + {c.default_tee_name && ( + + Standardutslag: {c.default_tee_name} + + )} +
    + +
  • + ))} +
+ )} + +
+
+ + setName(e.target.value)} + placeholder="F.eks. Damer" + className="h-11" + /> +
+
+ + +
+ {tees.length > 0 && ( +
+ + +
+ )} + +
+
+ ) +} + function ParticipantsCard({ participants, pool, + classes, onAddParticipant, onAddNewPlayer, onRemoveParticipant, + onSetClass, }: { participants: ApiParticipant[] pool: ApiPlayer[] + classes: ApiTournamentClass[] onAddParticipant: (playerId: string) => Promise onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise onRemoveParticipant: (id: string) => Promise + onSetClass: (participantId: string, classId: string | null) => Promise }) { return (
@@ -662,13 +909,28 @@ function ParticipantsCard({
    {participants.map((p) => ( -
  • +
  • {p.player_name} {p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"}
    + {classes.length > 0 && ( + + )} +
  • + ))} +
+ )} + +
+
+ + setName(e.target.value)} + placeholder="F.eks. Damer" + className="h-11" + /> +
+
+ + +
+ {tees.length > 0 && ( +
+ + +
+ )} + +
+
+ ) +} + function CreateTeamCard({ slot, onCreate, @@ -586,6 +854,7 @@ function TeamPanel({ team, otherTeam, pool, + classes, chatHref, findPlayerTeam, onToggleCaptain, @@ -595,10 +864,12 @@ function TeamPanel({ onAddNew, onUpdatePlayer, onConcedeTournament, + onSetClass, }: { team: Team otherTeam: Team | null pool: ApiPlayer[] + classes: ApiTournamentClass[] chatHref: string findPlayerTeam: (playerId: string) => Team | null onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void @@ -611,6 +882,7 @@ function TeamPanel({ updates: { display_name?: string; handicap_index?: number | null; gender?: string | null }, ) => void onConcedeTournament: (teamId: string) => Promise + onSetClass: (teamId: string, rosterId: string, classId: string | null) => void }) { const [confirmingId, setConfirmingId] = useState(null) const [editingPlayerId, setEditingPlayerId] = useState(null) @@ -737,6 +1009,11 @@ function TeamPanel({ Kaptein )} + {entry.class_name && ( + + {entry.class_name} + + )} {formatHandicap(entry.handicap_index_snapshot)} @@ -763,6 +1040,25 @@ function TeamPanel({