teecup/frontend/components/session-blind-draw.tsx
Erol Haagenrud 9f81797324 Shotgun-start: frontend (individuell + Cup-format)
Løpende/Shotgun-veksler i rundens gruppeoppsett og øktoppsett, per-
gruppe/per-match starthull-felt i shotgun-modus. Fant og rettet en
reell bug underveis: begge skjermene hadde en hardkodet sequence-sort
som stille overstyrte backendens starthull-sortering for shotgun.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:59:54 +02:00

1093 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Clock, EyeOff, Flag, Lock, Mail, PartyPopper, Plus, Trophy, Users, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/matches.py/courses.py)
// Fire nye org-lagturnering-formater (2026-07-30) -- se tournament-program.tsx.
type Format =
| "foursome"
| "greensome"
| "scramble_2"
| "scramble_4"
| "fourball"
| "singles"
| "chapman"
| "shamble"
| "money_ball"
| "high_low_high"
type HoleConfig = "full_18" | "front_9" | "back_9"
type ScoringMode = "stroke" | "hole_result"
type ApiTeam = { id: string; name: string; color: string | null }
type ApiRosterEntry = {
id: string
player_id: string
display_name: string
handicap_index_snapshot: number | null
is_captain: boolean
// Konkurranseklasse (2026-08-03) -- brukt KUN til å forhåndsutfylle riktig
// utslag under, se AddSlotForm. Ingen egen resultatliste her.
class_id: string | null
class_name: string | null
}
// ADR-029: kjønn hører til RATINGEN, ikke selve utslaget -- riktig
// kjønnsspesifikk rating løses automatisk server-side ut fra spilleren, så
// frontend trenger kun å vise/velge blant de fysiske utslagene.
type ApiTee = {
id: string
name: string
}
type ApiTournamentClass = {
id: string
name: string
default_tee_id: string | null
default_tee_name: string | null
}
type ApiSession = {
id: string
name: string | null
format: Format
hole_config: HoleConfig
course_id: string
points_per_match: number
scoring_mode: ScoringMode
// Shotgun-start (migrasjon 088, ADR-096) -- 'shotgun': matchene under
// legges til med eget starthull hver (se addMatch), ikke felles
// klokkeslett-staggering.
start_mode: "consecutive" | "shotgun"
locked_team_ids: string[]
}
type ApiParticipant = {
id: string
team_side: "a" | "b"
team_roster_id: string
player_name: string
tee_id: string
tee_name: string
// Money Ball (2026-07-30) -- fast plass i rotasjonsrekkefølgen (0-3).
lineup_order: number | null
}
type ApiMatch = {
id: string
sequence: number
team_a_id: string
team_b_id: string
status_text: string | null
points_side_a: number | null
points_side_b: number | null
leading_side: "a" | "b" | null
tee_time: string | null
// Kun meningsfullt (og satt av organisator ved opprettelse) når økten
// er 'shotgun' (migrasjon 088, ADR-096).
start_hole: number | null
participants: ApiParticipant[]
}
// --- Labels ------------------------------------------------------------------
const FORMAT_LABELS: Record<Format, string> = {
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2)",
scramble_4: "Scramble (4)",
fourball: "Fourball",
singles: "Singel",
chapman: "Chapman",
shamble: "Shamble",
money_ball: "Money Ball",
high_low_high: "High-low-high",
}
const HOLE_LABELS: Record<HoleConfig, string> = { full_18: "18 hull", front_9: "Front 9", back_9: "Back 9" }
const SCORING_LABELS: Record<ScoringMode, string> = { stroke: "Slag for slag", hole_result: "Kun hullresultat" }
// Minimum påkrevd per side FØR laget kan låses (brukt av teamIsComplete OG
// av "N spillere per side"-teksten). Shamble er BEVISST forskjellig fra sitt
// eget tak (se maxSlotsPerSide) -- fleksibel 2-4, ingen fast antall.
function slotsPerSide(format: Format): number {
if (format === "singles") return 1
if (format === "scramble_4" || format === "money_ball") return 4
if (format === "shamble") return 2
return 2
}
// Maks antall AddSlotForm skal tillate å legge til (backend sin egen
// 409 LIMIT_REACHED-grense) -- kun Shamble skiller seg fra slotsPerSide.
function maxSlotsPerSide(format: Format): number {
return format === "shamble" ? 4 : slotsPerSide(format)
}
// --- Component ---------------------------------------------------------------
export function SessionBlindDraw({
organizationId,
tournamentId,
sessionId,
tournamentName,
}: {
organizationId: string
tournamentId: string
sessionId: string
tournamentName: string
}) {
const [session, setSession] = useState<ApiSession | null>(null)
const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null)
const [rosters, setRosters] = useState<Record<string, ApiRosterEntry[]>>({})
const [tees, setTees] = useState<ApiTee[]>([])
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
const [matches, setMatches] = useState<ApiMatch[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [confirmingLock, setConfirmingLock] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const [sessionsRes, teamsRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include" }),
])
if (!sessionsRes.ok || !teamsRes.ok) throw new Error("load failed")
const sessionsData: ApiSession[] = await sessionsRes.json()
const foundSession = sessionsData.find((s) => s.id === sessionId)
if (!foundSession) throw new Error("session not found")
const teamsData: ApiTeam[] = await teamsRes.json()
if (teamsData.length !== 2) throw new Error("expected 2 teams")
const [rosterARes, rosterBRes, teesRes, matchesRes, classesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/teams/${teamsData[0].id}/roster`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/teams/${teamsData[1].id}/roster`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/tees`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, { credentials: "include" }),
])
if (!rosterARes.ok || !rosterBRes.ok || !teesRes.ok || !matchesRes.ok) throw new Error("load failed")
if (cancelled) return
setSession(foundSession)
setTeams([teamsData[0], teamsData[1]])
setRosters({
[teamsData[0].id]: await rosterARes.json(),
[teamsData[1].id]: await rosterBRes.json(),
})
setTees(await teesRes.json())
setMatches(await matchesRes.json())
if (classesRes.ok) setClasses(await classesRes.json())
} catch {
if (!cancelled) setError("Klarte ikke å laste blind draw-siden. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId, sessionId])
// Shotgun (migrasjon 088, ADR-096): vis sortert etter STARTHULL, ikke
// sequence -- speiler backend sin tilsvarende sortering (fetch_matches
// i matches.py), men må også gjelde HER siden lokale, nettopp lagt-til
// matcher ellers ikke ville vist i riktig rekkefølge før neste reload.
const sortedMatches = useMemo(
() =>
[...matches].sort((a, b) =>
session?.start_mode === "shotgun"
? (a.start_hole === null ? 1 : 0) - (b.start_hole === null ? 1 : 0) ||
(a.start_hole ?? 0) - (b.start_hole ?? 0)
: a.sequence - b.sequence,
),
[matches, session?.start_mode],
)
const nextSequence = useMemo(
() => (matches.length === 0 ? 1 : Math.max(...matches.map((m) => m.sequence)) + 1),
[matches],
)
const bothLocked = (session?.locked_team_ids.length ?? 0) >= 2
async function refetchMatches() {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" })
if (res.ok) setMatches(await res.json())
}
async function addMatch(startHole?: number) {
if (!teams) return
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
sequence: nextSequence,
team_a_id: teams[0].id,
team_b_id: teams[1].id,
start_hole: startHole ?? null,
}),
})
if (!res.ok) throw new Error(`create match: ${res.status}`)
const created: ApiMatch = await res.json()
setMatches((prev) => [...prev, created])
} catch {
setError("Klarte ikke å legge til match. Prøv igjen.")
}
}
async function addParticipant(
matchId: string,
teamSide: "a" | "b",
teamRosterId: string,
teeId: string,
lineupOrder: number | null = null,
) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/participants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
team_side: teamSide,
team_roster_id: teamRosterId,
tee_id: teeId,
lineup_order: lineupOrder,
}),
})
if (!res.ok) throw new Error(`add participant: ${res.status}`)
const created: ApiParticipant = await res.json()
setMatches((prev) =>
prev.map((m) => (m.id === matchId ? { ...m, participants: [...m.participants, created] } : m)),
)
} catch {
setError("Klarte ikke å legge til spilleren. Kanskje laget allerede har låst, eller du mangler tilgang.")
}
}
async function removeParticipant(matchId: string, participantId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/participants/${participantId}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok && res.status !== 204) throw new Error(`remove participant: ${res.status}`)
setMatches((prev) =>
prev.map((m) =>
m.id === matchId
? { ...m, participants: m.participants.filter((p) => p.id !== participantId) }
: m,
),
)
} catch {
setError("Klarte ikke å fjerne spilleren. Prøv igjen.")
}
}
async function lockTeam(teamId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/lock`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ team_id: teamId }),
})
if (!res.ok) throw new Error(`lock: ${res.status}`)
setSession((prev) =>
prev ? { ...prev, locked_team_ids: [...prev.locked_team_ids, teamId] } : prev,
)
// Motstanderens deltakere er skjult server-side inntil begge har låst
// (ADR-013) -- hent matchene på nytt for å få den avslørte visningen
// hvis dette var det andre laget som nettopp låste.
await refetchMatches()
} catch {
setError("Klarte ikke å låse oppstillingen. Prøv igjen.")
} finally {
setConfirmingLock(null)
}
}
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
if (loading || !session || !teams) {
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-5 py-4">
<Link
href={programHref}
aria-label="Tilbake til program"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
</div>
</div>
</header>
<main className="mx-auto flex w-full max-w-5xl flex-1 items-center justify-center px-5 py-6">
{error ? (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
) : (
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
)}
</main>
</div>
)
}
const perSide = slotsPerSide(session.format)
function usedPlayerIds(teamId: string, exceptMatchId: string): Set<string> {
const side: "a" | "b" = teamId === teams![0].id ? "a" : "b"
const used = new Set<string>()
for (const m of matches) {
if (m.id === exceptMatchId) continue
for (const p of m.participants) {
if (p.team_side === side) used.add(p.team_roster_id)
}
}
return used
}
function teamIsComplete(teamId: string): boolean {
const side: "a" | "b" = teamId === teams![0].id ? "a" : "b"
if (matches.length === 0) return false
return matches.every((m) => m.participants.filter((p) => p.team_side === side).length >= perSide)
}
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-5xl items-center gap-3 px-5 py-4">
<Link
href={programHref}
aria-label="Tilbake til program"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
<span className="truncate text-sm text-muted-foreground">
{session.name?.trim() || "Økt"} · {FORMAT_LABELS[session.format]}
</span>
</div>
</div>
</header>
<main className="mx-auto w-full max-w-5xl flex-1 px-5 py-6 sm:py-8">
<div className="mb-4 flex flex-wrap items-center gap-2">
<MetaBadge>{FORMAT_LABELS[session.format]}</MetaBadge>
<MetaBadge>{HOLE_LABELS[session.hole_config]}</MetaBadge>
<MetaBadge>{SCORING_LABELS[session.scoring_mode]}</MetaBadge>
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
</div>
{(session.format === "singles" || session.format === "fourball") && session.scoring_mode === "stroke" && (
<Link
href={`/tournaments/${tournamentId}/sessions/${sessionId}/individual-leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
className="mb-4 flex w-fit items-center gap-1.5 rounded-xl border border-border bg-card px-3 py-2 text-sm font-semibold text-foreground transition-colors hover:bg-accent/60"
>
<Trophy aria-hidden="true" className="size-4 text-primary" />
Individuell rangering
</Link>
)}
{matches.length > 0 && <SendInvitationsButton organizationId={organizationId} sessionId={sessionId} />}
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
</p>
)}
<StatusBanner teams={teams} lockedTeamIds={session.locked_team_ids} bothLocked={bothLocked} />
{bothLocked ? (
<RevealedView
teams={teams}
matches={sortedMatches}
startMode={session.start_mode}
scorecardHrefFor={(matchId) =>
`/tournaments/${tournamentId}/sessions/${sessionId}/matches/${matchId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
}
/>
) : (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{teams.map((team, teamIndex) => (
<TeamColumn
key={team.id}
team={team}
side={teamIndex === 0 ? "a" : "b"}
roster={rosters[team.id] ?? []}
tees={tees}
classes={classes}
matches={sortedMatches}
perSide={maxSlotsPerSide(session.format)}
format={session.format}
startMode={session.start_mode}
locked={session.locked_team_ids.includes(team.id)}
usedPlayerIds={usedPlayerIds}
onAddParticipant={addParticipant}
onRemoveParticipant={removeParticipant}
onAddMatch={addMatch}
complete={teamIsComplete(team.id)}
confirming={confirmingLock === team.id}
onRequestLock={() => setConfirmingLock(team.id)}
onCancelLock={() => setConfirmingLock(null)}
onConfirmLock={() => lockTeam(team.id)}
/>
))}
</div>
)}
</main>
</div>
)
}
// --- Status banner -------------------------------------------------------------
function StatusBanner({
teams,
lockedTeamIds,
bothLocked,
}: {
teams: [ApiTeam, ApiTeam]
lockedTeamIds: string[]
bothLocked: boolean
}) {
if (bothLocked) {
return (
<div className="flex items-center gap-3 rounded-3xl bg-primary px-5 py-4 text-primary-foreground shadow-sm">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary-foreground/15">
<PartyPopper aria-hidden="true" className="size-6" />
</div>
<div className="flex flex-col">
<span className="text-base font-extrabold tracking-tight">Oppstilling avslørt!</span>
<span className="text-sm text-primary-foreground/80">
Begge lag har låst. Her er paringene for økten.
</span>
</div>
</div>
)
}
const lockedCount = lockedTeamIds.length
if (lockedCount === 1) {
const lockedTeam = lockedTeamIds.includes(teams[0].id) ? teams[0] : teams[1]
const otherTeam = lockedTeam.id === teams[0].id ? teams[1] : teams[0]
return (
<div className="flex items-center gap-3 rounded-3xl border border-border bg-card px-5 py-4 shadow-sm">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-secondary">
<Clock aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-base font-bold tracking-tight text-foreground text-pretty">
{lockedTeam.name} har låst
</span>
<span className="text-sm text-muted-foreground">
Venter at {otherTeam.name} låser sin oppstilling.
</span>
</div>
</div>
)
}
return (
<div className="flex items-center gap-3 rounded-3xl border border-border bg-card px-5 py-4 shadow-sm">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-secondary">
<EyeOff aria-hidden="true" className="size-6 text-muted-foreground" />
</div>
<div className="flex flex-col">
<span className="text-base font-bold tracking-tight text-foreground">Begge lag setter opp i skjul</span>
<span className="text-sm text-muted-foreground text-pretty">
Ingen ser motstanderens valg før begge har låst oppstillingen.
</span>
</div>
</div>
)
}
// --- Team column ---------------------------------------------------------------
function TeamColumn({
team,
side,
roster,
tees,
classes,
matches,
perSide,
format,
startMode,
locked,
usedPlayerIds,
onAddParticipant,
onRemoveParticipant,
onAddMatch,
complete,
confirming,
onRequestLock,
onCancelLock,
onConfirmLock,
}: {
team: ApiTeam
side: "a" | "b"
roster: ApiRosterEntry[]
tees: ApiTee[]
classes: ApiTournamentClass[]
matches: ApiMatch[]
perSide: number
format: Format
// Shotgun-start (migrasjon 088, ADR-096) -- styrer om "Legg til match"
// spør om starthull (shotgun) eller ikke (consecutive, som før).
startMode: "consecutive" | "shotgun"
locked: boolean
usedPlayerIds: (teamId: string, exceptMatchId: string) => Set<string>
onAddParticipant: (
matchId: string,
side: "a" | "b",
teamRosterId: string,
teeId: string,
lineupOrder?: number | null,
) => void
onRemoveParticipant: (matchId: string, participantId: string) => void
onAddMatch: (startHole?: number) => void
complete: boolean
confirming: boolean
onRequestLock: () => void
onCancelLock: () => void
onConfirmLock: () => void
}) {
const color = team.color ?? "#64748b"
const [newMatchStartHole, setNewMatchStartHole] = useState("")
return (
<section
className="flex flex-col gap-3 rounded-3xl border border-border bg-card/40 p-4 shadow-md shadow-black/8 sm:p-5"
style={{ borderLeftWidth: 6, borderLeftColor: color }}
aria-label={`Oppstilling for ${team.name}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5">
<span aria-hidden="true" className="size-4 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<h2 className="text-lg font-extrabold tracking-tight text-foreground">{team.name}</h2>
</div>
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground">
<Users aria-hidden="true" className="size-4" />
{matches.length} {matches.length === 1 ? "match" : "matcher"}
</span>
</div>
{locked && (
<div className="inline-flex w-fit items-center gap-1.5 rounded-full bg-secondary px-3 py-1 text-xs font-bold text-secondary-foreground">
<Lock aria-hidden="true" className="size-3.5" />
Låst
</div>
)}
<div className="flex flex-col gap-3">
{matches.map((match) => {
const sideParticipants = match.participants.filter((p) => p.team_side === side)
const disabled = usedPlayerIds(team.id, match.id)
return (
<div key={match.id} className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="flex size-8 items-center justify-center rounded-xl bg-primary text-sm font-extrabold tabular-nums text-primary-foreground">
{match.sequence}
</span>
{startMode === "shotgun" ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Flag aria-hidden="true" className="size-4" />
{match.start_hole ? `Starthull ${match.start_hole}` : "Starthull ikke satt"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
</span>
)}
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
{sideParticipants.map((p) => (
<div
key={p.id}
className="flex items-center justify-between gap-3 rounded-xl bg-secondary/60 px-3 py-2.5"
>
<span className="truncate text-sm font-semibold text-foreground">{p.player_name}</span>
<div className="flex shrink-0 items-center gap-2">
{format === "money_ball" && p.lineup_order !== null && (
<span className="inline-flex size-6 items-center justify-center rounded-full bg-muted text-xs font-extrabold text-muted-foreground">
{p.lineup_order}
</span>
)}
<span className="inline-flex items-center gap-1.5 rounded-full bg-card px-2.5 py-1 text-xs font-semibold text-foreground">
{p.tee_name}
</span>
{!locked && (
<button
type="button"
onClick={() => onRemoveParticipant(match.id, p.id)}
aria-label={`Fjern ${p.player_name}`}
className="flex size-7 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<X aria-hidden="true" className="size-4" />
</button>
)}
</div>
</div>
))}
{!locked && sideParticipants.length < perSide && (
<AddSlotForm
roster={roster}
tees={tees}
classes={classes}
disabledPlayerIds={disabled}
// Money Ball -- 0-3 ledig, avhengig av hva laget allerede har brukt.
availableLineupOrders={
format === "money_ball"
? [0, 1, 2, 3].filter((n) => !sideParticipants.some((p) => p.lineup_order === n))
: null
}
onAdd={(teamRosterId, teeId, lineupOrder) =>
onAddParticipant(match.id, side, teamRosterId, teeId, lineupOrder)
}
/>
)}
{perSide > 1 && (
<p className="text-xs text-muted-foreground">{perSide} spillere per side i dette formatet</p>
)}
</div>
</div>
)
})}
</div>
{!locked && startMode === "shotgun" && (
<div className="flex items-center gap-2">
<label className="flex flex-1 items-center gap-2">
<span className="text-xs font-bold text-muted-foreground">Starthull</span>
<input
type="number"
min={1}
max={18}
value={newMatchStartHole}
onChange={(e) => setNewMatchStartHole(e.target.value)}
placeholder="Hull"
className="h-10 w-full rounded-xl border border-border bg-background px-3 text-sm font-semibold text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</label>
</div>
)}
{!locked && (
<button
type="button"
onClick={() => {
const hole = newMatchStartHole.trim() ? Number(newMatchStartHole) : undefined
onAddMatch(hole)
setNewMatchStartHole("")
}}
className="flex items-center justify-center gap-2 rounded-2xl border border-dashed border-border bg-background/50 px-4 py-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<Plus aria-hidden="true" className="size-4" />
Legg til match
</button>
)}
{!locked && (
<div className="mt-1">
{confirming ? (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background/60 p-4">
<p className="text-sm font-medium text-foreground text-pretty">
Er du sikker? Du kan ikke endre oppstillingen etter at du har låst.
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={onCancelLock} className="h-11 flex-1 rounded-2xl font-semibold">
Avbryt
</Button>
<Button type="button" onClick={onConfirmLock} className="h-11 flex-1 rounded-2xl font-bold">
<Lock aria-hidden="true" className="size-4" />
Lås
</Button>
</div>
</div>
) : (
<Button
type="button"
onClick={onRequestLock}
disabled={!complete}
className="h-12 w-full rounded-2xl text-base font-bold shadow-sm"
>
<Lock aria-hidden="true" className="size-5" />
Lås oppstilling
</Button>
)}
{!complete && !confirming && (
<p className="mt-2 text-center text-xs text-muted-foreground text-pretty">
Velg spiller og tee for alle plasser før du kan låse.
</p>
)}
</div>
)}
</section>
)
}
// --- Add-slot inline form --------------------------------------------------
function AddSlotForm({
roster,
tees,
classes,
disabledPlayerIds,
availableLineupOrders,
onAdd,
}: {
roster: ApiRosterEntry[]
tees: ApiTee[]
classes: ApiTournamentClass[]
disabledPlayerIds: Set<string>
// Money Ball (2026-07-30) -- ledige 0-3-plasser, null for alle andre formater.
availableLineupOrders: number[] | null
onAdd: (teamRosterId: string, teeId: string, lineupOrder: number | null) => void
}) {
const [rosterId, setRosterId] = useState("")
const [teeId, setTeeId] = useState("")
const [lineupOrder, setLineupOrder] = useState("")
// Konkurranseklasser (2026-08-03) -- forhåndsvelg spillerens klasses
// standardutslag, MEN kun hvis det utslaget faktisk finnes blant DENNE
// øktens baneutslag (klassens standardutslag kan tilhøre en annen bane
// enn økten er satt opp med) -- fortsatt fritt overstyrbart under.
useEffect(() => {
if (!rosterId) return
const entry = roster.find((r) => r.id === rosterId)
const cls = entry?.class_id ? classes.find((c) => c.id === entry.class_id) : null
const defaultTee = cls?.default_tee_id ? tees.find((t) => t.id === cls.default_tee_id) : null
if (defaultTee) setTeeId(defaultTee.id)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rosterId])
const needsLineupOrder = availableLineupOrders !== null
const canAdd = rosterId && teeId && (!needsLineupOrder || lineupOrder !== "")
function handleAdd() {
if (!canAdd) return
onAdd(rosterId, teeId, needsLineupOrder ? Number(lineupOrder) : null)
setRosterId("")
setTeeId("")
setLineupOrder("")
}
return (
<div className="flex flex-col gap-2 rounded-xl border border-dashed border-border bg-background/20 p-2">
<div className="grid grid-cols-[1fr_1fr_auto] gap-2">
<label className="flex flex-col gap-1">
<span className="sr-only">Velg spiller</span>
<select
value={rosterId}
onChange={(e) => setRosterId(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Velg spiller</option>
{roster
.filter((p) => !disabledPlayerIds.has(p.id))
.map((p) => (
<option key={p.id} value={p.id}>
{p.display_name}
{p.handicap_index_snapshot !== null ? ` (${formatHcp(p.handicap_index_snapshot)})` : ""}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="sr-only">Velg tee</span>
<select
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 transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Velg tee</option>
{tees.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</label>
<Button
type="button"
size="icon"
disabled={!canAdd}
onClick={handleAdd}
className="h-11 w-11 shrink-0 rounded-lg"
aria-label="Legg til spiller"
>
<Plus aria-hidden="true" className="size-4" />
</Button>
</div>
{needsLineupOrder && (
<label className="flex flex-col gap-1">
<span className="text-xs font-semibold text-muted-foreground">Plass i rekkefølgen (Money Ball)</span>
<select
value={lineupOrder}
onChange={(e) => setLineupOrder(e.target.value)}
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="">Velg plass</option>
{availableLineupOrders!.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
)}
</div>
)
}
// --- Revealed view ---------------------------------------------------------
function RevealedView({
teams,
matches,
startMode,
scorecardHrefFor,
}: {
teams: [ApiTeam, ApiTeam]
matches: ApiMatch[]
startMode: "consecutive" | "shotgun"
scorecardHrefFor: (matchId: string) => string
}) {
return (
<div className="mt-6 flex flex-col gap-3">
{matches.map((match) => {
// Fargekoding etter ledende lag (ADR-020) -- umiddelbar visuell
// status i stedet for kun tekst, samme mønster som TV-dekning av
// Ryder Cup. Ingen farge når leading_side er null (ikke startet/AS).
const leadingTeam =
match.leading_side === "a" ? teams[0] : match.leading_side === "b" ? teams[1] : null
const leadColor = leadingTeam?.color ?? null
const decided = match.points_side_a !== null
return (
<Link
key={match.id}
href={scorecardHrefFor(match.id)}
className="flex flex-col gap-3 rounded-3xl border border-border bg-card p-4 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/50 sm:p-5"
style={leadColor ? { borderTopWidth: 4, borderTopColor: leadColor } : undefined}
>
<div className="flex items-center justify-between gap-2">
<span className="flex size-9 items-center justify-center rounded-xl bg-primary text-base font-extrabold tabular-nums text-primary-foreground">
{match.sequence}
</span>
{match.status_text ? (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-extrabold uppercase tracking-wide",
decided ? "text-white" : "text-foreground",
)}
style={{ backgroundColor: leadColor ? `${leadColor}${decided ? "" : "26"}` : undefined }}
>
{decided && <PartyPopper aria-hidden="true" className="size-3.5" />}
{match.status_text}
</span>
) : startMode === "shotgun" ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Flag aria-hidden="true" className="size-4" />
{match.start_hole ? `Starthull ${match.start_hole}` : "Starthull ikke satt"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4" />
{match.tee_time ? `Utslag ${formatTime(match.tee_time)}` : "Tid ikke satt"}
</span>
)}
</div>
<div className="grid grid-cols-[1fr_auto_1fr] items-stretch gap-2 border-t border-border pt-3 sm:gap-3">
<RevealSide
team={teams[0]}
participants={match.participants.filter((p) => p.team_side === "a")}
align="left"
leading={match.leading_side === "a"}
/>
<div className="flex items-center justify-center">
<span className="rounded-full bg-secondary px-2.5 py-1 text-xs font-extrabold uppercase tracking-wide text-secondary-foreground">
vs
</span>
</div>
<RevealSide
team={teams[1]}
participants={match.participants.filter((p) => p.team_side === "b")}
align="right"
leading={match.leading_side === "b"}
/>
</div>
</Link>
)
})}
</div>
)
}
function RevealSide({
team,
participants,
align,
leading,
}: {
team: ApiTeam
participants: ApiParticipant[]
align: "left" | "right"
leading: boolean
}) {
const right = align === "right"
const color = team.color ?? "#64748b"
return (
<div
className={cn("flex flex-col gap-2 rounded-2xl bg-background/50 p-3", right ? "items-end text-right" : "items-start text-left")}
style={{
...(right ? { borderRightWidth: 4, borderRightColor: color } : { borderLeftWidth: 4, borderLeftColor: color }),
backgroundColor: leading ? `${color}14` : undefined,
}}
>
<div className={cn("flex items-center gap-2", right && "flex-row-reverse")}>
<span aria-hidden="true" className="size-3 shrink-0 rounded-full" style={{ backgroundColor: color }} />
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{team.name}</span>
</div>
{participants.map((p) => (
<div key={p.id} className={cn("flex flex-col", right ? "items-end" : "items-start")}>
<span className="text-sm font-semibold text-foreground text-pretty">{p.player_name}</span>
<span className="text-xs text-muted-foreground">{p.tee_name}</span>
</div>
))}
</div>
)
}
// --- Midlertidige spillere: etter-runde-invitasjon (migrasjon 034) --------
// Eksplisitt organisator-knapp, økt-nivå (FEATURE_BACKLOG.md, avklart
// 2026-07-28) -- sender scorekort + innloggingslenke KUN til spillere uten
// egen konto ennå, KUN én gang per (økt, deltaker), håndtert av backend.
type SendInvitationsResult = {
sent: number
skipped_has_account: number
skipped_no_email: number
skipped_already_sent: number
}
function SendInvitationsButton({ organizationId, sessionId }: { organizationId: string; sessionId: string }) {
const [sending, setSending] = useState(false)
const [result, setResult] = useState<SendInvitationsResult | null>(null)
const [error, setError] = useState<string | null>(null)
async function handleSend() {
if (
!confirm(
"Send scorekort og innloggingslenke til alle spillere med registrert e-post i denne økten som ikke har konto ennå?",
)
)
return
setSending(true)
setError(null)
setResult(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}/send-scorecard-invitations`, {
method: "POST",
credentials: "include",
})
if (!res.ok) throw new Error(`send: ${res.status}`)
setResult(await res.json())
} catch {
setError("Klarte ikke å sende invitasjoner. Prøv igjen.")
} finally {
setSending(false)
}
}
return (
<div className="mb-4 flex flex-col gap-2">
<Button
type="button"
variant="outline"
onClick={handleSend}
disabled={sending}
className="h-10 w-fit rounded-xl font-semibold"
>
<Mail aria-hidden="true" className="size-4" />
{sending ? "Sender …" : "Send scorekort til alle med e-post"}
</Button>
{result && (
<p className="text-sm text-muted-foreground">
{result.sent} {result.sent === 1 ? "invitasjon sendt" : "invitasjoner sendt"}
{result.skipped_already_sent > 0 && `, ${result.skipped_already_sent} allerede sendt tidligere`}
{result.skipped_has_account > 0 && `, ${result.skipped_has_account} har allerede konto`}
{result.skipped_no_email > 0 && `, ${result.skipped_no_email} mangler registrert e-post`}.
</p>
)}
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
</div>
)
}
// --- Small helpers ---------------------------------------------------------
function MetaBadge({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
{children}
</span>
)
}
function formatHcp(value: number | null) {
if (value === null) return ""
return value.toFixed(1).replace(".", ",")
}
function formatTime(iso: string) {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" })
}
function formatPoints(points: number) {
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
}