Oppfølger til ADR-092: fant og fikset misvisende "alle er gruppert"- tekst i Utslagsgrupper-panelet (viste seg egentlig å bety "ingen deltakere i runden ennå"). Egentlig årsak: å tilordne en deltaker til en runde var kun mulig ett om gangen, gjemt bak et rundekort. Ny bulk-tabell i "Spillere" (V0-generert) med sjekkbokser per runde, kjønn/alder/utslagssted redigerbart samme sted. Nytt PATCH-endepunkt for å endre utslagssted uten å miste gruppemedlemskap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
345 lines
14 KiB
TypeScript
345 lines
14 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useRef, useState } from "react"
|
|
import { Loader2, TriangleAlert } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
// V0-generert (ADR-093, 2026-08-19) -- ren kontrollert/presentasjonell
|
|
// komponent, ingen egne fetch-kall. SetupTab (individual-tournament-
|
|
// detail.tsx) eier all datahenting/orkestrering og sender ferdig
|
|
// sammenslått data + callback-props inn.
|
|
|
|
export type RoundParticipationPlayer = {
|
|
participantId: string
|
|
playerId: string
|
|
name: string
|
|
gender: "m" | "f" | "x" | null
|
|
birthDate: string | null // ISO "YYYY-MM-DD", null if unknown
|
|
}
|
|
|
|
export type RoundParticipationRound = {
|
|
id: string
|
|
label: string // e.g. "Runde 1"
|
|
tees: { id: string; name: string }[] // this round's course's available tees
|
|
}
|
|
|
|
export type RoundParticipationCell = {
|
|
assigned: boolean
|
|
teeId: string | null // null when not assigned
|
|
teeName: string | null
|
|
}
|
|
|
|
export type RoundParticipationTableProps = {
|
|
players: RoundParticipationPlayer[]
|
|
rounds: RoundParticipationRound[]
|
|
// cells[roundId][participantId]
|
|
cells: Record<string, Record<string, RoundParticipationCell>>
|
|
onToggle: (roundId: string, participantId: string, checked: boolean, teeId: string) => void
|
|
onBulkToggle: (roundId: string, checked: boolean, teeId: string) => void
|
|
onChangeTee: (roundId: string, participantId: string, teeId: string) => void
|
|
onChangeGender: (playerId: string, gender: "m" | "f" | "x") => void
|
|
onChangeBirthDate: (playerId: string, birthDate: string) => void
|
|
busyRoundIds: string[]
|
|
errors: string[]
|
|
}
|
|
|
|
// Standard age-from-birthdate: subtract years, then step back one if this
|
|
// year's birthday hasn't happened yet.
|
|
function computeAge(birthDate: string): number {
|
|
const birth = new Date(birthDate)
|
|
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
|
|
}
|
|
|
|
const selectClass =
|
|
"h-10 rounded-lg border border-border bg-background px-2 text-sm font-medium text-foreground transition-all duration-200 ease-in-out hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
|
|
|
export function RoundParticipationTable({
|
|
players,
|
|
rounds,
|
|
cells,
|
|
onToggle,
|
|
onBulkToggle,
|
|
onChangeTee,
|
|
onChangeGender,
|
|
onChangeBirthDate,
|
|
busyRoundIds,
|
|
errors,
|
|
}: RoundParticipationTableProps) {
|
|
// Local-only convenience state: each round column's "Standardutslag"
|
|
// selection, used to pre-fill the teeId when checking a box. Resets to each
|
|
// round's first tee on mount / when the round set changes.
|
|
const [defaultTees, setDefaultTees] = useState<Record<string, string>>(() =>
|
|
Object.fromEntries(rounds.map((r) => [r.id, r.tees[0]?.id ?? ""])),
|
|
)
|
|
|
|
useEffect(() => {
|
|
setDefaultTees((prev) => {
|
|
const next: Record<string, string> = {}
|
|
for (const r of rounds) {
|
|
// keep an existing valid selection, otherwise fall back to first tee
|
|
const existing = prev[r.id]
|
|
const stillValid = existing && r.tees.some((t) => t.id === existing)
|
|
next[r.id] = stillValid ? existing : (r.tees[0]?.id ?? "")
|
|
}
|
|
return next
|
|
})
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [rounds.map((r) => r.id + ":" + r.tees.map((t) => t.id).join(",")).join("|")])
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{errors.length > 0 && (
|
|
<div
|
|
role="alert"
|
|
className="flex flex-col gap-1.5 rounded-2xl border border-destructive/30 bg-destructive/10 p-4"
|
|
>
|
|
<div className="flex items-center gap-2 font-bold text-destructive">
|
|
<TriangleAlert aria-hidden="true" className="size-5 shrink-0" />
|
|
<span>Noen endringer feilet</span>
|
|
</div>
|
|
<ul className="flex flex-col gap-1 pl-7 text-sm text-destructive">
|
|
{errors.map((msg, i) => (
|
|
<li key={i} className="text-pretty leading-relaxed">
|
|
{msg}
|
|
</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 min-w-48 bg-card px-4 py-3 align-bottom text-sm font-bold text-foreground"
|
|
>
|
|
Spiller
|
|
</th>
|
|
<th scope="col" className="min-w-32 bg-card px-3 py-3 align-bottom text-sm font-bold text-foreground">
|
|
Kjønn
|
|
</th>
|
|
<th scope="col" className="min-w-56 bg-card px-3 py-3 align-bottom text-sm font-bold text-foreground">
|
|
Alder
|
|
</th>
|
|
{rounds.map((round) => {
|
|
const busy = busyRoundIds.includes(round.id)
|
|
return (
|
|
<th
|
|
key={round.id}
|
|
scope="col"
|
|
className="min-w-56 bg-card px-3 py-3 align-bottom"
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-bold text-foreground">{round.label}</span>
|
|
{busy && (
|
|
<span className="inline-flex items-center gap-1 text-xs font-semibold text-muted-foreground">
|
|
<Loader2 aria-hidden="true" className="size-3.5 animate-spin" />
|
|
Oppdaterer
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-xs font-medium text-muted-foreground">Standardutslag</span>
|
|
<select
|
|
value={defaultTees[round.id] ?? ""}
|
|
disabled={busy}
|
|
onChange={(e) =>
|
|
setDefaultTees((prev) => ({ ...prev, [round.id]: e.target.value }))
|
|
}
|
|
className={selectClass}
|
|
aria-label={`Standardutslag for ${round.label}`}
|
|
>
|
|
{round.tees.map((tee) => (
|
|
<option key={tee.id} value={tee.id}>
|
|
{tee.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<BulkToggle
|
|
round={round}
|
|
players={players}
|
|
cells={cells}
|
|
busy={busy}
|
|
onBulkToggle={(checked) =>
|
|
onBulkToggle(round.id, checked, defaultTees[round.id] ?? "")
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
)
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{players.map((player) => {
|
|
const age = player.birthDate ? computeAge(player.birthDate) : null
|
|
return (
|
|
<tr key={player.participantId} className="divide-x divide-border">
|
|
<th
|
|
scope="row"
|
|
className="sticky left-0 z-10 bg-card px-4 py-3 text-left font-bold text-foreground"
|
|
>
|
|
{player.name}
|
|
</th>
|
|
|
|
{/* Kjønn */}
|
|
<td className="px-3 py-3">
|
|
<select
|
|
value={player.gender ?? ""}
|
|
onChange={(e) => onChangeGender(player.playerId, e.target.value as "m" | "f" | "x")}
|
|
className={cn(selectClass, "w-full", player.gender === null && "text-muted-foreground")}
|
|
aria-label={`Kjønn for ${player.name}`}
|
|
>
|
|
<option value="" disabled>
|
|
Ikke satt
|
|
</option>
|
|
<option value="m">Mann</option>
|
|
<option value="f">Kvinne</option>
|
|
<option value="x">Annet</option>
|
|
</select>
|
|
</td>
|
|
|
|
{/* Alder */}
|
|
<td className="px-3 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="date"
|
|
value={player.birthDate ?? ""}
|
|
onChange={(e) => onChangeBirthDate(player.playerId, e.target.value)}
|
|
className={cn(selectClass, "w-36")}
|
|
aria-label={`Fødselsdato for ${player.name}`}
|
|
/>
|
|
<span className="shrink-0 text-sm tabular-nums text-muted-foreground">
|
|
{age !== null ? `${age} år` : "Ukjent"}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
{/* Round columns */}
|
|
{rounds.map((round) => {
|
|
const busy = busyRoundIds.includes(round.id)
|
|
const cell = cells[round.id]?.[player.participantId]
|
|
const assigned = cell?.assigned ?? false
|
|
const cellId = `cell-${round.id}-${player.participantId}`
|
|
return (
|
|
<td key={round.id} className="px-3 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<label
|
|
htmlFor={cellId}
|
|
className={cn(
|
|
"inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-lg px-2 transition-all duration-200 ease-in-out hover:bg-accent",
|
|
busy && "pointer-events-none opacity-50",
|
|
)}
|
|
>
|
|
<input
|
|
id={cellId}
|
|
type="checkbox"
|
|
checked={assigned}
|
|
disabled={busy}
|
|
onChange={(e) =>
|
|
onToggle(
|
|
round.id,
|
|
player.participantId,
|
|
e.target.checked,
|
|
e.target.checked ? (defaultTees[round.id] ?? "") : (cell?.teeId ?? ""),
|
|
)
|
|
}
|
|
className="size-5 shrink-0 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
<span className="text-sm font-medium text-foreground">
|
|
{assigned ? "Spiller" : "Ikke satt"}
|
|
</span>
|
|
</label>
|
|
|
|
{assigned && (
|
|
<select
|
|
value={cell?.teeId ?? ""}
|
|
disabled={busy}
|
|
onChange={(e) => onChangeTee(round.id, player.participantId, e.target.value)}
|
|
className={selectClass}
|
|
aria-label={`Utslagssted for ${player.name} i ${round.label}`}
|
|
>
|
|
{round.tees.map((tee) => (
|
|
<option key={tee.id} value={tee.id}>
|
|
{tee.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
</td>
|
|
)
|
|
})}
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function BulkToggle({
|
|
round,
|
|
players,
|
|
cells,
|
|
busy,
|
|
onBulkToggle,
|
|
}: {
|
|
round: RoundParticipationRound
|
|
players: RoundParticipationPlayer[]
|
|
cells: Record<string, Record<string, RoundParticipationCell>>
|
|
busy: boolean
|
|
onBulkToggle: (checked: boolean) => void
|
|
}) {
|
|
const ref = useRef<HTMLInputElement>(null)
|
|
const assignedCount = players.reduce(
|
|
(acc, p) => acc + (cells[round.id]?.[p.participantId]?.assigned ? 1 : 0),
|
|
0,
|
|
)
|
|
const total = players.length
|
|
const allAssigned = total > 0 && assignedCount === total
|
|
const someAssigned = assignedCount > 0 && assignedCount < total
|
|
|
|
useEffect(() => {
|
|
if (ref.current) ref.current.indeterminate = someAssigned
|
|
}, [someAssigned])
|
|
|
|
const id = `bulk-${round.id}`
|
|
return (
|
|
<label
|
|
htmlFor={id}
|
|
className={cn(
|
|
"inline-flex min-h-10 cursor-pointer items-center gap-2 rounded-lg px-2 transition-all duration-200 ease-in-out hover:bg-accent",
|
|
busy && "pointer-events-none opacity-50",
|
|
)}
|
|
>
|
|
<input
|
|
id={id}
|
|
ref={ref}
|
|
type="checkbox"
|
|
checked={allAssigned}
|
|
disabled={busy}
|
|
onChange={() => onBulkToggle(!allAssigned)}
|
|
className="size-5 shrink-0 rounded border-border text-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
/>
|
|
<span className="text-sm font-semibold text-foreground">
|
|
{allAssigned ? "Velg ingen" : "Velg alle"}
|
|
</span>
|
|
</label>
|
|
)
|
|
}
|
|
|