466 lines
16 KiB
TypeScript
466 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>
|
||
|
|
)
|
||
|
|
}
|