"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 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 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 = { 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 (
{/* Header: title left, large close button right */}

{mode.kind === "roster" ? "Rediger spillere" : "Importer spillere"}

{mode.kind === "team" ? "Lagturnering" : mode.kind === "individual" ? "Individuell turnering" : "Organisasjonens spillerpool"}

{/* Scrollable body */}
{/* Global error box — shown in every step */} {errorMessage && (
)} {step === "upload" && } {step === "map" && ( )} {isEditLike && ( )} {step === "done" && }
{/* Sticky action bar — always visible for the edit/saving step so the organizer never has to scroll to reach "Lagre" with many rows. */} {isEditLike && (
{rows.length} {rows.length === 1 ? "rad" : "rader"}
)}
) } function UploadStep({ onFileSelected }: { onFileSelected: (file: File) => void }) { const inputRef = useRef(null) function handleFiles(files: FileList | null) { const file = files?.[0] if (file) onFileSelected(file) } return ( ) } function MapStep({ csvHeaders, columnMapping, selectableFields, rowCount, onColumnMappingChange, onConfirmMapping, }: { csvHeaders: string[] columnMapping: Record selectableFields: FieldKey[] rowCount: number onColumnMappingChange: (columnIndex: number, field: FieldKey | "") => void onConfirmMapping: () => void }) { return (

Koble kolonner

Velg hvilket felt hver kolonne i filen tilsvarer. Kolonner du ikke trenger settes til{" "} Ignorer.

{/* Multi-column grid so many mappings are visible at once on a wide screen without scrolling. CSV header left, field dropdown right. */}
    {csvHeaders.map((header, index) => { const selectId = `col-map-${index}` return (
  • Kolonne {index + 1}
  • ) })}
) } // 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> = { 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 (

Kontroller spillerne

{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."}

{rows.length === 0 ? (

Ingen rader ennå

Legg til en rad for å komme i gang.

) : ( // 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.
{editFields.map((field) => ( ))} {editFields.map((field, i) => ( ))} {rows.map((row, rowIndex) => ( {editFields.map((field, i) => ( ))} ))}
{FIELD_LABELS[field]} Handlinger
{field === "gender" ? ( ) : field === "team_name" && mode.kind === "team" ? ( ) : field === "class_name" && mode.kind === "individual" ? ( ) : ( onCellChange(row.key, field, e.target.value)} className={cn(CELL, field === "handicap_index" && "text-right tabular-nums")} /> )}
)}
) } function DoneStep({ mode, saveWarnings, onClose }: { mode: Mode; saveWarnings: string[]; onClose: () => void }) { return (

{mode.kind === "roster" ? "Endringene er lagret" : "Importen er fullført"}

{mode.kind === "roster" ? "Spillerpoolen er oppdatert." : "Spillerne er lagret i turneringen."}

{saveWarnings.length > 0 && ( // Warnings are informational, not errors — amber, not red.
    {saveWarnings.map((warning, i) => (
  • ))}
)}
) }