2026-08-19 07:54:07 +02:00
|
|
|
"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" },
|
|
|
|
|
]
|
|
|
|
|
|
2026-08-19 08:37:15 +02:00
|
|
|
// 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"
|
2026-08-19 07:54:07 +02:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-08-19 08:37:15 +02:00
|
|
|
<div className="overflow-x-auto rounded-xl border border-border bg-card shadow-sm shadow-black/5">
|
|
|
|
|
{/* Fluid layout: the table fills the available width instead of leaving
|
|
|
|
|
dead space and pushing round columns off-screen. */}
|
|
|
|
|
<table className="w-full border-collapse text-left [&_td]:last:border-r-0 [&_th]:last:border-r-0">
|
|
|
|
|
<colgroup>
|
|
|
|
|
<col className="w-[13rem]" />
|
|
|
|
|
<col className="w-[5.5rem]" />
|
|
|
|
|
{showClassColumn && <col className="w-[9rem]" />}
|
|
|
|
|
<col className="w-[9rem]" />
|
|
|
|
|
<col className="w-[7rem]" />
|
|
|
|
|
<col className="w-[7rem]" />
|
|
|
|
|
<col className="w-[10rem]" />
|
|
|
|
|
{rounds.map((r) => (
|
|
|
|
|
<col key={r.id} />
|
|
|
|
|
))}
|
|
|
|
|
<col className="w-[5.5rem]" />
|
|
|
|
|
</colgroup>
|
2026-08-19 07:54:07 +02:00
|
|
|
<thead>
|
2026-08-19 08:37:15 +02:00
|
|
|
<tr>
|
2026-08-19 07:54:07 +02:00
|
|
|
<th
|
|
|
|
|
scope="col"
|
2026-08-19 08:37:15 +02:00
|
|
|
className={`${TH} sticky left-0 top-0 z-30`}
|
2026-08-19 07:54:07 +02:00
|
|
|
>
|
|
|
|
|
Spiller
|
|
|
|
|
</th>
|
2026-08-19 08:37:15 +02:00
|
|
|
<th scope="col" className={`${TH} sticky top-0 z-20 text-right`}>
|
2026-08-19 07:54:07 +02:00
|
|
|
Hcp
|
|
|
|
|
</th>
|
|
|
|
|
{showClassColumn && (
|
2026-08-19 08:37:15 +02:00
|
|
|
<th scope="col" className={`${TH} sticky top-0 z-20`}>
|
2026-08-19 07:54:07 +02:00
|
|
|
Klasse
|
|
|
|
|
</th>
|
|
|
|
|
)}
|
2026-08-19 08:37:15 +02:00
|
|
|
<th scope="col" className={`${TH} sticky top-0 z-20`}>
|
2026-08-19 07:54:07 +02:00
|
|
|
Statistikknivå
|
|
|
|
|
</th>
|
2026-08-19 08:37:15 +02:00
|
|
|
<th scope="col" className={`${TH} sticky top-0 z-20`}>
|
2026-08-19 07:54:07 +02:00
|
|
|
Status
|
|
|
|
|
</th>
|
2026-08-19 08:37:15 +02:00
|
|
|
<th scope="col" className={`${TH} sticky top-0 z-20`}>
|
2026-08-19 07:54:07 +02:00
|
|
|
Kjønn
|
|
|
|
|
</th>
|
2026-08-19 08:37:15 +02:00
|
|
|
<th scope="col" className={`${TH} sticky top-0 z-20`}>
|
2026-08-19 07:54:07 +02:00
|
|
|
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"
|
2026-08-19 08:37:15 +02:00
|
|
|
className={`${TH} sticky top-0 z-20 min-w-[9.5rem] align-top`}
|
2026-08-19 07:54:07 +02:00
|
|
|
>
|
2026-08-19 08:37:15 +02:00
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
2026-08-19 07:54:07 +02:00
|
|
|
<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" />
|
2026-08-19 08:37:15 +02:00
|
|
|
<span className="text-[10px] font-semibold">Oppdaterer</span>
|
2026-08-19 07:54:07 +02:00
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-08-19 08:37:15 +02:00
|
|
|
<select
|
|
|
|
|
aria-label={`Standardutslag for ${round.label}`}
|
|
|
|
|
className={HEAD_CELL}
|
|
|
|
|
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>
|
2026-08-19 07:54:07 +02:00
|
|
|
<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>
|
|
|
|
|
)
|
|
|
|
|
})}
|
2026-08-19 08:37:15 +02:00
|
|
|
<th scope="col" className={`${TH} sticky top-0 z-20 text-center`}>
|
|
|
|
|
<span className="sr-only">Handlinger</span>
|
|
|
|
|
<span aria-hidden="true">···</span>
|
2026-08-19 07:54:07 +02:00
|
|
|
</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
2026-08-19 08:37:15 +02:00
|
|
|
<tbody>
|
2026-08-19 07:54:07 +02:00
|
|
|
{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 (
|
2026-08-19 08:37:15 +02:00
|
|
|
<label className="flex items-center gap-1.5 normal-case">
|
2026-08-19 07:54:07 +02:00
|
|
|
<input
|
|
|
|
|
ref={ref}
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={checked}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onChange={(e) => onChange(e.target.checked)}
|
2026-08-19 08:37:15 +02:00
|
|
|
className="size-4 rounded border-border text-primary outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
2026-08-19 07:54:07 +02:00
|
|
|
/>
|
2026-08-19 08:37:15 +02:00
|
|
|
<span className="text-[10px] font-semibold text-muted-foreground">{label}</span>
|
2026-08-19 07:54:07 +02:00
|
|
|
</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 (
|
2026-08-19 08:37:15 +02:00
|
|
|
<tr className="group h-9">
|
2026-08-19 07:54:07 +02:00
|
|
|
<th
|
|
|
|
|
scope="row"
|
2026-08-19 08:37:15 +02:00
|
|
|
className={`${TD} sticky left-0 z-10 bg-card px-2 py-1.5 text-left text-[13px] font-semibold text-foreground group-hover:bg-accent/30`}
|
2026-08-19 07:54:07 +02:00
|
|
|
>
|
|
|
|
|
{player.playerName}
|
|
|
|
|
</th>
|
|
|
|
|
|
2026-08-19 08:37:15 +02:00
|
|
|
<td className={TD}>
|
2026-08-19 07:54:07 +02:00
|
|
|
<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}
|
2026-08-19 08:37:15 +02:00
|
|
|
className={`${CELL} text-right tabular-nums`}
|
2026-08-19 07:54:07 +02:00
|
|
|
/>
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
|
|
{showClassColumn && (
|
2026-08-19 08:37:15 +02:00
|
|
|
<td className={TD}>
|
2026-08-19 07:54:07 +02:00
|
|
|
<select
|
|
|
|
|
aria-label={`Klasse for ${player.playerName}`}
|
2026-08-19 08:37:15 +02:00
|
|
|
className={CELL}
|
2026-08-19 07:54:07 +02:00
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-08-19 08:37:15 +02:00
|
|
|
<td className={TD}>
|
2026-08-19 07:54:07 +02:00
|
|
|
<select
|
|
|
|
|
aria-label={`Statistikknivå for ${player.playerName}`}
|
2026-08-19 08:37:15 +02:00
|
|
|
className={CELL}
|
2026-08-19 07:54:07 +02:00
|
|
|
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>
|
|
|
|
|
|
2026-08-19 08:37:15 +02:00
|
|
|
<td className={TD}>
|
2026-08-19 07:54:07 +02:00
|
|
|
<select
|
|
|
|
|
aria-label={`Status for ${player.playerName}`}
|
2026-08-19 08:37:15 +02:00
|
|
|
className={`${CELL} ${statusAbnormal ? "bg-destructive/10 font-semibold text-destructive hover:bg-destructive/15" : ""}`}
|
2026-08-19 07:54:07 +02:00
|
|
|
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>
|
|
|
|
|
|
2026-08-19 08:37:15 +02:00
|
|
|
<td className={TD}>
|
2026-08-19 07:54:07 +02:00
|
|
|
<select
|
|
|
|
|
aria-label={`Kjønn for ${player.playerName}`}
|
2026-08-19 08:37:15 +02:00
|
|
|
className={CELL}
|
2026-08-19 07:54:07 +02:00
|
|
|
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>
|
|
|
|
|
|
2026-08-19 08:37:15 +02:00
|
|
|
<td className={TD}>
|
|
|
|
|
<div className="flex items-center">
|
2026-08-19 07:54:07 +02:00
|
|
|
<input
|
|
|
|
|
type="date"
|
|
|
|
|
aria-label={`Fødselsdato for ${player.playerName}`}
|
2026-08-19 08:37:15 +02:00
|
|
|
className={`${CELL} min-w-0`}
|
2026-08-19 07:54:07 +02:00
|
|
|
value={player.birthDate ?? ""}
|
|
|
|
|
onChange={(e) => onChangeBirthDate(player.playerId, e.target.value)}
|
|
|
|
|
/>
|
2026-08-19 08:37:15 +02:00
|
|
|
<span className="shrink-0 whitespace-nowrap px-1.5 text-[11px] font-semibold tabular-nums text-muted-foreground">
|
2026-08-19 07:54:07 +02:00
|
|
|
{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 (
|
2026-08-19 08:37:15 +02:00
|
|
|
<td key={round.id} className={TD}>
|
|
|
|
|
<div className="flex items-center gap-1.5 px-2">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={assigned}
|
|
|
|
|
disabled={busy}
|
|
|
|
|
aria-label={`${player.playerName} spiller ${round.label}`}
|
|
|
|
|
title={assigned ? "Spiller runden" : "Spiller ikke runden"}
|
|
|
|
|
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-4 shrink-0 rounded border-border text-primary outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
|
|
/>
|
|
|
|
|
{assigned ? (
|
2026-08-19 07:54:07 +02:00
|
|
|
<select
|
|
|
|
|
aria-label={`Utslag for ${player.playerName} i ${round.label}`}
|
2026-08-19 08:37:15 +02:00
|
|
|
className="h-7 min-w-0 flex-1 rounded-md border border-border bg-background px-1.5 text-xs 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"
|
2026-08-19 07:54:07 +02:00
|
|
|
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>
|
2026-08-19 08:37:15 +02:00
|
|
|
) : (
|
|
|
|
|
<span aria-hidden="true" className="flex-1 text-xs text-muted-foreground/60">
|
|
|
|
|
—
|
|
|
|
|
</span>
|
2026-08-19 07:54:07 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
)
|
|
|
|
|
})}
|
|
|
|
|
|
2026-08-19 08:37:15 +02:00
|
|
|
<td className={TD}>
|
|
|
|
|
<div className="flex items-center justify-center gap-0.5 px-1">
|
|
|
|
|
<a
|
|
|
|
|
href={`/organizations/${organizationId}/players?highlight=${player.playerId}`}
|
|
|
|
|
aria-label={`Rediger ${player.playerName} i spillerpoolen`}
|
|
|
|
|
title="Rediger i spillerpoolen"
|
|
|
|
|
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
|
|
|
>
|
|
|
|
|
<ExternalLink aria-hidden="true" className="size-4" />
|
|
|
|
|
</a>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label={`Fjern ${player.playerName}`}
|
|
|
|
|
title="Fjern spiller"
|
|
|
|
|
onClick={() => onRemoveParticipant(player.participantId)}
|
|
|
|
|
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring active:scale-95"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 aria-hidden="true" className="size-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-08-19 07:54:07 +02:00
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)
|
|
|
|
|
}
|