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):
|
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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
#
|
#
|
||||||
|
|
|
||||||
|
|
@ -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 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({
|
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,11 +2161,31 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Konkurranseklasser (2026-08-03) -- den mottatte listen er ALLEREDE
|
||||||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
|
// sortert riktig av backend (individual_leaderboard, uendret
|
||||||
{entries.map((entry, index) => {
|
// 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 { label, value } = valueFor(entry)
|
||||||
const rank = index + 1
|
|
||||||
const isLeader = rank === 1 && value !== null
|
const isLeader = rank === 1 && value !== null
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
|
|
@ -1915,7 +2218,22 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 !== "")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,6 +526,14 @@ export function TournamentDetail({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
<ClassesCard
|
||||||
|
classes={classes}
|
||||||
|
courses={courses}
|
||||||
|
organizationId={organizationId}
|
||||||
|
onCreate={createClass}
|
||||||
|
onDelete={deleteClass}
|
||||||
|
/>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{([0, 1] as const).map((slot) => {
|
{([0, 1] as const).map((slot) => {
|
||||||
const team = teams[slot]
|
const team = teams[slot]
|
||||||
|
|
@ -435,6 +545,7 @@ export function TournamentDetail({
|
||||||
team={team}
|
team={team}
|
||||||
otherTeam={otherTeam}
|
otherTeam={otherTeam}
|
||||||
pool={pool}
|
pool={pool}
|
||||||
|
classes={classes}
|
||||||
chatHref={`/tournaments/${tournamentId}/teams/${team.id}/chat?org=${organizationId}&name=${encodeURIComponent(tournamentName)}&team=${encodeURIComponent(team.name)}`}
|
chatHref={`/tournaments/${tournamentId}/teams/${team.id}/chat?org=${organizationId}&name=${encodeURIComponent(tournamentName)}&team=${encodeURIComponent(team.name)}`}
|
||||||
findPlayerTeam={findPlayerTeam}
|
findPlayerTeam={findPlayerTeam}
|
||||||
onToggleCaptain={toggleCaptain}
|
onToggleCaptain={toggleCaptain}
|
||||||
|
|
@ -444,12 +555,14 @@ export function TournamentDetail({
|
||||||
onAddNew={addNewPlayer}
|
onAddNew={addNewPlayer}
|
||||||
onUpdatePlayer={updatePlayer}
|
onUpdatePlayer={updatePlayer}
|
||||||
onConcedeTournament={concedeTournament}
|
onConcedeTournament={concedeTournament}
|
||||||
|
onSetClass={setRosterClass}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <CreateTeamCard key={`slot-${slot}`} slot={slot} onCreate={createTeam} />
|
return <CreateTeamCard key={`slot-${slot}`} slot={slot} onCreate={createTeam} />
|
||||||
})}
|
})}
|
||||||
</div>
|
</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)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue