"use client" import { useEffect, useMemo, useRef, useState } from "react" import { ChevronDown, ChevronUp, ExternalLink, Loader2, Trash2, TriangleAlert } from "lucide-react" export type TournamentPlayerRow = { participantId: string playerId: string playerName: string handicapSnapshot: number | null classId: string | null statLevel: "strokes_only" | "strokes_and_putts" | "full" status: "active" | "dsq" | "rtd" | "dnf" | "dns" gender: "m" | "f" | "x" | null birthDate: string | null // ISO "YYYY-MM-DD", null if unknown } export type ClassOption = { id: string; name: string } export type TournamentPlayersRound = { id: string label: string // e.g. "Runde 1" tees: { id: string; name: string }[] } export type TournamentPlayersCell = { assigned: boolean teeId: string | null teeName: string | null } export type TournamentPlayersTableProps = { players: TournamentPlayerRow[] classes: ClassOption[] rounds: TournamentPlayersRound[] // cells[roundId][participantId] cells: Record> organizationId: string onSetHandicap: (participantId: string, value: number | null) => void onSetClass: (participantId: string, classId: string | null) => void onSetStatLevel: (participantId: string, statLevel: TournamentPlayerRow["statLevel"]) => void onSetStatus: (participantId: string, status: TournamentPlayerRow["status"]) => void onRemoveParticipant: (participantId: string) => void onChangeGender: (playerId: string, gender: "m" | "f" | "x") => void onChangeBirthDate: (playerId: string, birthDate: string) => void onToggleRound: (roundId: string, participantId: string, checked: boolean, teeId: string) => void onBulkToggleRound: (roundId: string, checked: boolean, teeId: string) => void onChangeRoundTee: (roundId: string, participantId: string, teeId: string) => void busyRoundIds: string[] errors: string[] } const STAT_LEVELS: { label: string; value: TournamentPlayerRow["statLevel"] }[] = [ { label: "Kun slag", value: "strokes_only" }, { label: "Slag og putt", value: "strokes_and_putts" }, { label: "Fullt", value: "full" }, ] const STATUSES: { label: string; value: TournamentPlayerRow["status"] }[] = [ { label: "Aktiv", value: "active" }, { label: "DSQ", value: "dsq" }, { label: "RTD", value: "rtd" }, { label: "DNF", value: "dnf" }, { label: "DNS", value: "dns" }, ] // Spreadsheet cell control: flat, fills the whole cell, no border of its own. // The grid lines define the structure; controls only light up on hover/focus. const CELL = "h-full w-full border-0 bg-transparent px-2 py-1.5 text-[13px] leading-tight text-foreground outline-none transition-colors hover:bg-accent/40 focus-visible:bg-background focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" // Compact header control (round default-tee select). const HEAD_CELL = "h-7 w-full rounded-md border border-border bg-background px-1.5 text-xs font-medium text-foreground outline-none transition-colors hover:border-primary/40 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50" // Shared cell frame: thin grid lines, zero padding (the control supplies it). const TD = "border-b border-r border-border p-0 align-middle" const TH = "border-b border-r border-border bg-muted px-2 py-2 text-left text-[11px] font-bold uppercase tracking-wide text-muted-foreground" function computeAge(birthDate: string): number | null { const birth = new Date(birthDate) if (Number.isNaN(birth.getTime())) return null const now = new Date() let age = now.getFullYear() - birth.getFullYear() const monthDiff = now.getMonth() - birth.getMonth() if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birth.getDate())) { age -= 1 } return age } // ── Sortering (2026-08-20) ────────────────────────────────────────────── // Kun disse syv kolonnene er sorterbare -- runde-kolonnene (deltar+utslag) // og handlingskolonnen er det bevisst IKKE, se V0-prompten. type SortKey = "name" | "hcp" | "className" | "statLevel" | "status" | "gender" | "age" type SortDir = "asc" | "desc" type SortState = { key: SortKey; dir: SortDir } | null const STAT_LEVEL_RANK: Record = { strokes_only: 0, strokes_and_putts: 1, full: 2, } const STATUS_RANK: Record = { active: 0, dsq: 1, rtd: 2, dnf: 3, dns: 4, } const GENDER_SORT_LABEL: Record<"m" | "f" | "x", string> = { f: "Kvinne", m: "Mann", x: "Annet" } // Sammenlignbar verdi per sorteringskolonne. Null sorteres alltid sist // (håndtert i compareRows), uansett retning. function sortValue(p: TournamentPlayerRow, key: SortKey, classNameById: Record): string | number | null { switch (key) { case "name": return p.playerName.toLocaleLowerCase("nb") case "hcp": return p.handicapSnapshot case "className": { const name = p.classId ? classNameById[p.classId] : undefined return name ? name.toLocaleLowerCase("nb") : null } case "statLevel": return STAT_LEVEL_RANK[p.statLevel] case "status": return STATUS_RANK[p.status] case "gender": return p.gender ? GENDER_SORT_LABEL[p.gender].toLocaleLowerCase("nb") : null case "age": return p.birthDate ? computeAge(p.birthDate) : null } } function compareRows( a: TournamentPlayerRow, b: TournamentPlayerRow, key: SortKey, dir: SortDir, classNameById: Record, ): number { const av = sortValue(a, key, classNameById) const bv = sortValue(b, key, classNameById) if (av === null && bv === null) return 0 if (av === null) return 1 if (bv === null) return -1 const res = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv), "nb") return dir === "asc" ? res : -res } function SortableHeader({ sortKey, label, sort, onSort, align, sticky, }: { sortKey: SortKey label: string sort: SortState onSort: (key: SortKey) => void align?: "right" sticky?: boolean }) { const active = sort?.key === sortKey const dir = active ? sort.dir : null const ariaSort = active ? (dir === "asc" ? "ascending" : "descending") : "none" return ( ) } export function TournamentPlayersTable({ players, classes, rounds, cells, organizationId, onSetHandicap, onSetClass, onSetStatLevel, onSetStatus, onRemoveParticipant, onChangeGender, onChangeBirthDate, onToggleRound, onBulkToggleRound, onChangeRoundTee, busyRoundIds, errors, }: TournamentPlayersTableProps) { const showClassColumn = classes.length > 0 // Local-only: each round's "Standardutslag" (default tee) selection. // Resets to each round's first tee whenever the round set changes. const [defaultTees, setDefaultTees] = useState>({}) useEffect(() => { const next: Record = {} for (const round of rounds) { next[round.id] = round.tees[0]?.id ?? "" } setDefaultTees(next) }, [rounds]) // Sortering: stigende -> synkende -> tilbake til opprinnelig rekkefølge. const [sort, setSort] = useState(null) const cycleSort = (key: SortKey) => { setSort((prev) => { if (!prev || prev.key !== key) return { key, dir: "asc" } if (prev.dir === "asc") return { key, dir: "desc" } return null }) } const classNameById = useMemo(() => Object.fromEntries(classes.map((c) => [c.id, c.name])), [classes]) const sortedPlayers = useMemo(() => { if (!sort) return players return [...players].sort((a, b) => compareRows(a, b, sort.key, sort.dir, classNameById)) }, [players, sort, classNameById]) return (
{errors.length > 0 && (
    {errors.map((err, i) => (
  • {err}
  • ))}
)}
{/* Fluid layout: the table fills the available width instead of leaving dead space and pushing round columns off-screen. */} {showClassColumn && } {rounds.map((r) => ( ))} {showClassColumn && ( )} {rounds.map((round) => { const busy = busyRoundIds.includes(round.id) const assignedCount = players.filter( (p) => cells[round.id]?.[p.participantId]?.assigned, ).length const allAssigned = players.length > 0 && assignedCount === players.length const someAssigned = assignedCount > 0 && assignedCount < players.length return ( ) })} {sortedPlayers.map((player) => ( ))}
{round.label} {busy && ( )}
onBulkToggleRound(round.id, next, defaultTees[round.id] ?? round.tees[0]?.id ?? "") } />
Handlinger
) } function BulkCheckbox({ checked, indeterminate, disabled, label, onChange, }: { checked: boolean indeterminate: boolean disabled: boolean label: string onChange: (next: boolean) => void }) { const ref = useRef(null) useEffect(() => { if (ref.current) ref.current.indeterminate = indeterminate && !checked }, [indeterminate, checked]) return ( ) } function PlayerRow({ player, classes, showClassColumn, rounds, cells, organizationId, defaultTees, busyRoundIds, onSetHandicap, onSetClass, onSetStatLevel, onSetStatus, onRemoveParticipant, onChangeGender, onChangeBirthDate, onToggleRound, onChangeRoundTee, }: { player: TournamentPlayerRow classes: ClassOption[] showClassColumn: boolean rounds: TournamentPlayersRound[] cells: Record> organizationId: string defaultTees: Record busyRoundIds: string[] onSetHandicap: (participantId: string, value: number | null) => void onSetClass: (participantId: string, classId: string | null) => void onSetStatLevel: (participantId: string, statLevel: TournamentPlayerRow["statLevel"]) => void onSetStatus: (participantId: string, status: TournamentPlayerRow["status"]) => void onRemoveParticipant: (participantId: string) => void onChangeGender: (playerId: string, gender: "m" | "f" | "x") => void onChangeBirthDate: (playerId: string, birthDate: string) => void onToggleRound: (roundId: string, participantId: string, checked: boolean, teeId: string) => void onChangeRoundTee: (roundId: string, participantId: string, teeId: string) => void }) { // Handicap is a local draft committed on blur, not per-keystroke. const [hcpDraft, setHcpDraft] = useState( player.handicapSnapshot === null ? "" : String(player.handicapSnapshot), ) useEffect(() => { setHcpDraft(player.handicapSnapshot === null ? "" : String(player.handicapSnapshot)) }, [player.handicapSnapshot]) const age = player.birthDate ? computeAge(player.birthDate) : null const statusAbnormal = player.status !== "active" function commitHcp() { const trimmed = hcpDraft.trim() if (trimmed === "") { onSetHandicap(player.participantId, null) return } // Komma-desimal (norsk skrivemåte, "12,4") skal fungere likt som punktum // -- 2026-08-19, bruker: "jeg vil at det skal være irrelevant". const num = Number(trimmed.replace(",", ".")) if (Number.isNaN(num)) { // Revert bad input back to the last known good value. setHcpDraft(player.handicapSnapshot === null ? "" : String(player.handicapSnapshot)) return } const clamped = Math.min(54, Math.max(-10, num)) onSetHandicap(player.participantId, clamped) } return ( {player.playerName} setHcpDraft(e.target.value)} onBlur={commitHcp} className={`${CELL} text-right tabular-nums`} /> {showClassColumn && ( )}
onChangeBirthDate(player.playerId, e.target.value)} /> {age !== null ? `${age} år` : "Ukjent"}
{rounds.map((round) => { const cell = cells[round.id]?.[player.participantId] const assigned = cell?.assigned ?? false const busy = busyRoundIds.includes(round.id) const previewTeeId = cell?.teeId ?? defaultTees[round.id] ?? round.tees[0]?.id ?? "" return (
) })}
) }