teecup/frontend/components/player-import-view.tsx
Erol Haagenrud 48f238d158 Fiks HCP-komma app-bredt, utdatert deltakertabell, zebra-striper i spillerpool
HCP: type="number" blokkerte komma-tegnet i nettleseren før JS-parsingen
noensinne så det; byttet til text+inputMode=decimal og lagt til manglende
.replace(",",".") i tre lagre-stier. Deltakertabell: "Rediger i
spillerpoolen" er en full sidenavigasjon, bfcache kunne gjenopprette
turneringssiden i utdatert tilstand -- løst med stille bakgrunns-refresh
ved visibilitychange/pageshow. Spillerpool: zebra-striper lagt tilbake
oppå rutenett-stilen fra forrige runde.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 09:50:56 +02:00

639 lines
25 KiB
TypeScript

"use client"
// V0-eksportert visning (2026-08-17, "stor skjerm først"-prompt, se
// ARCHITECTURE_DECISIONS.md ADR-082) -- ren kontrollert visning, ingen
// egne API-kall. All logikk (CSV-parsing/kolonnegjetting/lagre-
// orkestrering) bor i player-import-panel.tsx, som importerer OG
// wrapper denne. IKKE endre denne filen for å tilpasse den til
// backend-detaljer (f.eks. kjønn-verdier) -- oversett i orkestrerings-
// laget i stedet, se player-import-panel.tsx sin handleSave.
import type React from "react"
import { useRef } from "react"
import {
AlertCircle,
CheckCircle2,
FileUp,
Plus,
Trash2,
TriangleAlert,
UploadCloud,
X,
} from "lucide-react"
import { cn } from "@/lib/utils"
export type FieldKey =
| "display_name"
| "email"
| "handicap_index"
| "gender"
| "birth_date"
| "mobile"
| "club"
| "nickname"
| "country"
| "club_member_number"
| "first_name"
| "last_name"
| "comment"
| "team_name"
| "class_name"
export type DraftRow = { key: string } & Record<FieldKey, string>
export type Mode =
| { kind: "team"; teams: { id: string; name: string }[] }
| { kind: "individual"; classes: { id: string; name: string }[] }
// Organisasjonens hele spillerpool, utenfor enhver turneringskontekst
// (2026-08-18, se org-player-roster-panel.tsx) -- ingen lag/klasse-
// kolonner (de gir ikke mening uten en turnering), og radene starter
// FYLT UT fra eksisterende spillere i stedet for en opplastet CSV.
| { kind: "roster" }
export type PlayerImportStep = "upload" | "map" | "edit" | "saving" | "done"
export type PlayerImportViewProps = {
mode: Mode
step: PlayerImportStep
csvHeaders: string[]
columnMapping: Record<number, FieldKey | "">
rows: DraftRow[]
errorMessage: string | null
saveWarnings: string[]
onFileSelected: (file: File) => void
onColumnMappingChange: (columnIndex: number, field: FieldKey | "") => void
onConfirmMapping: () => void
onCellChange: (rowKey: string, field: FieldKey, value: string) => void
onDeleteRow: (rowKey: string) => void
onAddBlankRow: () => void
onSave: () => void
onClose: () => void
}
// Norwegian labels for every field. team_name / class_name are filtered by mode.
const FIELD_LABELS: Record<FieldKey, string> = {
display_name: "Navn",
email: "E-post",
handicap_index: "HCP",
gender: "Kjønn",
birth_date: "Fødselsdato",
mobile: "Mobil",
club: "Klubb",
nickname: "Kallenavn",
country: "Land",
club_member_number: "Medlemsnr.",
first_name: "Fornavn",
last_name: "Etternavn",
comment: "Kommentar",
team_name: "Lag",
class_name: "Klasse",
}
// Field order for the mapping dropdown and edit table columns.
const FIELD_ORDER: FieldKey[] = [
"display_name",
"first_name",
"last_name",
"nickname",
"email",
"mobile",
"handicap_index",
"gender",
"birth_date",
"club",
"club_member_number",
"country",
"team_name",
"class_name",
"comment",
]
const GENDER_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "—" },
{ value: "female", label: "Kvinne" },
{ value: "male", label: "Mann" },
{ value: "other", label: "Annet" },
]
// Shared control classes. Desktop-first: compact but comfortable (mouse is
// precise) with a clear keyboard focus ring that works in light and dark mode.
const CONTROL_BASE =
"h-9 rounded-lg border border-border bg-card text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
const PRIMARY_BTN =
"inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-primary px-5 font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"
const SECONDARY_BTN =
"inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-border bg-card px-4 font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"
export function PlayerImportView({
mode,
step,
csvHeaders,
columnMapping,
rows,
errorMessage,
saveWarnings,
onFileSelected,
onColumnMappingChange,
onConfirmMapping,
onCellChange,
onDeleteRow,
onAddBlankRow,
onSave,
onClose,
}: PlayerImportViewProps) {
// Fields selectable in the mapping step, filtered so mode-specific fields
// only appear for the matching mode.
const selectableFields = FIELD_ORDER.filter((f) => {
if (f === "team_name") return mode.kind === "team"
if (f === "class_name") return mode.kind === "individual"
return true
})
// In the edit step, only render columns that some row actually uses (i.e.
// fields that were mapped), keeping mode-specific columns filtered too.
// Roster mode never goes through the upload/map steps (rows start pre-
// filled from existing players, see org-player-roster-panel.tsx), so
// `columnMapping` is always empty there -- show every selectable field
// instead of falling back to just the name column.
const activeFields =
mode.kind === "roster"
? selectableFields
: selectableFields.filter((field) => Object.values(columnMapping).includes(field))
// Guarantee at least the name column so a blank table is still usable.
const editFields = activeFields.length > 0 ? activeFields : ["display_name" as FieldKey]
const isSaving = step === "saving"
const isEditLike = step === "edit" || step === "saving"
return (
<div
role="dialog"
aria-modal="true"
aria-label={mode.kind === "roster" ? "Rediger spillere" : "Importer spillere"}
className="fixed inset-0 z-50 flex flex-col bg-background text-foreground"
>
{/* Header: title left, large close button right */}
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-5 py-3.5 lg:px-8">
<div className="flex min-w-0 flex-col">
<h1 className="truncate text-lg font-extrabold lg:text-xl">
{mode.kind === "roster" ? "Rediger spillere" : "Importer spillere"}
</h1>
<p className="truncate text-sm text-muted-foreground">
{mode.kind === "team" ? "Lagturnering" : mode.kind === "individual" ? "Individuell turnering" : "Organisasjonens spillerpool"}
</p>
</div>
<button
type="button"
onClick={onClose}
aria-label={mode.kind === "roster" ? "Lukk redigering" : "Lukk import"}
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
<X aria-hidden="true" className="size-6" />
</button>
</header>
{/* Scrollable body */}
<div className="min-h-0 flex-1 overflow-y-auto">
<div
className={cn(
"mx-auto flex w-full flex-col gap-5 px-5 py-6 lg:px-8",
// Wide steps use most of the screen; narrow steps stay readable.
isEditLike ? "max-w-[110rem]" : "max-w-4xl",
)}
>
{/* Global error box — shown in every step */}
{errorMessage && (
<div
role="alert"
className="flex items-start gap-3 rounded-xl border border-destructive/40 bg-destructive/10 p-4 text-destructive"
>
<AlertCircle aria-hidden="true" className="mt-0.5 size-5 shrink-0" />
<p className="text-sm font-semibold leading-relaxed">{errorMessage}</p>
</div>
)}
{step === "upload" && <UploadStep onFileSelected={onFileSelected} />}
{step === "map" && (
<MapStep
csvHeaders={csvHeaders}
columnMapping={columnMapping}
selectableFields={selectableFields}
rowCount={rows.length}
onColumnMappingChange={onColumnMappingChange}
onConfirmMapping={onConfirmMapping}
/>
)}
{isEditLike && (
<EditTable
mode={mode}
rows={rows}
editFields={editFields}
onCellChange={onCellChange}
onDeleteRow={onDeleteRow}
/>
)}
{step === "done" && <DoneStep mode={mode} saveWarnings={saveWarnings} onClose={onClose} />}
</div>
</div>
{/* Sticky action bar — always visible for the edit/saving step so the
organizer never has to scroll to reach "Lagre" with many rows. */}
{isEditLike && (
<footer className="shrink-0 border-t border-border bg-card/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-[110rem] flex-wrap items-center justify-between gap-3 px-5 py-3 lg:px-8">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onAddBlankRow}
disabled={isSaving}
className={SECONDARY_BTN}
>
<Plus aria-hidden="true" className="size-5" />
Legg til rad
</button>
<span className="text-sm font-semibold tabular-nums text-muted-foreground">
{rows.length} {rows.length === 1 ? "rad" : "rader"}
</span>
</div>
<button
type="button"
onClick={onSave}
disabled={isSaving || rows.length === 0}
aria-busy={isSaving}
className={cn(PRIMARY_BTN, "px-6")}
>
{isSaving ? (
<>
<span
aria-hidden="true"
className="size-5 animate-spin rounded-full border-2 border-primary-foreground/30 border-t-primary-foreground"
/>
Lagrer
</>
) : (
<>
Lagre {rows.length} {rows.length === 1 ? "spiller" : "spillere"}
</>
)}
</button>
</div>
</footer>
)}
</div>
)
}
function UploadStep({ onFileSelected }: { onFileSelected: (file: File) => void }) {
const inputRef = useRef<HTMLInputElement>(null)
function handleFiles(files: FileList | null) {
const file = files?.[0]
if (file) onFileSelected(file)
}
return (
<label
htmlFor="csv-file-input"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault()
handleFiles(e.dataTransfer.files)
}}
className={cn(
"mx-auto flex w-full max-w-3xl cursor-pointer flex-col items-center gap-5 rounded-3xl border-2 border-dashed border-border bg-card/50 px-8 py-16 text-center transition-colors",
"hover:border-primary/60 hover:bg-accent/30",
"focus-within:border-primary focus-within:ring-2 focus-within:ring-ring",
)}
>
<span className="flex size-20 items-center justify-center rounded-2xl bg-primary/15 text-primary">
<UploadCloud aria-hidden="true" className="size-10" />
</span>
<span className="flex flex-col gap-1.5">
<span className="text-xl font-extrabold text-foreground">Dra CSV-filen hit</span>
<span className="text-base leading-relaxed text-muted-foreground text-pretty">
eller trykk for å velge en fil fra maskinen. Første rad inneholde kolonnenavn.
</span>
</span>
<span className={cn(PRIMARY_BTN, "px-6")}>
<FileUp aria-hidden="true" className="size-5" />
Velg fil
</span>
<input
ref={inputRef}
id="csv-file-input"
type="file"
accept=".csv,text/csv"
className="sr-only"
onChange={(e) => handleFiles(e.target.files)}
/>
</label>
)
}
function MapStep({
csvHeaders,
columnMapping,
selectableFields,
rowCount,
onColumnMappingChange,
onConfirmMapping,
}: {
csvHeaders: string[]
columnMapping: Record<number, FieldKey | "">
selectableFields: FieldKey[]
rowCount: number
onColumnMappingChange: (columnIndex: number, field: FieldKey | "") => void
onConfirmMapping: () => void
}) {
return (
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-extrabold text-foreground">Koble kolonner</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Velg hvilket felt hver kolonne i filen tilsvarer. Kolonner du ikke trenger settes til{" "}
<span className="font-semibold text-foreground">Ignorer</span>.
</p>
</div>
{/* Multi-column grid so many mappings are visible at once on a wide
screen without scrolling. CSV header left, field dropdown right. */}
<ul className="grid grid-cols-1 gap-x-8 gap-y-3 lg:grid-cols-2 2xl:grid-cols-3">
{csvHeaders.map((header, index) => {
const selectId = `col-map-${index}`
return (
<li
key={index}
className="flex items-center justify-between gap-4 rounded-xl border border-border bg-card px-4 py-2.5"
>
<div className="flex min-w-0 flex-col">
<span className="text-[0.7rem] font-bold uppercase tracking-wide text-muted-foreground">
Kolonne {index + 1}
</span>
<label htmlFor={selectId} className="truncate text-sm font-bold text-foreground">
{header || <span className="italic text-muted-foreground">(uten navn)</span>}
</label>
</div>
<select
id={selectId}
value={columnMapping[index] ?? ""}
onChange={(e) => onColumnMappingChange(index, e.target.value as FieldKey | "")}
className={cn(CONTROL_BASE, "w-44 shrink-0 px-2.5 text-sm font-semibold")}
>
<option value="">Ignorer</option>
{selectableFields.map((field) => (
<option key={field} value={field}>
{FIELD_LABELS[field]}
</option>
))}
</select>
</li>
)
})}
</ul>
<div className="flex justify-end">
<button type="button" onClick={onConfirmMapping} className={cn(PRIMARY_BTN, "px-6")}>
Neste: forhåndsvis {rowCount} {rowCount === 1 ? "rad" : "rader"}
</button>
</div>
</div>
)
}
// Realistisk, innholds-tilpasset kolonnebredde (px), ikke en rundhåndet
// min-bredde -- med table-fixed + w-full fordeler nettleseren overskytende
// plass proporsjonalt, så alle ~14 felt (spillerpool-modus) får plass uten
// sidescroll på en bred skjerm (bruker eksplisitt: "jeg ønsker at tabellen
// skal være så bred at jeg slipper å scrolle sidelengs", 2026-08-19).
// overflow-x-auto-wrapperen står igjen som fallback for smal skjerm.
const COL_W: Partial<Record<FieldKey, number>> = {
display_name: 150,
first_name: 110,
last_name: 110,
nickname: 100,
email: 190,
mobile: 120,
handicap_index: 70,
gender: 92,
birth_date: 130,
club: 140,
club_member_number: 104,
country: 90,
team_name: 128,
class_name: 128,
comment: 180,
}
// Rutenett-cellekontroll (2026-08-19): flat, fyller hele cellen, ingen egen
// kant -- rutenett-linjene definerer strukturen, kontrollen lyser kun opp
// ved hover/fokus. Samme mønster som tournament-players-table.tsx.
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"
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"
function EditTable({
mode,
rows,
editFields,
onCellChange,
onDeleteRow,
}: {
mode: Mode
rows: DraftRow[]
editFields: FieldKey[]
onCellChange: (rowKey: string, field: FieldKey, value: string) => void
onDeleteRow: (rowKey: string) => void
}) {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<h2 className="text-xl font-extrabold text-foreground">Kontroller spillerne</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{mode.kind === "roster"
? "Rediger cellene direkte, og trykk Lagre. \"Slett\" fjerner raden herfra, men sletter IKKE spilleren fra organisasjonen -- det er ikke bygget ennå."
: "Rediger cellene direkte før du lagrer. Du kan legge til eller slette rader."}
</p>
</div>
{rows.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-2xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
<p className="text-base font-bold text-foreground">Ingen rader ennå</p>
<p className="text-sm text-muted-foreground">Legg til en rad for å komme i gang.</p>
</div>
) : (
// Horizontal scroll is isolated to this wrapper as a fallback for
// narrow screens; on a wide screen every column is visible. `relative`
// gives absolutely-positioned descendants (sr-only) a clipped block.
<div className="relative min-w-0 overflow-x-auto rounded-2xl border border-border">
<table className="w-full table-fixed border-collapse text-left">
<colgroup>
{editFields.map((field) => (
<col key={field} style={{ width: COL_W[field] ?? 120 }} />
))}
<col style={{ width: 72 }} />
</colgroup>
<thead className="sticky top-0 z-20">
<tr className="h-9">
{editFields.map((field, i) => (
<th key={field} scope="col" className={cn(TH, "truncate", i === 0 && "sticky left-0 z-30")}>
{FIELD_LABELS[field]}
</th>
))}
<th scope="col" className={cn(TH, "border-r-0 text-right")}>
<span className="sr-only">Handlinger</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => {
// Annenhver rad-bakgrunn (2026-08-19, bruker: "hvit og lys
// grå") -- ren pekepinn for å lese på tvers av en rad i en
// bred tabell, samme rutenett-linjer som ellers. Den sticky
// første cellen må få SAMME bakgrunn som resten av raden
// (ikke alltid bg-card), ellers ser den frakoblet ut fra
// stripen bak den ved sidescroll.
const rowBg = rowIndex % 2 === 1 ? "bg-muted/40" : "bg-card"
return (
<tr
key={row.key}
// data-row-key (2026-08-18): rent orkestrerings-anker, IKKE
// et visuelt/atferdsmessig felt -- lar en ekstern "hopp til
// denne spilleren"-lenke (org-player-roster-panel.tsx)
// scrolle til riktig rad uten å måtte utvide selve
// props-kontrakten eller endre noe synlig her.
data-row-key={row.key}
className={cn("group h-9 hover:bg-accent/20", rowBg)}
>
{editFields.map((field, i) => (
<td key={field} className={cn(TD, i === 0 && `sticky left-0 z-10 ${rowBg} group-hover:bg-accent/20`)}>
{field === "gender" ? (
<select
aria-label={`${FIELD_LABELS[field]} for rad ${rowIndex + 1}`}
value={row[field] ?? ""}
onChange={(e) => onCellChange(row.key, field, e.target.value)}
className={cn(CELL, "cursor-pointer font-medium")}
>
{GENDER_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : field === "team_name" && mode.kind === "team" ? (
<select
aria-label={`Lag for rad ${rowIndex + 1}`}
value={row[field] ?? ""}
onChange={(e) => onCellChange(row.key, field, e.target.value)}
className={cn(CELL, "cursor-pointer font-medium")}
>
<option value=""></option>
{mode.teams.map((t) => (
<option key={t.id} value={t.name}>
{t.name}
</option>
))}
</select>
) : field === "class_name" && mode.kind === "individual" ? (
<select
aria-label={`Klasse for rad ${rowIndex + 1}`}
value={row[field] ?? ""}
onChange={(e) => onCellChange(row.key, field, e.target.value)}
className={cn(CELL, "cursor-pointer font-medium")}
>
<option value=""></option>
{mode.classes.map((c) => (
<option key={c.id} value={c.name}>
{c.name}
</option>
))}
</select>
) : (
<input
aria-label={`${FIELD_LABELS[field]} for rad ${rowIndex + 1}`}
value={row[field] ?? ""}
inputMode={field === "email" ? "email" : field === "mobile" ? "tel" : "text"}
onChange={(e) => onCellChange(row.key, field, e.target.value)}
className={cn(CELL, field === "handicap_index" && "text-right tabular-nums")}
/>
)}
</td>
))}
<td className={cn(TD, "border-r-0 px-1 text-center")}>
<button
type="button"
onClick={() => onDeleteRow(row.key)}
aria-label={`Slett rad ${rowIndex + 1}${row.display_name ? ` for ${row.display_name}` : ""}`}
className={cn(
"inline-flex h-7 items-center gap-1 rounded-md px-2 text-xs font-bold text-muted-foreground transition-colors",
"hover:bg-destructive/10 hover:text-destructive active:scale-[0.98]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
)}
>
<Trash2 aria-hidden="true" className="size-3.5" />
Slett
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
}
function DoneStep({ mode, saveWarnings, onClose }: { mode: Mode; saveWarnings: string[]; onClose: () => void }) {
return (
<div className="mx-auto flex w-full max-w-2xl flex-col items-center gap-6 py-10 text-center">
<span className="flex size-20 items-center justify-center rounded-full bg-primary/15 text-primary">
<CheckCircle2 aria-hidden="true" className="size-11" />
</span>
<div className="flex flex-col gap-1.5">
<h2 className="text-2xl font-extrabold text-foreground">
{mode.kind === "roster" ? "Endringene er lagret" : "Importen er fullført"}
</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
{mode.kind === "roster" ? "Spillerpoolen er oppdatert." : "Spillerne er lagret i turneringen."}
</p>
</div>
{saveWarnings.length > 0 && (
// Warnings are informational, not errors — amber, not red.
<div className="w-full rounded-2xl border border-brand-orange/40 bg-brand-orange/10 p-4 text-left">
<div className="flex items-center gap-2 text-brand-orange">
<TriangleAlert aria-hidden="true" className="size-5 shrink-0" />
<h3 className="text-sm font-extrabold">
{saveWarnings.length} {saveWarnings.length === 1 ? "rad trenger" : "rader trenger"}{" "}
oppfølging
</h3>
</div>
<ul className="mt-2 flex flex-col gap-1.5 pl-1">
{saveWarnings.map((warning, i) => (
<li key={i} className="flex gap-2 text-sm leading-relaxed text-foreground">
<span aria-hidden="true" className="mt-2 size-1.5 shrink-0 rounded-full bg-brand-orange" />
<span>{warning}</span>
</li>
))}
</ul>
</div>
)}
<button type="button" onClick={onClose} className={cn(PRIMARY_BTN, "w-full sm:w-56")}>
Lukk
</button>
</div>
)
}