diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index 81611a0..1e76590 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index e086684..8b5e963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/routers/players.py b/app/routers/players.py index 2294775..ba21f04 100644 --- a/app/routers/players.py +++ b/app/routers/players.py @@ -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 på 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). diff --git a/frontend/components/individual-tournament-detail.tsx b/frontend/components/individual-tournament-detail.tsx index f52eaf0..c3ba019 100644 --- a/frontend/components/individual-tournament-detail.tsx +++ b/frontend/components/individual-tournament-detail.tsx @@ -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(`${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 onAddNewPlayer: (input: NewPlayerInput) => Promise + onImportComplete: () => Promise onRemoveParticipant: (id: string) => Promise onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise onDeleteRound: (id: string) => Promise @@ -846,6 +860,11 @@ function SetupTab({ }) { const [pool, setPool] = useState([]) + async function refreshPool() { + const data = await getJson(`/orgs/${organizationId}/players`) + setPool(data ?? []) + } + useEffect(() => { let cancelled = false getJson(`/orgs/${organizationId}/players`).then((data) => { @@ -939,11 +958,16 @@ function SetupTab({ /> { + await Promise.all([refreshPool(), onImportComplete()]) + }} onRemoveParticipant={onRemoveParticipant} onSetClass={onSetParticipantClass} onSetStatLevel={onSetParticipantStatLevel} @@ -1114,20 +1138,26 @@ const STAT_LEVEL_LABELS: Record = { } 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 onAddNewPlayer: (input: NewPlayerInput) => Promise + onImportComplete: () => Promise onRemoveParticipant: (id: string) => Promise onSetClass: (participantId: string, classId: string | null) => Promise onSetStatLevel: ( @@ -1135,6 +1165,8 @@ function ParticipantsCard({ statLevel: "strokes_only" | "strokes_and_putts" | "full", ) => Promise }) { + const [showImport, setShowImport] = useState(false) + return (
@@ -1144,6 +1176,23 @@ function ParticipantsCard({
+ + + {showImport && ( + ({ id: c.id, name: c.name })) }} + onClose={() => setShowImport(false)} + onImportComplete={() => { + void onImportComplete() + }} + /> + )} + {participants.length === 0 && (

Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller. diff --git a/frontend/components/player-import-panel.tsx b/frontend/components/player-import-panel.tsx new file mode 100644 index 0000000..8815577 --- /dev/null +++ b/frontend/components/player-import-panel.tsx @@ -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 + +const EMPTY_ROW: Omit = { + 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 = { + 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 = { + 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([]) + const [csvRows, setCsvRows] = useState([]) + const [mapping, setMapping] = useState>({}) + const [rows, setRows] = useState([]) + const [error, setError] = useState(null) + const [saveErrors, setSaveErrors] = useState([]) + const fileInputRef = useRef(null) + + function handleFile(file: File) { + setError(null) + Papa.parse(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 = {} + 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 ( +

+
+

Importer spillere fra CSV

+ +
+ +
+ {error && ( +
+ {error} +
+ )} + + {step === "upload" && ( +
+
+ )} + + {step === "map" && ( +
+

+ Velg hvilket felt hver kolonne skal fylle. "Ignorer" hopper over kolonnen. +

+
+ {csvHeaders.map((h, i) => ( +
+ {h} + +
+ ))} +
+ +
+ )} + + {(step === "edit" || step === "saving") && ( +
+
+ + + + {(Object.keys(FIELD_LABELS) as FieldKey[]) + .filter((f) => mode.kind === "team" || f !== "team_name") + .filter((f) => mode.kind === "individual" || f !== "class_name") + .map((f) => ( + + ))} + + + + {rows.map((row) => ( + + {(Object.keys(FIELD_LABELS) as FieldKey[]) + .filter((f) => mode.kind === "team" || f !== "team_name") + .filter((f) => mode.kind === "individual" || f !== "class_name") + .map((f) => ( + + ))} + + + ))} + +
+ {FIELD_LABELS[f]} + +
+ updateCell(row.key, f, e.target.value)} + disabled={step === "saving"} + className="min-w-24" + /> + + +
+
+
+ + +
+
+ )} + + {step === "done" && ( +
+

Importen er fullført.

+ {saveErrors.length > 0 && ( +
+

Noen rader trenger oppfølging:

+
    + {saveErrors.map((e, i) => ( +
  • {e}
  • + ))} +
+
+ )} + +
+ )} +
+
+ ) +} diff --git a/frontend/components/tournament-detail.tsx b/frontend/components/tournament-detail.tsx index 8af92d2..7671259 100644 --- a/frontend/components/tournament-detail.tsx +++ b/frontend/components/tournament-detail.tsx @@ -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(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({

)} + {!loading && ( +
+ +
+ )} + + {showImport && ( + t !== null).map((t) => ({ id: t.id, name: t.name })), + }} + onClose={() => setShowImport(false)} + onImportComplete={() => { + void refreshPoolAndTeams() + }} + /> + )} + {loading ? (
=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 diff --git a/tests/test_players_bulk.py b/tests/test_players_bulk.py new file mode 100644 index 0000000..9ae255c --- /dev/null +++ b/tests/test_players_bulk.py @@ -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