"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 { TriangleAlert } from "lucide-react" 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 } type DuplicateAction = "merge" | "overwrite" | "create_new" // Mulig duplikat (2026-08-18, ekte produksjonsbug): en importrad UTEN // e-post har ingen pålitelig matchingsnøkkel i seg selv (se players.py // sin create_players_bulk-docstring for hele historikken), så et // navnetreff mot en spiller som allerede finnes i poolen blir aldri // slått sammen automatisk -- for risikabelt, to ulike ekte personer kan // dele navn. Organisator spørres eksplisitt i stedet, se DuplicateStep. type DuplicateCandidate = { row: DraftRow existing: ApiPlayer action: DuplicateAction } 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" | "duplicates" | "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([]) const [duplicates, setDuplicates] = 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 } // Trykk på "Lagre" i redigeringstabellen -- sjekker FØRST om noen av // radene ser ut som mulige duplikater (ingen e-post + navnetreff mot en // spiller som allerede finnes i poolen). Finnes det slike, stopper vi // her og spør organisator eksplisitt (DuplicateStep) i stedet for å // stille velge -- se DuplicateCandidate-kommentaren over. Ellers går vi // rett til doSave(). 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 } setError(null) const noEmailRows = validRows.filter((r) => !r.email.trim()) if (noEmailRows.length > 0) { const existingRes = await fetch(`/orgs/${organizationId}/players`, { credentials: "include" }) if (existingRes.ok) { const existingPlayers: ApiPlayer[] = await existingRes.json() const byName = new Map() for (const p of existingPlayers) byName.set(p.display_name.trim().toLowerCase(), p) const candidates: DuplicateCandidate[] = noEmailRows .map((row) => { const existing = byName.get(row.display_name.trim().toLowerCase()) return existing ? { row, existing, action: "merge" as DuplicateAction } : null }) .filter((c): c is DuplicateCandidate => c !== null) if (candidates.length > 0) { setDuplicates(candidates) setStep("duplicates") return } } } await doSave(validRows, []) } async function doSave(validRows: DraftRow[], resolvedDuplicates: DuplicateCandidate[]) { setStep("saving") setSaveWarnings([]) try { const resolutionByRowKey = new Map(resolvedDuplicates.map((d) => [d.row.key, d])) const payload = validRows.map((r) => { const resolution = resolutionByRowKey.get(r.key) return { data: { 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, }, match_player_id: resolution && resolution.action !== "create_new" ? resolution.existing.id : null, duplicate_action: resolution?.action ?? 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") } } if (step === "duplicates") { return ( setDuplicates((prev) => prev.map((d) => (d.row.key === rowKey ? { ...d, action } : d))) } onCancel={() => setStep("edit")} onContinue={() => void doSave(rows.filter((r) => r.display_name.trim().length > 0), duplicates)} /> ) } return ( setMapping((prev) => ({ ...prev, [colIdx]: field }))} onConfirmMapping={confirmMapping} onCellChange={updateCell} onDeleteRow={removeRow} onAddBlankRow={addBlankRow} onSave={() => void handleSave()} onClose={onClose} /> ) } // Duplikat-varsel (2026-08-18, se DuplicateCandidate-kommentaren over) -- // funksjonell, håndkodet av Claude (ikke V0-eksportert som resten av // import-visningen): en liten, avgrenset spørre-skjerm satt inn FØR selve // lagringen, ikke en ny selvstendig UI-flate. Bruker de samme globale // design-tokenene (border/bg-card/bg-primary osv.) som resten av appen. function DuplicateStep({ duplicates, onChangeAction, onCancel, onContinue, }: { duplicates: DuplicateCandidate[] onChangeAction: (rowKey: string, action: DuplicateAction) => void onCancel: () => void onContinue: () => void }) { const ACTIONS: { value: DuplicateAction; label: string; hint: string }[] = [ { value: "merge", label: "Slå sammen", hint: "Fyll kun inn tomme felt på den eksisterende spilleren" }, { value: "overwrite", label: "Overskriv", hint: "Erstatt ALLE felt på den eksisterende spilleren" }, { value: "create_new", label: "Importer som ny", hint: "Behold begge -- bevisst dobbeloppføring" }, ] return (
    {duplicates.map((d) => (
  • {d.row.display_name.trim()}

    Finnes allerede i spillerpoolen {d.existing.club ? ` -- ${d.existing.club}` : ""} {d.existing.handicap_index != null ? `, HCP ${d.existing.handicap_index}` : ""}

    Hva skal skje med {d.row.display_name.trim()}? {ACTIONS.map((a) => { const id = `dup-${d.row.key}-${a.value}` const checked = d.action === a.value return ( ) })}
  • ))}
) }