teecup/frontend/components/tournament-players-table.tsx

528 lines
20 KiB
TypeScript
Raw Normal View History

"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import { 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<string, Record<string, TournamentPlayersCell>>
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" },
]
// Shared control styling so every inline field feels like one family.
const CONTROL =
"h-9 rounded-lg border border-border bg-background px-2.5 text-sm text-foreground transition-all duration-200 ease-in-out hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
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
}
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<Record<string, string>>({})
useEffect(() => {
const next: Record<string, string> = {}
for (const round of rounds) {
next[round.id] = round.tees[0]?.id ?? ""
}
setDefaultTees(next)
}, [rounds])
return (
<div className="flex flex-col gap-4">
{errors.length > 0 && (
<div
role="alert"
className="flex flex-col gap-1 rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-destructive"
>
<div className="flex items-center gap-2">
<TriangleAlert aria-hidden="true" className="size-5 shrink-0" />
<h3 className="text-base font-bold">Noen endringer feilet</h3>
</div>
<ul className="pl-7 text-sm leading-relaxed">
{errors.map((err, i) => (
<li key={i} className="list-disc">
{err}
</li>
))}
</ul>
</div>
)}
<div className="overflow-x-auto rounded-2xl border border-border bg-card shadow-md shadow-black/8">
<table className="w-full border-collapse text-left">
<thead>
<tr className="divide-x divide-border border-b border-border">
<th
scope="col"
className="sticky left-0 z-20 bg-card px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground"
>
Spiller
</th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Hcp
</th>
{showClassColumn && (
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Klasse
</th>
)}
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Statistikknivå
</th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Status
</th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Kjønn
</th>
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Alder
</th>
{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 (
<th
key={round.id}
scope="col"
className="min-w-52 px-4 py-3 align-top text-xs font-bold uppercase tracking-wide text-muted-foreground"
>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span>{round.label}</span>
{busy && (
<span className="flex items-center gap-1 normal-case text-primary">
<Loader2 aria-hidden="true" className="size-3.5 animate-spin" />
<span className="text-[11px] font-semibold">Oppdaterer</span>
</span>
)}
</div>
<label className="flex flex-col gap-1 normal-case">
<span className="text-[11px] font-semibold text-muted-foreground">Standardutslag</span>
<select
aria-label={`Standardutslag for ${round.label}`}
className={CONTROL}
value={defaultTees[round.id] ?? ""}
disabled={busy}
onChange={(e) =>
setDefaultTees((prev) => ({ ...prev, [round.id]: e.target.value }))
}
>
{round.tees.map((tee) => (
<option key={tee.id} value={tee.id}>
{tee.name}
</option>
))}
</select>
</label>
<BulkCheckbox
checked={allAssigned}
indeterminate={someAssigned}
disabled={busy || players.length === 0}
label="Velg alle"
onChange={(next) =>
onBulkToggleRound(round.id, next, defaultTees[round.id] ?? round.tees[0]?.id ?? "")
}
/>
</div>
</th>
)
})}
<th scope="col" className="px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Spillerpool
</th>
<th scope="col" className="px-4 py-3 text-right text-xs font-bold uppercase tracking-wide text-muted-foreground">
Fjern
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{players.map((player) => (
<PlayerRow
key={player.participantId}
player={player}
classes={classes}
showClassColumn={showClassColumn}
rounds={rounds}
cells={cells}
organizationId={organizationId}
defaultTees={defaultTees}
busyRoundIds={busyRoundIds}
onSetHandicap={onSetHandicap}
onSetClass={onSetClass}
onSetStatLevel={onSetStatLevel}
onSetStatus={onSetStatus}
onRemoveParticipant={onRemoveParticipant}
onChangeGender={onChangeGender}
onChangeBirthDate={onChangeBirthDate}
onToggleRound={onToggleRound}
onChangeRoundTee={onChangeRoundTee}
/>
))}
</tbody>
</table>
</div>
</div>
)
}
function BulkCheckbox({
checked,
indeterminate,
disabled,
label,
onChange,
}: {
checked: boolean
indeterminate: boolean
disabled: boolean
label: string
onChange: (next: boolean) => void
}) {
const ref = useRef<HTMLInputElement>(null)
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate && !checked
}, [indeterminate, checked])
return (
<label className="flex items-center gap-2 normal-case">
<input
ref={ref}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
className="size-5 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<span className="text-[11px] font-semibold text-muted-foreground">{label}</span>
</label>
)
}
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<string, Record<string, TournamentPlayersCell>>
organizationId: string
defaultTees: Record<string, string>
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
}
const num = Number(trimmed)
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 (
<tr className="divide-x divide-border transition-colors duration-200 ease-in-out hover:bg-accent/40">
<th
scope="row"
className="sticky left-0 z-10 bg-card px-4 py-3 text-left align-middle text-sm font-bold text-foreground"
>
{player.playerName}
</th>
<td className="px-4 py-3 align-middle">
<input
type="number"
inputMode="decimal"
step={0.1}
min={-10}
max={54}
aria-label={`Handicap for ${player.playerName}`}
value={hcpDraft}
onChange={(e) => setHcpDraft(e.target.value)}
onBlur={commitHcp}
className={`${CONTROL} w-20 text-right tabular-nums`}
/>
</td>
{showClassColumn && (
<td className="px-4 py-3 align-middle">
<select
aria-label={`Klasse for ${player.playerName}`}
className={`${CONTROL} w-40`}
value={player.classId ?? ""}
onChange={(e) => onSetClass(player.participantId, e.target.value === "" ? null : e.target.value)}
>
<option value="">Ingen klasse</option>
{classes.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</td>
)}
<td className="px-4 py-3 align-middle">
<select
aria-label={`Statistikknivå for ${player.playerName}`}
className={`${CONTROL} w-36`}
value={player.statLevel}
onChange={(e) => onSetStatLevel(player.participantId, e.target.value as TournamentPlayerRow["statLevel"])}
>
{STAT_LEVELS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</td>
<td className="px-4 py-3 align-middle">
<select
aria-label={`Status for ${player.playerName}`}
className={`${CONTROL} w-28 ${
statusAbnormal ? "border-destructive/40 bg-destructive/10 text-destructive" : ""
}`}
value={player.status}
onChange={(e) => onSetStatus(player.participantId, e.target.value as TournamentPlayerRow["status"])}
>
{STATUSES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</td>
<td className="px-4 py-3 align-middle">
<select
aria-label={`Kjønn for ${player.playerName}`}
className={`${CONTROL} w-28`}
value={player.gender ?? ""}
onChange={(e) => onChangeGender(player.playerId, e.target.value as "m" | "f" | "x")}
>
<option value="" disabled>
Ikke satt
</option>
<option value="m">Mann</option>
<option value="f">Kvinne</option>
<option value="x">Annet</option>
</select>
</td>
<td className="px-4 py-3 align-middle">
<div className="flex items-center gap-2">
<input
type="date"
aria-label={`Fødselsdato for ${player.playerName}`}
className={`${CONTROL} w-40`}
value={player.birthDate ?? ""}
onChange={(e) => onChangeBirthDate(player.playerId, e.target.value)}
/>
<span className="min-w-12 text-xs font-semibold tabular-nums text-muted-foreground">
{age !== null ? `${age} år` : "Ukjent"}
</span>
</div>
</td>
{rounds.map((round) => {
const cell = cells[round.id]?.[player.participantId]
const assigned = cell?.assigned ?? false
const busy = busyRoundIds.includes(round.id)
return (
<td key={round.id} className="px-4 py-3 align-middle">
<div className="flex items-center gap-2">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={assigned}
disabled={busy}
aria-label={`${player.playerName} spiller ${round.label}`}
onChange={(e) => {
const checked = e.target.checked
const teeId = checked
? defaultTees[round.id] ?? round.tees[0]?.id ?? ""
: cell?.teeId ?? round.tees[0]?.id ?? ""
onToggleRound(round.id, player.participantId, checked, teeId)
}}
className="size-5 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
<span className="text-xs font-semibold text-muted-foreground">
{assigned ? "Spiller" : "Ikke satt"}
</span>
</label>
{assigned && (
<select
aria-label={`Utslag for ${player.playerName} i ${round.label}`}
className={`${CONTROL} w-28`}
value={cell?.teeId ?? ""}
disabled={busy}
onChange={(e) => onChangeRoundTee(round.id, player.participantId, e.target.value)}
>
{round.tees.map((tee) => (
<option key={tee.id} value={tee.id}>
{tee.name}
</option>
))}
</select>
)}
</div>
</td>
)
})}
<td className="px-4 py-3 align-middle">
<a
href={`/organizations/${organizationId}/players?highlight=${player.playerId}`}
className="inline-flex items-center gap-1.5 text-xs font-semibold text-muted-foreground underline-offset-2 transition-colors duration-200 ease-in-out hover:text-foreground hover:underline"
>
<ExternalLink aria-hidden="true" className="size-3.5" />
Rediger i spillerpoolen
</a>
</td>
<td className="px-4 py-3 text-right align-middle">
<button
type="button"
aria-label={`Fjern ${player.playerName}`}
onClick={() => onRemoveParticipant(player.participantId)}
className="inline-flex size-9 items-center justify-center rounded-lg text-muted-foreground transition-all duration-200 ease-in-out hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring active:scale-95"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
</td>
</tr>
)
}