"use client" import type React from "react" import { useEffect, useMemo, useState } from "react" import Link from "next/link" import { ArrowLeft, ArrowLeftRight, Check, Copy, Flag, KeyRound, MessageCircle, MoreVertical, Pencil, Plus, Star, Trash2, UserPlus, Users, X, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { TournamentStatusPicker, type TournamentStatus } from "@/components/tournament-status-badge" import { cn } from "@/lib/utils" // --- Types (matcher API-kontrakten i app/routers/tournaments.py/players.py) - type ApiPlayer = { id: string display_name: string handicap_index: number | null gender: "m" | "f" | "x" | null } type ApiRosterEntry = { id: string player_id: string display_name: string handicap_index_snapshot: number | null is_captain: boolean class_id: string | null class_name: string | null } type Team = { id: string name: string color: string roster: ApiRosterEntry[] } // Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt // standardutslag brukt til å forhåndsutfylle riktig utslag når en spiller // legges til en match (session-blind-draw.tsx). Ren utslag-bekvemmelighet // her -- INGEN egen resultatliste for lagturneringer (bekreftet med // bruker: poeng er knyttet til hele kamper, ikke enkeltspillere). type ApiTournamentClass = { id: string name: string default_tee_id: string | null default_tee_name: string | null } // Team colors er DATA brukt på scorekort senere -- bevisst atskilt fra // app-ens merkevare-grønn/oransje. const TEAM_COLORS: { value: string; label: string }[] = [ { value: "#2563eb", label: "Blå" }, { value: "#dc2626", label: "Rød" }, { value: "#7c3aed", label: "Lilla" }, { value: "#0891b2", label: "Turkis" }, { value: "#db2777", label: "Rosa" }, { value: "#475569", label: "Skifer" }, ] // --- Component ------------------------------------------------------------- export function TournamentDetail({ organizationId, tournamentId, tournamentName, }: { organizationId: string tournamentId: string tournamentName: string }) { const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null]) const [pool, setPool] = useState([]) const [joinCode, setJoinCode] = useState(null) const [status, setStatus] = useState(null) const [classes, setClasses] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) useEffect(() => { let cancelled = false async function load() { try { const [teamsRes, poolRes, tournamentsRes, classesRes] = await Promise.all([ fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include", }), fetch(`/orgs/${organizationId}/players`, { credentials: "include" }), fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" }), fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, { credentials: "include", }), ]) if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed") const teamsData: { id: string; name: string; color: string | null }[] = await teamsRes.json() const poolData: ApiPlayer[] = await poolRes.json() // Ingen enkelt-turnering-GET finnes ennå -- listen bærer allerede // join_code (ADR-020), så vi finner raden herfra i stedet for å // legge til et nytt endepunkt kun for dette. if (tournamentsRes.ok) { const tournamentsData: { id: string; join_code: string; status: TournamentStatus }[] = await tournamentsRes.json() const mine = tournamentsData.find((t) => t.id === tournamentId) if (mine) { setJoinCode(mine.join_code) setStatus(mine.status) } } if (classesRes.ok) setClasses(await classesRes.json()) const withRosters = await Promise.all( teamsData.map(async (t) => { const rosterRes = await fetch(`/orgs/${organizationId}/teams/${t.id}/roster`, { credentials: "include", }) const roster: ApiRosterEntry[] = rosterRes.ok ? await rosterRes.json() : [] return { id: t.id, name: t.name, color: t.color ?? TEAM_COLORS[0].value, roster } }), ) if (cancelled) return setTeams([withRosters[0] ?? null, withRosters[1] ?? null]) setPool(poolData) } catch { if (!cancelled) setError("Klarte ikke å laste lag og spillere. Prøv å laste siden på nytt.") } finally { if (!cancelled) setLoading(false) } } void load() return () => { cancelled = true } }, [organizationId, tournamentId]) async function createTeam(slot: 0 | 1, name: string, color: string) { setError(null) try { const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ name, color }), }) if (!res.ok) throw new Error(`create team: ${res.status}`) const created: { id: string; name: string; color: string | null } = await res.json() setTeams((prev) => { const next: [Team | null, Team | null] = [prev[0], prev[1]] next[slot] = { id: created.id, name: created.name, color: created.color ?? color, roster: [] } return next }) } catch { setError("Klarte ikke å opprette laget. Prøv igjen.") } } async function addExistingPlayer(teamId: string, playerId: string) { setError(null) try { const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ player_id: playerId, is_captain: false }), }) if (!res.ok) throw new Error(`add roster: ${res.status}`) const entry: ApiRosterEntry = await res.json() setTeams((prev) => prev.map((t) => (t && t.id === teamId ? { ...t, roster: [...t.roster, entry] } : t)) as [ Team | null, Team | null, ], ) } catch { setError("Klarte ikke å legge til spilleren. Prøv igjen.") } } async function addNewPlayer(teamId: string, name: string, handicap?: number) { setError(null) try { const playerRes = await fetch(`/orgs/${organizationId}/players`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ display_name: name, handicap_index: handicap ?? null }), }) if (!playerRes.ok) throw new Error(`create player: ${playerRes.status}`) const player: ApiPlayer = await playerRes.json() setPool((prev) => [...prev, player]) await addExistingPlayer(teamId, player.id) } catch { setError("Klarte ikke å opprette spilleren. Prøv igjen.") } } // Redigerer spillerpoolen (`player`), IKKE et lags frosne // handicap_index_snapshot (ADR-007) -- se PlayerUpdate sin docstring i // app/routers/players.py. Slår derfor ikke automatisk inn på tallet som // allerede vises på et lag denne spilleren er rostret på. async function updatePlayer( playerId: string, updates: { display_name?: string; handicap_index?: number | null; gender?: string | null }, ) { setError(null) try { const res = await fetch(`/orgs/${organizationId}/players/${playerId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(updates), }) if (!res.ok) throw new Error(`update player: ${res.status}`) const updated: ApiPlayer = await res.json() setPool((prev) => prev.map((p) => (p.id === updated.id ? updated : p))) } catch { setError("Klarte ikke å oppdatere spilleren. Prøv igjen.") } } async function toggleCaptain(teamId: string, rosterId: string, current: boolean) { setError(null) try { const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ is_captain: !current }), }) if (!res.ok) throw new Error(`toggle captain: ${res.status}`) const updated: ApiRosterEntry = await res.json() setTeams((prev) => prev.map((t) => t && t.id === teamId ? { ...t, roster: t.roster.map((r) => (r.id === updated.id ? updated : r)) } : t, ) as [Team | null, Team | null], ) } catch { setError("Klarte ikke å endre kaptein. Prøv igjen.") } } async function setRosterClass(teamId: string, rosterId: string, classId: string | null) { setError(null) try { const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ class_id: classId }), }) if (!res.ok) throw new Error(`set class: ${res.status}`) const updated: ApiRosterEntry = await res.json() setTeams((prev) => prev.map((t) => t && t.id === teamId ? { ...t, roster: t.roster.map((r) => (r.id === updated.id ? updated : r)) } : t, ) as [Team | null, Team | null], ) } catch { setError("Klarte ikke å endre klasse. Prøv igjen.") } } async function removePlayer(teamId: string, rosterId: string) { setError(null) try { const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}`, { method: "DELETE", credentials: "include", }) if (!res.ok && res.status !== 204) throw new Error(`remove roster: ${res.status}`) setTeams((prev) => prev.map((t) => t && t.id === teamId ? { ...t, roster: t.roster.filter((r) => r.id !== rosterId) } : t, ) as [Team | null, Team | null], ) } catch { setError("Klarte ikke å fjerne spilleren. Prøv igjen.") } } async function movePlayer(teamId: string, rosterId: string, targetTeamId: string) { setError(null) try { const res = await fetch( `/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}/move`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ target_team_id: targetTeamId }), }, ) if (!res.ok) { const body = await res.json().catch(() => null) throw new Error(body?.detail?.message ?? `move roster: ${res.status}`) } const moved: ApiRosterEntry = await res.json() setTeams( (prev) => prev.map((t) => { if (!t) return t if (t.id === teamId) return { ...t, roster: t.roster.filter((r) => r.id !== rosterId) } if (t.id === targetTeamId) return { ...t, roster: [...t.roster, moved] } return t }) as [Team | null, Team | null], ) } catch (err) { setError(err instanceof Error ? err.message : "Klarte ikke å flytte spilleren. Prøv igjen.") } } async function updateStatus(newStatus: TournamentStatus) { setError(null) const previous = status setStatus(newStatus) // optimistisk -- rulles tilbake under ved feil try { const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ status: newStatus }), }) if (!res.ok) throw new Error(`update status: ${res.status}`) } catch { setStatus(previous) setError("Klarte ikke å endre turnering-status. Prøv igjen.") } } // 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 { 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? function findPlayerTeam(playerId: string): Team | null { for (const t of teams) { if (t && t.roster.some((r) => r.player_id === playerId)) return t } return null } return (

Lag og spillere

Ryder Cup-format: nøyaktig to lag. Sett opp begge lagene og fyll troppene før turneringen kan starte.

{error && (

{error}

)} {loading ? (
) : ( <>
{([0, 1] as const).map((slot) => { const team = teams[slot] if (team) { const otherTeam = teams[slot === 0 ? 1 : 0] return ( ) } return })}
)}
) } // --- Invitasjonskode-chip (ADR-020) ----------------------------------------- // Vises til organisator slik at koden kan gis muntlig/på en lapp til // spillere som ellers ikke ville funnet turneringen. Overstyrer // tournament.visibility når den brukes -- se login-skjermets kode-felt. function JoinCodeChip({ code }: { code: string }) { const [copied, setCopied] = useState(false) async function handleCopy() { try { await navigator.clipboard.writeText(code) setCopied(true) setTimeout(() => setCopied(false), 2000) } catch { // Utilgjengelig clipboard-API (f.eks. usikker kontekst) -- koden er // uansett synlig i chipen, bare uten kopier-snarveien. } } return ( ) } // --- Create team (State A) ------------------------------------------------- function CreateTeamCard({ slot, onCreate, }: { slot: 0 | 1 onCreate: (slot: 0 | 1, name: string, color: string) => void }) { const [name, setName] = useState("") const [color, setColor] = useState(TEAM_COLORS[slot === 0 ? 0 : 1].value) const valid = name.trim().length >= 2 function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!valid) return onCreate(slot, name.trim(), color) } return (
Lag {slot + 1} Opprett lag
setName(e.target.value)} className="h-12 rounded-2xl text-base" />
{TEAM_COLORS.map((c) => { const selected = c.value === color return ( ) })}
) } // --- Team panel with roster (State B) -------------------------------------- function TeamPanel({ team, otherTeam, pool, classes, chatHref, findPlayerTeam, onToggleCaptain, onRemovePlayer, onMovePlayer, onAddExisting, onAddNew, onUpdatePlayer, onConcedeTournament, onSetClass, }: { team: Team otherTeam: Team | null pool: ApiPlayer[] classes: ApiTournamentClass[] chatHref: string findPlayerTeam: (playerId: string) => Team | null onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void onRemovePlayer: (teamId: string, rosterId: string) => void onMovePlayer: (teamId: string, rosterId: string, targetTeamId: string) => void onAddExisting: (teamId: string, playerId: string) => void onAddNew: (teamId: string, name: string, handicap?: number) => void onUpdatePlayer: ( playerId: string, updates: { display_name?: string; handicap_index?: number | null; gender?: string | null }, ) => void onConcedeTournament: (teamId: string) => Promise onSetClass: (teamId: string, rosterId: string, classId: string | null) => void }) { const [confirmingId, setConfirmingId] = useState(null) const [editingPlayerId, setEditingPlayerId] = useState(null) const [confirmingGiveUp, setConfirmingGiveUp] = useState(false) const [giveUpError, setGiveUpError] = useState(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 (
    {team.roster.length === 0 && (
  • Ingen spillere ennå. Legg til nedenfor.
  • )} {team.roster.map((entry) => { const confirming = confirmingId === entry.id const editing = editingPlayerId === entry.player_id if (editing) { const player = pool.find((p) => p.id === entry.player_id) return (
  • { onUpdatePlayer(entry.player_id, updates) setEditingPlayerId(null) }} onCancel={() => setEditingPlayerId(null)} />
  • ) } if (confirming) { return (
  • Fjern {entry.display_name} fra laget?
  • ) } return (
  • {entry.display_name} {entry.is_captain && ( )} {entry.class_name && ( {entry.class_name} )}
    {formatHandicap(entry.handicap_index_snapshot)} setEditingPlayerId(entry.player_id)} className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold" > onToggleCaptain(team.id, entry.id, entry.is_captain)} className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold" > {classes.map((c) => ( onSetClass(team.id, entry.id, c.id)} className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold" > ))} {entry.class_id && ( onSetClass(team.id, entry.id, null)} className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold" > )} {otherTeam && ( onMovePlayer(team.id, entry.id, otherTeam.id)} className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold" > )} setConfirmingId(entry.id)} className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive" >
  • ) })}
{confirmingGiveUp ? (
{giveUpError && (

{giveUpError}

)}

Er du sikker på at {team.name} gir opp resten av turneringen? Motstanderen får full poengsum for ALLE ikke-avgjorte matcher, med én gang.

) : ( )}
) } // --- Add player type-ahead ------------------------------------------------- function AddPlayerControl({ team, pool, findPlayerTeam, onAddExisting, onAddNew, }: { team: Team otherTeam: Team | null pool: ApiPlayer[] findPlayerTeam: (playerId: string) => Team | null onAddExisting: (teamId: string, playerId: string) => void onAddNew: (teamId: string, name: string, handicap?: number) => void }) { const [open, setOpen] = useState(false) const [query, setQuery] = useState("") const [creating, setCreating] = useState(false) const [newHandicap, setNewHandicap] = useState("") const trimmed = query.trim() // Alle poolspillere som ikke allerede er rostret på DETTE laget (spillere // på det andre laget blir værende med, vist nedgraderte -- se onOtherTeam // under). Skilt ut fra `matches` slik at "bla i eksisterende"-listen kan // vises uavhengig av om søkefeltet er tomt (samme fiks som // AddParticipantControl i individual-tournament-detail.tsx, ADR-070). const availableForThisTeam = useMemo(() => { return pool .map((p) => { const onTeam = findPlayerTeam(p.id) return { player: p, onThisTeam: onTeam?.id === team.id, onOtherTeam: onTeam && onTeam.id !== team.id ? onTeam : null, } }) .filter((m) => !m.onThisTeam) }, [pool, findPlayerTeam, team.id]) const matches = useMemo(() => { if (!trimmed) return availableForThisTeam const q = trimmed.toLowerCase() return availableForThisTeam.filter((m) => m.player.display_name.toLowerCase().includes(q)) }, [availableForThisTeam, trimmed]) const exactMatch = useMemo( () => pool.some((p) => p.display_name.toLowerCase() === trimmed.toLowerCase()), [pool, trimmed], ) const showCreate = trimmed.length >= 2 && !exactMatch function reset() { setQuery("") setCreating(false) setNewHandicap("") } function handleAddExisting(playerId: string) { onAddExisting(team.id, playerId) reset() } function handleCreate() { if (trimmed.length < 2) return const hcpValue = newHandicap.trim() === "" ? undefined : Number(newHandicap.replace(",", ".")) const hcp = hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined onAddNew(team.id, trimmed, hcp) reset() } if (!open) { return ( ) } return (
{ setQuery(e.target.value) setCreating(false) }} onKeyDown={(e) => { if (e.key === "Escape") { setOpen(false) reset() } }} className="h-11 flex-1 rounded-2xl text-base" />
    {matches.map(({ player, onOtherTeam }) => { if (onOtherTeam) { return (
  • {player.display_name} allerede på {onOtherTeam.name}
    {formatHandicap(player.handicap_index)}
  • ) } return (
  • ) })} {matches.length === 0 && availableForThisTeam.length === 0 && (
  • {pool.length === 0 ? "Ingen spillere i organisasjonen ennå -- opprett den første under." : "Alle spillere i organisasjonen er allerede lagt til dette laget."}
  • )} {matches.length === 0 && availableForThisTeam.length > 0 && trimmed.length > 0 && (
  • Ingen treff blant eksisterende spillere.
  • )} {showCreate && !creating && (
  • )} {showCreate && creating && (
  • Ny spiller: {trimmed}
    setNewHandicap(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() handleCreate() } }} className="h-11 rounded-xl text-base" />
  • )}
) } // --- Rediger spiller (spillerpoolen, ikke et lags frosne snapshot) -------- function EditPlayerForm({ displayName, handicapIndex, gender, onSave, onCancel, }: { displayName: string handicapIndex: number | null gender: "m" | "f" | "x" | null onSave: (updates: { display_name: string; handicap_index: number | null; gender: string | null }) => void onCancel: () => void }) { const [name, setName] = useState(displayName) const [hcp, setHcp] = useState(handicapIndex === null ? "" : String(handicapIndex)) const [genderValue, setGenderValue] = useState(gender ?? "") const valid = name.trim().length >= 1 function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!valid) return onSave({ display_name: name.trim(), handicap_index: hcp.trim() === "" ? null : Number(hcp.replace(",", ".")), gender: genderValue === "" ? null : genderValue, }) } return (
setName(e.target.value)} placeholder="Navn" className="h-10 rounded-xl text-sm sm:col-span-1" /> setHcp(e.target.value)} placeholder="HCP" inputMode="decimal" className="h-10 rounded-xl text-sm" />

Endrer spilleren i poolen (brukes ved fremtidig rostring). Endrer IKKE HCP-tallet som allerede er registrert på et lag i denne turneringen — fjern og legg til spilleren på nytt på laget for å oppdatere det.

) } // --- Helpers --------------------------------------------------------------- function formatHandicap(handicap: number | null | undefined) { if (handicap === null || handicap === undefined) return "—" return handicap.toFixed(1) }