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:
Erol Haagenrud 2026-07-19 21:25:31 +02:00
parent c3cca377ed
commit ab23674521
6 changed files with 429 additions and 12 deletions

View file

@ -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:

View file

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

View file

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

View file

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

View file

@ -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 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">

View file

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