Backend og frontend er bygget og scratch-verifisert (15 + 15 automatiserte sjekker), ADR-024 skrevet, .md-filer oppdatert. Ingen migrasjon denne runden (ren applogikk).
Klar for utrulling — samme mønster som sist: docker compose up -d --build teecup_api teecup_frontend Verifisere /health + /dashboard fortsatt 200, teeoff.no upåvirket
This commit is contained in:
parent
c3cca377ed
commit
ab23674521
6 changed files with 429 additions and 12 deletions
|
|
@ -967,6 +967,62 @@ scratch-verifisering og utrulling.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ADR-024: Walkover/konsesjon
|
||||||
|
|
||||||
|
Reist 2026-07-19, direkte oppfølging av det tidligere åpne punktet i
|
||||||
|
FEATURE_BACKLOG.md: en side som aldri stiller nok spillere får ALDRI
|
||||||
|
beregnet handicap, og matchen kan derfor ALDRI avgjøres — den henger uendelig.
|
||||||
|
Ventet tidligere på Brukerroller (ADR-023), som nå er avgjort. Fire
|
||||||
|
delspørsmål, alle avklart eksplisitt med bruker før bygging.
|
||||||
|
|
||||||
|
**Beslutning A — Kun den TAPENDE siden (eller org-eier/admin) kan erklære,
|
||||||
|
til fordel for motstanderen.** Speiler ekte golf-etikette: du gir bort DITT
|
||||||
|
EGET tap, du krever ikke seier på motstanderens vegne. Bruker
|
||||||
|
`app/team_authz.py` sin eksisterende `user_is_team_captain` (ADR-023) på det
|
||||||
|
KONSEDERENDE laget spesifikt — ingen ny autorisasjonsfunksjon nødvendig. Rene
|
||||||
|
no-show-tilfeller (den tapende siden har ingen innlogget/rostret spiller i
|
||||||
|
det hele tatt) dekkes av org-admin-fallbacken som allerede finnes i
|
||||||
|
`user_is_team_captain`.
|
||||||
|
|
||||||
|
**Beslutning B — Ensidig erklæring, ingen bekreftelse fra motparten.**
|
||||||
|
Samme tillitsnivå som all annen scoring i appen (allerede en upsert uten
|
||||||
|
godkjenning). Ingen ny "venter på bekreftelse"-tilstand eller
|
||||||
|
varslingsmekanisme bygget.
|
||||||
|
|
||||||
|
**Beslutning C — Både match- og turnering-nivå, i samme runde.**
|
||||||
|
`POST /orgs/{id}/matches/{id}/concede` (`app/routers/scoring.py`) for én
|
||||||
|
match. `POST /orgs/{id}/tournaments/{id}/concede`
|
||||||
|
(`app/routers/tournaments.py`) for å gi opp ALLE ikke-avgjorte matcher laget
|
||||||
|
har i turneringen, i én operasjon — v1 er låst til nøyaktig to lag
|
||||||
|
(ADR-011), så det finnes bare ÉN motstander uansett hvor mange
|
||||||
|
sesjoner/matcher turneringen har, og turnering-nivå-konsesjon er derfor
|
||||||
|
bare match-nivå-logikken (`apply_concession`) kjørt per ikke-avgjorte match.
|
||||||
|
Hull-nivå konsesjon er bevisst IKKE en egen mekanisme — `hole_result`-modus
|
||||||
|
dekker det allerede (rapporter bare hvem som vant hullet).
|
||||||
|
|
||||||
|
**Beslutning D — Kan erklæres uansett hvor mange hull som allerede er
|
||||||
|
registrert.** I match-play teller kun seier/tap/delt for poeng, ikke
|
||||||
|
marginen — det er derfor ingen reell forskjell, poengmessig, på "ga opp
|
||||||
|
etter 5 hull" og "ga opp før start". `apply_concession` overstyrer status/
|
||||||
|
poeng direkte, uavhengig av `_compute_hole_results`; allerede registrerte
|
||||||
|
hull forblir urørt i `hole_score`/`match_hole_result` (kun matchens
|
||||||
|
avgjørelses-felt endres), så scorekortet fortsatt viser nøyaktig hva som ble
|
||||||
|
spilt før konsesjonen.
|
||||||
|
|
||||||
|
**Gjenbruk, ikke duplisering:** `apply_concession` skriver til nøyaktig de
|
||||||
|
samme fire kolonnene (`status_text`/`points_side_a/b`/`leading_side`) som
|
||||||
|
`recompute_and_cache_match_state` — samme "matchen er avgjort"-signal
|
||||||
|
(`points_side_a IS NOT NULL`) som allerede stopper videre hull-innsending
|
||||||
|
(`submit_hole_score`/`submit_hole_result`), ingen ny låsemekanisme
|
||||||
|
nødvendig. Status-teksten "Walkover (A)"/"Walkover (B)" følger samme
|
||||||
|
"(bokstav)"-visningskonvensjon som `compute_match_state.describe()` sine
|
||||||
|
egne strenger (f.eks. "9&7 (A)").
|
||||||
|
|
||||||
|
**Status: ✅ BYGGET 2026-07-19.** Ingen migrasjon (ren applogikk, ingen
|
||||||
|
skjemaendring). Se CLAUDE.md-status for scratch-verifisering og utrulling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Åpne spørsmål (ikke besluttet ennå)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
|
|
@ -352,19 +352,22 @@
|
||||||
Turnering har et `status`-felt (draft/active/completed/archived) men
|
Turnering har et `status`-felt (draft/active/completed/archived) men
|
||||||
INGEN endepunkt endrer det ennå — organisator kan ikke markere en turnering
|
INGEN endepunkt endrer det ennå — organisator kan ikke markere en turnering
|
||||||
ferdig via API-et i dag (eget, senere punkt).
|
ferdig via API-et i dag (eget, senere punkt).
|
||||||
- **Manglende WO/konsesjon:** hvis en side aldri stiller nok spillere
|
- **Walkover/konsesjon — ✅ BYGGET 2026-07-19 (ADR-024).** Løste det
|
||||||
(`match_participant`-antallet når aldri det økten krever), beregnes
|
opprinnelige hullet: en side som aldri stiller nok spillere kan nå
|
||||||
handicap ALDRI (se `app/handicap.py`), og matchen kan derfor ALDRI få et
|
avsluttes ved at den TAPENDE siden (kaptein, eller org-admin) erklærer
|
||||||
hull-resultat — den blir hengende uavgjort for alltid. Det finnes ingen
|
walkover — ensidig, ingen bekreftelse fra motparten. `POST /orgs/{id}/
|
||||||
«gi bort hullet/matchen/turneringen»-mekanisme (walkover/konsesjon) i det
|
matches/{id}/concede` (én match) og `POST /orgs/{id}/tournaments/{id}/
|
||||||
hele tatt ennå — verken datamodell eller endepunkt.
|
concede` (gi opp ALLE ikke-avgjorte matcher laget har, i én operasjon —
|
||||||
|
v1 er låst til nøyaktig to lag, så det finnes bare én motstander). Kan
|
||||||
|
erklæres uansett hvor mange hull som allerede er registrert (poengmessig
|
||||||
|
teller kun seier/tap/delt, ikke marginen) — allerede registrerte hull
|
||||||
|
forblir urørt i scorekortet. Se ARCHITECTURE_DECISIONS.md ADR-024.
|
||||||
|
Frontend: `session-scorecard.tsx` (gi opp én match) og
|
||||||
|
`tournament-detail.tsx` (gi opp resten av turneringen, per lag).
|
||||||
- **Avgjort 2026-07-16:**
|
- **Avgjort 2026-07-16:**
|
||||||
- **Match-lås ved avgjørelse: ✅ bygget** — se eget punkt over. Automatisk
|
- **Match-lås ved avgjørelse: ✅ bygget** — se eget punkt over. Automatisk
|
||||||
(ikke en handling noen utfører), så «hvem får låse» ble aldri et
|
(ikke en handling noen utfører), så «hvem får låse» ble aldri et
|
||||||
spørsmål som trengte avklaring.
|
spørsmål som trengte avklaring.
|
||||||
- **Walkover/konsesjon kan nå tas fatt på** — brukerroller (kaptein/
|
|
||||||
deltaker-avgrensning) er avgjort og bygget (ADR-023), grunnlaget den
|
|
||||||
tidligere ventet på er ikke lenger løst.
|
|
||||||
- **Fortsatt åpent:** (a) ~~skal score-føring begrenses til faktiske
|
- **Fortsatt åpent:** (a) ~~skal score-føring begrenses til faktiske
|
||||||
matchdeltakere~~ ✅ avgjort/bygget 2026-07-19 (ADR-023, se over). (b) skal
|
matchdeltakere~~ ✅ avgjort/bygget 2026-07-19 (ADR-023, se over). (b) skal
|
||||||
korrigering kreve motpartens godkjenning, eller er upsert-modellen god nok
|
korrigering kreve motpartens godkjenning, eller er upsert-modellen god nok
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ status med en mindre komplett.
|
||||||
prefiksen fra hull 1 og ut til første hull som ikke kan avgjøres -- aldri hull
|
prefiksen fra hull 1 og ut til første hull som ikke kan avgjøres -- aldri hull
|
||||||
lenger ute i sekvensen, selv om de tilfeldigvis er komplette, siden det ville
|
lenger ute i sekvensen, selv om de tilfeldigvis er komplette, siden det ville
|
||||||
forskjøvet holes_remaining/is_dormie/is_closed for resten av matchen.
|
forskjøvet holes_remaining/is_dormie/is_closed for resten av matchen.
|
||||||
|
|
||||||
|
Walkover/konsesjon (ADR-024): `apply_concession` er en egen, enklere vei til
|
||||||
|
akkurat de samme fire kolonnene (`status_text`/`points_side_a/b`/
|
||||||
|
`leading_side`) som `recompute_and_cache_match_state` skriver -- brukt når
|
||||||
|
matchen avgjøres ved at en side GIR SEG, ikke ved utledning fra faktiske
|
||||||
|
hull. Delt med tournaments.py sin turnering-nivå-konsesjon (gir opp ALLE
|
||||||
|
ikke-avgjorte matcher laget har, i én operasjon).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -43,7 +50,7 @@ from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import parse_allowance_config, relative_strokes_for_match
|
from ..handicap import parse_allowance_config, relative_strokes_for_match
|
||||||
from ..team_authz import user_is_match_participant
|
from ..team_authz import user_is_match_participant, user_is_team_captain
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
@ -210,6 +217,97 @@ async def recompute_and_cache_match_state(conn, match_id: str) -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConcedeOut(BaseModel):
|
||||||
|
match_id: str
|
||||||
|
status_text: str
|
||||||
|
points_side_a: float
|
||||||
|
points_side_b: float
|
||||||
|
leading_side: str
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_concession(conn, match_id: str, conceding_side: str, points_per_match: float) -> ConcedeOut:
|
||||||
|
"""Setter matchen som avgjort ved walkover -- full poengsum til motstanderen,
|
||||||
|
uansett hvor mange hull som ev. allerede er registrert (marginen betyr
|
||||||
|
ingenting for poeng i match-play, kun seier/tap/delt gjør). Kaller MÅ selv
|
||||||
|
ha låst match-raden (FOR UPDATE) og bekreftet at den ikke allerede er
|
||||||
|
avgjort FØR denne kalles -- se de to kallstedene (matches.py sin
|
||||||
|
concede_match, tournaments.py sin concede_tournament)."""
|
||||||
|
winner = "b" if conceding_side == "a" else "a"
|
||||||
|
points_a = 0.0 if conceding_side == "a" else points_per_match
|
||||||
|
points_b = points_per_match if conceding_side == "a" else 0.0
|
||||||
|
status_text = f"Walkover ({winner.upper()})"
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE match SET status_text = $1, points_side_a = $2, points_side_b = $3, leading_side = $4
|
||||||
|
WHERE id = $5
|
||||||
|
""",
|
||||||
|
status_text,
|
||||||
|
points_a,
|
||||||
|
points_b,
|
||||||
|
winner,
|
||||||
|
match_id,
|
||||||
|
)
|
||||||
|
return ConcedeOut(
|
||||||
|
match_id=match_id, status_text=status_text, points_side_a=points_a, points_side_b=points_b, leading_side=winner
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConcedeRequest(BaseModel):
|
||||||
|
conceding_team_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/orgs/{organization_id}/matches/{match_id}/concede",
|
||||||
|
response_model=ConcedeOut,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def concede_match(
|
||||||
|
match_id: str,
|
||||||
|
body: ConcedeRequest,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> ConcedeOut:
|
||||||
|
"""Walkover/konsesjon (ADR-024): kun kaptein for det TAPENDE laget (laget
|
||||||
|
som selv gir seg -- speiler ekte golf-etikette, du kan ikke kreve seier
|
||||||
|
på motstanderens vegne) eller org-eier/admin kan erklære. Ensidig, ingen
|
||||||
|
bekreftelse fra motparten kreves -- samme tillitsnivå som all annen
|
||||||
|
scoring. Kan erklæres uansett hvor mange hull som allerede er
|
||||||
|
registrert."""
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
match = await conn.fetchrow(
|
||||||
|
"""
|
||||||
|
SELECT m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
|
||||||
|
m.points_side_a::float AS points_side_a,
|
||||||
|
s.points_per_match::float AS points_per_match
|
||||||
|
FROM match m JOIN session s ON s.id = m.session_id
|
||||||
|
WHERE m.id = $1
|
||||||
|
FOR UPDATE OF m
|
||||||
|
""",
|
||||||
|
match_id,
|
||||||
|
)
|
||||||
|
if match is None:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Matchen finnes ikke.")
|
||||||
|
if match["points_side_a"] is not None:
|
||||||
|
raise app_error(409, "ALREADY_DECIDED", "Matchen er avgjort og kan ikke lenger endres.")
|
||||||
|
|
||||||
|
if body.conceding_team_id == match["team_a_id"]:
|
||||||
|
conceding_side = "a"
|
||||||
|
elif body.conceding_team_id == match["team_b_id"]:
|
||||||
|
conceding_side = "b"
|
||||||
|
else:
|
||||||
|
raise app_error(400, "VALIDATION_FAILED", "Laget hører ikke til denne matchen.")
|
||||||
|
|
||||||
|
if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id):
|
||||||
|
raise app_error(
|
||||||
|
403,
|
||||||
|
"NOT_TEAM_CAPTAIN",
|
||||||
|
"Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å erklære walkover.",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await apply_concession(conn, match_id, conceding_side, match["points_per_match"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class HoleScoreCreate(BaseModel):
|
class HoleScoreCreate(BaseModel):
|
||||||
team_side: str = Field(pattern="^[ab]$")
|
team_side: str = Field(pattern="^[ab]$")
|
||||||
match_participant_id: str | None = None
|
match_participant_id: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,13 @@ import asyncpg
|
||||||
from fastapi import APIRouter, Depends, UploadFile
|
from fastapi import APIRouter, Depends, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import get_authorized_org
|
from ..auth import CurrentUser, get_authorized_org, get_current_user
|
||||||
from ..blind_draw import locked_team_ids
|
from ..blind_draw import locked_team_ids
|
||||||
from ..db import org_connection
|
from ..db import org_connection
|
||||||
from ..errors import app_error, translate_db_errors
|
from ..errors import app_error, translate_db_errors
|
||||||
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
from ..handicap import compute_and_store_side_handicaps, parse_allowance_config
|
||||||
from .scoring import recompute_and_cache_match_state
|
from ..team_authz import user_is_team_captain
|
||||||
|
from .scoring import apply_concession, recompute_and_cache_match_state
|
||||||
from .. import storage
|
from .. import storage
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -268,6 +269,75 @@ async def create_team(
|
||||||
return Team(**dict(row))
|
return Team(**dict(row))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Turnering-nivå walkover/konsesjon (ADR-024)
|
||||||
|
#
|
||||||
|
# v1 er låst til nøyaktig to lag (ADR-011), så det finnes bare ÉN motstander
|
||||||
|
# uansett hvor mange sesjoner/matcher turneringen har -- "gi opp turneringen"
|
||||||
|
# er derfor bare "gi opp hver ikke-avgjorte match laget har" i én operasjon,
|
||||||
|
# gjenbruker match-nivå-logikken (apply_concession, scoring.py) per match.
|
||||||
|
# Samme autorisasjon som enkelt-match-konsesjon: kaptein for laget som gir
|
||||||
|
# seg selv, eller org-eier/admin.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ConcedeTournamentRequest(BaseModel):
|
||||||
|
conceding_team_id: str
|
||||||
|
|
||||||
|
|
||||||
|
class ConcedeTournamentOut(BaseModel):
|
||||||
|
matches_conceded: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/orgs/{organization_id}/tournaments/{tournament_id}/concede",
|
||||||
|
response_model=ConcedeTournamentOut,
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
async def concede_tournament(
|
||||||
|
tournament_id: str,
|
||||||
|
body: ConcedeTournamentRequest,
|
||||||
|
organization_id: str = Depends(get_authorized_org),
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> ConcedeTournamentOut:
|
||||||
|
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||||
|
team = await conn.fetchrow(
|
||||||
|
"SELECT tournament_id::text AS tournament_id FROM team WHERE id = $1",
|
||||||
|
body.conceding_team_id,
|
||||||
|
)
|
||||||
|
if team is None or team["tournament_id"] != tournament_id:
|
||||||
|
raise app_error(404, "NOT_FOUND", "Laget finnes ikke i denne turneringen.")
|
||||||
|
|
||||||
|
if not await user_is_team_captain(conn, organization_id, body.conceding_team_id, user.user_id):
|
||||||
|
raise app_error(
|
||||||
|
403,
|
||||||
|
"NOT_TEAM_CAPTAIN",
|
||||||
|
"Du må være kaptein for laget som gir seg (eller organisasjonsadministrator) for å gi opp turneringen.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# FOR UPDATE OF m: låser alle berørte match-rader FØR de endres, samme
|
||||||
|
# race-vern som enkelt-match-konsesjon og recompute_and_cache_match_state.
|
||||||
|
matches = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT m.id::text AS id, m.team_a_id::text AS team_a_id, m.team_b_id::text AS team_b_id,
|
||||||
|
s.points_per_match::float AS points_per_match
|
||||||
|
FROM match m
|
||||||
|
JOIN session s ON s.id = m.session_id
|
||||||
|
WHERE s.tournament_id = $1
|
||||||
|
AND m.points_side_a IS NULL
|
||||||
|
AND (m.team_a_id = $2 OR m.team_b_id = $2)
|
||||||
|
FOR UPDATE OF m
|
||||||
|
""",
|
||||||
|
tournament_id,
|
||||||
|
body.conceding_team_id,
|
||||||
|
)
|
||||||
|
for m in matches:
|
||||||
|
side = "a" if m["team_a_id"] == body.conceding_team_id else "b"
|
||||||
|
await apply_concession(conn, m["id"], side, m["points_per_match"])
|
||||||
|
|
||||||
|
return ConcedeTournamentOut(matches_conceded=len(matches))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Roster (spiller-pool per lag, ADR-007)
|
# Roster (spiller-pool per lag, ADR-007)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,8 @@ export function SessionScorecard({
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [currentHole, setCurrentHole] = useState<number | null>(null)
|
const [currentHole, setCurrentHole] = useState<number | null>(null)
|
||||||
const [showSummary, setShowSummary] = useState(false)
|
const [showSummary, setShowSummary] = useState(false)
|
||||||
|
const [showConcede, setShowConcede] = useState(false)
|
||||||
|
const [concedeTeamIndex, setConcedeTeamIndex] = useState<0 | 1 | null>(null)
|
||||||
|
|
||||||
async function refetchScorecard() {
|
async function refetchScorecard() {
|
||||||
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, {
|
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, {
|
||||||
|
|
@ -211,6 +213,31 @@ export function SessionScorecard({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Walkover/konsesjon (ADR-024): kun kaptein for laget som GIR SEG (eller
|
||||||
|
// org-admin) får lov -- backend håndhever dette, UI-et viser bare begge
|
||||||
|
// knappene og lar en 403 melde seg som en vanlig feiltekst, samme mønster
|
||||||
|
// som resten av appen (ingen klientside-forhåndsfiltrering på kapteinstatus).
|
||||||
|
async function concede(teamId: string) {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/concede`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ conceding_team_id: teamId }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
throw new Error(body?.detail?.message ?? "Klarte ikke å registrere walkover.")
|
||||||
|
}
|
||||||
|
setConcedeTeamIndex(null)
|
||||||
|
setShowConcede(false)
|
||||||
|
await refetchScorecard()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Klarte ikke å registrere walkover.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function submitHoleResult(holeNumber: number, winningSide: "a" | "b" | null) {
|
async function submitHoleResult(holeNumber: number, winningSide: "a" | "b" | null) {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -444,6 +471,73 @@ export function SessionScorecard({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6 overflow-hidden rounded-2xl border border-border bg-card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowConcede((v) => !v)
|
||||||
|
setConcedeTeamIndex(null)
|
||||||
|
}}
|
||||||
|
aria-expanded={showConcede}
|
||||||
|
className="flex w-full items-center justify-between gap-2 px-4 py-3 text-left"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold text-foreground">Gi opp matchen (walkover)</span>
|
||||||
|
<ChevronsUpDown aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
{showConcede && (
|
||||||
|
<div className="flex flex-col gap-3 border-t border-border p-4">
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
|
||||||
|
Kun kapteinen for laget som gir seg (eller en organisasjonsadministrator) kan
|
||||||
|
gjøre dette. Motstanderen får full poengsum for matchen, uansett hvor mange hull
|
||||||
|
som allerede er registrert.
|
||||||
|
</p>
|
||||||
|
{teams.map((team, i) =>
|
||||||
|
concedeTeamIndex === i ? (
|
||||||
|
<div
|
||||||
|
key={team.id}
|
||||||
|
className="flex flex-col gap-3 rounded-xl bg-destructive/5 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground text-pretty">
|
||||||
|
Er du sikker på at <span className="font-semibold">{team.name}</span> gir seg?
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={() => concede(team.id)}
|
||||||
|
>
|
||||||
|
Gi opp
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="rounded-xl"
|
||||||
|
onClick={() => setConcedeTeamIndex(null)}
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
key={team.id}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-11 justify-start rounded-xl text-sm font-semibold"
|
||||||
|
onClick={() => setConcedeTeamIndex(i as 0 | 1)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="mr-2 size-3 rounded-full" style={{ backgroundColor: team.color ?? "#64748b" }} />
|
||||||
|
{team.name} gir seg
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{session.scoring_mode === "stroke" ? (
|
{session.scoring_mode === "stroke" ? (
|
||||||
<section aria-label={`Hull ${currentHole}`}>
|
<section aria-label={`Hull ${currentHole}`}>
|
||||||
<div className="mb-4 flex items-baseline gap-2">
|
<div className="mb-4 flex items-baseline gap-2">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Check,
|
Check,
|
||||||
Copy,
|
Copy,
|
||||||
|
Flag,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -228,6 +229,33 @@ export function TournamentDetail({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Walkover/konsesjon på turnering-nivå (ADR-024): gir opp ALLE
|
||||||
|
// ikke-avgjorte matcher laget har i turneringen, i én operasjon. Kun
|
||||||
|
// kaptein for laget som gir seg (eller org-admin) får lov -- backend
|
||||||
|
// håndhever dette, en 403 vises bare som vanlig feiltekst.
|
||||||
|
async function concedeTournament(teamId: string): Promise<string | null> {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/concede`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ conceding_team_id: teamId }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null)
|
||||||
|
const message: string = body?.detail?.message ?? "Klarte ikke å gi opp turneringen."
|
||||||
|
setError(message)
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch {
|
||||||
|
const message = "Klarte ikke å gi opp turneringen."
|
||||||
|
setError(message)
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Hvilket lag er en gitt spiller (fra pool) allerede rostret på, om noen?
|
// Hvilket lag er en gitt spiller (fra pool) allerede rostret på, om noen?
|
||||||
function findPlayerTeam(playerId: string): Team | null {
|
function findPlayerTeam(playerId: string): Team | null {
|
||||||
for (const t of teams) {
|
for (const t of teams) {
|
||||||
|
|
@ -319,6 +347,7 @@ export function TournamentDetail({
|
||||||
onRemovePlayer={removePlayer}
|
onRemovePlayer={removePlayer}
|
||||||
onAddExisting={addExistingPlayer}
|
onAddExisting={addExistingPlayer}
|
||||||
onAddNew={addNewPlayer}
|
onAddNew={addNewPlayer}
|
||||||
|
onConcedeTournament={concedeTournament}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -466,6 +495,7 @@ function TeamPanel({
|
||||||
onRemovePlayer,
|
onRemovePlayer,
|
||||||
onAddExisting,
|
onAddExisting,
|
||||||
onAddNew,
|
onAddNew,
|
||||||
|
onConcedeTournament,
|
||||||
}: {
|
}: {
|
||||||
team: Team
|
team: Team
|
||||||
otherTeam: Team | null
|
otherTeam: Team | null
|
||||||
|
|
@ -475,8 +505,23 @@ function TeamPanel({
|
||||||
onRemovePlayer: (teamId: string, rosterId: string) => void
|
onRemovePlayer: (teamId: string, rosterId: string) => void
|
||||||
onAddExisting: (teamId: string, playerId: string) => void
|
onAddExisting: (teamId: string, playerId: string) => void
|
||||||
onAddNew: (teamId: string, name: string, handicap?: number) => void
|
onAddNew: (teamId: string, name: string, handicap?: number) => void
|
||||||
|
onConcedeTournament: (teamId: string) => Promise<string | null>
|
||||||
}) {
|
}) {
|
||||||
const [confirmingId, setConfirmingId] = useState<string | null>(null)
|
const [confirmingId, setConfirmingId] = useState<string | null>(null)
|
||||||
|
const [confirmingGiveUp, setConfirmingGiveUp] = useState(false)
|
||||||
|
const [giveUpError, setGiveUpError] = useState<string | null>(null)
|
||||||
|
const [givingUp, setGivingUp] = useState(false)
|
||||||
|
|
||||||
|
async function handleGiveUp() {
|
||||||
|
setGivingUp(true)
|
||||||
|
const message = await onConcedeTournament(team.id)
|
||||||
|
setGivingUp(false)
|
||||||
|
if (message) {
|
||||||
|
setGiveUpError(message)
|
||||||
|
} else {
|
||||||
|
setConfirmingGiveUp(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
|
|
@ -603,6 +648,57 @@ function TeamPanel({
|
||||||
onAddNew={onAddNew}
|
onAddNew={onAddNew}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-border p-4">
|
||||||
|
{confirmingGiveUp ? (
|
||||||
|
<div className="flex flex-col gap-3 rounded-2xl bg-destructive/5 p-3">
|
||||||
|
{giveUpError && (
|
||||||
|
<p role="alert" className="text-xs font-medium text-destructive text-pretty">
|
||||||
|
{giveUpError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-foreground text-pretty">
|
||||||
|
Er du sikker på at <span className="font-semibold">{team.name}</span> gir opp
|
||||||
|
resten av turneringen? Motstanderen får full poengsum for ALLE ikke-avgjorte
|
||||||
|
matcher, med én gang.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="h-9 rounded-xl"
|
||||||
|
disabled={givingUp}
|
||||||
|
onClick={handleGiveUp}
|
||||||
|
>
|
||||||
|
Gi opp turneringen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9 rounded-xl"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmingGiveUp(false)
|
||||||
|
setGiveUpError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Avbryt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConfirmingGiveUp(true)}
|
||||||
|
className="h-9 w-full justify-start rounded-xl text-xs font-semibold text-muted-foreground hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Flag aria-hidden="true" className="size-3.5" />
|
||||||
|
Gi opp resten av turneringen for laget (walkover)
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue