teecup/frontend/components/player-import-panel.tsx
Erol Haagenrud 00809d6817 Massimport av spillere til turneringer (CSV) + redigerbar spillertabell
Ny POST /orgs/{id}/players/bulk (samme e-post-match-dedup-mønster som
selvregistreringens ADR-017 Beslutning B, utvidet til alle felt).
CSV parses klient-side (papaparse), enkel kolonnegjetting, redigerbar
forhåndsvisning før lagring. Dekker både lagturneringer (lagtildeling)
og individuelle turneringer (klassetilknytning) i samme flyt -- begge
steg samlet (org-pool + turneringspåmelding).

Midlertidig hånd-kodet UI (player-import-panel.tsx) bygget for å bevise
hele kjeden ende-til-ende -- venter på V0-eksport for den polerte
visningen før samlet utrulling, samme rekkefølge som FlagPlantSheet.

5 nye backend-tester (111/111 totalt), tsc rent, 45/45 vitest, full
scratch-verifisert i begge turneringsformater.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 14:53:55 +02:00

465 lines
16 KiB
TypeScript

"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).
import { useRef, 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>
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: "",
}
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.
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",
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(/[\s_-]+/g, "")
}
function guessField(header: string): FieldKey | "" {
return HEADER_GUESSES[normalizeHeader(header)] ?? ""
}
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 Mode =
| { kind: "team"; teams: { id: string; name: string }[] }
| { kind: "individual"; classes: { id: string; name: string }[] }
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 [saveErrors, setSaveErrors] = useState<string[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
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) row[field] = (cells[colIdx] ?? "").trim()
})
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)
setSaveErrors([])
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,
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 errors: 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()) {
errors.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) {
errors.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) {
errors.push(`${row.display_name}: klarte ikke å melde på turneringen.`)
}
}
}
setSaveErrors(errors)
setStep("done")
onImportComplete(results)
} catch {
setError("Importen feilet. Prøv igjen.")
setStep("edit")
}
}
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>
)
}