teecup/frontend/components/player-import-panel.tsx
Erol Haagenrud ced4bf869b 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>
2026-08-17 15:51:31 +02:00

335 lines
10 KiB
TypeScript

"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<DraftRow, "key"> = {
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<string, FieldKey> = {
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<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
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<string[]>([])
const [csvRows, setCsvRows] = useState<string[][]>([])
const [mapping, setMapping] = useState<Record<number, FieldKey | "">>({})
const [rows, setRows] = useState<DraftRow[]>([])
const [error, setError] = useState<string | null>(null)
const [saveWarnings, setSaveWarnings] = useState<string[]>([])
function handleFile(file: File) {
setError(null)
Papa.parse<string[]>(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<number, FieldKey | ""> = {}
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 (
<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}
/>
)
}