599 lines
23 KiB
TypeScript
599 lines
23 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 }[] }
|
||
|
|
|
||
|
|
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",
|
||
|
|
]
|
||
|
|
|
||
|
|
// Per-field column width for the edit table. Compact by default so a wide
|
||
|
|
// screen shows many columns at once; wider for free-text-heavy fields.
|
||
|
|
const FIELD_MIN_W: Partial<Record<FieldKey, string>> = {
|
||
|
|
display_name: "min-w-44",
|
||
|
|
first_name: "min-w-36",
|
||
|
|
last_name: "min-w-36",
|
||
|
|
nickname: "min-w-32",
|
||
|
|
email: "min-w-52",
|
||
|
|
mobile: "min-w-36",
|
||
|
|
handicap_index: "min-w-20",
|
||
|
|
gender: "min-w-28",
|
||
|
|
birth_date: "min-w-36",
|
||
|
|
club: "min-w-44",
|
||
|
|
club_member_number: "min-w-28",
|
||
|
|
country: "min-w-28",
|
||
|
|
team_name: "min-w-40",
|
||
|
|
class_name: "min-w-40",
|
||
|
|
comment: "min-w-56",
|
||
|
|
}
|
||
|
|
|
||
|
|
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.
|
||
|
|
const activeFields = 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="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">Importer spillere</h1>
|
||
|
|
<p className="truncate text-sm text-muted-foreground">
|
||
|
|
{mode.kind === "team" ? "Lagturnering" : "Individuell turnering"}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onClose}
|
||
|
|
aria-label="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 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 må 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>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
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">
|
||
|
|
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 border-collapse text-left">
|
||
|
|
<thead className="sticky top-0 z-10">
|
||
|
|
<tr className="border-b border-border bg-muted">
|
||
|
|
{editFields.map((field) => (
|
||
|
|
<th
|
||
|
|
key={field}
|
||
|
|
scope="col"
|
||
|
|
className="whitespace-nowrap px-3 py-2.5 text-xs font-extrabold uppercase tracking-wide text-muted-foreground"
|
||
|
|
>
|
||
|
|
{FIELD_LABELS[field]}
|
||
|
|
</th>
|
||
|
|
))}
|
||
|
|
<th scope="col" className="px-3 py-2.5 text-right">
|
||
|
|
<span className="sr-only">Handlinger</span>
|
||
|
|
</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{rows.map((row, rowIndex) => (
|
||
|
|
<tr
|
||
|
|
key={row.key}
|
||
|
|
className={cn(
|
||
|
|
"border-b border-border last:border-b-0",
|
||
|
|
rowIndex % 2 === 1 && "bg-muted/30",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{editFields.map((field) => (
|
||
|
|
<td key={field} className="px-2 py-1.5 align-middle">
|
||
|
|
{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(CONTROL_BASE, FIELD_MIN_W[field], "w-full px-2 text-sm font-semibold")}
|
||
|
|
>
|
||
|
|
{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(CONTROL_BASE, FIELD_MIN_W[field], "w-full px-2 text-sm font-semibold")}
|
||
|
|
>
|
||
|
|
<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(CONTROL_BASE, FIELD_MIN_W[field], "w-full px-2 text-sm font-semibold")}
|
||
|
|
>
|
||
|
|
<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(CONTROL_BASE, FIELD_MIN_W[field], "w-full px-2.5 text-sm")}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</td>
|
||
|
|
))}
|
||
|
|
<td className="px-2 py-1.5 align-middle">
|
||
|
|
<div className="flex justify-end">
|
||
|
|
<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-9 items-center gap-1.5 rounded-lg border border-border px-2.5 text-sm font-bold text-muted-foreground transition-colors",
|
||
|
|
"hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive 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",
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
<Trash2 aria-hidden="true" className="size-4" />
|
||
|
|
Slett
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function DoneStep({ saveWarnings, onClose }: { 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">Importen er fullført</h2>
|
||
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||
|
|
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>
|
||
|
|
)
|
||
|
|
}
|