V0-integrering: polert spillerimport-tabell, stor skjerm først
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 <noreply@anthropic.com>
This commit is contained in:
parent
d83bfef7c4
commit
ced4bf869b
4 changed files with 761 additions and 233 deletions
|
|
@ -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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
21
CHANGELOG.md
21
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).
|
||||
|
|
|
|||
|
|
@ -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<FieldKey, string>
|
||||
import {
|
||||
PlayerImportView,
|
||||
type DraftRow,
|
||||
type FieldKey,
|
||||
type Mode,
|
||||
} from "@/components/player-import-view"
|
||||
|
||||
const EMPTY_ROW: Omit<DraftRow, "key"> = {
|
||||
display_name: "",
|
||||
|
|
@ -52,26 +34,11 @@ const EMPTY_ROW: Omit<DraftRow, "key"> = {
|
|||
class_name: "",
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
// 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<string, FieldKey> = {
|
||||
navn: "display_name",
|
||||
name: "display_name",
|
||||
|
|
@ -102,6 +69,7 @@ const HEADER_GUESSES: Record<string, FieldKey> = {
|
|||
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<string, FieldKey> = {
|
|||
}
|
||||
|
||||
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<string, string> = {
|
||||
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<Record<number, FieldKey | "">>({})
|
||||
const [rows, setRows] = useState<DraftRow[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saveErrors, setSaveErrors] = useState<string[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [saveWarnings, setSaveWarnings] = useState<string[]>([])
|
||||
|
||||
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 (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||
<header className="flex min-h-14 shrink-0 items-center justify-between border-b border-border px-4">
|
||||
<h2 className="text-base font-extrabold text-foreground">Importer spillere fra CSV</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Lukk"
|
||||
className="flex size-11 items-center justify-center rounded-xl text-foreground hover:bg-accent"
|
||||
>
|
||||
<X aria-hidden="true" className="size-5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-5">
|
||||
{error && (
|
||||
<div className="mb-4 rounded-2xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm font-medium text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "upload" && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-2xl border-2 border-dashed border-border p-10 text-center">
|
||||
<Upload aria-hidden="true" className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Velg en CSV-fil med spillere.</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
}}
|
||||
/>
|
||||
<Button type="button" onClick={() => fileInputRef.current?.click()} className="min-h-11">
|
||||
Velg fil
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "map" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Velg hvilket felt hver kolonne skal fylle. "Ignorer" hopper over kolonnen.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{csvHeaders.map((h, i) => (
|
||||
<div key={i} className="flex items-center gap-3 rounded-xl border border-border p-3">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{h}</span>
|
||||
<select
|
||||
value={mapping[i] ?? ""}
|
||||
onChange={(e) =>
|
||||
setMapping((prev) => ({ ...prev, [i]: e.target.value as FieldKey | "" }))
|
||||
}
|
||||
className="min-h-11 rounded-lg border border-input bg-transparent px-2 text-sm"
|
||||
>
|
||||
<option value="">Ignorer</option>
|
||||
{(Object.keys(FIELD_LABELS) as FieldKey[])
|
||||
.filter((f) => mode.kind === "team" || f !== "team_name")
|
||||
.filter((f) => mode.kind === "individual" || f !== "class_name")
|
||||
.map((f) => (
|
||||
<option key={f} value={f}>
|
||||
{FIELD_LABELS[f]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button type="button" onClick={confirmMapping} className="min-h-11 self-start">
|
||||
Neste: forhåndsvis {csvRows.length} rader
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(step === "edit" || step === "saving") && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||||
<table className="w-full min-w-[900px] border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/50">
|
||||
{(Object.keys(FIELD_LABELS) as FieldKey[])
|
||||
.filter((f) => mode.kind === "team" || f !== "team_name")
|
||||
.filter((f) => mode.kind === "individual" || f !== "class_name")
|
||||
.map((f) => (
|
||||
<th key={f} className="whitespace-nowrap px-2 py-2 text-left font-bold text-foreground">
|
||||
{FIELD_LABELS[f]}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-2 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.key} className="border-b border-border last:border-0">
|
||||
{(Object.keys(FIELD_LABELS) as FieldKey[])
|
||||
.filter((f) => mode.kind === "team" || f !== "team_name")
|
||||
.filter((f) => mode.kind === "individual" || f !== "class_name")
|
||||
.map((f) => (
|
||||
<td key={f} className="px-2 py-1.5">
|
||||
<Input
|
||||
value={row[f]}
|
||||
onChange={(e) => updateCell(row.key, f, e.target.value)}
|
||||
disabled={step === "saving"}
|
||||
className="min-w-24"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(row.key)}
|
||||
disabled={step === "saving"}
|
||||
aria-label={`Slett rad for ${row.display_name || "tom rad"}`}
|
||||
className="flex size-9 items-center justify-center rounded-lg text-muted-foreground hover:bg-accent hover:text-destructive"
|
||||
>
|
||||
<X aria-hidden="true" className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button type="button" variant="outline" onClick={addBlankRow} disabled={step === "saving"} className="min-h-11">
|
||||
Legg til rad
|
||||
</Button>
|
||||
<Button type="button" onClick={handleSave} disabled={step === "saving"} className="min-h-11">
|
||||
{step === "saving" ? "Lagrer …" : `Lagre ${rows.filter((r) => r.display_name.trim()).length} spillere`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "done" && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm font-semibold text-foreground">Importen er fullført.</p>
|
||||
{saveErrors.length > 0 && (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
<p className="mb-2 font-bold">Noen rader trenger oppfølging:</p>
|
||||
<ul className="list-disc pl-5">
|
||||
{saveErrors.map((e, i) => (
|
||||
<li key={i}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" onClick={onClose} className="min-h-11 self-start">
|
||||
Lukk
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
<PlayerImportView
|
||||
mode={mode}
|
||||
step={step}
|
||||
csvHeaders={csvHeaders}
|
||||
columnMapping={mapping}
|
||||
rows={rows}
|
||||
errorMessage={error}
|
||||
saveWarnings={saveWarnings}
|
||||
onFileSelected={handleFile}
|
||||
onColumnMappingChange={(colIdx, field) => setMapping((prev) => ({ ...prev, [colIdx]: field }))}
|
||||
onConfirmMapping={confirmMapping}
|
||||
onCellChange={updateCell}
|
||||
onDeleteRow={removeRow}
|
||||
onAddBlankRow={addBlankRow}
|
||||
onSave={() => void handleSave()}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
598
frontend/components/player-import-view.tsx
Normal file
598
frontend/components/player-import-view.tsx
Normal file
|
|
@ -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<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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue