"use client" import type React from "react" import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { ArrowLeft, Clock, EyeOff, 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) type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles" 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 } // 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 ApiSession = { id: string name: string | null format: Format hole_config: HoleConfig course_id: string points_per_match: number scoring_mode: ScoringMode 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 } 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 participants: ApiParticipant[] } // --- Labels ------------------------------------------------------------------ const FORMAT_LABELS: Record = { foursome: "Foursome", greensome: "Greensome", scramble_2: "Scramble (2)", scramble_4: "Scramble (4)", fourball: "Fourball", singles: "Singel", } const HOLE_LABELS: Record = { full_18: "18 hull", front_9: "Front 9", back_9: "Back 9" } const SCORING_LABELS: Record = { stroke: "Slag for slag", hole_result: "Kun hullresultat" } function slotsPerSide(format: Format): number { if (format === "singles") return 1 if (format === "scramble_4") return 4 return 2 } // --- Component --------------------------------------------------------------- export function SessionBlindDraw({ organizationId, tournamentId, sessionId, tournamentName, }: { organizationId: string tournamentId: string sessionId: string tournamentName: string }) { const [session, setSession] = useState(null) const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null) const [rosters, setRosters] = useState>({}) const [tees, setTees] = useState([]) const [matches, setMatches] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [confirmingLock, setConfirmingLock] = useState(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] = 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" }), ]) 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()) } 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]) const sortedMatches = useMemo(() => [...matches].sort((a, b) => a.sequence - b.sequence), [matches]) 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() { 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 }), }) 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) { 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 }), }) 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 (
{error ? (

{error}

) : (
) } const perSide = slotsPerSide(session.format) function usedPlayerIds(teamId: string, exceptMatchId: string): Set { const side: "a" | "b" = teamId === teams![0].id ? "a" : "b" const used = new Set() 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 (
{FORMAT_LABELS[session.format]} {HOLE_LABELS[session.hole_config]} {SCORING_LABELS[session.scoring_mode]} {formatPoints(session.points_per_match)} poeng
{(session.format === "singles" || session.format === "fourball") && session.scoring_mode === "stroke" && (
) } // --- Status banner ------------------------------------------------------------- function StatusBanner({ teams, lockedTeamIds, bothLocked, }: { teams: [ApiTeam, ApiTeam] lockedTeamIds: string[] bothLocked: boolean }) { if (bothLocked) { return (
Oppstilling avslørt! Begge lag har låst. Her er paringene for økten.
) } 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 (
{lockedTeam.name} har låst Venter på at {otherTeam.name} låser sin oppstilling.
) } return (
Begge lag setter opp i skjul Ingen ser motstanderens valg før begge har låst oppstillingen.
) } // --- Team column --------------------------------------------------------------- function TeamColumn({ team, side, roster, tees, matches, perSide, locked, usedPlayerIds, onAddParticipant, onRemoveParticipant, onAddMatch, complete, confirming, onRequestLock, onCancelLock, onConfirmLock, }: { team: ApiTeam side: "a" | "b" roster: ApiRosterEntry[] tees: ApiTee[] matches: ApiMatch[] perSide: number locked: boolean usedPlayerIds: (teamId: string, exceptMatchId: string) => Set onAddParticipant: (matchId: string, side: "a" | "b", teamRosterId: string, teeId: string) => void onRemoveParticipant: (matchId: string, participantId: string) => void onAddMatch: () => void complete: boolean confirming: boolean onRequestLock: () => void onCancelLock: () => void onConfirmLock: () => void }) { const color = team.color ?? "#64748b" return (
{locked && (
)}
{matches.map((match) => { const sideParticipants = match.participants.filter((p) => p.team_side === side) const disabled = usedPlayerIds(team.id, match.id) return (
{match.sequence}
{sideParticipants.map((p) => (
{p.player_name}
{p.tee_name} {!locked && ( )}
))} {!locked && sideParticipants.length < perSide && ( onAddParticipant(match.id, side, teamRosterId, teeId)} /> )} {perSide > 1 && (

{perSide} spillere per side i dette formatet

)}
) })}
{!locked && ( )} {!locked && (
{confirming ? (

Er du sikker? Du kan ikke endre oppstillingen etter at du har låst.

) : ( )} {!complete && !confirming && (

Velg spiller og tee for alle plasser før du kan låse.

)}
)}
) } // --- Add-slot inline form -------------------------------------------------- function AddSlotForm({ roster, tees, disabledPlayerIds, onAdd, }: { roster: ApiRosterEntry[] tees: ApiTee[] disabledPlayerIds: Set onAdd: (teamRosterId: string, teeId: string) => void }) { const [rosterId, setRosterId] = useState("") const [teeId, setTeeId] = useState("") function handleAdd() { if (!rosterId || !teeId) return onAdd(rosterId, teeId) setRosterId("") setTeeId("") } return (
) } // --- Revealed view --------------------------------------------------------- function RevealedView({ teams, matches, scorecardHrefFor, }: { teams: [ApiTeam, ApiTeam] matches: ApiMatch[] scorecardHrefFor: (matchId: string) => string }) { return (
{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 (
{match.sequence} {match.status_text ? ( {decided && ) : ( )}
p.team_side === "a")} align="left" leading={match.leading_side === "a"} />
vs
p.team_side === "b")} align="right" leading={match.leading_side === "b"} />
) })}
) } 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 (
{participants.map((p) => (
{p.player_name} {p.tee_name}
))}
) } // --- 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(null) const [error, setError] = useState(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 (
{result && (

{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`}.

)} {error &&

{error}

}
) } // --- Small helpers --------------------------------------------------------- function MetaBadge({ children }: { children: React.ReactNode }) { return ( {children} ) } 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(".", ",") }