create_players_bulk matchet kun på e-post -- rader uten e-post ble alltid satt inn på nytt selv om en identisk spiller allerede fantes (ni ekte dubletter funnet i produksjon). Bulk-endepunktet tar nå et eksplisitt match_player_id/duplicate_action per rad; frontend oppdager mulige duplikater (navnetreff, ingen e-post) og spør organisator eksplisitt før lagring, i stedet for å gjette.
505 lines
18 KiB
TypeScript
505 lines
18 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 { TriangleAlert } from "lucide-react"
|
|
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
|
|
}
|
|
|
|
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<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[]>([])
|
|
const [duplicates, setDuplicates] = useState<DuplicateCandidate[]>([])
|
|
|
|
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
|
|
}
|
|
|
|
// 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<string, ApiPlayer>()
|
|
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 (
|
|
<DuplicateStep
|
|
duplicates={duplicates}
|
|
onChangeAction={(rowKey, action) =>
|
|
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 (
|
|
<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}
|
|
/>
|
|
)
|
|
}
|
|
|
|
// 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 (
|
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-4 sm:p-6">
|
|
<div className="flex items-start gap-3 rounded-xl border border-border bg-card p-4 sm:p-5">
|
|
<TriangleAlert aria-hidden="true" className="mt-0.5 size-6 shrink-0 text-amber-600 dark:text-amber-400" />
|
|
<div className="flex flex-col gap-1">
|
|
<h2 className="text-lg font-bold text-foreground">
|
|
{duplicates.length === 1 ? "1 mulig duplikat funnet" : `${duplicates.length} mulige duplikater funnet`}
|
|
</h2>
|
|
<p className="text-sm font-medium text-muted-foreground text-pretty">
|
|
Disse radene mangler e-post og har samme navn som en spiller som allerede finnes i
|
|
spillerpoolen. Velg hva som skal skje med hver av dem før import fortsetter.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<ul className="flex flex-col gap-4">
|
|
{duplicates.map((d) => (
|
|
<li key={d.row.key} className="rounded-xl border border-border bg-card p-4 sm:p-5">
|
|
<p className="text-base font-bold text-foreground">{d.row.display_name.trim()}</p>
|
|
<p className="text-sm font-medium text-muted-foreground">
|
|
Finnes allerede i spillerpoolen
|
|
{d.existing.club ? ` -- ${d.existing.club}` : ""}
|
|
{d.existing.handicap_index != null ? `, HCP ${d.existing.handicap_index}` : ""}
|
|
</p>
|
|
<fieldset className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
|
<legend className="sr-only">Hva skal skje med {d.row.display_name.trim()}?</legend>
|
|
{ACTIONS.map((a) => {
|
|
const id = `dup-${d.row.key}-${a.value}`
|
|
const checked = d.action === a.value
|
|
return (
|
|
<label
|
|
key={a.value}
|
|
htmlFor={id}
|
|
className={`flex min-h-11 flex-1 cursor-pointer items-start gap-2 rounded-lg border p-3 transition-colors ${
|
|
checked
|
|
? "border-primary bg-primary/10"
|
|
: "border-border bg-background hover:border-primary/50"
|
|
}`}
|
|
>
|
|
<input
|
|
id={id}
|
|
type="radio"
|
|
name={`dup-${d.row.key}`}
|
|
value={a.value}
|
|
checked={checked}
|
|
onChange={() => onChangeAction(d.row.key, a.value)}
|
|
className="mt-1 size-4 shrink-0 accent-primary"
|
|
/>
|
|
<span className="flex flex-col">
|
|
<span className="text-sm font-bold text-foreground">{a.label}</span>
|
|
<span className="text-xs font-medium text-muted-foreground">{a.hint}</span>
|
|
</span>
|
|
</label>
|
|
)
|
|
})}
|
|
</fieldset>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
<div className="flex items-center justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg border border-border bg-card px-5 font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
Tilbake til tabellen
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onContinue}
|
|
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-5 font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
Fortsett importen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|