From ced4bf869bf94983838f647fc1b1b2980cd15f27 Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Mon, 17 Aug 2026 15:51:31 +0200 Subject: [PATCH] =?UTF-8?q?V0-integrering:=20polert=20spillerimport-tabell?= =?UTF-8?q?,=20stor=20skjerm=20f=C3=B8rst?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrerer V0-eksporten (tee-cup-login-screen.zip) for spillerimport- panelet. Splittet i player-import-view.tsx (V0, ren kontrollert visning) + player-import-panel.tsx (orkestrering, uendret ekstern kontrakt) -- samme mønster som FlagPlantSheet. Fant og fikset en reell integreringsfeil i scratch: V0s kjønn-nedtrekk bruker female/male/other, rå CSV-tekst ("m"/"f") ble kopiert inn uendret og ville stille mistet data ved lagring -- fikset med egen verdinormalisering ved kolonnetilknytningen. æøå-kolonnegjetting- begrensningen fra forrige commit fikset samtidig. Ny STÅENDE regel lagt til CLAUDE.md underveis: turneringsoppsett designes stor skjerm først (organisatoren sitter foran en PC), ikke mobil-først -- V0-prompten oppdatert og kjørt på nytt med dette kravet før eksporten som integreres her. Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE_DECISIONS.md | 65 ++- CHANGELOG.md | 21 +- frontend/components/player-import-panel.tsx | 310 +++------- frontend/components/player-import-view.tsx | 598 ++++++++++++++++++++ 4 files changed, 761 insertions(+), 233 deletions(-) create mode 100644 frontend/components/player-import-view.tsx diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 1e76590..dc37d5d 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -8181,20 +8181,63 @@ riktig klassetilknytning der navnet matchet, `Ingen klasse` der det ikke gjorde. Lys+mørk bekreftet på selve importpanelet. Scratch-stacken fullstendig revet ned. -**Kjent begrensning i denne midlertidige versjonen (ikke rettet, siden -UI-et likevel erstattes):** kolonnegjettingen normaliserer ikke norske -spesialtegn (æ/ø/å) -- "Kjønn" traff ikke automatisk `gender`-feltet i -scratch-testen, måtte rettes manuelt i kolonnevalget (som fungerte som -forventet). Brukeren kan alltid overstyre enhver kolonnetilknytning, så -dette blokkerer ikke import -- men bør fikses i V0-integreringsrunden -eller en liten oppfølgings-commit. +**✅ FIKSET ved V0-integrering:** kolonnegjettingen normaliserer nå æ/ø/å +(`normalizeHeader` i `player-import-panel.tsx`), "Kjønn" treffer +automatisk. + +**Tillegg 2026-08-17 -- "stor skjerm først" (ny STÅENDE regel, se +CLAUDE.md).** MENS V0-eksporten ble ventet på ga bruker en ny, generell +instruks: turneringsOPPSETT (i motsetning til spillerens skjermer UNDER +selve runden) skal designes for stor skjerm først, siden organisatoren +sitter foran en PC. Lagt til i CLAUDE.md som egen STÅENDE regel (samme +mønster som tilgjengelighets-/navneformat-reglene), og V0-prompten for +DENNE komponenten ble oppdatert og sendt PÅ NYTT før brukeren kjørte den +i v0.app -- bred, tett flerkolonne-tabell i stedet for en smal mobil- +modal, sticky handlingsrad, fortsatt betjenbar (ikke ekskluderende) på +smal skjerm. + +**V0-eksport mottatt og integrert (`tee-cup-login-screen.zip`, +2026-08-17).** Fulgte props-kontrakten i prompten presist. Delt i to +filer, samme "Claude eier logikk, V0 eier presentasjon"-todeling som +FlagPlantSheet: `player-import-view.tsx` (V0-eksportert, ren kontrollert +visning, urørt bortsett fra en forklarende kommentar øverst) + +`player-import-panel.tsx` (orkestreringslag, uendret ekstern kontrakt +mot de to kallestedene). Kun reelt nye/relevante filer plukket fra det +fulle V0-prosjekt-re-eksporten (samme diff-mot-levende-tre-disiplin som +tidligere runder) -- resten av eksporten (`tournament-detail.tsx`, +`round-detail.tsx` osv., alle V0s egen kopi av dagens app) ignorert som +forventede reverts. + +**Én reell integreringsfeil funnet og fikset i scratch, FØR +produksjon:** V0s Kjønn-nedtrekk bruker lesbare verdier +(`female`/`male`/`other`), men rå CSV-tekst ("m"/"f") ble kopiert inn +UENDRET i kolonnetilknytningen -- nedtrekket viste da "—" (ingen synlig +verdi) for enhver rad, og siden lagre-oversettelsen (`toBackendGender`) +kun kjenner igjen de tre eksakte V0-verdiene, ville rå "m"/"f" blitt +STILLE MISTET ved lagring (ikke en synlig feil, bare tapt data) med +mindre brukeren manuelt valgte kjønn på nytt for HVER rad. Fikset med en +egen `guessGenderValue()`-normalisering i `player-import-panel.tsx`, +anvendt ved selve kolonnetilknytningen (samme sted som header- +normaliseringen) -- IKKE ved å endre V0-visningen. Funnet ved å faktisk +laste opp en CSV med `m`/`f`-verdier i scratch og se det manglende +utvalget, ikke ved kodelesing alene. + +**Verifisert (etter V0-integrering):** `tsc --noEmit` rent + 45/45 +vitest uendret. Ny scratch-database + scratch `teecup_api` + lokal +`next dev`, 1440×900-visning ("stor skjerm først"): full import-flyt på +nytt i BÅDE lagturnering og individuell turnering med den polerte +visningen -- bekreftet bred flerkolonne-tabell, kolonnegjetting +(inkl. Kjønn med æøå), riktig `Mann`/`Kvinne`/`Annet`-visning etter +kjønn-normaliseringsfiksen, korrekt lagret `m`/`f` i databasen etterpå, +gul (ikke rød) oppfølgings-boks for ukjent lagnavn, riktig `aria- +label`er per celle ("Kjønn for rad 1" osv.). Lys+mørk bekreftet på +selve tabellen. Scratch-stacken fullstendig revet ned. **Ingen migrasjon.** -**Rullet ut: ikke ennå** -- venter på V0-eksport for den polerte -visningen før utrulling (backend-endepunktet i seg selv er additivt og -kunne vært rullet ut isolert, men rulles ut samlet med frontend-biten -for å unngå en dødt-kode-periode). +**Rullet ut: ikke ennå** -- klar for utrulling, venter på bekreftelse +fra bruker før `docker compose build teecup_api teecup_frontend && up +-d` (ingen migrasjon å kjøre -- kun kode). --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b5e963..70556fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11967,5 +11967,22 @@ Neste steg: **Ingen migrasjon.** - **Rullet ut: ikke ennå** -- venter på V0-eksport før samlet - utrulling. + **V0-eksport mottatt og integrert samme dag.** Delt i `player- + import-view.tsx` (V0, ren visning) + `player-import-panel.tsx` + (Claude, orkestrering) -- FlagPlantSheet-mønsteret. Fant og fikset + én reell integreringsfeil i scratch: V0s kjønn-nedtrekk bruker + `female`/`male`/`other`, rå CSV-tekst ("m"/"f") ble kopiert inn + uendret og ville stille tapt data ved lagring -- fikset med egen + normalisering ved kolonnetilknytningen. æøå-diakritikk-begrensningen + fra tidligere fikset samtidig. Ny scratch-runde bekreftet begge + turneringsformater med den polerte visningen, korrekt lagret + `m`/`f` i databasen, lys+mørk. Se ADR-082 for full detalj. + + Underveis: bruker ga en ny STÅENDE regel (turneringsoppsett skal + designes "stor skjerm først", ikke mobil-først, siden organisatoren + sitter foran en PC) -- lagt til i CLAUDE.md, V0-prompten for denne + komponenten oppdatert og sendt på nytt før kjøring i v0.app. + + **Rullet ut: ikke ennå** -- klar, venter på bekreftelse før + `docker compose build teecup_api teecup_frontend && up -d` (ingen + migrasjon). diff --git a/frontend/components/player-import-panel.tsx b/frontend/components/player-import-panel.tsx index 8815577..d676d61 100644 --- a/frontend/components/player-import-panel.tsx +++ b/frontend/components/player-import-panel.tsx @@ -1,38 +1,20 @@ "use client" -// Massimport av spillere via CSV (2026-08-17) -- MIDLERTIDIG hånd-kodet -// versjon, bygget for å verifisere hele kjeden (CSV -> kolonnetilknytning -// -> redigerbar tabell -> POST .../players/bulk -> løkke over -// participants/roster-endepunktene) ende-til-ende FØR V0-eksporten er -// klar. Erstattes med V0-designet visning når den kommer tilbake (samme -// rekkefølge som FlagPlantSheet, se ARCHITECTURE_DECISIONS.md). +// Massimport av spillere via CSV (2026-08-17, ADR-082). Denne filen er +// ORKESTRERINGSLAGET (CSV-parsing, kolonnegjetting, lagre-orkestrering +// mot ekte API-endepunkter) -- selve VISNINGEN er V0-eksportert +// (player-import-view.tsx, "stor skjerm først"-prompt), en ren +// kontrollert komponent uten egne API-kall. Speiler FlagPlantSheet- +// mønsteret: Claude skriver/eier logikken, V0 eier presentasjonen. -import { useRef, useState } from "react" +import { useState } from "react" import Papa from "papaparse" -import { Upload, X } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" - -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" - -type DraftRow = { - key: string -} & Record +import { + PlayerImportView, + type DraftRow, + type FieldKey, + type Mode, +} from "@/components/player-import-view" const EMPTY_ROW: Omit = { display_name: "", @@ -52,26 +34,11 @@ const EMPTY_ROW: Omit = { class_name: "", } -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", -} - // Fuzzy gjetting av kolonnetilknytning -- små bokstaver, uten mellomrom/ -// bindestrek, sammenlignet mot vanlige norske/engelske varianter. +// bindestrek/norske spesialtegn, sammenlignet mot vanlige norske/ +// engelske varianter. Diakritisk normalisering (æøå -- fikset 2026-08-17, +// se ADR-082 sin "kjent begrensning"-note) lar f.eks. "Kjønn" treffe +// "kjonn" i tabellen under uten at brukeren må skrive om headeren. const HEADER_GUESSES: Record = { navn: "display_name", name: "display_name", @@ -102,6 +69,7 @@ const HEADER_GUESSES: Record = { etternavn: "last_name", lastname: "last_name", lag: "team_name", + lagnavn: "team_name", team: "team_name", klasse: "class_name", class: "class_name", @@ -114,13 +82,56 @@ const HEADER_GUESSES: Record = { } function normalizeHeader(h: string): string { - return h.trim().toLowerCase().replace(/[\s_-]+/g, "") + return h + .trim() + .toLowerCase() + .replace(/[æä]/g, "a") + .replace(/[øö]/g, "o") + .replace(/å/g, "a") + .replace(/[\s_-]+/g, "") } function guessField(header: string): FieldKey | "" { return HEADER_GUESSES[normalizeHeader(header)] ?? "" } +// V0-visningens Kjønn-nedtrekk forventer nøyaktig "female"/"male"/"other" +// (se player-import-view.tsx GENDER_OPTIONS) -- rå CSV-tekst ("m"/"f"/ +// "Mann"/"Kvinne"/osv.) normaliseres til det vokabularet HER, ved selve +// kolonnetilknytningen, IKKE bare kopiert inn rått. Ukjent verdi -> "" +// (vises som "—", brukeren kan velge manuelt -- ikke tapt data siden det +// bare skjer ved selve mappingen, aldri ved lagring). +const GENDER_VALUE_GUESSES: Record = { + m: "male", + mann: "male", + male: "male", + herre: "male", + h: "male", + f: "female", + female: "female", + kvinne: "female", + dame: "female", + k: "female", + x: "other", + annet: "other", + other: "other", +} + +function guessGenderValue(raw: string): string { + return GENDER_VALUE_GUESSES[raw.trim().toLowerCase()] ?? "" +} + +// V0s visning bruker lesbare engelske verdier for kjønn-nedtrekket +// (female/male/other) -- backend (app/routers/players.py) forventer +// PlayerCreate.gender = ett tegn (^[mfx]$). Oversettes her, i +// orkestreringslaget, IKKE ved å endre V0-visningen. +function toBackendGender(value: string): string | null { + if (value === "female") return "f" + if (value === "male") return "m" + if (value === "other") return "x" + return null +} + type ApiPlayer = { id: string display_name: string @@ -139,10 +150,6 @@ type ApiPlayer = { comment: string | null } -type Mode = - | { kind: "team"; teams: { id: string; name: string }[] } - | { kind: "individual"; classes: { id: string; name: string }[] } - export function PlayerImportPanel({ organizationId, tournamentId, @@ -162,8 +169,7 @@ export function PlayerImportPanel({ const [mapping, setMapping] = useState>({}) const [rows, setRows] = useState([]) const [error, setError] = useState(null) - const [saveErrors, setSaveErrors] = useState([]) - const fileInputRef = useRef(null) + const [saveWarnings, setSaveWarnings] = useState([]) function handleFile(file: File) { setError(null) @@ -195,7 +201,9 @@ export function PlayerImportPanel({ const row: DraftRow = { key: `csv-${i}`, ...EMPTY_ROW } csvHeaders.forEach((_, colIdx) => { const field = mapping[colIdx] - if (field) row[field] = (cells[colIdx] ?? "").trim() + if (!field) return + const raw = (cells[colIdx] ?? "").trim() + row[field] = field === "gender" ? guessGenderValue(raw) : raw }) return row }) @@ -235,13 +243,13 @@ export function PlayerImportPanel({ } setStep("saving") setError(null) - setSaveErrors([]) + setSaveWarnings([]) try { const payload = validRows.map((r) => ({ display_name: r.display_name.trim(), email: r.email.trim() || null, handicap_index: r.handicap_index.trim() ? Number(r.handicap_index) : null, - gender: r.gender.trim() || null, + gender: toBackendGender(r.gender), birth_date: r.birth_date.trim() || null, mobile: r.mobile.trim() || null, club: r.club.trim() || null, @@ -261,7 +269,7 @@ export function PlayerImportPanel({ if (!bulkRes.ok) throw new Error(`bulk: ${bulkRes.status}`) const results: { player: ApiPlayer; status: string }[] = await bulkRes.json() - const errors: string[] = [] + const warnings: string[] = [] for (let i = 0; i < validRows.length; i++) { const row = validRows[i] const player = results[i]?.player @@ -270,7 +278,7 @@ export function PlayerImportPanel({ const teamId = teamIdForName(row.team_name) if (!teamId) { if (row.team_name.trim()) { - errors.push(`${row.display_name}: fant ikke laget "${row.team_name}" -- lagt i spillerpoolen, ikke på lag.`) + warnings.push(`${row.display_name}: fant ikke laget "${row.team_name}" -- lagt i spillerpoolen, ikke på lag.`) } continue } @@ -281,7 +289,7 @@ export function PlayerImportPanel({ body: JSON.stringify({ player_id: player.id }), }) if (!res.ok && res.status !== 409) { - errors.push(`${row.display_name}: klarte ikke å legge til på laget.`) + warnings.push(`${row.display_name}: klarte ikke å legge til på laget.`) } } else { const classId = row.class_name.trim() ? classIdForName(row.class_name) : null @@ -292,11 +300,11 @@ export function PlayerImportPanel({ body: JSON.stringify({ player_id: player.id, class_id: classId }), }) if (!res.ok && res.status !== 409) { - errors.push(`${row.display_name}: klarte ikke å melde på turneringen.`) + warnings.push(`${row.display_name}: klarte ikke å melde på turneringen.`) } } } - setSaveErrors(errors) + setSaveWarnings(warnings) setStep("done") onImportComplete(results) } catch { @@ -306,160 +314,22 @@ export function PlayerImportPanel({ } return ( -
-
-

Importer spillere fra CSV

- -
- -
- {error && ( -
- {error} -
- )} - - {step === "upload" && ( -
-
- )} - - {step === "map" && ( -
-

- Velg hvilket felt hver kolonne skal fylle. "Ignorer" hopper over kolonnen. -

-
- {csvHeaders.map((h, i) => ( -
- {h} - -
- ))} -
- -
- )} - - {(step === "edit" || step === "saving") && ( -
-
- - - - {(Object.keys(FIELD_LABELS) as FieldKey[]) - .filter((f) => mode.kind === "team" || f !== "team_name") - .filter((f) => mode.kind === "individual" || f !== "class_name") - .map((f) => ( - - ))} - - - - {rows.map((row) => ( - - {(Object.keys(FIELD_LABELS) as FieldKey[]) - .filter((f) => mode.kind === "team" || f !== "team_name") - .filter((f) => mode.kind === "individual" || f !== "class_name") - .map((f) => ( - - ))} - - - ))} - -
- {FIELD_LABELS[f]} - -
- updateCell(row.key, f, e.target.value)} - disabled={step === "saving"} - className="min-w-24" - /> - - -
-
-
- - -
-
- )} - - {step === "done" && ( -
-

Importen er fullført.

- {saveErrors.length > 0 && ( -
-

Noen rader trenger oppfølging:

-
    - {saveErrors.map((e, i) => ( -
  • {e}
  • - ))} -
-
- )} - -
- )} -
-
+ setMapping((prev) => ({ ...prev, [colIdx]: field }))} + onConfirmMapping={confirmMapping} + onCellChange={updateCell} + onDeleteRow={removeRow} + onAddBlankRow={addBlankRow} + onSave={() => void handleSave()} + onClose={onClose} + /> ) } diff --git a/frontend/components/player-import-view.tsx b/frontend/components/player-import-view.tsx new file mode 100644 index 0000000..cd286cc --- /dev/null +++ b/frontend/components/player-import-view.tsx @@ -0,0 +1,598 @@ +"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 }[] } + +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", +] + +// 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> = { + 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 ( +
+ {/* Header: title left, large close button right */} +
+
+

Importer spillere

+

+ {mode.kind === "team" ? "Lagturnering" : "Individuell turnering"} +

+
+ +
+ + {/* 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} + + +
    + +
  • + ) + })} +
+ +
+ +
+
+ ) +} + +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

+

+ 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) => ( + + ))} + + + + + {rows.map((row, rowIndex) => ( + + {editFields.map((field) => ( + + ))} + + + ))} + +
+ {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(CONTROL_BASE, FIELD_MIN_W[field], "w-full px-2.5 text-sm")} + /> + )} + +
+ +
+
+
+ )} +
+ ) +} + +function DoneStep({ saveWarnings, onClose }: { saveWarnings: string[]; onClose: () => void }) { + return ( +
+ + +
+

Importen er fullført

+

+ Spillerne er lagret i turneringen. +

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