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:
parent
544c952cab
commit
6603e94fd3
1 changed files with 124 additions and 26 deletions
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
|
||||
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 = {
|
||||
participantId: string
|
||||
|
|
@ -90,6 +90,106 @@ function computeAge(birthDate: string): number | null {
|
|||
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({
|
||||
players,
|
||||
classes,
|
||||
|
|
@ -122,6 +222,21 @@ export function TournamentPlayersTable({
|
|||
setDefaultTees(next)
|
||||
}, [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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{errors.length > 0 && (
|
||||
|
|
@ -162,32 +277,15 @@ export function TournamentPlayersTable({
|
|||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
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>
|
||||
<SortableHeader sortKey="name" label="Spiller" sort={sort} onSort={cycleSort} sticky />
|
||||
<SortableHeader sortKey="hcp" label="Hcp" sort={sort} onSort={cycleSort} align="right" />
|
||||
{showClassColumn && (
|
||||
<th scope="col" className={`${TH} sticky top-0 z-20`}>
|
||||
Klasse
|
||||
</th>
|
||||
<SortableHeader sortKey="className" label="Klasse" sort={sort} onSort={cycleSort} />
|
||||
)}
|
||||
<th scope="col" className={`${TH} sticky top-0 z-20`}>
|
||||
Statistikknivå
|
||||
</th>
|
||||
<th scope="col" className={`${TH} sticky top-0 z-20`}>
|
||||
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>
|
||||
<SortableHeader sortKey="statLevel" label="Statistikknivå" sort={sort} onSort={cycleSort} />
|
||||
<SortableHeader sortKey="status" label="Status" sort={sort} onSort={cycleSort} />
|
||||
<SortableHeader sortKey="gender" label="Kjønn" sort={sort} onSort={cycleSort} />
|
||||
<SortableHeader sortKey="age" label="Alder" sort={sort} onSort={cycleSort} />
|
||||
{rounds.map((round) => {
|
||||
const busy = busyRoundIds.includes(round.id)
|
||||
const assignedCount = players.filter(
|
||||
|
|
@ -246,7 +344,7 @@ export function TournamentPlayersTable({
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{players.map((player) => (
|
||||
{sortedPlayers.map((player) => (
|
||||
<PlayerRow
|
||||
key={player.participantId}
|
||||
player={player}
|
||||
|
|
|
|||
Loading…
Reference in a new issue