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
This commit is contained in:
parent
83caddf193
commit
7bda1bf789
6 changed files with 1038 additions and 105 deletions
41
053_tournament_classes.sql
Normal file
41
053_tournament_classes.sql
Normal 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);
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
#
|
||||
|
|
|
|||
|
|
@ -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<ApiParticipant[]>([])
|
||||
const [rounds, setRounds] = useState<ApiRound[]>([])
|
||||
const [courses, setCourses] = useState<ApiCourse[]>([])
|
||||
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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<ApiTournamentInfo[]>(`/orgs/${organizationId}/tournaments`),
|
||||
getJson<ApiParticipant[]>(`${base}/participants`),
|
||||
getJson<ApiRound[]>(`${base}/rounds`),
|
||||
getJson<ApiCourse[]>(`/orgs/${organizationId}/courses`),
|
||||
getJson<ApiTournamentClass[]>(`${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<void>
|
||||
|
|
@ -551,6 +625,9 @@ function SetupTab({
|
|||
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
||||
onDeleteRound: (id: string) => Promise<void>
|
||||
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
|
||||
}) {
|
||||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||
|
|
@ -608,12 +685,22 @@ function SetupTab({
|
|||
)}
|
||||
</section>
|
||||
|
||||
<ClassesCard
|
||||
classes={classes}
|
||||
courses={courses}
|
||||
organizationId={organizationId}
|
||||
onCreate={onCreateClass}
|
||||
onDelete={onDeleteClass}
|
||||
/>
|
||||
|
||||
<ParticipantsCard
|
||||
participants={participants}
|
||||
pool={pool}
|
||||
classes={classes}
|
||||
onAddParticipant={onAddParticipant}
|
||||
onAddNewPlayer={onAddNewPlayer}
|
||||
onRemoveParticipant={onRemoveParticipant}
|
||||
onSetClass={onSetParticipantClass}
|
||||
/>
|
||||
|
||||
<RoundsCard
|
||||
|
|
@ -622,6 +709,7 @@ function SetupTab({
|
|||
rounds={rounds}
|
||||
courses={courses}
|
||||
participants={participants}
|
||||
classes={classes}
|
||||
scoringMethod={tournament.scoring_method}
|
||||
onAddRound={onAddRound}
|
||||
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 på
|
||||
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({
|
||||
participants,
|
||||
pool,
|
||||
classes,
|
||||
onAddParticipant,
|
||||
onAddNewPlayer,
|
||||
onRemoveParticipant,
|
||||
onSetClass,
|
||||
}: {
|
||||
participants: ApiParticipant[]
|
||||
pool: ApiPlayer[]
|
||||
classes: ApiTournamentClass[]
|
||||
onAddParticipant: (playerId: string) => Promise<void>
|
||||
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
|
||||
onRemoveParticipant: (id: string) => Promise<void>
|
||||
onSetClass: (participantId: string, classId: string | null) => Promise<void>
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
{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">
|
||||
<span className="truncate text-sm font-bold text-foreground">{p.player_name}</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
{p.handicap_index_snapshot !== null ? `Hcp ${p.handicap_index_snapshot}` : "Ingen HCP registrert"}
|
||||
</span>
|
||||
</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
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
|
@ -868,6 +1130,7 @@ function RoundsCard({
|
|||
rounds,
|
||||
courses,
|
||||
participants,
|
||||
classes,
|
||||
scoringMethod,
|
||||
onAddRound,
|
||||
onDeleteRound,
|
||||
|
|
@ -879,6 +1142,7 @@ function RoundsCard({
|
|||
rounds: ApiRound[]
|
||||
courses: ApiCourse[]
|
||||
participants: ApiParticipant[]
|
||||
classes: ApiTournamentClass[]
|
||||
scoringMethod: string | null
|
||||
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
||||
onDeleteRound: (id: string) => Promise<void>
|
||||
|
|
@ -911,6 +1175,7 @@ function RoundsCard({
|
|||
organizationId={organizationId}
|
||||
round={r}
|
||||
participants={participants}
|
||||
classes={classes}
|
||||
scoringMethod={scoringMethod}
|
||||
onDelete={() => onDeleteRound(r.id)}
|
||||
onError={onError}
|
||||
|
|
@ -1091,6 +1356,7 @@ function RoundCard({
|
|||
organizationId,
|
||||
round,
|
||||
participants,
|
||||
classes,
|
||||
scoringMethod,
|
||||
onDelete,
|
||||
onError,
|
||||
|
|
@ -1099,6 +1365,7 @@ function RoundCard({
|
|||
organizationId: string
|
||||
round: ApiRound
|
||||
participants: ApiParticipant[]
|
||||
classes: ApiTournamentClass[]
|
||||
scoringMethod: string | null
|
||||
onDelete: () => Promise<void>
|
||||
onError: (message: string) => void
|
||||
|
|
@ -1251,6 +1518,7 @@ function RoundCard({
|
|||
<AssignRoundParticipantControl
|
||||
candidates={unassigned}
|
||||
tees={tees}
|
||||
classes={classes}
|
||||
onAssign={addRoundParticipant}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1271,10 +1539,12 @@ function RoundCard({
|
|||
function AssignRoundParticipantControl({
|
||||
candidates,
|
||||
tees,
|
||||
classes,
|
||||
onAssign,
|
||||
}: {
|
||||
candidates: ApiParticipant[]
|
||||
tees: ApiTee[]
|
||||
classes: ApiTournamentClass[]
|
||||
onAssign: (tournamentParticipantId: string, teeId: string) => Promise<void>
|
||||
}) {
|
||||
const [participantId, setParticipantId] = useState(candidates[0]?.id ?? "")
|
||||
|
|
@ -1286,6 +1556,19 @@ function AssignRoundParticipantControl({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [candidates])
|
||||
|
||||
// Konkurranseklasser (2026-08-03) -- forhåndsvelg deltakerens klasses
|
||||
// standardutslag, kun hvis det utslaget faktisk finnes på DENNE rundens
|
||||
// bane (klassens standardutslag kan tilhøre en annen bane). Fritt
|
||||
// overstyrbart under.
|
||||
useEffect(() => {
|
||||
if (!participantId) return
|
||||
const candidate = candidates.find((c) => c.id === participantId)
|
||||
const cls = candidate?.class_id ? classes.find((c) => c.id === candidate.class_id) : null
|
||||
const defaultTee = cls?.default_tee_id ? tees.find((t) => t.id === cls.default_tee_id) : null
|
||||
if (defaultTee) setTeeId(defaultTee.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [participantId])
|
||||
|
||||
async function handleAssign() {
|
||||
if (!participantId || !teeId || submitting) return
|
||||
setSubmitting(true)
|
||||
|
|
@ -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 (
|
||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
|
||||
{entries.map((entry, index) => {
|
||||
const { label, value } = valueFor(entry)
|
||||
const rank = index + 1
|
||||
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>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="flex flex-col gap-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.key} className="flex flex-col gap-2">
|
||||
{showSectionHeaders && (
|
||||
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
{group.label}
|
||||
</h3>
|
||||
)}
|
||||
<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))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,10 @@ type ApiRosterEntry = {
|
|||
display_name: string
|
||||
handicap_index_snapshot: number | null
|
||||
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
|
||||
|
|
@ -42,6 +46,13 @@ type ApiTee = {
|
|||
name: string
|
||||
}
|
||||
|
||||
type ApiTournamentClass = {
|
||||
id: string
|
||||
name: string
|
||||
default_tee_id: string | null
|
||||
default_tee_name: string | null
|
||||
}
|
||||
|
||||
type ApiSession = {
|
||||
id: string
|
||||
name: string | null
|
||||
|
|
@ -127,6 +138,7 @@ export function SessionBlindDraw({
|
|||
const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null)
|
||||
const [rosters, setRosters] = useState<Record<string, ApiRosterEntry[]>>({})
|
||||
const [tees, setTees] = useState<ApiTee[]>([])
|
||||
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
|
||||
const [matches, setMatches] = useState<ApiMatch[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
|
@ -147,11 +159,12 @@ export function SessionBlindDraw({
|
|||
const teamsData: ApiTeam[] = await teamsRes.json()
|
||||
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[1].id}/roster`, { credentials: "include" }),
|
||||
fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/tees`, { 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")
|
||||
|
||||
|
|
@ -164,6 +177,7 @@ export function SessionBlindDraw({
|
|||
})
|
||||
setTees(await teesRes.json())
|
||||
setMatches(await matchesRes.json())
|
||||
if (classesRes.ok) setClasses(await classesRes.json())
|
||||
} catch {
|
||||
if (!cancelled) setError("Klarte ikke å laste blind draw-siden. Prøv å laste siden på nytt.")
|
||||
} finally {
|
||||
|
|
@ -412,6 +426,7 @@ export function SessionBlindDraw({
|
|||
side={teamIndex === 0 ? "a" : "b"}
|
||||
roster={rosters[team.id] ?? []}
|
||||
tees={tees}
|
||||
classes={classes}
|
||||
matches={sortedMatches}
|
||||
perSide={maxSlotsPerSide(session.format)}
|
||||
format={session.format}
|
||||
|
|
@ -504,6 +519,7 @@ function TeamColumn({
|
|||
side,
|
||||
roster,
|
||||
tees,
|
||||
classes,
|
||||
matches,
|
||||
perSide,
|
||||
format,
|
||||
|
|
@ -522,6 +538,7 @@ function TeamColumn({
|
|||
side: "a" | "b"
|
||||
roster: ApiRosterEntry[]
|
||||
tees: ApiTee[]
|
||||
classes: ApiTournamentClass[]
|
||||
matches: ApiMatch[]
|
||||
perSide: number
|
||||
format: Format
|
||||
|
|
@ -618,6 +635,7 @@ function TeamColumn({
|
|||
<AddSlotForm
|
||||
roster={roster}
|
||||
tees={tees}
|
||||
classes={classes}
|
||||
disabledPlayerIds={disabled}
|
||||
// Money Ball -- 0-3 ledig, avhengig av hva laget allerede har brukt.
|
||||
availableLineupOrders={
|
||||
|
|
@ -695,12 +713,14 @@ function TeamColumn({
|
|||
function AddSlotForm({
|
||||
roster,
|
||||
tees,
|
||||
classes,
|
||||
disabledPlayerIds,
|
||||
availableLineupOrders,
|
||||
onAdd,
|
||||
}: {
|
||||
roster: ApiRosterEntry[]
|
||||
tees: ApiTee[]
|
||||
classes: ApiTournamentClass[]
|
||||
disabledPlayerIds: Set<string>
|
||||
// Money Ball (2026-07-30) -- ledige 0-3-plasser, null for alle andre formater.
|
||||
availableLineupOrders: number[] | null
|
||||
|
|
@ -710,6 +730,19 @@ function AddSlotForm({
|
|||
const [teeId, setTeeId] = 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 canAdd = rosterId && teeId && (!needsLineupOrder || lineupOrder !== "")
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ type ApiRosterEntry = {
|
|||
display_name: string
|
||||
handicap_index_snapshot: number | null
|
||||
is_captain: boolean
|
||||
class_id: string | null
|
||||
class_name: string | null
|
||||
}
|
||||
|
||||
type Team = {
|
||||
|
|
@ -56,6 +58,28 @@ type Team = {
|
|||
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
|
||||
// app-ens merkevare-grønn/oransje.
|
||||
const TEAM_COLORS: { value: string; label: string }[] = [
|
||||
|
|
@ -82,6 +106,8 @@ export function TournamentDetail({
|
|||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||
const [joinCode, setJoinCode] = useState<string | 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 [error, setError] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -89,12 +115,16 @@ export function TournamentDetail({
|
|||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const [teamsRes, poolRes, tournamentsRes] = await Promise.all([
|
||||
const [teamsRes, poolRes, tournamentsRes, classesRes, coursesRes] = await Promise.all([
|
||||
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, {
|
||||
credentials: "include",
|
||||
}),
|
||||
fetch(`/orgs/${organizationId}/players`, { 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")
|
||||
const teamsData: { id: string; name: string; color: string | null }[] =
|
||||
|
|
@ -112,6 +142,8 @@ export function TournamentDetail({
|
|||
setStatus(mine.status)
|
||||
}
|
||||
}
|
||||
if (classesRes.ok) setClasses(await classesRes.json())
|
||||
if (coursesRes.ok) setCourses(await coursesRes.json())
|
||||
|
||||
const withRosters = await Promise.all(
|
||||
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) {
|
||||
setError(null)
|
||||
try {
|
||||
|
|
@ -424,32 +526,43 @@ export function TournamentDetail({
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{([0, 1] as const).map((slot) => {
|
||||
const team = teams[slot]
|
||||
if (team) {
|
||||
const otherTeam = teams[slot === 0 ? 1 : 0]
|
||||
return (
|
||||
<TeamPanel
|
||||
key={team.id}
|
||||
team={team}
|
||||
otherTeam={otherTeam}
|
||||
pool={pool}
|
||||
chatHref={`/tournaments/${tournamentId}/teams/${team.id}/chat?org=${organizationId}&name=${encodeURIComponent(tournamentName)}&team=${encodeURIComponent(team.name)}`}
|
||||
findPlayerTeam={findPlayerTeam}
|
||||
onToggleCaptain={toggleCaptain}
|
||||
onRemovePlayer={removePlayer}
|
||||
onMovePlayer={movePlayer}
|
||||
onAddExisting={addExistingPlayer}
|
||||
onAddNew={addNewPlayer}
|
||||
onUpdatePlayer={updatePlayer}
|
||||
onConcedeTournament={concedeTournament}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <CreateTeamCard key={`slot-${slot}`} slot={slot} onCreate={createTeam} />
|
||||
})}
|
||||
</div>
|
||||
<>
|
||||
<ClassesCard
|
||||
classes={classes}
|
||||
courses={courses}
|
||||
organizationId={organizationId}
|
||||
onCreate={createClass}
|
||||
onDelete={deleteClass}
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{([0, 1] as const).map((slot) => {
|
||||
const team = teams[slot]
|
||||
if (team) {
|
||||
const otherTeam = teams[slot === 0 ? 1 : 0]
|
||||
return (
|
||||
<TeamPanel
|
||||
key={team.id}
|
||||
team={team}
|
||||
otherTeam={otherTeam}
|
||||
pool={pool}
|
||||
classes={classes}
|
||||
chatHref={`/tournaments/${tournamentId}/teams/${team.id}/chat?org=${organizationId}&name=${encodeURIComponent(tournamentName)}&team=${encodeURIComponent(team.name)}`}
|
||||
findPlayerTeam={findPlayerTeam}
|
||||
onToggleCaptain={toggleCaptain}
|
||||
onRemovePlayer={removePlayer}
|
||||
onMovePlayer={movePlayer}
|
||||
onAddExisting={addExistingPlayer}
|
||||
onAddNew={addNewPlayer}
|
||||
onUpdatePlayer={updatePlayer}
|
||||
onConcedeTournament={concedeTournament}
|
||||
onSetClass={setRosterClass}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <CreateTeamCard key={`slot-${slot}`} slot={slot} onCreate={createTeam} />
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -495,6 +608,161 @@ function JoinCodeChip({ code }: { code: string }) {
|
|||
|
||||
// --- 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({
|
||||
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<string | null>
|
||||
onSetClass: (teamId: string, rosterId: string, classId: string | null) => void
|
||||
}) {
|
||||
const [confirmingId, setConfirmingId] = useState<string | null>(null)
|
||||
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null)
|
||||
|
|
@ -737,6 +1009,11 @@ function TeamPanel({
|
|||
Kaptein
|
||||
</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>
|
||||
<span className="shrink-0 tabular-nums text-sm font-medium text-muted-foreground">
|
||||
{formatHandicap(entry.handicap_index_snapshot)}
|
||||
|
|
@ -763,6 +1040,25 @@ function TeamPanel({
|
|||
<Star aria-hidden="true" className="size-4" />
|
||||
{entry.is_captain ? "Fjern som kaptein" : "Gjør til kaptein"}
|
||||
</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 && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onMovePlayer(team.id, entry.id, otherTeam.id)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue