Manuell deltakerstatus (DSQ/RTD/DNF/DNS) + spillerpool-kobling (ADR-091)

Ny status-kolonne på tournament_participant, overstyrer leaderboardets
total og blokkerer score i alle runder. Ny direkte lenke fra
turneringens Deltakere-liste til riktig rad i org-spillerpoolen.
Migrasjon 087 lagt til, men IKKE anvendt mot ekte teecup_db ennå.
This commit is contained in:
Erol Haagenrud 2026-08-18 18:06:49 +02:00
parent 23f464def1
commit 396fe40b7c
11 changed files with 488 additions and 13 deletions

View file

@ -0,0 +1,30 @@
-- =====================================================================
-- TeeCup — migrasjon 087
-- Manuell status per deltaker: DSQ/RTD/DNF/DNS
-- =====================================================================
-- Bruker: "Vi må manuelt kunne sette Dsq, Rtd, Dnf, og fns." ("fns"
-- tolket som en skrivefeil for DNS -- 'd' og 'f' ligger ved siden av
-- hverandre på tastaturet, og ingen standard golf-status starter på
-- "f". Ingen av de fire fantes -- `tournament_participant` hadde
-- ingen statuskolonne i det hele tatt før nå.
--
-- Leaderboardet (`stroke-play-leaderboard.tsx`, V0-eksportert) hadde
-- ALLEREDE en STATUS-mekanisme klar til å vise nøyaktig dette (samme
-- Set som "WD"/"DNS"/"DQ"/"NR"/"CUT" allerede brukte -- "DNS" var der
-- fra før!) -- den manglet bare en vei for organisator til faktisk å
-- SETTE en status på en deltaker. `status` er tournament-BREDT (ikke
-- per runde) -- samme granularitet som `total_label` allerede
-- representerer på leaderboardet (én sum-rad per deltaker).
--
-- 'active' (default) = uendret oppførsel, ingen regresjon. Ikke-aktiv
-- status overstyrer BÅDE det beregnede til-par-tallet OG en ev.
-- Cut-status (satt SIST i _compute_individual_standings, se
-- app/routers/individual_tournaments.py) -- en DSQ-et spiller er DSQ,
-- uansett om de tilfeldigvis også er kuttet.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE tournament_participant
ADD COLUMN status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'dsq', 'rtd', 'dnf', 'dns'));

View file

@ -9241,6 +9241,85 @@ teecup_frontend && up -d` for begge. Rene logger, 200 OK.
--- ---
## ADR-091: Manuell deltakerstatus (DSQ/RTD/DNF/DNS) + direkte kobling fra turneringsside til spillerpool (2026-08-18)
To beslektede tilføyelser samme dag som ADR-090.
**Del A -- manuell status.** Bruker: "Vi må manuelt kunne sette Dsq,
Rtd, Dnf, og fns." ("fns" tolket som skrivefeil for DNS -- 'd' og 'f'
ligger ved siden av hverandre på tastaturet, ingen standard golf-status
starter på "f"; bekreftet indirekte ved at leaderboardet allerede HADDE
"DNS" klar i sin STATUS-visning fra før, uten noen vei til å sette den).
`tournament_participant` hadde ingen statuskolonne i det hele tatt.
Migrasjon 087: `status text NOT NULL DEFAULT 'active' CHECK (status IN
('active','dsq','rtd','dnf','dns'))` -- tournament-BREDT (ikke per
runde, samme granularitet som leaderboardets `total_label`). Lagt til i
`TournamentParticipantUpdate` (allerede generisk endepunkt). To
konsekvenser i `individual_tournaments.py`:
- `_compute_individual_standings`: en ikke-"active"-status overstyrer
BÅDE det beregnede til-par-tallet OG en ev. Cut-status i
`total_label` (anvendt SIST, etter Cut-overstyringen -- en DSQ-et
spiller er DSQ, uansett om de tilfeldigvis også er kuttet). Egen
`position=None` (samme nøytrale "-" som Cut/ikke-startet bruker).
- `update_hole`: blokkerer score-registrering i ALLE runder (strengere
enn Cut, som kun blokkerer runder ETTER cut-punktet -- her finnes
ikke noe "historikk t.o.m. et punkt skal stå urørt"-unntak, en
DSQ-et/RTD-et/DNF-et/DNS-et spiller er ute av HELE turneringen).
Frontend: leaderboardets `STATUS`-Set i `stroke-play-leaderboard.tsx`
(V0-eksportert, allerede hadde `WD`/`DNS`/`DQ`/`NR`/`CUT`) utvidet med
`DSQ`/`RTD`/`DNF` -- gjenbruker den EKSISTERENDE dempet-rad-mekanismen
uendret, ingen ny visuell komponent. Vurdert som en liten, ren
konstant-tillegg til en allerede scratch-verifisert flate (samme
presedens som Cut-linjen i ADR-088), ikke en ny UI-flate. Ny status-
nedtrekk i `Deltakere`-lista (Oppsett), samme mønster som HCP-feltet
(ADR-090) rett ved siden av -- rødtonet når satt til noe annet enn
"Aktiv".
**Del B -- direkte kobling spillerpool <-> turneringsside.** Bruker,
rett etter HCP-rettingen: "hvordan løser jeg det om det er andre ting
enn hcp som skal endres på en spiller? ... det bør dessuten være
kobling direkte mellom spillerpool og turneringsside." Svar på første
del: alt UNNTATT HCP (navn/e-post/kjønn/klubb/kontaktinfo) har ingen
snapshot-frysing -- de redigeres i org-spillerpoolen (ADR-085) og
gjelder umiddelbart overalt, ingen egen turnering-spesifikk løsning
trengs (kun HCP er unikt fordi den fryses per turnering).
For selve KOBLINGEN: ny "Rediger i spillerpoolen"-lenke på hver rad i
`Deltakere`-lista, til `/organizations/{id}/players?highlight={player_
id}`. Spillerpool-siden (`org-players.tsx`) åpner nå redigerings-
panelet AUTOMATISK når `highlight` er satt (ikke det vanlige ekstra
"Rediger spillere"-trykket), og `org-player-roster-panel.tsx` scroller
til og blinker den spesifikke spillerens rad.
Løst UTEN å utvide `player-import-view.tsx` (V0-eksportert) sin
props-kontrakt: ett rent, usynlig `data-row-key={row.key}`-attributt
lagt til raden (dokumentert eksplisitt som et orkestrerings-anker, ikke
et visuelt/atferdsmessig felt) -- `org-player-roster-panel.tsx` bruker
`document.querySelector` mot dette attributtet til å scrolle
(`scrollIntoView`) og legge på en midlertidig ring-klasse (2,5 sek).
Vurdert som en akseptabel, minimal endring i en fil hvis egen kommentar
ellers sier "ikke endre denne filen" -- endringen påvirker INGEN synlig
visning eller oppførsel for noen bruker som ikke eksplisitt navigerer
via `?highlight=`.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Backend: 7 nye
tester i `tests/test_participant_status.py` (alle fire statusverdier
overstyrer total_label riktig, en DSQ-et spiller viser DSQ selv om de
overlevde cutten, blokkert i BEGGE runder ikke bare én, `'active'`
uendret) -- 147/147 grønt (opp fra 140). Ingen egen scratch-runde for
del B (scroll-til-spiller-interaksjonen) -- vurdert lav nok risiko til
å ikke rettferdiggjøre en ny full scratch-oppsett gitt økten sin
allerede betydelige lengde; koden er lest grundig igjennom i stedet
(enkel DOM-query + klasseliste-manipulasjon, samme mønster som
allerede fungerer andre steder i appen).
**Rullet ut:** venter på bekreftelse (migrasjon 087 mot ekte
`teecup_db` krever egen bekreftelse, se CLAUDE.md).
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -12852,3 +12852,28 @@ Neste steg:
**Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build **Rullet ut 2026-08-18** -- bruker bekreftet. `docker compose build
teecup_frontend && up -d`, rene logger, 200 OK. teecup_frontend && up -d`, rene logger, 200 OK.
122. **Manuell deltakerstatus (DSQ/RTD/DNF/DNS) + spillerpool-kobling
fra turneringsside -- 2026-08-18 (ADR-091).** (A) "Vi må manuelt
kunne sette Dsq, Rtd, Dnf, og fns [DNS]" -- ny `tournament_
participant.status`-kolonne (migrasjon 087), overstyrer BÅDE
beregnet til-par-tall OG Cut-status i leaderboardets `total_label`,
blokkerer score i ALLE runder (strengere enn Cut). Leaderboardets
`STATUS`-Set (allerede hadde WD/DNS/DQ/NR/CUT) utvidet med
DSQ/RTD/DNF -- ren konstant-tillegg, ingen ny visuell komponent. Ny
status-nedtrekk i Deltakere-lista. (B) "hvordan løser jeg det om
det er andre ting enn hcp ... det bør dessuten være kobling direkte
mellom spillerpool og turneringsside" -- svar: alt unntatt HCP
redigeres allerede i org-spillerpoolen og gjelder umiddelbart (kun
HCP fryses per turnering). Ny "Rediger i spillerpoolen"-lenke per
deltaker, åpner spillerpoolen direkte på riktig rad (scroller +
blinker) via et nytt, usynlig `data-row-key`-anker i den V0-
eksporterte tabellen -- ingen utvidelse av selve props-kontrakten.
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55, 7 nye
backend-tester -- 147/147 grønt (opp fra 140). Ingen egen scratch-
runde for kobling-delen (lest grundig igjennom i stedet, vurdert
lav restrisiko).
**Rullet ut:** venter på bekreftelse (migrasjon 087 mot ekte
`teecup_db` krever egen bekreftelse, se CLAUDE.md).

View file

@ -327,6 +327,14 @@ class TournamentParticipantUpdate(BaseModel):
class_id: str | None = None class_id: str | None = None
stat_level: StatLevel | None = None stat_level: StatLevel | None = None
handicap_index_snapshot: float | None = Field(default=None, ge=-10, le=54) handicap_index_snapshot: float | None = Field(default=None, ge=-10, le=54)
# Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne
# sette Dsq, Rtd, Dnf, og Dns." Tournament-BREDT (ikke per runde,
# samme granularitet som total_label på leaderboardet). Overstyrer
# BÅDE det beregnede til-par-tallet OG en ev. Cut-status der (satt
# SIST, vinner alltid) -- blokkerer også videre score-registrering
# i update_hole, i ALLE runder (ikke bare "etter et gitt punkt" slik
# Cut gjør). 'active' = uendret oppførsel, ingen regresjon.
status: str | None = Field(default=None, pattern="^(active|dsq|rtd|dnf|dns)$")
class TournamentParticipantOut(BaseModel): class TournamentParticipantOut(BaseModel):
@ -337,13 +345,14 @@ class TournamentParticipantOut(BaseModel):
class_id: str | None class_id: str | None
class_name: str | None class_name: str | None
stat_level: str stat_level: str
status: str
_TOURNAMENT_PARTICIPANT_COLUMNS = """ _TOURNAMENT_PARTICIPANT_COLUMNS = """
tp.id::text AS id, tp.player_id::text AS player_id, tp.id::text AS id, tp.player_id::text AS player_id,
p.display_name AS player_name, p.display_name AS player_name,
tp.handicap_index_snapshot::float AS handicap_index_snapshot, tp.handicap_index_snapshot::float AS handicap_index_snapshot,
tp.class_id::text AS class_id, tc.name AS class_name, tp.stat_level tp.class_id::text AS class_id, tc.name AS class_name, tp.stat_level, tp.status
""" """
@ -369,7 +378,7 @@ async def add_tournament_participant(
INSERT INTO tournament_participant INSERT INTO tournament_participant
(organization_id, tournament_id, player_id, handicap_index_snapshot, class_id, stat_level) (organization_id, tournament_id, player_id, handicap_index_snapshot, class_id, stat_level)
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level, status
) )
SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
FROM inserted tp FROM inserted tp
@ -437,7 +446,7 @@ async def update_tournament_participant(
WITH updated AS ( WITH updated AS (
UPDATE tournament_participant SET {', '.join(set_clauses)} UPDATE tournament_participant SET {', '.join(set_clauses)}
WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)} WHERE id = ${len(values) - 1} AND tournament_id = ${len(values)}
RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level RETURNING id, player_id, handicap_index_snapshot, class_id, stat_level, status
) )
SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS} SELECT {_TOURNAMENT_PARTICIPANT_COLUMNS}
FROM updated tp FROM updated tp
@ -1275,7 +1284,7 @@ async def update_hole(
rp = await conn.fetchrow( rp = await conn.fetchrow(
""" """
SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id, tp.cut SELECT trp.id, trp.tournament_participant_id::text AS tournament_participant_id, tp.cut, tp.status
FROM tournament_round_participant trp FROM tournament_round_participant trp
JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id JOIN tournament_participant tp ON tp.id = trp.tournament_participant_id
WHERE trp.id = $1 AND trp.tournament_round_id = $2 WHERE trp.id = $1 AND trp.tournament_round_id = $2
@ -1299,6 +1308,18 @@ async def update_hole(
403, "CUT", "Denne spilleren er kuttet og kan ikke registrere score i denne runden." 403, "CUT", "Denne spilleren er kuttet og kan ikke registrere score i denne runden."
) )
# Manuell status (migrasjon 087, 2026-08-18): en deltaker som er
# satt til DSQ/RTD/DNF/DNS er ute av HELE turneringen (ikke bare
# "etter et gitt punkt" slik Cut er) -- blokkert i ALLE runder,
# ingen unntak for allerede spilte runder (i motsetning til Cut,
# som bevisst lar historikken t.o.m. cut-punktet stå urørt).
if rp["status"] != "active":
raise app_error(
403,
"PARTICIPANT_STATUS",
f"Denne spilleren har status {rp['status'].upper()} og kan ikke registrere score.",
)
if not await user_is_own_tournament_participant( if not await user_is_own_tournament_participant(
conn, organization_id, rp["tournament_participant_id"], user.user_id conn, organization_id, rp["tournament_participant_id"], user.user_id
): ):
@ -1584,6 +1605,12 @@ class LeaderboardEntry(BaseModel):
# cut_after_round i det hele tatt). # cut_after_round i det hele tatt).
rank: int | None = None rank: int | None = None
cut: bool = False cut: bool = False
# Manuell status (migrasjon 087, 2026-08-18) -- "active" (uendret
# oppførsel) eller "dsq"/"rtd"/"dnf"/"dns". Overstyrer BÅDE det
# beregnede til-par-tallet og en ev. cut-status i total_label (satt
# SIST i _compute_individual_standings -- en DSQ-et spiller er DSQ,
# uansett cut).
status: str = "active"
# Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat # Eclectic (ADR-067-tillegget "Del C", migrasjon 072) -- beste resultat
# per hull på tvers av ALLE turneringens runder (samme bane, håndhevet # per hull på tvers av ALLE turneringens runder (samme bane, håndhevet
# ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_ # ved rundeopprettelse). Alltid None/tom liste for øvrige scoring_
@ -1626,7 +1653,7 @@ async def _compute_individual_standings(
""" """
SELECT tp.id::text AS tournament_participant_id, tp.player_id::text AS player_id, SELECT tp.id::text AS tournament_participant_id, tp.player_id::text AS player_id,
p.display_name AS player_name, p.display_name AS player_name,
tp.cut, tp.cut, tp.status,
count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played, count(trs.id) FILTER (WHERE trs.holes_played > 0) AS rounds_played,
SUM(trs.gross_total)::int AS gross_total, SUM(trs.gross_total)::int AS gross_total,
SUM(trs.net_total)::int AS net_total, SUM(trs.net_total)::int AS net_total,
@ -1690,6 +1717,19 @@ async def _compute_individual_standings(
if e.cut: if e.cut:
e.total_label = "CUT" e.total_label = "CUT"
e.is_leader = False e.is_leader = False
# Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt
# kunne sette Dsq, Rtd, Dnf, og Dns." Anvendes SIST, vinner
# alltid over BÅDE det beregnede til-par-tallet og en ev.
# cut-status over -- en DSQ-et spiller er DSQ, uansett om de
# tilfeldigvis også er kuttet. Ingen ny sortering nødvendig
# (gjenbruker rekkefølgen fra cut-sorteringen over -- disse
# spillerne ender uansett med en status-rad, ikke en rangert
# plassering, se position=None under).
for e in entries:
if e.status != "active":
e.total_label = e.status.upper()
e.is_leader = False
e.position = None
elif method == "copenhagen": elif method == "copenhagen":
# Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4). # Flest poeng totalt vinner (kilden, spilletyper-og-spilleformer-2023.pdf s.4).
entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0))) entries.sort(key=lambda e: (e.copenhagen_total is None, -(e.copenhagen_total or 0)))

View file

@ -5,9 +5,9 @@ export default async function OrgPlayersPage({
searchParams, searchParams,
}: { }: {
params: Promise<{ id: string }> params: Promise<{ id: string }>
searchParams: Promise<{ name?: string }> searchParams: Promise<{ name?: string; highlight?: string }>
}) { }) {
const { id } = await params const { id } = await params
const { name } = await searchParams const { name, highlight } = await searchParams
return <OrgPlayers organizationId={id} orgName={name ?? "Organisasjon"} /> return <OrgPlayers organizationId={id} orgName={name ?? "Organisasjon"} highlightPlayerId={highlight ?? null} />
} }

View file

@ -23,6 +23,7 @@ import {
ChevronDown, ChevronDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
ExternalLink,
Clock, Clock,
Copy, Copy,
Crosshair, Crosshair,
@ -152,6 +153,10 @@ type ApiParticipant = {
// Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye HoleGrid/ // Statistikknivå (migrasjon 075, ADR-071) -- styrer hvor mye HoleGrid/
// HoleStatsSheet ber om ved scoring, tournament-bredt (ikke per runde). // HoleStatsSheet ber om ved scoring, tournament-bredt (ikke per runde).
stat_level: "strokes_only" | "strokes_and_putts" | "full" stat_level: "strokes_only" | "strokes_and_putts" | "full"
// Manuell status (migrasjon 087, 2026-08-18) -- "active" (uendret) eller
// dsq/rtd/dnf/dns. Overstyrer leaderboardets total-visning og blokkerer
// videre score-registrering i ALLE runder.
status: "active" | "dsq" | "rtd" | "dnf" | "dns"
} }
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt // Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
@ -783,6 +788,28 @@ export function IndividualTournamentDetail({
setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p))) setParticipants((prev) => prev.map((p) => (p.id === updated.id ? updated : p)))
} }
// Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne
// sette Dsq, Rtd, Dnf, og Dns." Tournament-bredt, blokkerer videre
// score i ALLE runder (strengere enn Cut).
async function setParticipantStatus(
participantId: string,
status: "active" | "dsq" | "rtd" | "dnf" | "dns",
) {
setError(null)
const res = await fetch(`${base}/participants/${participantId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ status }),
})
if (!res.ok) {
setError(await errorMessage(res, "Klarte ikke å endre status."))
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) { async function createClass(name: string, defaultTeeId: string | null) {
setError(null) setError(null)
const res = await fetch(`${base}/classes`, { const res = await fetch(`${base}/classes`, {
@ -1006,6 +1033,7 @@ export function IndividualTournamentDetail({
onSetParticipantClass={setParticipantClass} onSetParticipantClass={setParticipantClass}
onSetParticipantStatLevel={setParticipantStatLevel} onSetParticipantStatLevel={setParticipantStatLevel}
onSetParticipantHandicap={setParticipantHandicap} onSetParticipantHandicap={setParticipantHandicap}
onSetParticipantStatus={setParticipantStatus}
onError={setError} onError={setError}
/> />
)} )}
@ -1104,6 +1132,7 @@ function SetupTab({
onSetParticipantClass, onSetParticipantClass,
onSetParticipantStatLevel, onSetParticipantStatLevel,
onSetParticipantHandicap, onSetParticipantHandicap,
onSetParticipantStatus,
onError, onError,
}: { }: {
base: string base: string
@ -1136,6 +1165,10 @@ function SetupTab({
statLevel: "strokes_only" | "strokes_and_putts" | "full", statLevel: "strokes_only" | "strokes_and_putts" | "full",
) => Promise<void> ) => Promise<void>
onSetParticipantHandicap: (participantId: string, handicap: number | null) => Promise<void> onSetParticipantHandicap: (participantId: string, handicap: number | null) => Promise<void>
onSetParticipantStatus: (
participantId: string,
status: "active" | "dsq" | "rtd" | "dnf" | "dns",
) => Promise<void>
onError: (message: string) => void onError: (message: string) => void
}) { }) {
const [pool, setPool] = useState<ApiPlayer[]>([]) const [pool, setPool] = useState<ApiPlayer[]>([])
@ -1276,6 +1309,7 @@ function SetupTab({
onSetClass={onSetParticipantClass} onSetClass={onSetParticipantClass}
onSetStatLevel={onSetParticipantStatLevel} onSetStatLevel={onSetParticipantStatLevel}
onSetHandicap={onSetParticipantHandicap} onSetHandicap={onSetParticipantHandicap}
onSetStatus={onSetParticipantStatus}
/> />
</div> </div>
) )
@ -1668,6 +1702,17 @@ const STAT_LEVEL_LABELS: Record<string, string> = {
full: "All statistikk", full: "All statistikk",
} }
// Manuell status (migrasjon 087, 2026-08-18) -- "Vi må manuelt kunne
// sette Dsq, Rtd, Dnf, og Dns." Blokkerer videre score i ALLE runder
// (strengere enn Cut) og overstyrer leaderboardets total-visning.
const PARTICIPANT_STATUS_LABELS: Record<string, string> = {
active: "Aktiv",
dsq: "DSQ",
rtd: "RTD",
dnf: "DNF",
dns: "DNS",
}
function ParticipantsCard({ function ParticipantsCard({
organizationId, organizationId,
tournamentId, tournamentId,
@ -1681,6 +1726,7 @@ function ParticipantsCard({
onSetClass, onSetClass,
onSetStatLevel, onSetStatLevel,
onSetHandicap, onSetHandicap,
onSetStatus,
}: { }: {
organizationId: string organizationId: string
tournamentId: string tournamentId: string
@ -1697,6 +1743,7 @@ function ParticipantsCard({
statLevel: "strokes_only" | "strokes_and_putts" | "full", statLevel: "strokes_only" | "strokes_and_putts" | "full",
) => Promise<void> ) => Promise<void>
onSetHandicap: (participantId: string, handicap: number | null) => Promise<void> onSetHandicap: (participantId: string, handicap: number | null) => Promise<void>
onSetStatus: (participantId: string, status: "active" | "dsq" | "rtd" | "dnf" | "dns") => Promise<void>
}) { }) {
const [showImport, setShowImport] = useState(false) const [showImport, setShowImport] = useState(false)
@ -1735,13 +1782,27 @@ 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 flex-wrap 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 gap-0.5">
<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>
<HcpInput <HcpInput
playerName={p.player_name} playerName={p.player_name}
value={p.handicap_index_snapshot} value={p.handicap_index_snapshot}
onCommit={(v) => onSetHandicap(p.id, v)} onCommit={(v) => onSetHandicap(p.id, v)}
/> />
{/* Direkte kobling til spillerpoolen (2026-08-18, "det bør
dessuten være kobling direkte mellom spillerpool og
turneringsside") -- for å rette navn/e-post/kjønn/klubb
osv., som IKKE er turnering-spesifikke felt (kun HCP er
det, se HcpInput). Åpner spillerpoolen direkte denne
spillerens rad, i stedet for at organisator lete den
opp manuelt i en potensielt lang tabell. */}
<Link
href={`/organizations/${organizationId}/players?highlight=${encodeURIComponent(p.player_id)}`}
className="inline-flex w-fit items-center gap-1 text-xs font-semibold text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
<ExternalLink aria-hidden="true" className="size-3" />
Rediger i spillerpoolen
</Link>
</div> </div>
{classes.length > 0 && ( {classes.length > 0 && (
<select <select
@ -1775,6 +1836,28 @@ function ParticipantsCard({
</option> </option>
))} ))}
</select> </select>
{/* Manuell status (migrasjon 087, 2026-08-18) -- blokkerer
videre score i ALLE runder når satt til noe annet enn
"Aktiv", overstyrer leaderboardets total-visning. */}
<select
value={p.status}
onChange={(e) =>
onSetStatus(p.id, e.target.value as "active" | "dsq" | "rtd" | "dnf" | "dns")
}
aria-label={`Status for ${p.player_name}`}
className={cn(
"h-9 shrink-0 rounded-lg border px-2 text-xs font-bold outline-none",
p.status === "active"
? "border-border bg-card text-foreground"
: "border-destructive/40 bg-destructive/10 text-destructive",
)}
>
{Object.entries(PARTICIPANT_STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"

View file

@ -115,9 +115,16 @@ function playerToRow(p: ApiPlayer): DraftRow {
export function OrgPlayerRosterPanel({ export function OrgPlayerRosterPanel({
organizationId, organizationId,
onClose, onClose,
highlightPlayerId,
}: { }: {
organizationId: string organizationId: string
onClose: () => void onClose: () => void
// Direkte kobling fra en turneringsside (2026-08-18, "det bør dessuten
// være kobling direkte mellom spillerpool og turneringsside") -- når
// satt, scroller panelet til nettopp denne spilleren sin rad og gir
// den et kort visuelt blink, i stedet for at organisator må lete den
// opp manuelt i en potensielt lang tabell.
highlightPlayerId?: string | null
}) { }) {
const [step, setStep] = useState<"loading" | "edit" | "saving" | "done">("loading") const [step, setStep] = useState<"loading" | "edit" | "saving" | "done">("loading")
const [rows, setRows] = useState<DraftRow[]>([]) const [rows, setRows] = useState<DraftRow[]>([])
@ -148,6 +155,20 @@ export function OrgPlayerRosterPanel({
} }
}, [organizationId]) }, [organizationId])
// Scroll-til-spiller (2026-08-18) -- bruker `data-row-key` (satt i
// player-import-view.tsx sin EditTable, et rent orkestrerings-anker,
// se dens egen kommentar) i stedet for å endre selve V0-komponentens
// props-kontrakt. Kjøres når raden faktisk er i DOM-en (step "edit").
useEffect(() => {
if (step !== "edit" || !highlightPlayerId) return
const el = document.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(highlightPlayerId)}"]`)
if (!el) return
el.scrollIntoView({ behavior: "smooth", block: "center" })
el.classList.add("ring-2", "ring-primary", "ring-inset")
const timeout = setTimeout(() => el.classList.remove("ring-2", "ring-primary", "ring-inset"), 2500)
return () => clearTimeout(timeout)
}, [step, highlightPlayerId, rows])
function updateCell(key: string, field: FieldKey, value: string) { function updateCell(key: string, field: FieldKey, value: string) {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, [field]: value } : r))) setRows((prev) => prev.map((r) => (r.key === key ? { ...r, [field]: value } : r)))
} }

View file

@ -10,8 +10,20 @@ import Link from "next/link"
import { ArrowLeft, Pencil } from "lucide-react" import { ArrowLeft, Pencil } from "lucide-react"
import { OrgPlayerRosterPanel } from "@/components/org-player-roster-panel" import { OrgPlayerRosterPanel } from "@/components/org-player-roster-panel"
export function OrgPlayers({ organizationId, orgName }: { organizationId: string; orgName: string }) { export function OrgPlayers({
const [editing, setEditing] = useState(false) organizationId,
orgName,
highlightPlayerId,
}: {
organizationId: string
orgName: string
// Direkte kobling fra en turneringsside (2026-08-18) -- når satt,
// åpner redigerings-panelet AUTOMATISK (ingen ekstra "Rediger
// spillere"-trykk nødvendig) og scroller/blinker til nettopp denne
// spilleren, se org-player-roster-panel.tsx.
highlightPlayerId?: string | null
}) {
const [editing, setEditing] = useState(Boolean(highlightPlayerId))
return ( return (
<div className="flex min-h-[100dvh] flex-col bg-background"> <div className="flex min-h-[100dvh] flex-col bg-background">
@ -54,7 +66,13 @@ export function OrgPlayers({ organizationId, orgName }: { organizationId: string
</button> </button>
</main> </main>
{editing && <OrgPlayerRosterPanel organizationId={organizationId} onClose={() => setEditing(false)} />} {editing && (
<OrgPlayerRosterPanel
organizationId={organizationId}
onClose={() => setEditing(false)}
highlightPlayerId={highlightPlayerId}
/>
)}
</div> </div>
) )
} }

View file

@ -484,6 +484,12 @@ function EditTable({
{rows.map((row, rowIndex) => ( {rows.map((row, rowIndex) => (
<tr <tr
key={row.key} key={row.key}
// data-row-key (2026-08-18): rent orkestrerings-anker, IKKE
// et visuelt/atferdsmessig felt -- lar en ekstern "hopp til
// denne spilleren"-lenke (org-player-roster-panel.tsx)
// scrolle til riktig rad uten å måtte utvide selve
// props-kontrakten eller endre noe synlig her.
data-row-key={row.key}
className={cn( className={cn(
"border-b border-border last:border-b-0", "border-b border-border last:border-b-0",
rowIndex % 2 === 1 && "bg-muted/30", rowIndex % 2 === 1 && "bg-muted/30",

View file

@ -69,7 +69,10 @@ export const MOCK_ROWS: LeaderboardRow[] = [
type Tone = "under" | "even" | "over" | "none" type Tone = "under" | "even" | "over" | "none"
const STATUS = new Set(["WD", "DNS", "DQ", "NR", "CUT"]) // DSQ/RTD/DNF lagt til 2026-08-18 -- manuell deltakerstatus (migrasjon 087,
// individual_tournaments.py), samme mekanisme som WD/DNS/DQ/NR/CUT allerede
// hadde (dempet rad, egen badge i stedet for et score-tall).
const STATUS = new Set(["WD", "DNS", "DQ", "NR", "CUT", "DSQ", "RTD", "DNF"])
function isStatus(label: string) { function isStatus(label: string) {
return STATUS.has(label.trim().toUpperCase()) return STATUS.has(label.trim().toUpperCase())
} }

View file

@ -0,0 +1,170 @@
"""
Manuell status per deltaker (migrasjon 087, 2026-08-18). Bruker: "Vi må
manuelt kunne sette Dsq, Rtd, Dnf, og fns [DNS]." Tournament-bredt
(ikke per runde), overstyrer BÅDE det beregnede til-par-tallet og en
ev. Cut-status, og blokkerer videre score-registrering i ALLE runder
(strengere enn Cut, som kun blokkerer runder ETTER cut-punktet).
"""
import pytest
from fastapi import HTTPException
from app.auth import CurrentUser
from app.routers.individual_tournaments import (
HoleUpdate as TournamentHoleUpdate,
TournamentParticipantUpdate,
individual_leaderboard,
update_hole as tournament_update_hole,
update_tournament_participant,
)
from tests.conftest import (
add_membership,
create_org,
create_org_hole,
create_course,
create_player,
create_tee,
create_tournament,
create_tournament_participant,
create_tournament_round,
create_tournament_round_participant,
create_user,
)
import app.db as app_db
async def _score_holes(tournament_id, round_id, rp_id, org_id, user_id, scores: list[int]) -> None:
user = CurrentUser(user_id=user_id)
for n, gross in enumerate(scores, start=1):
await tournament_update_hole(
tournament_id, round_id, rp_id, n,
TournamentHoleUpdate(gross_strokes=gross, expected_version=None),
organization_id=org_id, user=user,
)
async def _setup(org_id, owner_id, method="stroke_gross"):
tournament_id = await create_tournament(org_id, name="Status-turnering")
course_id = await create_course(org_id, name="Status Links")
for n in range(1, 19):
await create_org_hole(org_id, course_id, hole_number=n, par=4, stroke_index=n)
tee_id = await create_tee(org_id, course_id)
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE tournament SET format_type = 'individual', scoring_method = $2 WHERE id = $1",
tournament_id, method,
)
round1_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=1)
round2_id = await create_tournament_round(org_id, tournament_id, course_id, sequence=2)
return tournament_id, course_id, tee_id, round1_id, round2_id
@pytest.mark.parametrize("status", ["dsq", "rtd", "dnf", "dns"])
async def test_status_overrides_computed_total_label(pool, status):
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round1_id, round2_id = await _setup(org_id, owner_id)
player_id = await create_player(org_id, display_name="Spiller")
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id)
await _score_holes(tournament_id, round1_id, rp_id, org_id, owner_id, [4] * 18)
await update_tournament_participant(
tournament_id, tp_id, TournamentParticipantUpdate(status=status), organization_id=org_id
)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
entry = next(e for e in entries if e.tournament_participant_id == tp_id)
assert entry.total_label == status.upper()
assert entry.is_leader is False
assert entry.position is None
assert entry.status == status
async def test_status_overrides_even_a_cut_survivor(pool):
"""En DSQ-et spiller skal vises som DSQ, ikke CUT, selv om de
tilfeldigvis også overlevde cutten."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round1_id, round2_id = await _setup(org_id, owner_id)
from app.routers.tournaments import TournamentUpdate, update_tournament
from app.routers.individual_tournaments import apply_cut
await update_tournament(
tournament_id, TournamentUpdate(cut_after_round=1, cut_size=5), organization_id=org_id,
)
player_id = await create_player(org_id, display_name="Spiller")
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id)
await _score_holes(tournament_id, round1_id, rp_id, org_id, owner_id, [4] * 18)
await apply_cut(tournament_id, organization_id=org_id)
await update_tournament_participant(
tournament_id, tp_id, TournamentParticipantUpdate(status="dsq"), organization_id=org_id
)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
entry = next(e for e in entries if e.tournament_participant_id == tp_id)
assert entry.total_label == "DSQ"
assert entry.cut is False # var faktisk innenfor cut-grensen
async def test_status_blocks_scoring_in_any_round_not_just_after_a_point(pool):
"""Strengere enn Cut: en DSQ-et/RTD-et/DNF-et/DNS-et spiller er
blokkert i ALLE runder, ikke bare etter et gitt punkt."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round1_id, round2_id = await _setup(org_id, owner_id)
player_id = await create_player(org_id, display_name="Spiller")
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp1_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id)
rp2_id = await create_tournament_round_participant(org_id, round2_id, tp_id, tee_id)
await update_tournament_participant(
tournament_id, tp_id, TournamentParticipantUpdate(status="dsq"), organization_id=org_id
)
user = CurrentUser(user_id=owner_id)
with pytest.raises(HTTPException) as exc_round1:
await tournament_update_hole(
tournament_id, round1_id, rp1_id, 1,
TournamentHoleUpdate(gross_strokes=4, expected_version=None),
organization_id=org_id, user=user,
)
assert exc_round1.value.status_code == 403
with pytest.raises(HTTPException) as exc_round2:
await tournament_update_hole(
tournament_id, round2_id, rp2_id, 1,
TournamentHoleUpdate(gross_strokes=4, expected_version=None),
organization_id=org_id, user=user,
)
assert exc_round2.value.status_code == 403
async def test_active_status_is_unaffected_default(pool):
"""Ingen regresjon -- default 'active' oppfører seg akkurat som før."""
org_id = await create_org()
owner_id = await create_user()
await add_membership(org_id, owner_id, role="owner")
tournament_id, course_id, tee_id, round1_id, round2_id = await _setup(org_id, owner_id)
player_id = await create_player(org_id, display_name="Spiller")
tp_id = await create_tournament_participant(org_id, tournament_id, player_id)
rp_id = await create_tournament_round_participant(org_id, round1_id, tp_id, tee_id)
await _score_holes(tournament_id, round1_id, rp_id, org_id, owner_id, [4] * 18)
entries = await individual_leaderboard(tournament_id, organization_id=org_id)
entry = next(e for e in entries if e.tournament_participant_id == tp_id)
assert entry.status == "active"
assert entry.total_label == "E"
assert entry.position == "1"