Compare commits

...

2 commits

Author SHA1 Message Date
7bda1bf789 Alt er bygget og grundig scratch-verifisert — både API-nivå og ekte nettleser-klikk, på begge turneringstyper. Før jeg ruller ut mot ekte teecup_db, her er planen:
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
2026-08-04 03:44:16 +02:00
83caddf193 Noter plan for internasjonale baner (golfapi.io) i backlog/ADR-019
Ren dokumentasjonsrunde, ingen kode - fanger retningen fra brukerens
eksterne research (Gemini) om hvordan TeeCup kan støtte baner utenfor
Norge når/hvis produktet kommersialiseres. Golfapi.io valgt som
leverandør, anbefalt tilnærming er live API + egen caching (samme
import-ved-eksplisitt-valg-prinsipp som ADR-019 allerede etablerte for
teeoff), ikke det dyre CSV-databasekjøpet. Ingen ADR skrevet ennå -
bevisst utsatt til dette faktisk skal bygges.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:15:13 +02:00
8 changed files with 1104 additions and 105 deletions

View file

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

View file

@ -660,6 +660,13 @@ db/RLS-avhengighet — samme isolasjonsprinsipp som `handicap_engine.py`).
`POST .../courses/official-import`. Migrasjon `010_official_course_unique_ `POST .../courses/official-import`. Migrasjon `010_official_course_unique_
ref.sql`. ref.sql`.
**Tillegg 2026-08-03 (research, ikke bygget):** samme import-ved-eksplisitt-
valg-prinsipp er identifisert som riktig mal for en fremtidig internasjonal
banekilde (golfapi.io, for baner utenfor Norge/`teeoff_db`s dekning) — en
tredje `course.source`-verdi + `app/golfapi_client.py` ved siden av
`app/teeoff_client.py`, ikke en ny arkitektur. Ingen ADR-runde startet ennå,
kun notert. Full detalj i FEATURE_BACKLOG.md, «Internasjonale baner».
--- ---
## ADR-020 — Invitasjonskode (oppdagelse), matchledelse og projisert stilling ## ADR-020 — Invitasjonskode (oppdagelse), matchledelse og projisert stilling

View file

@ -2130,6 +2130,65 @@ sammenslåing eller rekkefølge-endring i en senere runde.
--- ---
### Internasjonale baner (utenfor Norge) — 📋 NOTERT, IKKE designet/bygget (research 2026-08-03)
Brukeren undersøkte (i en ekstern Gemini-samtale, ikke denne økten — notert
her for å fange retningen før den glemmes, samme rutine som Order of
Merit-notatet over) hvordan TeeCup kan støtte baner utenfor Norge, siden
`teeoff_db` (ADR-004/ADR-019) kun dekker norske baner. Ingen kode skrevet,
ingen ADR-runde startet ennå — dette er et fremtidig, ikke-akutt behov,
relevant når/hvis TeeCup faktisk kommersialiseres og får brukere utenfor
Norge (se `teecup.golf`-domeneplanen, kjøpt med sikte på at TeeCup etter
hvert flytter dit fra `teecup.teeoff.no`).
**Valgt dataleverandør: Golf API (golfapi.io)** — ~42 000 baner globalt,
tilbyr BÅDE et REST-API og en fullstendig CSV-database-eksport
(`clubs.csv`/`courses.csv`/`tees.csv`/`coordinates.csv`). Andre vurderte
og forkastet: iGolf (40 000+, men B2B/enterprise-dyrt), GolfCourseAPI
(~30 000, gratisnivå men mer begrenset), Golf Intelligence (fokusert på
simulatorer/3D, feil målgruppe).
**Anbefalt tilnærming: Live API + egen caching, IKKE CSV-database-kjøp
foreløpig.** Dette er strukturelt identisk med mønsteret ADR-019 allerede
etablerte for teeoff-import (`source='official'` + `external_course_ref`,
kopiér-ved-eksplisitt-valg i stedet for live oppslag ved hver bruk) — samme
"importer én gang, lagre lokalt, aldri avhengig av en ekstern tjenestes
oppetid ved handicap-beregning"-prinsipp, bare med en ny ekstern kilde i
tillegg til teeoff. Golfapi.io sin avtale tillater eksplisitt caching
("you can store/cache the fetched course data... no need to call the API
to fetch the same course multiple times") — så en tredje `course.source`-
verdi (f.eks. `'international'`, mønster fra `'official'`) med et eget
`app/golfapi_client.py` (samme isolasjonsprinsipp som `app/teeoff_client.py`)
er den naturlige videreføringen NÅR dette designes, ikke en ny arkitektur.
CSV-database-kjøpet (3 995-5 995 € for alle baner, avhengig av om
koordinater inkluderes) er en betydelig forhåndskostnad som er unødvendig
så lenge caching-tilnærmingen dekker behovet organisk (bruker søker opp en
bane → 1 API-kall → lagres lokalt for alltid).
**Prisplan hos golfapi.io** (hentet 2026-08-03, kan være utdatert ved
faktisk bygging — bekreft på nytt): API-kall/måned: 50→29€, 500→99€,
2000→199€, 4000→299€, ubegrenset→399€ (krever årsabonnement). Et
engangskjøp på 500 tidsubegrensede kall finnes for 199€ (ingen løpende
kostnad, egnet for en beta-fase med få brukere). 15 dagers gratis prøve.
CSV-eksport (hvis man likevel velger det senere): «All courses» 3 995€
uten koordinater/5 995€ med, «Europa»/«USA»/«Asia-Pacific+Africa» hver
1 995€/2 995€. CSV gir 6 måneders oppdateringstilgang, deretter 20% av
opprinnelig pris per år for videre oppdateringer.
**Bevisst utsatt, naturlig neste steg NÅR dette skal bygges:** en liten
ADR (samme mønster som ADR-019) som dekker (1) ny `course.source`-verdi +
migrasjon, (2) `app/golfapi_client.py` sitt kontraktsformat + enhets-
konvertering (advarsel fra research: amerikanske kilder returnerer ofte
yards, må konverteres til meter FØR lagring — `hole`/`tee_rating`-
tabellene har ingen enhets-diskriminator i dag), (3) hvordan bane-søket i
frontend (samme to-felts-forvirring som teeoff-søket over, se det åpne
punktet rett over) skal skille "søk norske baner (teeoff)" fra "søk
internasjonale baner (golfapi.io)" — trolig ett samlet søkefelt med
landkode-basert ruting internt, ikke to synlige knapper. Ikke noe hastverk
— ingen brukere utenfor Norge ennå.
---
## Invitasjonskode, ledende side og projisert stilling — ADR-020 ## Invitasjonskode, ledende side og projisert stilling — ADR-020
Reist av brukeren 2026-07-19, rett etter at kamp-play-flyten var komplett. Reist av brukeren 2026-07-19, rett etter at kamp-play-flyten var komplett.

View file

@ -202,6 +202,15 @@ async def delete_round(
class TournamentParticipantCreate(BaseModel): class TournamentParticipantCreate(BaseModel):
player_id: str 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): class TournamentParticipantOut(BaseModel):
@ -209,6 +218,16 @@ class TournamentParticipantOut(BaseModel):
player_id: str player_id: str
player_name: str player_name: str
handicap_index_snapshot: float | None 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( @router.post(
@ -228,22 +247,25 @@ async def add_tournament_participant(
if player is None: if player is None:
raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.") raise app_error(404, "NOT_FOUND", "Spilleren finnes ikke.")
row = await conn.fetchrow( row = await conn.fetchrow(
""" f"""
INSERT INTO tournament_participant (organization_id, tournament_id, player_id, handicap_index_snapshot) WITH inserted AS (
VALUES ($1, $2, $3, $4) INSERT INTO tournament_participant
RETURNING id::text (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, organization_id,
tournament_id, tournament_id,
body.player_id, body.player_id,
player["handicap_index"], player["handicap_index"],
body.class_id,
) )
return TournamentParticipantOut( return TournamentParticipantOut(**dict(row))
id=row["id"],
player_id=body.player_id,
player_name=player["display_name"],
handicap_index_snapshot=player["handicap_index"],
)
@router.get( @router.get(
@ -259,12 +281,11 @@ async def list_tournament_participants(
if exists is None: if exists is None:
raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.") raise app_error(404, "NOT_FOUND", "Turneringen finnes ikke.")
rows = await conn.fetch( rows = await conn.fetch(
""" f"""
SELECT tp.id::text AS id, tp.player_id::text AS player_id, SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
p.display_name AS player_name,
tp.handicap_index_snapshot::float AS handicap_index_snapshot
FROM tournament_participant tp FROM tournament_participant tp
JOIN player p ON p.id = tp.player_id 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 WHERE tp.tournament_id = $1
ORDER BY p.display_name ORDER BY p.display_name
""", """,
@ -273,6 +294,45 @@ async def list_tournament_participants(
return [TournamentParticipantOut(**dict(r)) for r in rows] 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( @router.delete(
"/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}", "/orgs/{organization_id}/tournaments/{tournament_id}/participants/{participant_id}",
status_code=204, status_code=204,
@ -973,6 +1033,11 @@ class LeaderboardEntry(BaseModel):
stableford_total: int | None stableford_total: int | None
copenhagen_total: int | None copenhagen_total: int | None
bbb_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( @router.get(
@ -1004,12 +1069,14 @@ async def individual_leaderboard(
SUM(trs.net_total)::int AS net_total, SUM(trs.net_total)::int AS net_total,
SUM(trs.stableford_points)::int AS stableford_total, SUM(trs.stableford_points)::int AS stableford_total,
SUM(trs.copenhagen_points)::int AS copenhagen_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 FROM tournament_participant tp
JOIN player p ON p.id = tp.player_id 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_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 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, tournament_id,
) )

View file

@ -454,6 +454,8 @@ class RosterEntryCreate(BaseModel):
is_captain: bool = False is_captain: bool = False
# None => fryses fra player.handicap_index ved innsetting (ADR-007). # None => fryses fra player.handicap_index ved innsetting (ADR-007).
handicap_index_snapshot: float | None = None handicap_index_snapshot: float | None = None
# Konkurranseklasse (2026-08-03) -- valgfri, se 053_tournament_classes.sql.
class_id: str | None = None
class RosterEntry(BaseModel): class RosterEntry(BaseModel):
@ -462,6 +464,16 @@ class RosterEntry(BaseModel):
display_name: str display_name: str
handicap_index_snapshot: float | None handicap_index_snapshot: float | None
is_captain: bool 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]) @router.get("/orgs/{organization_id}/teams/{team_id}/roster", response_model=list[RosterEntry])
@ -471,12 +483,11 @@ async def list_roster(
) -> list[RosterEntry]: ) -> list[RosterEntry]:
async with org_connection(organization_id) as conn: async with org_connection(organization_id) as conn:
rows = await conn.fetch( rows = await conn.fetch(
""" f"""
SELECT tr.id::text, tr.player_id::text, SELECT {_ROSTER_COLUMNS}
tr.handicap_index_snapshot::float AS handicap_index_snapshot,
tr.is_captain, p.display_name
FROM team_roster tr FROM team_roster tr
JOIN player p ON p.id = tr.player_id 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 WHERE tr.team_id = $1
ORDER BY p.display_name ORDER BY p.display_name
""", """,
@ -543,26 +554,37 @@ async def add_roster_entry(
""" """
WITH inserted AS ( WITH inserted AS (
INSERT INTO team_roster INSERT INTO team_roster
(organization_id, team_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) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, player_id, handicap_index_snapshot, is_captain RETURNING id, player_id, handicap_index_snapshot, is_captain, class_id
) )
SELECT inserted.id::text, inserted.player_id::text, SELECT inserted.id::text, inserted.player_id::text,
inserted.handicap_index_snapshot::float AS handicap_index_snapshot, inserted.handicap_index_snapshot::float AS handicap_index_snapshot,
inserted.is_captain, p.display_name inserted.is_captain, p.display_name,
FROM inserted JOIN player p ON p.id = inserted.player_id 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, organization_id,
team_id, team_id,
body.player_id, body.player_id,
snapshot, snapshot,
body.is_captain, body.is_captain,
body.class_id,
) )
return RosterEntry(**dict(row)) return RosterEntry(**dict(row))
class RosterEntryUpdate(BaseModel): 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( @router.patch(
@ -575,6 +597,10 @@ async def update_roster_entry(
body: RosterEntryUpdate, body: RosterEntryUpdate,
organization_id: str = Depends(get_authorized_org), organization_id: str = Depends(get_authorized_org),
) -> RosterEntry: ) -> 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 # 2026-07-19 (Brukerroller-runden, ADR-023): kaptein er nå en reell
# autorisasjonsrolle (se app/team_authz.py sin user_is_team_captain), så # autorisasjonsrolle (se app/team_authz.py sin user_is_team_captain), så
# "kun én kaptein per lag" håndheves eksplisitt her -- å sette en NY # "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). # SAMME lag, i samme transaksjon (org_connection åpner allerede én).
# Å fjerne kapteinmerket (is_captain=false) rører ingen andre rader. # Å fjerne kapteinmerket (is_captain=false) rører ingen andre rader.
async with org_connection(organization_id) as conn: async with org_connection(organization_id) as conn:
if body.is_captain: if updates.get("is_captain"):
await conn.execute( await conn.execute(
"UPDATE team_roster SET is_captain = false WHERE team_id = $1 AND id <> $2", "UPDATE team_roster SET is_captain = false WHERE team_id = $1 AND id <> $2",
team_id, team_id,
roster_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( row = await conn.fetchrow(
""" f"""
WITH updated AS ( WITH updated AS (
UPDATE team_roster UPDATE team_roster SET {', '.join(set_clauses)}
SET is_captain = $1 WHERE id = ${len(values) - 1} AND team_id = ${len(values)}
WHERE id = $2 AND team_id = $3 RETURNING id, player_id, handicap_index_snapshot, is_captain, class_id
RETURNING id, player_id, handicap_index_snapshot, is_captain
) )
SELECT updated.id::text, updated.player_id::text, SELECT updated.id::text, updated.player_id::text,
updated.handicap_index_snapshot::float AS handicap_index_snapshot, updated.handicap_index_snapshot::float AS handicap_index_snapshot,
updated.is_captain, p.display_name updated.is_captain, p.display_name,
FROM updated JOIN player p ON p.id = updated.player_id 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, *values,
roster_id,
team_id,
) )
if row is None: if row is None:
raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.") 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.") 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) # Leaderboard (turnering-total, summert på tvers av alle økter/matcher)
# #

View file

@ -66,6 +66,21 @@ type ApiParticipant = {
player_id: string player_id: string
player_name: string player_name: string
handicap_index_snapshot: number | null 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 = { type ApiRound = {
@ -108,6 +123,8 @@ type ApiLeaderboardEntry = {
stableford_total: number | null stableford_total: number | null
copenhagen_total: number | null copenhagen_total: number | null
bbb_total: number | null bbb_total: number | null
class_id: string | null
class_name: string | null
} }
type ApiFlagResult = { type ApiFlagResult = {
@ -180,6 +197,7 @@ export function IndividualTournamentDetail({
const [participants, setParticipants] = useState<ApiParticipant[]>([]) const [participants, setParticipants] = useState<ApiParticipant[]>([])
const [rounds, setRounds] = useState<ApiRound[]>([]) const [rounds, setRounds] = useState<ApiRound[]>([])
const [courses, setCourses] = useState<ApiCourse[]>([]) const [courses, setCourses] = useState<ApiCourse[]>([])
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [tab, setTab] = useState<"setup" | "score" | "leaderboard" | "presentation">("setup") const [tab, setTab] = useState<"setup" | "score" | "leaderboard" | "presentation">("setup")
@ -190,11 +208,12 @@ export function IndividualTournamentDetail({
let cancelled = false let cancelled = false
async function load() { async function load() {
try { try {
const [tournamentsData, participantsData, roundsData, coursesData] = await Promise.all([ const [tournamentsData, participantsData, roundsData, coursesData, classesData] = await Promise.all([
getJson<ApiTournamentInfo[]>(`/orgs/${organizationId}/tournaments`), getJson<ApiTournamentInfo[]>(`/orgs/${organizationId}/tournaments`),
getJson<ApiParticipant[]>(`${base}/participants`), getJson<ApiParticipant[]>(`${base}/participants`),
getJson<ApiRound[]>(`${base}/rounds`), getJson<ApiRound[]>(`${base}/rounds`),
getJson<ApiCourse[]>(`/orgs/${organizationId}/courses`), getJson<ApiCourse[]>(`/orgs/${organizationId}/courses`),
getJson<ApiTournamentClass[]>(`${base}/classes`),
]) ])
if (cancelled) return if (cancelled) return
const mine = tournamentsData?.find((t) => t.id === tournamentId) ?? null const mine = tournamentsData?.find((t) => t.id === tournamentId) ?? null
@ -206,6 +225,7 @@ export function IndividualTournamentDetail({
setParticipants(participantsData ?? []) setParticipants(participantsData ?? [])
setRounds(roundsData ?? []) setRounds(roundsData ?? [])
setCourses(coursesData ?? []) setCourses(coursesData ?? [])
setClasses(classesData ?? [])
} catch { } catch {
if (!cancelled) setError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.") if (!cancelled) setError("Klarte ikke å laste turneringen. Prøv å laste siden på nytt.")
} finally { } finally {
@ -317,6 +337,51 @@ export function IndividualTournamentDetail({
setParticipants((prev) => prev.filter((p) => p.id !== participantId)) 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"]) { async function addRound(name: string, courseId: string, holeConfig: ApiRound["hole_config"]) {
setError(null) setError(null)
const res = await fetch(`${base}/rounds`, { const res = await fetch(`${base}/rounds`, {
@ -462,6 +527,7 @@ export function IndividualTournamentDetail({
participants={participants} participants={participants}
rounds={rounds} rounds={rounds}
courses={courses} courses={courses}
classes={classes}
onUpdateScoringMethod={updateScoringMethod} onUpdateScoringMethod={updateScoringMethod}
onUpdateBbbSweepBonus={updateBbbSweepBonus} onUpdateBbbSweepBonus={updateBbbSweepBonus}
onAddParticipant={addParticipant} onAddParticipant={addParticipant}
@ -470,6 +536,9 @@ export function IndividualTournamentDetail({
onAddRound={addRound} onAddRound={addRound}
onDeleteRound={deleteRound} onDeleteRound={deleteRound}
onAddCourse={addCourse} onAddCourse={addCourse}
onCreateClass={createClass}
onDeleteClass={deleteClass}
onSetParticipantClass={setParticipantClass}
onError={setError} onError={setError}
/> />
)} )}
@ -527,6 +596,7 @@ function SetupTab({
participants, participants,
rounds, rounds,
courses, courses,
classes,
onUpdateScoringMethod, onUpdateScoringMethod,
onUpdateBbbSweepBonus, onUpdateBbbSweepBonus,
onAddParticipant, onAddParticipant,
@ -535,6 +605,9 @@ function SetupTab({
onAddRound, onAddRound,
onDeleteRound, onDeleteRound,
onAddCourse, onAddCourse,
onCreateClass,
onDeleteClass,
onSetParticipantClass,
onError, onError,
}: { }: {
base: string base: string
@ -543,6 +616,7 @@ function SetupTab({
participants: ApiParticipant[] participants: ApiParticipant[]
rounds: ApiRound[] rounds: ApiRound[]
courses: ApiCourse[] courses: ApiCourse[]
classes: ApiTournamentClass[]
onUpdateScoringMethod: (method: string) => void onUpdateScoringMethod: (method: string) => void
onUpdateBbbSweepBonus: (enabled: boolean) => void onUpdateBbbSweepBonus: (enabled: boolean) => void
onAddParticipant: (playerId: string) => Promise<void> onAddParticipant: (playerId: string) => Promise<void>
@ -551,6 +625,9 @@ function SetupTab({
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void> onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onDeleteRound: (id: string) => Promise<void> onDeleteRound: (id: string) => Promise<void>
onAddCourse: (name: string) => Promise<ApiCourse | null> onAddCourse: (name: string) => Promise<ApiCourse | null>
onCreateClass: (name: string, defaultTeeId: string | null) => Promise<void>
onDeleteClass: (id: string) => Promise<void>
onSetParticipantClass: (participantId: string, classId: string | null) => Promise<void>
onError: (message: string) => void onError: (message: string) => void
}) { }) {
const [pool, setPool] = useState<ApiPlayer[]>([]) const [pool, setPool] = useState<ApiPlayer[]>([])
@ -608,12 +685,22 @@ function SetupTab({
)} )}
</section> </section>
<ClassesCard
classes={classes}
courses={courses}
organizationId={organizationId}
onCreate={onCreateClass}
onDelete={onDeleteClass}
/>
<ParticipantsCard <ParticipantsCard
participants={participants} participants={participants}
pool={pool} pool={pool}
classes={classes}
onAddParticipant={onAddParticipant} onAddParticipant={onAddParticipant}
onAddNewPlayer={onAddNewPlayer} onAddNewPlayer={onAddNewPlayer}
onRemoveParticipant={onRemoveParticipant} onRemoveParticipant={onRemoveParticipant}
onSetClass={onSetParticipantClass}
/> />
<RoundsCard <RoundsCard
@ -622,6 +709,7 @@ function SetupTab({
rounds={rounds} rounds={rounds}
courses={courses} courses={courses}
participants={participants} participants={participants}
classes={classes}
scoringMethod={tournament.scoring_method} scoringMethod={tournament.scoring_method}
onAddRound={onAddRound} onAddRound={onAddRound}
onDeleteRound={onDeleteRound} onDeleteRound={onDeleteRound}
@ -632,18 +720,177 @@ function SetupTab({
) )
} }
// Konkurranseklasser (2026-08-03) -- se tournament-detail.tsx sin
// ClassesCard for lagturnering-varianten (samme mønster, men uten
// leaderboard-koblingen -- her deler klasse OGSÅ opp resultatlisten).
function ClassesCard({
classes,
courses,
organizationId,
onCreate,
onDelete,
}: {
classes: ApiTournamentClass[]
courses: ApiCourse[]
organizationId: string
onCreate: (name: string, defaultTeeId: string | null) => Promise<void>
onDelete: (classId: string) => Promise<void>
}) {
const [name, setName] = useState("")
const [courseId, setCourseId] = useState("")
const [teeId, setTeeId] = useState("")
const [tees, setTees] = useState<ApiTee[]>([])
const [adding, setAdding] = useState(false)
useEffect(() => {
if (!courseId) {
setTees([])
setTeeId("")
return
}
let cancelled = false
getJson<ApiTee[]>(`/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 (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex items-center justify-between gap-2">
<h2 className="text-base font-bold text-foreground">Klasser</h2>
<span className="rounded-full bg-muted px-2.5 py-1 text-xs font-bold tabular-nums text-muted-foreground">
{classes.length}
</span>
</div>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Foreslår riktig utslag ved oppsett av en runde, og gir hver klasse sin egen seksjon
leaderboardet.
</p>
{classes.length > 0 && (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{classes.map((c) => (
<li key={c.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-bold text-foreground">{c.name}</span>
{c.default_tee_name && (
<span className="text-xs text-muted-foreground">
Standardutslag: {c.default_tee_name}
</span>
)}
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => onDelete(c.id)}
aria-label={`Fjern klassen ${c.name}`}
className="size-9 shrink-0 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</Button>
</li>
))}
</ul>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="ind-class-name" className="text-xs font-semibold text-muted-foreground">
Klassenavn
</Label>
<Input
id="ind-class-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="F.eks. Damer"
className="h-11"
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="ind-class-course" className="text-xs font-semibold text-muted-foreground">
Bane (for standardutslag)
</Label>
<select
id="ind-class-course"
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
>
<option value="">Ingen standardutslag</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
{tees.length > 0 && (
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="ind-class-tee" className="text-xs font-semibold text-muted-foreground">
Standardutslag
</Label>
<select
id="ind-class-tee"
value={teeId}
onChange={(e) => setTeeId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 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="submit"
variant="secondary"
disabled={adding || name.trim().length === 0}
className="h-11 shrink-0 rounded-xl font-semibold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til
</Button>
</form>
</section>
)
}
function ParticipantsCard({ function ParticipantsCard({
participants, participants,
pool, pool,
classes,
onAddParticipant, onAddParticipant,
onAddNewPlayer, onAddNewPlayer,
onRemoveParticipant, onRemoveParticipant,
onSetClass,
}: { }: {
participants: ApiParticipant[] participants: ApiParticipant[]
pool: ApiPlayer[] pool: ApiPlayer[]
classes: ApiTournamentClass[]
onAddParticipant: (playerId: string) => Promise<void> onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void> onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
onRemoveParticipant: (id: string) => Promise<void> onRemoveParticipant: (id: string) => Promise<void>
onSetClass: (participantId: string, classId: string | null) => Promise<void>
}) { }) {
return ( return (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8"> <section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
@ -662,13 +909,28 @@ function ParticipantsCard({
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border"> <ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{participants.map((p) => ( {participants.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-3 px-4 py-3"> <li key={p.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3">
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-bold text-foreground">{p.player_name}</span> <span className="truncate text-sm font-bold text-foreground">{p.player_name}</span>
<span className="text-xs font-semibold text-muted-foreground"> <span className="text-xs font-semibold text-muted-foreground">
{p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"} {p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"}
</span> </span>
</div> </div>
{classes.length > 0 && (
<select
value={p.class_id ?? ""}
onChange={(e) => onSetClass(p.id, e.target.value || null)}
aria-label={`Klasse for ${p.player_name}`}
className="h-9 shrink-0 rounded-lg border border-border bg-card px-2 text-xs font-semibold text-foreground outline-none"
>
<option value="">Ingen klasse</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
)}
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@ -868,6 +1130,7 @@ function RoundsCard({
rounds, rounds,
courses, courses,
participants, participants,
classes,
scoringMethod, scoringMethod,
onAddRound, onAddRound,
onDeleteRound, onDeleteRound,
@ -879,6 +1142,7 @@ function RoundsCard({
rounds: ApiRound[] rounds: ApiRound[]
courses: ApiCourse[] courses: ApiCourse[]
participants: ApiParticipant[] participants: ApiParticipant[]
classes: ApiTournamentClass[]
scoringMethod: string | null scoringMethod: string | null
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void> onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onDeleteRound: (id: string) => Promise<void> onDeleteRound: (id: string) => Promise<void>
@ -911,6 +1175,7 @@ function RoundsCard({
organizationId={organizationId} organizationId={organizationId}
round={r} round={r}
participants={participants} participants={participants}
classes={classes}
scoringMethod={scoringMethod} scoringMethod={scoringMethod}
onDelete={() => onDeleteRound(r.id)} onDelete={() => onDeleteRound(r.id)}
onError={onError} onError={onError}
@ -1091,6 +1356,7 @@ function RoundCard({
organizationId, organizationId,
round, round,
participants, participants,
classes,
scoringMethod, scoringMethod,
onDelete, onDelete,
onError, onError,
@ -1099,6 +1365,7 @@ function RoundCard({
organizationId: string organizationId: string
round: ApiRound round: ApiRound
participants: ApiParticipant[] participants: ApiParticipant[]
classes: ApiTournamentClass[]
scoringMethod: string | null scoringMethod: string | null
onDelete: () => Promise<void> onDelete: () => Promise<void>
onError: (message: string) => void onError: (message: string) => void
@ -1251,6 +1518,7 @@ function RoundCard({
<AssignRoundParticipantControl <AssignRoundParticipantControl
candidates={unassigned} candidates={unassigned}
tees={tees} tees={tees}
classes={classes}
onAssign={addRoundParticipant} onAssign={addRoundParticipant}
/> />
)} )}
@ -1271,10 +1539,12 @@ function RoundCard({
function AssignRoundParticipantControl({ function AssignRoundParticipantControl({
candidates, candidates,
tees, tees,
classes,
onAssign, onAssign,
}: { }: {
candidates: ApiParticipant[] candidates: ApiParticipant[]
tees: ApiTee[] tees: ApiTee[]
classes: ApiTournamentClass[]
onAssign: (tournamentParticipantId: string, teeId: string) => Promise<void> onAssign: (tournamentParticipantId: string, teeId: string) => Promise<void>
}) { }) {
const [participantId, setParticipantId] = useState(candidates[0]?.id ?? "") const [participantId, setParticipantId] = useState(candidates[0]?.id ?? "")
@ -1286,6 +1556,19 @@ function AssignRoundParticipantControl({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [candidates]) }, [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() { async function handleAssign() {
if (!participantId || !teeId || submitting) return if (!participantId || !teeId || submitting) return
setSubmitting(true) setSubmitting(true)
@ -1878,44 +2161,79 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
) )
} }
// Konkurranseklasser (2026-08-03) -- den mottatte listen er ALLEREDE
// sortert riktig av backend (individual_leaderboard, uendret
// sorteringslogikk); her grupperes den samme rekkefølgen visuelt på
// class_id (stabil gruppering bevarer riktig rangering INNAD i hver
// klasse). Ingen klasser opprettet ennå => alle entries havner i samme
// "Ingen klasse"-gruppe => identisk med tidligere flat visning,
// bakoverkompatibelt uten migrasjonsflagg.
const groups: { key: string; label: string; entries: ApiLeaderboardEntry[] }[] = []
const indexByKey = new Map<string, number>()
for (const entry of entries) {
const key = entry.class_id ?? "__none__"
let idx = indexByKey.get(key)
if (idx === undefined) {
idx = groups.length
indexByKey.set(key, idx)
groups.push({ key, label: entry.class_name ?? "Ingen klasse", entries: [] })
}
groups[idx].entries.push(entry)
}
// "Ingen klasse" sist, uansett hvor den først dukket opp i sorteringen.
groups.sort((a, b) => (a.key === "__none__" ? 1 : b.key === "__none__" ? -1 : 0))
const showSectionHeaders = groups.length > 1 || groups[0]?.key !== "__none__"
function renderRow(entry: ApiLeaderboardEntry, rank: number) {
const { label, value } = valueFor(entry)
const isLeader = rank === 1 && value !== null
return (
<li
key={entry.tournament_participant_id}
className={cn("flex items-center gap-3 px-4 py-3", isLeader && "bg-gold/10")}
>
<span
className={cn(
"flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums",
isLeader ? "bg-gold text-gold-foreground" : "bg-muted text-muted-foreground",
)}
>
{isLeader ? <Medal aria-hidden="true" className="size-4" /> : rank}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-bold text-foreground">{entry.player_name}</span>
<span className="text-xs font-semibold text-muted-foreground">
{entry.rounds_played} {entry.rounds_played === 1 ? "runde" : "runder"} spilt
</span>
</div>
<span className="shrink-0 text-right text-lg font-extrabold tabular-nums text-foreground">
{value !== null ? (
<>
{value}
<span className="ml-1 text-xs font-semibold text-muted-foreground">{label}</span>
</>
) : (
<span className="text-sm font-medium text-muted-foreground"></span>
)}
</span>
</li>
)
}
return ( return (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8"> <div className="flex flex-col gap-4">
{entries.map((entry, index) => { {groups.map((group) => (
const { label, value } = valueFor(entry) <div key={group.key} className="flex flex-col gap-2">
const rank = index + 1 {showSectionHeaders && (
const isLeader = rank === 1 && value !== null <h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
return ( {group.label}
<li </h3>
key={entry.tournament_participant_id} )}
className={cn("flex items-center gap-3 px-4 py-3", isLeader && "bg-gold/10")} <ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
> {group.entries.map((entry, index) => renderRow(entry, index + 1))}
<span </ul>
className={cn( </div>
"flex size-9 shrink-0 items-center justify-center rounded-full text-sm font-extrabold tabular-nums", ))}
isLeader ? "bg-gold text-gold-foreground" : "bg-muted text-muted-foreground", </div>
)}
>
{isLeader ? <Medal aria-hidden="true" className="size-4" /> : rank}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-bold text-foreground">{entry.player_name}</span>
<span className="text-xs font-semibold text-muted-foreground">
{entry.rounds_played} {entry.rounds_played === 1 ? "runde" : "runder"} spilt
</span>
</div>
<span className="shrink-0 text-right text-lg font-extrabold tabular-nums text-foreground">
{value !== null ? (
<>
{value}
<span className="ml-1 text-xs font-semibold text-muted-foreground">{label}</span>
</>
) : (
<span className="text-sm font-medium text-muted-foreground"></span>
)}
</span>
</li>
)
})}
</ul>
) )
} }

View file

@ -32,6 +32,10 @@ type ApiRosterEntry = {
display_name: string display_name: string
handicap_index_snapshot: number | null handicap_index_snapshot: number | null
is_captain: boolean is_captain: boolean
// Konkurranseklasse (2026-08-03) -- brukt KUN til å forhåndsutfylle riktig
// utslag under, se AddSlotForm. Ingen egen resultatliste her.
class_id: string | null
class_name: string | null
} }
// ADR-029: kjønn hører til RATINGEN, ikke selve utslaget -- riktig // ADR-029: kjønn hører til RATINGEN, ikke selve utslaget -- riktig
@ -42,6 +46,13 @@ type ApiTee = {
name: string name: string
} }
type ApiTournamentClass = {
id: string
name: string
default_tee_id: string | null
default_tee_name: string | null
}
type ApiSession = { type ApiSession = {
id: string id: string
name: string | null name: string | null
@ -127,6 +138,7 @@ export function SessionBlindDraw({
const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null) const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null)
const [rosters, setRosters] = useState<Record<string, ApiRosterEntry[]>>({}) const [rosters, setRosters] = useState<Record<string, ApiRosterEntry[]>>({})
const [tees, setTees] = useState<ApiTee[]>([]) const [tees, setTees] = useState<ApiTee[]>([])
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
const [matches, setMatches] = useState<ApiMatch[]>([]) const [matches, setMatches] = useState<ApiMatch[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -147,11 +159,12 @@ export function SessionBlindDraw({
const teamsData: ApiTeam[] = await teamsRes.json() const teamsData: ApiTeam[] = await teamsRes.json()
if (teamsData.length !== 2) throw new Error("expected 2 teams") if (teamsData.length !== 2) throw new Error("expected 2 teams")
const [rosterARes, rosterBRes, teesRes, matchesRes] = await Promise.all([ const [rosterARes, rosterBRes, teesRes, matchesRes, classesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/teams/${teamsData[0].id}/roster`, { credentials: "include" }), fetch(`/orgs/${organizationId}/teams/${teamsData[0].id}/roster`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/teams/${teamsData[1].id}/roster`, { credentials: "include" }), fetch(`/orgs/${organizationId}/teams/${teamsData[1].id}/roster`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/tees`, { credentials: "include" }), fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/tees`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }), fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, { credentials: "include" }),
]) ])
if (!rosterARes.ok || !rosterBRes.ok || !teesRes.ok || !matchesRes.ok) throw new Error("load failed") if (!rosterARes.ok || !rosterBRes.ok || !teesRes.ok || !matchesRes.ok) throw new Error("load failed")
@ -164,6 +177,7 @@ export function SessionBlindDraw({
}) })
setTees(await teesRes.json()) setTees(await teesRes.json())
setMatches(await matchesRes.json()) setMatches(await matchesRes.json())
if (classesRes.ok) setClasses(await classesRes.json())
} catch { } catch {
if (!cancelled) setError("Klarte ikke å laste blind draw-siden. Prøv å laste siden på nytt.") if (!cancelled) setError("Klarte ikke å laste blind draw-siden. Prøv å laste siden på nytt.")
} finally { } finally {
@ -412,6 +426,7 @@ export function SessionBlindDraw({
side={teamIndex === 0 ? "a" : "b"} side={teamIndex === 0 ? "a" : "b"}
roster={rosters[team.id] ?? []} roster={rosters[team.id] ?? []}
tees={tees} tees={tees}
classes={classes}
matches={sortedMatches} matches={sortedMatches}
perSide={maxSlotsPerSide(session.format)} perSide={maxSlotsPerSide(session.format)}
format={session.format} format={session.format}
@ -504,6 +519,7 @@ function TeamColumn({
side, side,
roster, roster,
tees, tees,
classes,
matches, matches,
perSide, perSide,
format, format,
@ -522,6 +538,7 @@ function TeamColumn({
side: "a" | "b" side: "a" | "b"
roster: ApiRosterEntry[] roster: ApiRosterEntry[]
tees: ApiTee[] tees: ApiTee[]
classes: ApiTournamentClass[]
matches: ApiMatch[] matches: ApiMatch[]
perSide: number perSide: number
format: Format format: Format
@ -618,6 +635,7 @@ function TeamColumn({
<AddSlotForm <AddSlotForm
roster={roster} roster={roster}
tees={tees} tees={tees}
classes={classes}
disabledPlayerIds={disabled} disabledPlayerIds={disabled}
// Money Ball -- 0-3 ledig, avhengig av hva laget allerede har brukt. // Money Ball -- 0-3 ledig, avhengig av hva laget allerede har brukt.
availableLineupOrders={ availableLineupOrders={
@ -695,12 +713,14 @@ function TeamColumn({
function AddSlotForm({ function AddSlotForm({
roster, roster,
tees, tees,
classes,
disabledPlayerIds, disabledPlayerIds,
availableLineupOrders, availableLineupOrders,
onAdd, onAdd,
}: { }: {
roster: ApiRosterEntry[] roster: ApiRosterEntry[]
tees: ApiTee[] tees: ApiTee[]
classes: ApiTournamentClass[]
disabledPlayerIds: Set<string> disabledPlayerIds: Set<string>
// Money Ball (2026-07-30) -- ledige 0-3-plasser, null for alle andre formater. // Money Ball (2026-07-30) -- ledige 0-3-plasser, null for alle andre formater.
availableLineupOrders: number[] | null availableLineupOrders: number[] | null
@ -710,6 +730,19 @@ function AddSlotForm({
const [teeId, setTeeId] = useState("") const [teeId, setTeeId] = useState("")
const [lineupOrder, setLineupOrder] = useState("") const [lineupOrder, setLineupOrder] = useState("")
// Konkurranseklasser (2026-08-03) -- forhåndsvelg spillerens klasses
// standardutslag, MEN kun hvis det utslaget faktisk finnes blant DENNE
// øktens baneutslag (klassens standardutslag kan tilhøre en annen bane
// enn økten er satt opp med) -- fortsatt fritt overstyrbart under.
useEffect(() => {
if (!rosterId) return
const entry = roster.find((r) => r.id === rosterId)
const cls = entry?.class_id ? classes.find((c) => c.id === entry.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
}, [rosterId])
const needsLineupOrder = availableLineupOrders !== null const needsLineupOrder = availableLineupOrders !== null
const canAdd = rosterId && teeId && (!needsLineupOrder || lineupOrder !== "") const canAdd = rosterId && teeId && (!needsLineupOrder || lineupOrder !== "")

View file

@ -47,6 +47,8 @@ type ApiRosterEntry = {
display_name: string display_name: string
handicap_index_snapshot: number | null handicap_index_snapshot: number | null
is_captain: boolean is_captain: boolean
class_id: string | null
class_name: string | null
} }
type Team = { type Team = {
@ -56,6 +58,28 @@ type Team = {
roster: ApiRosterEntry[] roster: ApiRosterEntry[]
} }
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
// standardutslag brukt til å forhåndsutfylle riktig utslag når en spiller
// legges til en match (session-blind-draw.tsx). Ren utslag-bekvemmelighet
// her -- INGEN egen resultatliste for lagturneringer (bekreftet med
// bruker: poeng er knyttet til hele kamper, ikke enkeltspillere).
type ApiTournamentClass = {
id: string
name: string
default_tee_id: string | null
default_tee_name: string | null
}
type ApiCourse = {
id: string
name: string
}
type ApiTee = {
id: string
name: string
}
// Team colors er DATA brukt på scorekort senere -- bevisst atskilt fra // Team colors er DATA brukt på scorekort senere -- bevisst atskilt fra
// app-ens merkevare-grønn/oransje. // app-ens merkevare-grønn/oransje.
const TEAM_COLORS: { value: string; label: string }[] = [ const TEAM_COLORS: { value: string; label: string }[] = [
@ -82,6 +106,8 @@ export function TournamentDetail({
const [pool, setPool] = useState<ApiPlayer[]>([]) const [pool, setPool] = useState<ApiPlayer[]>([])
const [joinCode, setJoinCode] = useState<string | null>(null) const [joinCode, setJoinCode] = useState<string | null>(null)
const [status, setStatus] = useState<TournamentStatus | null>(null) const [status, setStatus] = useState<TournamentStatus | null>(null)
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
const [courses, setCourses] = useState<ApiCourse[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -89,12 +115,16 @@ export function TournamentDetail({
let cancelled = false let cancelled = false
async function load() { async function load() {
try { try {
const [teamsRes, poolRes, tournamentsRes] = await Promise.all([ const [teamsRes, poolRes, tournamentsRes, classesRes, coursesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
credentials: "include", credentials: "include",
}), }),
fetch(`/orgs/${organizationId}/players`, { credentials: "include" }), fetch(`/orgs/${organizationId}/players`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" }), fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
credentials: "include",
}),
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
]) ])
if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed") if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed")
const teamsData: { id: string; name: string; color: string | null }[] = const teamsData: { id: string; name: string; color: string | null }[] =
@ -112,6 +142,8 @@ export function TournamentDetail({
setStatus(mine.status) setStatus(mine.status)
} }
} }
if (classesRes.ok) setClasses(await classesRes.json())
if (coursesRes.ok) setCourses(await coursesRes.json())
const withRosters = await Promise.all( const withRosters = await Promise.all(
teamsData.map(async (t) => { teamsData.map(async (t) => {
@ -245,6 +277,76 @@ export function TournamentDetail({
} }
} }
async function setRosterClass(teamId: string, rosterId: string, classId: string | null) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ class_id: classId }),
})
if (!res.ok) throw new Error(`set class: ${res.status}`)
const updated: ApiRosterEntry = await res.json()
setTeams((prev) =>
prev.map((t) =>
t && t.id === teamId
? { ...t, roster: t.roster.map((r) => (r.id === updated.id ? updated : r)) }
: t,
) as [Team | null, Team | null],
)
} catch {
setError("Klarte ikke å endre klasse. Prøv igjen.")
}
}
async function createClass(name: string, defaultTeeId: string | null) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name, default_tee_id: defaultTeeId }),
})
if (!res.ok) throw new Error(`create class: ${res.status}`)
const created: ApiTournamentClass = await res.json()
setClasses((prev) => [...prev, created])
} catch {
setError("Klarte ikke å opprette klassen. Prøv igjen.")
}
}
async function deleteClass(classId: string) {
setError(null)
try {
const res = await fetch(
`/orgs/${organizationId}/tournaments/${tournamentId}/classes/${classId}`,
{ method: "DELETE", credentials: "include" },
)
if (res.status !== 204) throw new Error(`delete class: ${res.status}`)
setClasses((prev) => prev.filter((c) => c.id !== classId))
// Roster-rader som pekte på klassen mister den lokalt også (backend
// satte allerede class_id=NULL via ON DELETE SET NULL) -- unngår en
// hengende, ugyldig klasse-referanse i UI-et inntil neste full reload.
setTeams(
(prev) =>
prev.map((t) =>
t
? {
...t,
roster: t.roster.map((r) =>
r.class_id === classId ? { ...r, class_id: null, class_name: null } : r,
),
}
: t,
) as [Team | null, Team | null],
)
} catch {
setError("Klarte ikke å fjerne klassen. Prøv igjen.")
}
}
async function removePlayer(teamId: string, rosterId: string) { async function removePlayer(teamId: string, rosterId: string) {
setError(null) setError(null)
try { try {
@ -424,32 +526,43 @@ export function TournamentDetail({
/> />
</div> </div>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2"> <>
{([0, 1] as const).map((slot) => { <ClassesCard
const team = teams[slot] classes={classes}
if (team) { courses={courses}
const otherTeam = teams[slot === 0 ? 1 : 0] organizationId={organizationId}
return ( onCreate={createClass}
<TeamPanel onDelete={deleteClass}
key={team.id} />
team={team} <div className="grid gap-4 md:grid-cols-2">
otherTeam={otherTeam} {([0, 1] as const).map((slot) => {
pool={pool} const team = teams[slot]
chatHref={`/tournaments/${tournamentId}/teams/${team.id}/chat?org=${organizationId}&name=${encodeURIComponent(tournamentName)}&team=${encodeURIComponent(team.name)}`} if (team) {
findPlayerTeam={findPlayerTeam} const otherTeam = teams[slot === 0 ? 1 : 0]
onToggleCaptain={toggleCaptain} return (
onRemovePlayer={removePlayer} <TeamPanel
onMovePlayer={movePlayer} key={team.id}
onAddExisting={addExistingPlayer} team={team}
onAddNew={addNewPlayer} otherTeam={otherTeam}
onUpdatePlayer={updatePlayer} pool={pool}
onConcedeTournament={concedeTournament} classes={classes}
/> chatHref={`/tournaments/${tournamentId}/teams/${team.id}/chat?org=${organizationId}&name=${encodeURIComponent(tournamentName)}&team=${encodeURIComponent(team.name)}`}
) findPlayerTeam={findPlayerTeam}
} onToggleCaptain={toggleCaptain}
return <CreateTeamCard key={`slot-${slot}`} slot={slot} onCreate={createTeam} /> onRemovePlayer={removePlayer}
})} onMovePlayer={movePlayer}
</div> onAddExisting={addExistingPlayer}
onAddNew={addNewPlayer}
onUpdatePlayer={updatePlayer}
onConcedeTournament={concedeTournament}
onSetClass={setRosterClass}
/>
)
}
return <CreateTeamCard key={`slot-${slot}`} slot={slot} onCreate={createTeam} />
})}
</div>
</>
)} )}
</main> </main>
</div> </div>
@ -495,6 +608,161 @@ function JoinCodeChip({ code }: { code: string }) {
// --- Create team (State A) ------------------------------------------------- // --- Create team (State A) -------------------------------------------------
// --- Konkurranseklasser (2026-08-03) ----------------------------------------
// Fritt navngitte klasser med et valgfritt standardutslag -- se
// tournament_class-tabellen (053_tournament_classes.sql). Kun utslag-
// bekvemmelighet i lagturneringer (ingen egen resultatliste her, bekreftet
// med bruker -- se AssignRoundParticipantControl/LeaderboardTab i
// individual-tournament-detail.tsx for der klasse OGSÅ deler leaderboardet).
function ClassesCard({
classes,
courses,
organizationId,
onCreate,
onDelete,
}: {
classes: ApiTournamentClass[]
courses: ApiCourse[]
organizationId: string
onCreate: (name: string, defaultTeeId: string | null) => void
onDelete: (classId: string) => void
}) {
const [name, setName] = useState("")
const [courseId, setCourseId] = useState("")
const [teeId, setTeeId] = useState("")
const [tees, setTees] = useState<ApiTee[]>([])
const [adding, setAdding] = useState(false)
useEffect(() => {
if (!courseId) {
setTees([])
setTeeId("")
return
}
let cancelled = false
fetch(`/orgs/${organizationId}/courses/${courseId}/tees`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiTee[]) => {
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 (
<section className="mb-4 flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<div className="flex flex-col">
<h3 className="text-sm font-bold text-foreground">Klasser</h3>
<p className="text-xs text-muted-foreground text-pretty">
Foreslår riktig utslag når en spiller legges til en kamp -- ingen egen resultatliste
her (poeng er knyttet til hele kamper, ikke enkeltspillere).
</p>
</div>
</div>
{classes.length > 0 && (
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
{classes.map((c) => (
<li key={c.id} className="flex items-center gap-3 px-3 py-2.5">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-foreground">{c.name}</span>
{c.default_tee_name && (
<span className="text-xs text-muted-foreground">
Standardutslag: {c.default_tee_name}
</span>
)}
</div>
<button
type="button"
onClick={() => onDelete(c.id)}
aria-label={`Fjern klassen ${c.name}`}
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
</li>
))}
</ul>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row sm:items-end">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="class-name">Klassenavn</Label>
<Input
id="class-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="F.eks. Damer"
className="h-11"
/>
</div>
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="class-course">Bane (for standardutslag)</Label>
<select
id="class-course"
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
>
<option value="">Ingen standardutslag</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
{tees.length > 0 && (
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor="class-tee">Standardutslag</Label>
<select
id="class-tee"
value={teeId}
onChange={(e) => setTeeId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 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="submit"
variant="outline"
disabled={adding || name.trim().length === 0}
className="h-11 shrink-0 rounded-xl font-semibold"
>
<Plus aria-hidden="true" className="size-4" />
Legg til
</Button>
</form>
</section>
)
}
function CreateTeamCard({ function CreateTeamCard({
slot, slot,
onCreate, onCreate,
@ -586,6 +854,7 @@ function TeamPanel({
team, team,
otherTeam, otherTeam,
pool, pool,
classes,
chatHref, chatHref,
findPlayerTeam, findPlayerTeam,
onToggleCaptain, onToggleCaptain,
@ -595,10 +864,12 @@ function TeamPanel({
onAddNew, onAddNew,
onUpdatePlayer, onUpdatePlayer,
onConcedeTournament, onConcedeTournament,
onSetClass,
}: { }: {
team: Team team: Team
otherTeam: Team | null otherTeam: Team | null
pool: ApiPlayer[] pool: ApiPlayer[]
classes: ApiTournamentClass[]
chatHref: string chatHref: string
findPlayerTeam: (playerId: string) => Team | null findPlayerTeam: (playerId: string) => Team | null
onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void 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 }, updates: { display_name?: string; handicap_index?: number | null; gender?: string | null },
) => void ) => void
onConcedeTournament: (teamId: string) => Promise<string | null> onConcedeTournament: (teamId: string) => Promise<string | null>
onSetClass: (teamId: string, rosterId: string, classId: string | null) => void
}) { }) {
const [confirmingId, setConfirmingId] = useState<string | null>(null) const [confirmingId, setConfirmingId] = useState<string | null>(null)
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null) const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null)
@ -737,6 +1009,11 @@ function TeamPanel({
Kaptein Kaptein
</span> </span>
)} )}
{entry.class_name && (
<span className="inline-flex shrink-0 items-center rounded-full bg-muted px-2 py-0.5 text-xs font-semibold text-muted-foreground">
{entry.class_name}
</span>
)}
</div> </div>
<span className="shrink-0 tabular-nums text-sm font-medium text-muted-foreground"> <span className="shrink-0 tabular-nums text-sm font-medium text-muted-foreground">
{formatHandicap(entry.handicap_index_snapshot)} {formatHandicap(entry.handicap_index_snapshot)}
@ -763,6 +1040,25 @@ function TeamPanel({
<Star aria-hidden="true" className="size-4" /> <Star aria-hidden="true" className="size-4" />
{entry.is_captain ? "Fjern som kaptein" : "Gjør til kaptein"} {entry.is_captain ? "Fjern som kaptein" : "Gjør til kaptein"}
</DropdownMenuItem> </DropdownMenuItem>
{classes.map((c) => (
<DropdownMenuItem
key={c.id}
onClick={() => onSetClass(team.id, entry.id, c.id)}
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
>
<Users aria-hidden="true" className="size-4" />
Sett klasse: {c.name}
</DropdownMenuItem>
))}
{entry.class_id && (
<DropdownMenuItem
onClick={() => onSetClass(team.id, entry.id, null)}
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
>
<X aria-hidden="true" className="size-4" />
Fjern klasse
</DropdownMenuItem>
)}
{otherTeam && ( {otherTeam && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onMovePlayer(team.id, entry.id, otherTeam.id)} onClick={() => onMovePlayer(team.id, entry.id, otherTeam.id)}