teecup/frontend/components/round-participation-table.tsx

346 lines
14 KiB
TypeScript
Raw Normal View History

"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>
)
}