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:
parent
cc793339e4
commit
00809d6817
9 changed files with 911 additions and 0 deletions
|
|
@ -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)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
||||||
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)
|
||||||
|
|
|
||||||
46
CHANGELOG.md
46
CHANGELOG.md
|
|
@ -11923,3 +11923,49 @@ Neste steg:
|
||||||
(samme mapping gjenbrukt, ikke re-avklart) via et engangsskript i
|
(samme mapping gjenbrukt, ikke re-avklart) via et engangsskript i
|
||||||
`teecup_api`-containeren mot det nye endepunktets tabell -- bekreftet
|
`teecup_api`-containeren mot det nye endepunktets tabell -- bekreftet
|
||||||
180/180 rader med korrekt `poi_type`-fordeling.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,98 @@ async def create_player(
|
||||||
return Player(**dict(row))
|
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):
|
class PlayerUpdate(BaseModel):
|
||||||
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
|
||||||
(PATCH-semantikk via Pydantic sin exclude_unset, se update_player).
|
(PATCH-semantikk via Pydantic sin exclude_unset, se update_player).
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
Trophy,
|
Trophy,
|
||||||
|
Upload,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
X,
|
X,
|
||||||
|
|
@ -38,6 +39,7 @@ import {
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { PlayerImportPanel } from "@/components/player-import-panel"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -473,6 +475,15 @@ export function IndividualTournamentDetail({
|
||||||
setParticipants((prev) => [...prev, created].sort((a, b) => a.player_name.localeCompare(b.player_name)))
|
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) {
|
async function addNewPlayerAndParticipant(input: NewPlayerInput) {
|
||||||
setError(null)
|
setError(null)
|
||||||
const displayName = `${input.firstName} ${input.lastName}`.trim()
|
const displayName = `${input.firstName} ${input.lastName}`.trim()
|
||||||
|
|
@ -735,6 +746,7 @@ export function IndividualTournamentDetail({
|
||||||
onUpdateFlagMapVisible={updateFlagMapVisible}
|
onUpdateFlagMapVisible={updateFlagMapVisible}
|
||||||
onAddParticipant={addParticipant}
|
onAddParticipant={addParticipant}
|
||||||
onAddNewPlayer={addNewPlayerAndParticipant}
|
onAddNewPlayer={addNewPlayerAndParticipant}
|
||||||
|
onImportComplete={refreshParticipants}
|
||||||
onRemoveParticipant={removeParticipant}
|
onRemoveParticipant={removeParticipant}
|
||||||
onAddRound={addRound}
|
onAddRound={addRound}
|
||||||
onDeleteRound={deleteRound}
|
onDeleteRound={deleteRound}
|
||||||
|
|
@ -807,6 +819,7 @@ function SetupTab({
|
||||||
onUpdateFlagMapVisible,
|
onUpdateFlagMapVisible,
|
||||||
onAddParticipant,
|
onAddParticipant,
|
||||||
onAddNewPlayer,
|
onAddNewPlayer,
|
||||||
|
onImportComplete,
|
||||||
onRemoveParticipant,
|
onRemoveParticipant,
|
||||||
onAddRound,
|
onAddRound,
|
||||||
onDeleteRound,
|
onDeleteRound,
|
||||||
|
|
@ -830,6 +843,7 @@ function SetupTab({
|
||||||
onUpdateFlagMapVisible: (visible: boolean) => void
|
onUpdateFlagMapVisible: (visible: boolean) => void
|
||||||
onAddParticipant: (playerId: string) => Promise<void>
|
onAddParticipant: (playerId: string) => Promise<void>
|
||||||
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
|
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
|
||||||
|
onImportComplete: () => Promise<void>
|
||||||
onRemoveParticipant: (id: string) => Promise<void>
|
onRemoveParticipant: (id: string) => Promise<void>
|
||||||
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
|
||||||
onDeleteRound: (id: string) => Promise<void>
|
onDeleteRound: (id: string) => Promise<void>
|
||||||
|
|
@ -846,6 +860,11 @@ function SetupTab({
|
||||||
}) {
|
}) {
|
||||||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||||
|
|
||||||
|
async function refreshPool() {
|
||||||
|
const data = await getJson<ApiPlayer[]>(`/orgs/${organizationId}/players`)
|
||||||
|
setPool(data ?? [])
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
getJson<ApiPlayer[]>(`/orgs/${organizationId}/players`).then((data) => {
|
getJson<ApiPlayer[]>(`/orgs/${organizationId}/players`).then((data) => {
|
||||||
|
|
@ -939,11 +958,16 @@ function SetupTab({
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ParticipantsCard
|
<ParticipantsCard
|
||||||
|
organizationId={organizationId}
|
||||||
|
tournamentId={tournament.id}
|
||||||
participants={participants}
|
participants={participants}
|
||||||
pool={pool}
|
pool={pool}
|
||||||
classes={classes}
|
classes={classes}
|
||||||
onAddParticipant={onAddParticipant}
|
onAddParticipant={onAddParticipant}
|
||||||
onAddNewPlayer={onAddNewPlayer}
|
onAddNewPlayer={onAddNewPlayer}
|
||||||
|
onImportComplete={async () => {
|
||||||
|
await Promise.all([refreshPool(), onImportComplete()])
|
||||||
|
}}
|
||||||
onRemoveParticipant={onRemoveParticipant}
|
onRemoveParticipant={onRemoveParticipant}
|
||||||
onSetClass={onSetParticipantClass}
|
onSetClass={onSetParticipantClass}
|
||||||
onSetStatLevel={onSetParticipantStatLevel}
|
onSetStatLevel={onSetParticipantStatLevel}
|
||||||
|
|
@ -1114,20 +1138,26 @@ const STAT_LEVEL_LABELS: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ParticipantsCard({
|
function ParticipantsCard({
|
||||||
|
organizationId,
|
||||||
|
tournamentId,
|
||||||
participants,
|
participants,
|
||||||
pool,
|
pool,
|
||||||
classes,
|
classes,
|
||||||
onAddParticipant,
|
onAddParticipant,
|
||||||
onAddNewPlayer,
|
onAddNewPlayer,
|
||||||
|
onImportComplete,
|
||||||
onRemoveParticipant,
|
onRemoveParticipant,
|
||||||
onSetClass,
|
onSetClass,
|
||||||
onSetStatLevel,
|
onSetStatLevel,
|
||||||
}: {
|
}: {
|
||||||
|
organizationId: string
|
||||||
|
tournamentId: string
|
||||||
participants: ApiParticipant[]
|
participants: ApiParticipant[]
|
||||||
pool: ApiPlayer[]
|
pool: ApiPlayer[]
|
||||||
classes: ApiTournamentClass[]
|
classes: ApiTournamentClass[]
|
||||||
onAddParticipant: (playerId: string) => Promise<void>
|
onAddParticipant: (playerId: string) => Promise<void>
|
||||||
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
|
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
|
||||||
|
onImportComplete: () => Promise<void>
|
||||||
onRemoveParticipant: (id: string) => Promise<void>
|
onRemoveParticipant: (id: string) => Promise<void>
|
||||||
onSetClass: (participantId: string, classId: string | null) => Promise<void>
|
onSetClass: (participantId: string, classId: string | null) => Promise<void>
|
||||||
onSetStatLevel: (
|
onSetStatLevel: (
|
||||||
|
|
@ -1135,6 +1165,8 @@ function ParticipantsCard({
|
||||||
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
statLevel: "strokes_only" | "strokes_and_putts" | "full",
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
|
const [showImport, setShowImport] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
<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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
|
@ -1144,6 +1176,23 @@ function ParticipantsCard({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 && (
|
{participants.length === 0 && (
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
<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.
|
Ingen deltakere lagt til ennå -- legg til hele feltet her, uavhengig av hvilke runder de spiller.
|
||||||
|
|
|
||||||
465
frontend/components/player-import-panel.tsx
Normal file
465
frontend/components/player-import-panel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
Star,
|
Star,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Upload,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
X,
|
X,
|
||||||
|
|
@ -23,6 +24,7 @@ import {
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
import { PlayerImportPanel } from "@/components/player-import-panel"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -100,6 +102,8 @@ export function TournamentDetail({
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [showImport, setShowImport] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function load() {
|
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) {
|
async function addNewPlayer(teamId: string, name: string, handicap?: number) {
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
|
|
@ -458,6 +491,30 @@ export function TournamentDetail({
|
||||||
</p>
|
</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 ? (
|
{loading ? (
|
||||||
<div className="flex justify-center py-16">
|
<div className="flex justify-center py-16">
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,13 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.5.0",
|
"@base-ui/react": "^1.5.0",
|
||||||
|
"@types/papaparse": "^5.5.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"mapbox-gl": "3.27.0",
|
"mapbox-gl": "3.27.0",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
"papaparse": "^5.6.0",
|
||||||
"react": "^19",
|
"react": "^19",
|
||||||
"react-dom": "^19",
|
"react-dom": "^19",
|
||||||
"shadcn": "^4.8.0",
|
"shadcn": "^4.8.0",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ importers:
|
||||||
'@base-ui/react':
|
'@base-ui/react':
|
||||||
specifier: ^1.5.0
|
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)
|
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:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
|
|
@ -26,6 +29,9 @@ importers:
|
||||||
next:
|
next:
|
||||||
specifier: 16.2.6
|
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)
|
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:
|
react:
|
||||||
specifier: ^19
|
specifier: ^19
|
||||||
version: 19.2.8
|
version: 19.2.8
|
||||||
|
|
@ -729,6 +735,9 @@ packages:
|
||||||
'@types/node@24.13.3':
|
'@types/node@24.13.3':
|
||||||
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==}
|
||||||
|
|
||||||
|
'@types/papaparse@5.5.2':
|
||||||
|
resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==}
|
||||||
|
|
||||||
'@types/react-dom@19.2.4':
|
'@types/react-dom@19.2.4':
|
||||||
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
|
resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -1723,6 +1732,9 @@ packages:
|
||||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
papaparse@5.6.0:
|
||||||
|
resolution: {integrity: sha512-N2vuNQAYGK1/4vs6HJX86+VYU6OkiSTgdJz3JQfTk1y51cFCO/U8gnaeTF4iNE4r57Tt0sV47dUua1/19pxO6Q==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
@ -2817,6 +2829,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 7.18.2
|
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)':
|
'@types/react-dom@19.2.4(@types/react@19.2.18)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.18
|
'@types/react': 19.2.18
|
||||||
|
|
@ -3675,6 +3691,8 @@ snapshots:
|
||||||
|
|
||||||
p-try@2.2.0: {}
|
p-try@2.2.0: {}
|
||||||
|
|
||||||
|
papaparse@5.6.0: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
|
|
|
||||||
93
tests/test_players_bulk.py
Normal file
93
tests/test_players_bulk.py
Normal 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
|
||||||
Loading…
Reference in a new issue