"use client" import type React from "react" import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { ArrowLeft, Check, ChevronLeft, ChevronRight, ChevronsUpDown, Minus, Plus, Trophy, } 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/scoring.py/courses.py) type Format = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles" type ScoringMode = "stroke" | "hole_result" type HoleConfig = "full_18" | "front_9" | "back_9" const INDIVIDUAL_FORMATS: Format[] = ["singles", "fourball"] const FORMAT_LABELS: Record = { foursome: "Foursome", greensome: "Greensome", scramble_2: "Scramble (2)", scramble_4: "Scramble (4)", fourball: "Fourball", singles: "Singel", } type ApiTeam = { id: string; name: string; color: string | null } type ApiSession = { id: string name: string | null format: Format hole_config: HoleConfig course_id: string scoring_mode: ScoringMode } 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 participants: ApiParticipant[] } type ApiHole = { hole_number: number; par: number; stroke_index: number } type ApiHoleScore = { hole_number: number team_side: "a" | "b" match_participant_id: string | null gross_strokes: number } type ApiHoleResult = { hole_number: number; winning_side: "a" | "b" | null } type ApiScorecard = { match_id: string status_text: string | null points_side_a: number | null points_side_b: number | null holes: { hole_number: number; result: "a" | "b" | "halved" }[] stroke_entries: ApiHoleScore[] | null hole_result_entries: ApiHoleResult[] | null } // Enhet som scores: én per SPILLER for individuelle formater (singles/ // fourball), én per SIDE for delt-ball-formater (foursome/greensome/ // scramble) -- se app/routers/scoring.py sin is_individual-sjekk. type Unit = { id: string; side: "a" | "b"; label: string; matchParticipantId: string | null } function playedHoleNumbers(holeConfig: HoleConfig): number[] { if (holeConfig === "front_9") return Array.from({ length: 9 }, (_, i) => i + 1) if (holeConfig === "back_9") return Array.from({ length: 9 }, (_, i) => i + 10) return Array.from({ length: 18 }, (_, i) => i + 1) } export function SessionScorecard({ organizationId, tournamentId, sessionId, matchId, tournamentName, }: { organizationId: string tournamentId: string sessionId: string matchId: string tournamentName: string }) { const [session, setSession] = useState(null) const [teams, setTeams] = useState<[ApiTeam, ApiTeam] | null>(null) const [match, setMatch] = useState(null) const [holes, setHoles] = useState([]) const [scorecard, setScorecard] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [currentHole, setCurrentHole] = useState(null) const [showSummary, setShowSummary] = useState(false) const [showConcede, setShowConcede] = useState(false) const [concedeTeamIndex, setConcedeTeamIndex] = useState<0 | 1 | null>(null) async function refetchScorecard() { const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, { credentials: "include", }) if (res.ok) setScorecard(await res.json()) } useEffect(() => { let cancelled = false async function load() { try { const [sessionsRes, teamsRes, matchesRes] = await Promise.all([ fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, { credentials: "include" }), fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include" }), fetch(`/orgs/${organizationId}/sessions/${sessionId}/matches`, { credentials: "include" }), ]) if (!sessionsRes.ok || !teamsRes.ok || !matchesRes.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 matchesData: ApiMatch[] = await matchesRes.json() const foundMatch = matchesData.find((m) => m.id === matchId) if (!foundMatch) throw new Error("match not found") const [holesRes, scorecardRes] = await Promise.all([ fetch(`/orgs/${organizationId}/courses/${foundSession.course_id}/holes`, { credentials: "include" }), fetch(`/orgs/${organizationId}/matches/${matchId}/scorecard`, { credentials: "include" }), ]) if (!holesRes.ok || !scorecardRes.ok) throw new Error("load failed") const holesData: ApiHole[] = await holesRes.json() const scorecardData: ApiScorecard = await scorecardRes.json() if (cancelled) return setSession(foundSession) setTeams([teamsData[0], teamsData[1]]) setMatch(foundMatch) setHoles(holesData) setScorecard(scorecardData) const played = playedHoleNumbers(foundSession.hole_config) const nextUnregistered = played[scorecardData.holes.length] ?? played[played.length - 1] setCurrentHole(nextUnregistered) } catch { if (!cancelled) setError("Klarte ikke å laste scorekortet. Prøv å laste siden på nytt.") } finally { if (!cancelled) setLoading(false) } } void load() return () => { cancelled = true } }, [organizationId, tournamentId, sessionId, matchId]) const units: Unit[] = useMemo(() => { if (!session || !match || !teams) return [] const isIndividual = INDIVIDUAL_FORMATS.includes(session.format) if (isIndividual) { return match.participants.map((p) => ({ id: p.id, side: p.team_side, label: p.player_name, matchParticipantId: p.id, })) } return [ { id: "a", side: "a", label: teams[0].name, matchParticipantId: null }, { id: "b", side: "b", label: teams[1].name, matchParticipantId: null }, ] }, [session, match, teams]) const decided = scorecard?.points_side_a !== null && scorecard?.points_side_b !== null async function submitStroke(holeNumber: number, unit: Unit, grossStrokes: number) { setError(null) try { const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-scores`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ team_side: unit.side, match_participant_id: unit.matchParticipantId, hole_number: holeNumber, gross_strokes: grossStrokes, }), }) if (!res.ok) throw new Error(`submit stroke: ${res.status}`) await refetchScorecard() } catch { setError("Klarte ikke å registrere slaget. Kanskje matchen allerede er avgjort.") } } // 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) { setError(null) try { const res = await fetch(`/orgs/${organizationId}/matches/${matchId}/hole-results`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ hole_number: holeNumber, winning_side: winningSide }), }) if (!res.ok) throw new Error(`submit result: ${res.status}`) await refetchScorecard() } catch { setError("Klarte ikke å registrere hull-resultatet. Kanskje matchen allerede er avgjort.") } } const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}` if (loading || !session || !teams || !match || !scorecard || currentHole === null) { return (
{error ? (

{error}

) : (
) } const played = playedHoleNumbers(session.hole_config) const totalHoles = played.length const par = holes.find((h) => h.hole_number === currentHole)?.par ?? 4 const registeredHoleNumbers = new Set(scorecard.holes.map((h) => h.hole_number)) const statusLabel = scorecard.status_text ?? "AS" function strokeValue(holeNumber: number, unit: Unit): number | null { const entry = (scorecard!.stroke_entries ?? []).find( (e) => e.hole_number === holeNumber && e.team_side === unit.side && e.match_participant_id === unit.matchParticipantId, ) return entry ? entry.gross_strokes : null } function stepStroke(unit: Unit, delta: number) { const current = strokeValue(currentHole!, unit) const base = current === null ? par : current const next = Math.max(1, Math.min(20, base + delta)) void submitStroke(currentHole!, unit, next) } const currentResultEntry = (scorecard.hole_result_entries ?? []).find( (e) => e.hole_number === currentHole, ) function goPrev() { setCurrentHole((h) => { const idx = played.indexOf(h!) return played[Math.max(0, idx - 1)] }) } function goNext() { setCurrentHole((h) => { const idx = played.indexOf(h!) return played[Math.min(played.length - 1, idx + 1)] }) } const isIndividual = INDIVIDUAL_FORMATS.includes(session.format) const isLast = played.indexOf(currentHole) >= played.length - 1 return (
{decided ? (
Avgjort
) : (
{statusLabel}
)}
{error && (

{error}

)} {decided ? ( ) : ( <>
{showSummary && (
)}
{showConcede && (

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.

{teams.map((team, i) => concedeTeamIndex === i ? (
Er du sikker på at {team.name} gir seg?
) : ( ), )}
)}
{session.scoring_mode === "stroke" ? (

Hull {currentHole}

· Par {par}
{units.map((unit) => { const teamColor = (unit.side === "a" ? teams[0].color : teams[1].color) ?? "#64748b" const value = strokeValue(currentHole, unit) return (
{value === null ? "–" : value} {value === null ? "Ikke registrert" : scoreToParLabel(value, par)}
) })}
) : (

Hull {currentHole}

· Par {par}
{( [ { key: "a", label: `${teams[0].name} vant`, color: teams[0].color, side: "a" as const }, { key: "h", label: "Delt", color: undefined, side: null }, { key: "b", label: `${teams[1].name} vant`, color: teams[1].color, side: "b" as const }, ] as const ).map((opt) => { const active = currentResultEntry ? currentResultEntry.winning_side === opt.side : false return ( ) })}
)}
{!isLast ? ( ) : ( )}
)}
) } // --- Small components -------------------------------------------------------- function TeamTag({ name, color, align }: { name: string; color: string; align: "left" | "right" }) { const right = align === "right" return (
) } function HoleSummaryTable({ teams, scorecard, holes, scoringMode, }: { teams: [ApiTeam, ApiTeam] scorecard: ApiScorecard holes: ApiHole[] scoringMode: ScoringMode }) { if (scorecard.holes.length === 0) { return

Ingen hull er registrert ennå.

} return (
    {scorecard.holes.map((h) => { const par = holes.find((x) => x.hole_number === h.hole_number)?.par return (
  • {h.hole_number} {par !== undefined && ( Par {par} )} {scoringMode === "stroke" && ( {grossPairLabel(scorecard, h.hole_number)} )}
  • ) })}
) } function grossPairLabel(scorecard: ApiScorecard, holeNumber: number): string { const entries = scorecard.stroke_entries ?? [] const sideGross = (side: "a" | "b"): string => { const vals = entries.filter((e) => e.hole_number === holeNumber && e.team_side === side).map((e) => e.gross_strokes) if (vals.length === 0) return "–" return String(Math.min(...vals)) } return `${sideGross("a")}–${sideGross("b")}` } function OutcomeBadge({ teams, result }: { teams: [ApiTeam, ApiTeam]; result: "a" | "b" | "halved" }) { if (result === "halved") { return ( Delt ) } const team = result === "a" ? teams[0] : teams[1] return ( {team.name} ) } function scoreToParLabel(value: number, par: number): string { const diff = value - par if (diff === 0) return "Par" if (diff === -1) return "Birdie" if (diff === -2) return "Eagle" if (diff <= -3) return "Albatross" if (diff === 1) return "Bogey" if (diff === 2) return "Dobbel bogey" return `+${diff}` } function DecidedView({ teams, scorecard, holes, units, scoringMode, }: { teams: [ApiTeam, ApiTeam] scorecard: ApiScorecard holes: ApiHole[] units: Unit[] scoringMode: ScoringMode }) { return (
Matchen er avgjort {scorecard.status_text ?? ""}
{formatPoints(scorecard.points_side_a ?? 0)}
Poeng
{formatPoints(scorecard.points_side_b ?? 0)}

Hull for hull

Resultatet kan ikke endres etter at matchen er avgjort.

) } function formatPoints(points: number) { return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",") }