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>
This commit is contained in:
Erol Haagenrud 2026-08-17 14:53:55 +02:00
parent cc793339e4
commit 00809d6817
9 changed files with 911 additions and 0 deletions

View file

@ -8109,6 +8109,95 @@ etterpå.
---
## ADR-082: Massimport av spillere til turneringer (CSV) + redigerbar spillertabell (2026-08-17)
Foranledning: å legge til spillere har til nå vært ren én-og-én-arbeid
(ett skjema, én `POST`, gjentatt manuelt). Bruker ba om en CSV-basert
massimport, med en tabell der ALLE felt er redigerbare. Bekreftet med
bruker (AskUserQuestion): importen gjør BEGGE steg samlet (org-
spillerpool + påmelding til turneringen du står i, ikke bare poolen),
og dekker BÅDE lagturneringer (med lagtildeling via en "Lag"-kolonne)
og individuelle turneringer (med valgfri "Klasse"-kolonne) i samme
runde.
**Backend: ett nytt endepunkt, gjenbrukt dedup-mønster.** `POST /orgs/
{organization_id}/players/bulk` (`players.py`) tar en liste av rader.
Samme "match på e-post, fyll KUN tomme felt, ellers opprett ny"-mønster
som `registration.py` sin selvregistrering (ADR-017 Beslutning B) --
her utvidet til ALLE `PlayerCreate`-felt (registration.py sin variant
manglet `first_name`/`last_name`/`paid`/`comment`, lagt til senere i
migrasjon 074). `display_name` røres ALDRI på en eksisterende match --
organisators kanoniske navn er urørlig fra en import. `paid` røres
heller aldri (ikke-nullbar boolean, `COALESCE` mot NULL er alltid en
no-op der -- riktig oppførsel: en import skal ikke stille kunne endre en
allerede satt betalt-status). Hele lista i ÉN transaksjon
(`org_connection` sin egen). Selve turnering-/lag-påmeldingen (steg 2)
gjenbruker de EKSISTERENDE `participants`/`roster`-endepunktene i en
frontend-løkke etter at bulk-kallet returnerer spiller-IDene -- speiler
det etablerte "loop over enkeltkall"-mønsteret som allerede fantes i
`tournament-detail.tsx` for manuell tillegging. Ingen ny bulk-
registrerings-endepunkt, ingen migrasjon (ingen skjemaendring).
**Frontend: CSV parses klient-side.** Ny avhengighet `papaparse` (+
`@types/papaparse`) -- ingen ny multipart-endepunkt nødvendig. Enkel
fuzzy kolonnegjetting (norske/engelske header-varianter) lar brukeren
overstyre hver kolonnetilknytning før forhåndsvisning. Rader uten treff
på lagnavn (lagturnering) markeres synlig i resultat-oppsummeringen
etter lagring -- IKKE blokkerende: spilleren legges uansett i org-
poolen, lagvalg kan rettes manuelt etterpå (samme "degrader grasiøst"-
filosofi som resten av appen).
**Midlertidig hånd-kodet UI, V0-eksport venter (samme rekkefølge som
FlagPlantSheet-presedensen).** Ny komponent `frontend/components/
player-import-panel.tsx` bygget FØRST, hånd-kodet, for å verifisere hele
kjeden (fil → kolonnetilknytning → redigerbar tabell → `bulk`-kall →
løkke over `participants`/`roster`) ende-til-ende mot ekte backend. Kun
selve VISNINGEN erstattes senere med en V0-eksport (Claude-skrevet
prompt, samme "STÅENDE tilgjengelighetsregel eksplisitt i prompten"-
disiplin som all annen V0-bruk denne perioden) -- logikken
(CSV-parsing/kolonne-gjetting/API-orkestrering) forblir Claude-skrevet
og uendret ved bytte, siden komponenten allerede er en kontrollert
"dum" visning utad (`rows`/`onCellChange`/`onDeleteRow`/`onAddRow`/
`onSave` som ville vært V0-prompten sin kontrakt).
Nytt inngangspunkt ("Importer fra CSV") lagt til ved siden av
eksisterende "Legg til spiller" i BÅDE `tournament-detail.tsx` (lag) og
`individual-tournament-detail.tsx` (individuell).
**Verifisert:** `tests/test_players_bulk.py`, 5 nye tester (e-post-match
oppdaterer kun tomme felt uten å overskrive eksisterende data,
`display_name`/`paid` aldri overskrevet, ingen e-post gir alltid ny
rad, transaksjonell konsistens på tvers av rader). Full backend-suite:
111/111 bestått. `tsc --noEmit` rent + 45/45 vitest. Scratch-database +
scratch `teecup_api` + lokal `next dev`: én org med både en
lagturnering (to lag) og en individuell turnering (én klasse), CSV med
tre blandede rader (ny spiller, spiller som matcher eksisterende på
e-post, spiller med et ukjent lagnavn) importert i lagturneringen --
bekreftet direkte mot databasen at den nye spilleren opprettes, den
matchede spilleren kun får sine TOMME felt fylt (allerede satt `country`
uendret), og ukjent-lagnavn-raden ble synlig flagget uten å blokkere de
to andre. Individuell-turnering-import med en "Klasse"-kolonne bekreftet
riktig klassetilknytning der navnet matchet, `Ingen klasse` der det ikke
gjorde. Lys+mørk bekreftet på selve importpanelet. Scratch-stacken
fullstendig revet ned.
**Kjent begrensning i denne midlertidige versjonen (ikke rettet, siden
UI-et likevel erstattes):** kolonnegjettingen normaliserer ikke norske
spesialtegn (æ/ø/å) -- "Kjønn" traff ikke automatisk `gender`-feltet i
scratch-testen, måtte rettes manuelt i kolonnevalget (som fungerte som
forventet). Brukeren kan alltid overstyre enhver kolonnetilknytning, så
dette blokkerer ikke import -- men bør fikses i V0-integreringsrunden
eller en liten oppfølgings-commit.
**Ingen migrasjon.**
**Rullet ut: ikke ennå** -- venter på V0-eksport for den polerte
visningen før utrulling (backend-endepunktet i seg selv er additivt og
kunne vært rullet ut isolert, men rulles ut samlet med frontend-biten
for å unngå en dødt-kode-periode).
---
## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -11923,3 +11923,49 @@ Neste steg:
(samme mapping gjenbrukt, ikke re-avklart) via et engangsskript i
`teecup_api`-containeren mot det nye endepunktets tabell -- bekreftet
180/180 rader med korrekt `poi_type`-fordeling.
99. **Massimport av spillere til turneringer (CSV) + redigerbar
spillertabell (ADR-082) — 2026-08-17.** Bruker ba om en CSV-basert
vei inn i stedet for én-og-én-skjema. Bekreftet med bruker: begge
steg samlet (org-pool + påmelding til turneringen du står i), og
både lagturneringer (lagtildeling via CSV-kolonne) og individuelle
turneringer (valgfri klasse-kolonne) dekket samtidig.
Nytt endepunkt `POST /orgs/{id}/players/bulk` -- samme "match på
e-post, fyll kun tomme felt"-mønster som selvregistreringens
ADR-017 Beslutning B, utvidet til alle felt. `display_name`/`paid`
røres aldri på en eksisterende match. Selve turnering-/lag-
påmeldingen gjenbruker de eksisterende participants-/roster-
endepunktene i en frontend-løkke -- ingen ny bulk-registrerings-vei,
ingen migrasjon. Ny `papaparse`-avhengighet, CSV parses klient-side,
enkel fuzzy kolonnegjetting (norsk/engelsk), overstyrbar per
kolonne. Nytt "Importer fra CSV"-inngangspunkt i BÅDE
`tournament-detail.tsx` og `individual-tournament-detail.tsx`.
**Midlertidig hånd-kodet UI (samme rekkefølge som FlagPlantSheet):**
`player-import-panel.tsx` bygget hånd-kodet FØRST for å bevise hele
kjeden ende-til-ende, venter nå på en V0-eksport for den polerte
visningen -- selve logikken (CSV-parsing/kolonne-gjetting/API-
orkestrering) forblir uendret ved bytte, komponenten er allerede en
kontrollert "dum" visning utad.
**Verifisert:** `tests/test_players_bulk.py`, 5 nye tester (111/111
backend totalt). `tsc --noEmit` rent + 45/45 vitest.
Scratch-database + scratch `teecup_api` + lokal `next dev`: én org
med lagturnering (to lag) + individuell turnering (én klasse), CSV
med blandede rader (ny/matchende-på-e-post/ukjent-lagnavn) importert
i lagturneringen -- bekreftet direkte mot databasen at match-på-
e-post kun fyller tomme felt, ukjent lagnavn flagget uten å
blokkere. Individuell-import med klasse-kolonne bekreftet riktig
tilknytning. Lys+mørk bekreftet på importpanelet. Scratch-stacken
revet ned.
**Kjent begrensning (ikke rettet, UI-et erstattes uansett):**
kolonnegjettingen normaliserer ikke æ/ø/å -- "Kjønn" traff ikke
automatisk, måtte rettes manuelt i kolonnevalget (fungerte som
forventet, ikke blokkerende).
**Ingen migrasjon.**
**Rullet ut: ikke ennå** -- venter på V0-eksport før samlet
utrulling.

View file

@ -114,6 +114,98 @@ async def create_player(
return Player(**dict(row))
class PlayerBulkResult(BaseModel):
player: Player
status: str # "created" | "updated"
@router.post("/orgs/{organization_id}/players/bulk", response_model=list[PlayerBulkResult], status_code=201)
async def create_players_bulk(
body: list[PlayerCreate],
organization_id: str = Depends(get_authorized_org),
) -> list[PlayerBulkResult]:
"""Massimport (CSV-basert, se FEATURE_BACKLOG.md). Samme "match på
e-post, fyll KUN tomme felt, ellers opprett ny"-mønster som
`registration.py` sin selvregistrering (ADR-017 Beslutning B) -- her
utvidet til ALLE `PlayerCreate`-felt (registration.py sin variant
mangler first_name/last_name/paid/comment, som ikke fantes da den ble
skrevet). `display_name` og `paid` røres ALDRI en eksisterende
match -- førstnevnte er organisators kanoniske navn, sistnevnte er en
ikke-nullbar boolean (COALESCE mot NULL er da alltid en no-op, som er
riktig oppførsel: en import skal ikke stille kunne endre en allerede
satt betalt-status). Hele lista i én transaksjon (org_connection sin
egen) -- ingen delvis-suksess-håndtering, brukeren har allerede
validert/rettet radene i tabellen før "Lagre" trykkes."""
results: list[PlayerBulkResult] = []
async with org_connection(organization_id) as conn, translate_db_errors():
for p in body:
existing = None
if p.email:
existing = await conn.fetchrow(
"SELECT id FROM player WHERE lower(email) = lower($1)", p.email
)
if existing is not None:
row = await conn.fetchrow(
f"""
UPDATE player SET
handicap_index = COALESCE(handicap_index, $2),
gender = COALESCE(gender, $3),
mobile = COALESCE(mobile, $4),
birth_date = COALESCE(birth_date, $5),
nickname = COALESCE(nickname, $6),
country = COALESCE(country, $7),
club = COALESCE(club, $8),
club_member_number = COALESCE(club_member_number, $9),
first_name = COALESCE(first_name, $10),
last_name = COALESCE(last_name, $11),
comment = COALESCE(comment, $12)
WHERE id = $1
RETURNING {_PLAYER_COLUMNS}
""",
existing["id"],
p.handicap_index,
p.gender,
p.mobile,
p.birth_date,
p.nickname,
p.country,
p.club,
p.club_member_number,
p.first_name,
p.last_name,
p.comment,
)
results.append(PlayerBulkResult(player=Player(**dict(row)), status="updated"))
else:
row = await conn.fetchrow(
f"""
INSERT INTO player
(organization_id, display_name, handicap_index, gender,
mobile, email, birth_date, nickname, country, club, club_member_number,
first_name, last_name, paid, comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING {_PLAYER_COLUMNS}
""",
organization_id,
p.display_name,
p.handicap_index,
p.gender,
p.mobile,
p.email.lower() if p.email else None,
p.birth_date,
p.nickname,
p.country,
p.club,
p.club_member_number,
p.first_name,
p.last_name,
p.paid,
p.comment,
)
results.append(PlayerBulkResult(player=Player(**dict(row)), status="created"))
return results
class PlayerUpdate(BaseModel):
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
(PATCH-semantikk via Pydantic sin exclude_unset, se update_player).

View file

@ -31,6 +31,7 @@ import {
Plus,
Trash2,
Trophy,
Upload,
UserPlus,
Users,
X,
@ -38,6 +39,7 @@ import {
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PlayerImportPanel } from "@/components/player-import-panel"
import {
DropdownMenu,
DropdownMenuContent,
@ -473,6 +475,15 @@ export function IndividualTournamentDetail({
setParticipants((prev) => [...prev, created].sort((a, b) => a.player_name.localeCompare(b.player_name)))
}
// Etter massimport (PlayerImportPanel) -- panelet gjør sine egne
// POST-kall direkte (bulk + participants), enklest å hente
// deltakerlisten på nytt etterpå fremfor å slå sammen delvis ukjent
// respons-tilstand manuelt.
async function refreshParticipants() {
const data = await getJson<ApiParticipant[]>(`${base}/participants`)
if (data) setParticipants(data)
}
async function addNewPlayerAndParticipant(input: NewPlayerInput) {
setError(null)
const displayName = `${input.firstName} ${input.lastName}`.trim()
@ -735,6 +746,7 @@ export function IndividualTournamentDetail({
onUpdateFlagMapVisible={updateFlagMapVisible}
onAddParticipant={addParticipant}
onAddNewPlayer={addNewPlayerAndParticipant}
onImportComplete={refreshParticipants}
onRemoveParticipant={removeParticipant}
onAddRound={addRound}
onDeleteRound={deleteRound}
@ -807,6 +819,7 @@ function SetupTab({
onUpdateFlagMapVisible,
onAddParticipant,
onAddNewPlayer,
onImportComplete,
onRemoveParticipant,
onAddRound,
onDeleteRound,
@ -830,6 +843,7 @@ function SetupTab({
onUpdateFlagMapVisible: (visible: boolean) => void
onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
onImportComplete: () => Promise<void>
onRemoveParticipant: (id: string) => Promise<void>
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onDeleteRound: (id: string) => Promise<void>
@ -846,6 +860,11 @@ function SetupTab({
}) {
const [pool, setPool] = useState<ApiPlayer[]>([])
async function refreshPool() {
const data = await getJson<ApiPlayer[]>(`/orgs/${organizationId}/players`)
setPool(data ?? [])
}
useEffect(() => {
let cancelled = false
getJson<ApiPlayer[]>(`/orgs/${organizationId}/players`).then((data) => {
@ -939,11 +958,16 @@ function SetupTab({
/>
<ParticipantsCard
organizationId={organizationId}
tournamentId={tournament.id}
participants={participants}
pool={pool}
classes={classes}
onAddParticipant={onAddParticipant}
onAddNewPlayer={onAddNewPlayer}
onImportComplete={async () => {
await Promise.all([refreshPool(), onImportComplete()])
}}
onRemoveParticipant={onRemoveParticipant}
onSetClass={onSetParticipantClass}
onSetStatLevel={onSetParticipantStatLevel}
@ -1114,20 +1138,26 @@ const STAT_LEVEL_LABELS: Record<string, string> = {
}
function ParticipantsCard({
organizationId,
tournamentId,
participants,
pool,
classes,
onAddParticipant,
onAddNewPlayer,
onImportComplete,
onRemoveParticipant,
onSetClass,
onSetStatLevel,
}: {
organizationId: string
tournamentId: string
participants: ApiParticipant[]
pool: ApiPlayer[]
classes: ApiTournamentClass[]
onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
onImportComplete: () => Promise<void>
onRemoveParticipant: (id: string) => Promise<void>
onSetClass: (participantId: string, classId: string | null) => Promise<void>
onSetStatLevel: (
@ -1135,6 +1165,8 @@ function ParticipantsCard({
statLevel: "strokes_only" | "strokes_and_putts" | "full",
) => Promise<void>
}) {
const [showImport, setShowImport] = useState(false)
return (
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
<div className="flex items-center justify-between gap-2">
@ -1144,6 +1176,23 @@ function ParticipantsCard({
</span>
</div>
<Button type="button" variant="outline" onClick={() => setShowImport(true)} className="min-h-11 self-start">
<Upload aria-hidden="true" className="mr-2 size-4" />
Importer fra CSV
</Button>
{showImport && (
<PlayerImportPanel
organizationId={organizationId}
tournamentId={tournamentId}
mode={{ kind: "individual", classes: classes.map((c) => ({ id: c.id, name: c.name })) }}
onClose={() => setShowImport(false)}
onImportComplete={() => {
void onImportComplete()
}}
/>
)}
{participants.length === 0 && (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller.

View file

@ -0,0 +1,465 @@
"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>
)
}

View file

@ -16,6 +16,7 @@ import {
Plus,
Star,
Trash2,
Upload,
UserPlus,
Users,
X,
@ -23,6 +24,7 @@ import {
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { PlayerImportPanel } from "@/components/player-import-panel"
import {
DropdownMenu,
DropdownMenuContent,
@ -100,6 +102,8 @@ export function TournamentDetail({
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showImport, setShowImport] = useState(false)
useEffect(() => {
let cancelled = false
async function load() {
@ -199,6 +203,35 @@ export function TournamentDetail({
}
}
// Etter massimport (PlayerImportPanel) -- panelet gjør sine egne
// POST-kall direkte (bulk + roster), enklest å bare hente poolen/
// rostrene på nytt etterpå fremfor å forsøke å slå sammen delvis
// ukjent respons-tilstand manuelt.
async function refreshPoolAndTeams() {
try {
const [poolRes, teamsRes] = await Promise.all([
fetch(`/orgs/${organizationId}/players`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { credentials: "include" }),
])
if (poolRes.ok) setPool(await poolRes.json())
if (teamsRes.ok) {
const teamsData: { id: string; name: string; color: string | null }[] = await teamsRes.json()
const withRosters = await Promise.all(
teamsData.map(async (t) => {
const rosterRes = await fetch(`/orgs/${organizationId}/teams/${t.id}/roster`, {
credentials: "include",
})
const roster: ApiRosterEntry[] = rosterRes.ok ? await rosterRes.json() : []
return { id: t.id, name: t.name, color: t.color ?? TEAM_COLORS[0].value, roster }
}),
)
setTeams([withRosters[0] ?? null, withRosters[1] ?? null])
}
} catch {
setError("Import fullført, men klarte ikke å oppdatere visningen -- last siden på nytt.")
}
}
async function addNewPlayer(teamId: string, name: string, handicap?: number) {
setError(null)
try {
@ -458,6 +491,30 @@ export function TournamentDetail({
</p>
)}
{!loading && (
<div className="mb-4 flex justify-end">
<Button type="button" variant="outline" onClick={() => setShowImport(true)} className="min-h-11">
<Upload aria-hidden="true" className="mr-2 size-4" />
Importer fra CSV
</Button>
</div>
)}
{showImport && (
<PlayerImportPanel
organizationId={organizationId}
tournamentId={tournamentId}
mode={{
kind: "team",
teams: teams.filter((t): t is Team => t !== null).map((t) => ({ id: t.id, name: t.name })),
}}
onClose={() => setShowImport(false)}
onImportComplete={() => {
void refreshPoolAndTeams()
}}
/>
)}
{loading ? (
<div className="flex justify-center py-16">
<div

View file

@ -11,11 +11,13 @@
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@types/papaparse": "^5.5.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"mapbox-gl": "3.27.0",
"next": "16.2.6",
"papaparse": "^5.6.0",
"react": "^19",
"react-dom": "^19",
"shadcn": "^4.8.0",

View file

@ -11,6 +11,9 @@ importers:
'@base-ui/react':
specifier: ^1.5.0
version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@types/papaparse':
specifier: ^5.5.2
version: 5.5.2
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -26,6 +29,9 @@ importers:
next:
specifier: 16.2.6
version: 16.2.6(@babel/core@7.29.7)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
papaparse:
specifier: ^5.6.0
version: 5.6.0
react:
specifier: ^19
version: 19.2.8
@ -729,6 +735,9 @@ packages:
'@types/node@24.13.3':
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
'@types/papaparse@5.5.2':
resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
'@types/react-dom@19.2.4':
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
peerDependencies:
@ -1723,6 +1732,9 @@ packages:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
papaparse@5.6.0:
resolution: {integrity: sha512-N2vuNQAYGK1/4vs6HJX86+VYU6OkiSTgdJz3JQfTk1y51cFCO/U8gnaeTF4iNE4r57Tt0sV47dUua1/19pxO6Q==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@ -2817,6 +2829,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/papaparse@5.5.2':
dependencies:
'@types/node': 24.13.3
'@types/react-dom@19.2.4(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
@ -3675,6 +3691,8 @@ snapshots:
p-try@2.2.0: {}
papaparse@5.6.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0

View file

@ -0,0 +1,93 @@
"""
Massimport av spillere (`POST /orgs/{id}/players/bulk`, se FEATURE_
BACKLOG.md "Massimport av spillere til organisasjon/turnering"). Samme
dedup-mønster som registration.py sin selvregistrering (ADR-017
Beslutning B), utvidet til alle PlayerCreate-felt.
"""
import app.db as app_db
from app.routers.players import PlayerCreate, create_players_bulk
from tests.conftest import create_org, create_player
async def test_bulk_creates_new_players_without_email(pool):
org_id = await create_org()
rows = [
PlayerCreate(display_name="Ny Spiller A"),
PlayerCreate(display_name="Ny Spiller B", handicap_index=12.5),
]
results = await create_players_bulk(rows, organization_id=org_id)
assert len(results) == 2
assert all(r.status == "created" for r in results)
assert {r.player.display_name for r in results} == {"Ny Spiller A", "Ny Spiller B"}
async def test_bulk_matches_existing_player_by_email_and_fills_only_empty_fields(pool):
org_id = await create_org()
existing_id = await create_player(org_id, display_name="Eksisterende Spiller")
# Sett e-post + ett annet felt på forhånd -- importen skal IKKE
# overskrive country (allerede satt), kun matche på e-posten.
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE player SET email = 'match@example.com', country = 'Norge' WHERE id = $1",
existing_id,
)
rows = [
PlayerCreate(
display_name="Annet Navn Fra CSV", # skal IKKE overskrive display_name
email="match@example.com",
country="Sverige", # skal IKKE overskrive et allerede satt felt
club="Ny Klubb", # skal FYLLES INN (var tomt før)
handicap_index=8.0, # skal FYLLES INN (var tomt før)
),
]
results = await create_players_bulk(rows, organization_id=org_id)
assert len(results) == 1
assert results[0].status == "updated"
p = results[0].player
assert p.id == existing_id
assert p.display_name == "Eksisterende Spiller" # uendret
assert p.country == "Norge" # uendret (var allerede satt)
assert p.club == "Ny Klubb" # fylt inn (var tomt)
assert p.handicap_index == 8.0 # fylt inn (var tomt)
async def test_bulk_without_email_never_matches_existing_and_always_creates(pool):
org_id = await create_org()
await create_player(org_id, display_name="Har Ikke E-post")
rows = [PlayerCreate(display_name="Har Ikke E-post")]
results = await create_players_bulk(rows, organization_id=org_id)
assert results[0].status == "created"
async with app_db.org_connection(org_id) as conn:
count = await conn.fetchval(
"SELECT count(*) FROM player WHERE display_name = 'Har Ikke E-post'"
)
assert count == 2 # den opprinnelige + den nye, ingen falsk dedup uten e-post
async def test_bulk_paid_flag_never_overwritten_on_existing_match(pool):
org_id = await create_org()
existing_id = await create_player(org_id, display_name="Betalt Spiller")
async with app_db.org_connection(org_id) as conn:
await conn.execute(
"UPDATE player SET email = 'paid@example.com', paid = true WHERE id = $1", existing_id
)
rows = [PlayerCreate(display_name="X", email="paid@example.com", paid=False)]
results = await create_players_bulk(rows, organization_id=org_id)
assert results[0].player.paid is True # uendret, COALESCE mot ikke-nullbar kolonne er en no-op
async def test_bulk_is_transactional_across_rows(pool):
"""Selve listen kjøres i org_connection sin egen transaksjon -- ingen
partial-suksess-forventning her, men bekreft at flere rader i samme
kall faktisk lander i samme, konsistente organisasjon."""
org_id = await create_org()
rows = [PlayerCreate(display_name=f"Spiller {i}") for i in range(5)]
results = await create_players_bulk(rows, organization_id=org_id)
assert len(results) == 5
assert len({r.player.id for r in results}) == 5