"use client" // 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 { useState } from "react" import Papa from "papaparse" import { PlayerImportView, type DraftRow, type FieldKey, type Mode, } from "@/components/player-import-view" const EMPTY_ROW: Omit = { display_name: "", email: "", handicap_index: "", gender: "", birth_date: "", mobile: "", club: "", nickname: "", country: "", club_member_number: "", first_name: "", last_name: "", comment: "", team_name: "", class_name: "", } // Fuzzy gjetting av kolonnetilknytning -- små bokstaver, uten mellomrom/ // 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", fulltnavn: "display_name", spillernavn: "display_name", epost: "email", email: "email", mail: "email", hcp: "handicap_index", handicap: "handicap_index", spillehandicap: "handicap_index", kjonn: "gender", gender: "gender", fodselsdato: "birth_date", fodtdato: "birth_date", birthdate: "birth_date", fodt: "birth_date", mobil: "mobile", mobile: "mobile", telefon: "mobile", phone: "mobile", klubb: "club", club: "club", medlemsnr: "club_member_number", medlemsnummer: "club_member_number", fornavn: "first_name", firstname: "first_name", etternavn: "last_name", lastname: "last_name", lag: "team_name", lagnavn: "team_name", team: "team_name", klasse: "class_name", class: "class_name", kommentar: "comment", comment: "comment", land: "country", country: "country", kallenavn: "nickname", nickname: "nickname", } function normalizeHeader(h: string): string { 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 handicap_index: number | null gender: string | null mobile: string | null email: string | null birth_date: string | null nickname: string | null country: string | null club: string | null club_member_number: string | null first_name: string | null last_name: string | null paid: boolean comment: string | null } export function PlayerImportPanel({ organizationId, tournamentId, mode, onClose, onImportComplete, }: { organizationId: string tournamentId: string mode: Mode onClose: () => void onImportComplete: (createdOrUpdated: { player: ApiPlayer; status: string }[]) => void }) { const [step, setStep] = useState<"upload" | "map" | "edit" | "saving" | "done">("upload") const [csvHeaders, setCsvHeaders] = useState([]) const [csvRows, setCsvRows] = useState([]) const [mapping, setMapping] = useState>({}) const [rows, setRows] = useState([]) const [error, setError] = useState(null) const [saveWarnings, setSaveWarnings] = useState([]) function handleFile(file: File) { setError(null) Papa.parse(file, { skipEmptyLines: true, complete: (result) => { const data = result.data if (data.length === 0) { setError("Fant ingen rader i filen.") return } const headers = data[0] const body = data.slice(1) setCsvHeaders(headers) setCsvRows(body) const guessed: Record = {} headers.forEach((h, i) => { guessed[i] = guessField(h) }) setMapping(guessed) setStep("map") }, error: () => setError("Klarte ikke å lese filen. Er det en gyldig CSV?"), }) } function confirmMapping() { const mapped: DraftRow[] = csvRows.map((cells, i) => { const row: DraftRow = { key: `csv-${i}`, ...EMPTY_ROW } csvHeaders.forEach((_, colIdx) => { const field = mapping[colIdx] if (!field) return const raw = (cells[colIdx] ?? "").trim() row[field] = field === "gender" ? guessGenderValue(raw) : raw }) return row }) setRows(mapped) setStep("edit") } function updateCell(key: string, field: FieldKey, value: string) { setRows((prev) => prev.map((r) => (r.key === key ? { ...r, [field]: value } : r))) } function addBlankRow() { setRows((prev) => [...prev, { key: `manual-${Date.now()}`, ...EMPTY_ROW }]) } function removeRow(key: string) { setRows((prev) => prev.filter((r) => r.key !== key)) } function teamIdForName(name: string): string | null { if (mode.kind !== "team") return null const match = mode.teams.find((t) => t.name.trim().toLowerCase() === name.trim().toLowerCase()) return match?.id ?? null } function classIdForName(name: string): string | null { if (mode.kind !== "individual") return null const match = mode.classes.find((c) => c.name.trim().toLowerCase() === name.trim().toLowerCase()) return match?.id ?? null } async function handleSave() { const validRows = rows.filter((r) => r.display_name.trim().length > 0) if (validRows.length === 0) { setError("Ingen rader med navn å lagre.") return } setStep("saving") setError(null) 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: toBackendGender(r.gender), birth_date: r.birth_date.trim() || null, mobile: r.mobile.trim() || null, club: r.club.trim() || null, nickname: r.nickname.trim() || null, country: r.country.trim() || null, club_member_number: r.club_member_number.trim() || null, first_name: r.first_name.trim() || null, last_name: r.last_name.trim() || null, comment: r.comment.trim() || null, })) const bulkRes = await fetch(`/orgs/${organizationId}/players/bulk`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(payload), }) if (!bulkRes.ok) throw new Error(`bulk: ${bulkRes.status}`) const results: { player: ApiPlayer; status: string }[] = await bulkRes.json() const warnings: string[] = [] for (let i = 0; i < validRows.length; i++) { const row = validRows[i] const player = results[i]?.player if (!player) continue if (mode.kind === "team") { const teamId = teamIdForName(row.team_name) if (!teamId) { if (row.team_name.trim()) { warnings.push(`${row.display_name}: fant ikke laget "${row.team_name}" -- lagt i spillerpoolen, ikke på lag.`) } continue } const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ player_id: player.id }), }) if (!res.ok && res.status !== 409) { warnings.push(`${row.display_name}: klarte ikke å legge til på laget.`) } } else { const classId = row.class_name.trim() ? classIdForName(row.class_name) : null const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/participants`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ player_id: player.id, class_id: classId }), }) if (!res.ok && res.status !== 409) { warnings.push(`${row.display_name}: klarte ikke å melde på turneringen.`) } } } setSaveWarnings(warnings) setStep("done") onImportComplete(results) } catch { setError("Importen feilet. Prøv igjen.") setStep("edit") } } return ( setMapping((prev) => ({ ...prev, [colIdx]: field }))} onConfirmMapping={confirmMapping} onCellChange={updateCell} onDeleteRow={removeRow} onAddBlankRow={addBlankRow} onSave={() => void handleSave()} onClose={onClose} /> ) }