Sorterbare kolonner i Spillere-tabellen (turneringsoppsett)

Klikk header sykler stigende/synkende/opprinnelig rekkefølge for
Spiller/Hcp/Klasse/Statistikknivå/Status/Kjønn/Alder. Runde-kolonnene
og handlingskolonnen forblir usorterbare. V0-mønster integrert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-21 08:04:57 +02:00
parent 544c952cab
commit 6603e94fd3

View file

@ -1,7 +1,7 @@
"use client" "use client"
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { ExternalLink, Loader2, Trash2, TriangleAlert } from "lucide-react" import { ChevronDown, ChevronUp, ExternalLink, Loader2, Trash2, TriangleAlert } from "lucide-react"
export type TournamentPlayerRow = { export type TournamentPlayerRow = {
participantId: string participantId: string
@ -90,6 +90,106 @@ function computeAge(birthDate: string): number | null {
return age 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<TournamentPlayerRow["statLevel"], number> = {
strokes_only: 0,
strokes_and_putts: 1,
full: 2,
}
const STATUS_RANK: Record<TournamentPlayerRow["status"], number> = {
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, string>): 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<string, string>,
): 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 (
<th
scope="col"
aria-sort={ariaSort}
className={`border-b border-r border-border bg-muted p-0 ${sticky ? "sticky left-0 top-0 z-30" : "sticky top-0 z-20"}`}
>
<button
type="button"
onClick={() => onSort(sortKey)}
aria-label={`Sorter etter ${label}${active ? (dir === "asc" ? ", stigende" : ", synkende") : ""}`}
className={`flex h-full min-h-9 w-full items-center gap-1 px-2 py-2 text-[11px] font-bold uppercase tracking-wide transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${
active ? "text-primary" : "text-muted-foreground"
} ${align === "right" ? "justify-end text-right" : "justify-start text-left"}`}
>
<span className="truncate">{label}</span>
{active && dir === "asc" && <ChevronUp aria-hidden="true" className="size-3.5 shrink-0" />}
{active && dir === "desc" && <ChevronDown aria-hidden="true" className="size-3.5 shrink-0" />}
</button>
</th>
)
}
export function TournamentPlayersTable({ export function TournamentPlayersTable({
players, players,
classes, classes,
@ -122,6 +222,21 @@ export function TournamentPlayersTable({
setDefaultTees(next) setDefaultTees(next)
}, [rounds]) }, [rounds])
// Sortering: stigende -> synkende -> tilbake til opprinnelig rekkefølge.
const [sort, setSort] = useState<SortState>(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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{errors.length > 0 && ( {errors.length > 0 && (
@ -162,32 +277,15 @@ export function TournamentPlayersTable({
</colgroup> </colgroup>
<thead> <thead>
<tr> <tr>
<th <SortableHeader sortKey="name" label="Spiller" sort={sort} onSort={cycleSort} sticky />
scope="col" <SortableHeader sortKey="hcp" label="Hcp" sort={sort} onSort={cycleSort} align="right" />
className={`${TH} sticky left-0 top-0 z-30`}
>
Spiller
</th>
<th scope="col" className={`${TH} sticky top-0 z-20 text-right`}>
Hcp
</th>
{showClassColumn && ( {showClassColumn && (
<th scope="col" className={`${TH} sticky top-0 z-20`}> <SortableHeader sortKey="className" label="Klasse" sort={sort} onSort={cycleSort} />
Klasse
</th>
)} )}
<th scope="col" className={`${TH} sticky top-0 z-20`}> <SortableHeader sortKey="statLevel" label="Statistikknivå" sort={sort} onSort={cycleSort} />
Statistikknivå <SortableHeader sortKey="status" label="Status" sort={sort} onSort={cycleSort} />
</th> <SortableHeader sortKey="gender" label="Kjønn" sort={sort} onSort={cycleSort} />
<th scope="col" className={`${TH} sticky top-0 z-20`}> <SortableHeader sortKey="age" label="Alder" sort={sort} onSort={cycleSort} />
Status
</th>
<th scope="col" className={`${TH} sticky top-0 z-20`}>
Kjønn
</th>
<th scope="col" className={`${TH} sticky top-0 z-20`}>
Alder
</th>
{rounds.map((round) => { {rounds.map((round) => {
const busy = busyRoundIds.includes(round.id) const busy = busyRoundIds.includes(round.id)
const assignedCount = players.filter( const assignedCount = players.filter(
@ -246,7 +344,7 @@ export function TournamentPlayersTable({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{players.map((player) => ( {sortedPlayers.map((player) => (
<PlayerRow <PlayerRow
key={player.participantId} key={player.participantId}
player={player} player={player}