Utvidet spillerskjema i "Deltakere" -- bla i eksisterende + nye felt (ADR-070)

Migrasjon 074 legger til first_name/last_name/paid/comment på player
(resten av de 11 ønskede feltene fantes allerede, bare ikke eksponert i
denne UI-flyten). AddParticipantControl fikk et "available"-fallback
slik at eksisterende org-spillere vises direkte ved tomt søk (tidligere
kun mulig å opprette ny), pluss et progressivt avslørt skjema for de nye
feltene. Se ADR-070 og CHANGELOG #86 for full detalj og verifisering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erol Haagenrud 2026-08-14 15:08:45 +02:00
parent 25b50d4e5b
commit a8a2c602ce
5 changed files with 497 additions and 41 deletions

View file

@ -0,0 +1,38 @@
-- =====================================================================
-- TeeCup — Utvidede spillerfelt i org-spillerpoolen (2026-08-14).
-- Bruker: midlertidige/nyopprettede spillere i "Deltakere"-flyten
-- (org-individuelle turneringer, individual-tournament-detail.tsx)
-- manglet felt en reell klubbadministrator trenger ved registrering.
--
-- Splitter navn i for-/etternavn -- SAMME mønster som migrasjon 052
-- (round_participant.guest_first_name/guest_last_name): display_name
-- beholdes UENDRET som det faktisk VISTE fulle navnet (unngår å røre
-- alle eksisterende steder som leser player.display_name direkte,
-- bl.a. leaderboards/matcher/rostre) -- first_name/last_name er en NY,
-- valgfri kilde til sannhet ved REDIGERING i det utvidede skjemaet,
-- ikke en erstatning. App-laget (ikke en DB-trigger) holder dem i sync
-- ved oppretting, samme "kalleren regner ut display_name"-ansvar som
-- round_participant sin ekvivalent.
--
-- `paid`/`comment`: registreringsadministrasjon (betalt-avkrysning,
-- fritekstnotat) -- fantes ikke i noen form før.
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE player ADD COLUMN first_name text;
ALTER TABLE player ADD COLUMN last_name text;
ALTER TABLE player ADD COLUMN paid boolean NOT NULL DEFAULT false;
ALTER TABLE player ADD COLUMN comment text;
-- Backfill for EKSISTERENDE spillere -- samme "første ord = fornavn,
-- resten = etternavn"-heuristikk som migrasjon 052 sin backfill.
UPDATE player
SET first_name = CASE
WHEN position(' ' IN display_name) > 0 THEN split_part(display_name, ' ', 1)
ELSE display_name
END,
last_name = CASE
WHEN position(' ' IN display_name) > 0
THEN NULLIF(trim(substring(display_name FROM position(' ' IN display_name) + 1)), '')
ELSE NULL
END;

View file

@ -6912,6 +6912,96 @@ containere startet rent.
---
## ADR-070: Utvidet spillerskjema i "Deltakere" -- for-/etternavn, betalt, kommentar + bla i eksisterende (2026-08-14)
Foranledning: bruker viste to skjermbilder av "Deltakere"-flyten i en
org-individuell turnering (`individual-tournament-detail.tsx`). Søkefeltet
tilbød KUN "Opprett ny spiller: «Erik»" -- ingen måte å velge en allerede
eksisterende spiller i org-poolen. Brukeren ba samtidig om at midlertidige/
nyopprettede spillere skal fange Fornavn, Etternavn, Kjønn, Fødselsdato,
E-postadresse, Medlemsnummer i hjemmeklubb, Hjemmeklubb, Land, Hcp,
Sjekkboks for betalt, Kommentar.
**Rotårsak for "kan ikke velge eksisterende": ikke en datalastings-bug.**
`AddParticipantControl` sin `matches`-liste ble KUN utledet når søkefeltet
hadde en ikke-tom, substring-matchende query -- det fantes ingen måte å
bla i poolen uten allerede å kjenne et treffende navn. Fikset ved å legge
til et `available`-memo (`pool` minus allerede lagt-til deltakere) som
`matches` faller tilbake til når søket er tomt, i stedet for å returnere
`[]`. `MAX_VISIBLE_MATCHES` (8, opp fra hardkodet 6) begrenser visningen.
**7 av 11 ønskede felt fantes allerede i `player`-skjemaet** (migrasjon
007: `birth_date`, `email`, `club_member_number`, `club`, `country`,
`handicap_index`, `gender`) -- bare ikke eksponert i DENNE UI-flyten.
`first_name`, `last_name`, `paid`, `comment` manglet reelt -- migrasjon
074 legger dem til på `player`, med samme for-/etternavn-splitt-mønster
som migrasjon 052 (`round_participant.guest_first_name/guest_last_name`):
`display_name` beholdes UENDRET som det faktisk viste navnet alle andre
steder i appen leser (leaderboards, matcher, rostring) -- `first_name`/
`last_name` er en NY, valgfri kilde ved siden av, ikke en erstatning.
Backfill for eksisterende spillere bruker samme "første ord = fornavn,
resten = etternavn"-heuristikk som migrasjon 052 sin backfill.
**Bevisst avvik fra det etablerte "backend synker display_name"-mønsteret:**
migrasjon 052/015 lot backend beregne/synke et avledet visningsnavn fra
for-/etternavn ved skriving. Her lot jeg i stedet FRONTEND beregne
`display_name = \`${firstName} ${lastName}\`.trim()` før sending, som et
helt normalt felt sammen med de nye `first_name`/`last_name`-feltene --
for å unngå å røre `create_player`/`update_player` sin eksisterende
kontrakt (generisk, whitelist-drevet PATCH via Pydantic sin
`exclude_unset`, brukt av mengder av eksisterende kallere). `paid`/
`comment` er ren registreringsadministrasjon, ingen kobling til noe
eksisterende.
**Frontend:** `AddParticipantControl` fikk et progressivt avslørt
skjema -- Fornavn/Etternavn/Kjønn/Hcp alltid synlig, resten bak en
"+ Flere detaljer"-knapp (Fødselsdato, E-post, Hjemmeklubb,
Medlemsnummer, Land, Betalt-avkrysning, Kommentar). `splitName()`-
hjelpefunksjon forhåndsutfyller Fornavn/Etternavn fra søkefeltets
frittekst når "Opprett ny spiller"-knappen trykkes (samme heuristikk som
backfillen).
**Fant og fikset underveis (selvfunnet under nettleserverifisering, ikke
brukerrapportert):** tom-tilstand-teksten når `available.length === 0`
sa "Ingen eksisterende spillere i organisasjonen ennå" uansett årsak --
misvisende når poolen faktisk HAR spillere, de er bare allerede lagt til
denne turneringen. Splittet i to riktige meldinger (`pool.length === 0`
vs. alle allerede lagt til), pluss en tredje ny melding for "søkte, null
substring-treff blant eksisterende" (viste tidligere ingenting).
**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (58/58, migrasjon 074 inkludert).
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
`teecup_api`-container (live-mountet kode) + lokal `next dev`: opprettet
en eksisterende spiller i poolen ("Kari Nordmann") og bekreftet den nå
dukker opp direkte ved åpning av "Legg til deltaker" med tomt søkefelt
(fikset bug), opprettet en ny spiller med alle 11 felt utfylt via det
utvidede skjemaet og bekreftet samtlige lagret korrekt i databasen.
Lys+mørk bekreftet, inkl. det utvidede skjemaet med avkrysningsboks.
**Verifiseringsfallgruve, ikke en app-bug:** Chrome DevTools MCP sin
`fill`-verktøy satte visuelt riktig verdi på et natively multi-segment
`<input type="date">`, men trigget ikke Reacts `onChange` pålitelig --
`birth_date` kom tilbake `null` fra API-et til tross for at feltet viste
riktig dato rett før innsending. Bekreftet ved å gjenta med
tastatur-drevet `press_key` inn i dato-feltets spinbuttons i stedet --
lagret korrekt. Ingen kodeendring nødvendig; kun automatiserings-
verktøyets begrensning med native dato-inputs, ikke en reell
applikasjonsbug.
**Bevisst UTENFOR omfang:** den strukturelt identiske "bla i eksisterende
spillere"-begrensningen finnes trolig også i lag-turneringers roster-
tillegg (`tournament-detail.tsx`, samme `pool`/`matches`/`exactMatch`-
mønster) -- IKKE rørt i denne runden, brukerens skjermbilder gjaldt
spesifikt den org-individuelle skjermen. Kjent, ikke glemt.
**Rullet ut 2026-08-14** -- migrasjon 074 kjørt mot ekte `teecup_db`
som `teeoff_admin` (4 eksisterende spillere backfillet), etter
eksplisitt bekreftelse fra bruker. `docker compose build teecup_api
teecup_frontend && up -d` -- begge containere startet rent.
---
## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -11282,3 +11282,66 @@ Neste steg:
som `teeoff_admin`, etter eksplisitt bekreftelse fra bruker.
`docker compose build teecup_api teecup_frontend && up -d` -- begge
containere startet rent.
86. **Utvidet spillerskjema i "Deltakere" (ADR-070) — 2026-08-14.**
Bruker viste skjermbilde av "Deltakere"-flyten i en org-individuell
turnering: søkefeltet tilbød KUN "Opprett ny spiller: «X»", ingen
måte å velge en eksisterende spiller i org-poolen. Ba samtidig om at
nye spillere skal fange Fornavn, Etternavn, Kjønn, Fødselsdato,
E-post, Medlemsnummer i hjemmeklubb, Hjemmeklubb, Land, Hcp, Betalt,
Kommentar.
Rotårsak for "kan ikke velge eksisterende": `AddParticipantControl`
sin `matches`-liste ble kun utledet ved ikke-tomt søk -- ingen måte å
bla i poolen uten å kjenne et treffende navn fra før. Fikset med et
`available`-memo (pool minus allerede lagt til) som `matches` faller
tilbake til ved tomt søk.
Migrasjon 074: 7 av 11 ønskede felt fantes allerede på `player`
(migrasjon 007), kun `first_name`/`last_name`/`paid`/`comment` var
reelt nye. Samme for-/etternavn-splitt-mønster som migrasjon 052
(`display_name` uendret som det faktisk viste navnet, `first_name`/
`last_name` en ny valgfri kilde ved siden av) -- men her beregner
FRONTEND `display_name` før sending i stedet for at backend synker
det, for å unngå å røre `create_player`/`update_player` sin
eksisterende generiske PATCH-kontrakt. Backfill av eksisterende
spillere med samme heuristikk som migrasjon 052.
Frontend: progressivt avslørt skjema i `AddParticipantControl`
("+ Flere detaljer"), `splitName()`-hjelper forhåndsutfyller Fornavn/
Etternavn fra søkefeltets frittekst ved "Opprett ny spiller".
**Fant og fikset underveis (selvfunnet, ikke brukerrapportert):**
misvisende tom-tilstand-tekst når alle pool-spillere allerede var
lagt til turneringen ("ingen spillere i org ennå" -- faktisk feil,
de var bare allerede lagt til). Splittet i riktige meldinger for de
tre reelle tilstandene.
**Verifisert:** `python3 -m py_compile` + full
`./scripts/run_backend_tests.sh` (58/58, migrasjon 074 inkludert).
`tsc --noEmit` rent + 45/45 vitest. Egen scratch-database + scratch
`teecup_api`-container (live-mountet kode) + lokal `next dev`: bla-i-
eksisterende-fikset bekreftet med en forhåndsopprettet spiller
synlig direkte ved tomt søk, alle 11 felt bekreftet lagret korrekt
for en ny spiller opprettet via det utvidede skjemaet. Lys+mørk
bekreftet inkl. avkrysningsboks-styling. Scratch-stacken fullstendig
revet ned -- ekte `teecup_db`/`teecup_api`/`teecup_frontend` urørt.
**Verifiseringsfallgruve, ikke app-bug:** Chrome DevTools MCP sin
`fill`-verktøy satte visuelt riktig verdi på et natively multi-
segment `<input type="date">`, men trigget ikke Reacts `onChange`
pålitelig -- `birth_date` lagret som `null` til tross for korrekt
visning rett før innsending. Bekreftet ved tastatur-drevet
`press_key` inn i dato-feltets spinbuttons i stedet -- lagret
korrekt. Kun et automatiseringsverktøy-kvirk med native dato-inputs,
ingen kodeendring nødvendig.
**Bevisst UTENFOR omfang:** samme "bla i eksisterende"-begrensning
finnes trolig også i lag-turneringers roster-tillegg
(`tournament-detail.tsx`, strukturelt identisk mønster) -- ikke rørt,
brukerens skjermbilder gjaldt spesifikt den org-individuelle skjermen.
**Rullet ut 2026-08-14** -- migrasjon 074 kjørt mot ekte `teecup_db`
som `teeoff_admin` (4 eksisterende spillere backfillet), etter
eksplisitt bekreftelse fra bruker. `docker compose build teecup_api
teecup_frontend && up -d` -- begge containere startet rent.

View file

@ -34,6 +34,14 @@ class PlayerCreate(BaseModel):
country: str | None = Field(default=None, max_length=100)
club: str | None = Field(default=None, max_length=200)
club_member_number: str | None = Field(default=None, max_length=50)
# Utvidet registreringsskjema (2026-08-14, migrasjon 074) -- for-/
# etternavn er en VALGFRI, separat kilde ved siden av display_name
# (som fortsatt er det som faktisk vises overalt ellers, uendret) --
# se migrasjonens header. paid/comment er ren registreringsadministrasjon.
first_name: str | None = Field(default=None, max_length=100)
last_name: str | None = Field(default=None, max_length=100)
paid: bool = False
comment: str | None = Field(default=None, max_length=2000)
class Player(BaseModel):
@ -48,11 +56,16 @@ class Player(BaseModel):
country: str | None
club: str | None
club_member_number: str | None
first_name: str | None
last_name: str | None
paid: bool
comment: str | None
_PLAYER_COLUMNS = """
id::text, display_name, handicap_index::float AS handicap_index, gender,
mobile, email, birth_date, nickname, country, club, club_member_number
mobile, email, birth_date, nickname, country, club, club_member_number,
first_name, last_name, paid, comment
"""
@ -77,8 +90,9 @@ async def create_player(
f"""
INSERT INTO player
(organization_id, display_name, handicap_index, gender,
mobile, email, birth_date, nickname, country, club, club_member_number)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
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,
@ -92,6 +106,10 @@ async def create_player(
body.country,
body.club,
body.club_member_number,
body.first_name,
body.last_name,
body.paid,
body.comment,
)
return Player(**dict(row))
@ -118,6 +136,10 @@ class PlayerUpdate(BaseModel):
country: str | None = Field(default=None, max_length=100)
club: str | None = Field(default=None, max_length=200)
club_member_number: str | None = Field(default=None, max_length=50)
first_name: str | None = Field(default=None, max_length=100)
last_name: str | None = Field(default=None, max_length=100)
paid: bool | None = None
comment: str | None = Field(default=None, max_length=2000)
@router.patch("/orgs/{organization_id}/players/{player_id}", response_model=Player)

View file

@ -56,6 +56,33 @@ type ApiPlayer = {
display_name: string
handicap_index: number | null
gender: "m" | "f" | "x" | null
// Utvidet registreringsskjema (2026-08-14, migrasjon 074).
first_name: string | null
last_name: string | null
birth_date: string | null // ISO yyyy-mm-dd
email: string | null
club_member_number: string | null
club: string | null
country: string | null
paid: boolean
comment: string | null
}
// Feltene sendt inn ved "Opprett ny spiller" (AddParticipantControl) --
// egen type fremfor å gjenbruke ApiPlayer direkte, siden dette er et
// SKJEMA (før spilleren finnes), ikke en allerede opprettet rad.
type NewPlayerInput = {
firstName: string
lastName: string
gender?: "m" | "f" | "x"
birthDate?: string
email?: string
clubMemberNumber?: string
club?: string
country?: string
handicapIndex?: number
paid: boolean
comment?: string
}
type ApiTournamentInfo = {
@ -414,16 +441,30 @@ export function IndividualTournamentDetail({
setParticipants((prev) => [...prev, created].sort((a, b) => a.player_name.localeCompare(b.player_name)))
}
async function addNewPlayerAndParticipant(name: string, handicap?: number, gender?: "m" | "f" | "x") {
async function addNewPlayerAndParticipant(input: NewPlayerInput) {
setError(null)
const displayName = `${input.firstName} ${input.lastName}`.trim()
const playerRes = await fetch(`/orgs/${organizationId}/players`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ display_name: name, handicap_index: handicap ?? null, gender: gender ?? null }),
body: JSON.stringify({
display_name: displayName,
first_name: input.firstName.trim() || null,
last_name: input.lastName.trim() || null,
handicap_index: input.handicapIndex ?? null,
gender: input.gender ?? null,
birth_date: input.birthDate || null,
email: input.email?.trim() || null,
club_member_number: input.clubMemberNumber?.trim() || null,
club: input.club?.trim() || null,
country: input.country?.trim() || null,
paid: input.paid,
comment: input.comment?.trim() || null,
}),
})
if (!playerRes.ok) {
setError("Klarte ikke å opprette spilleren.")
setError(await errorMessage(playerRes, "Klarte ikke å opprette spilleren."))
return
}
const player: ApiPlayer = await playerRes.json()
@ -735,7 +776,7 @@ function SetupTab({
onUpdateBbbSweepBonus: (enabled: boolean) => void
onUpdateFlagMapVisible: (visible: boolean) => void
onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
onRemoveParticipant: (id: string) => Promise<void>
onAddRound: (name: string, courseId: string, holeConfig: ApiRound["hole_config"]) => Promise<void>
onDeleteRound: (id: string) => Promise<void>
@ -1021,7 +1062,7 @@ function ParticipantsCard({
pool: ApiPlayer[]
classes: ApiTournamentClass[]
onAddParticipant: (playerId: string) => Promise<void>
onAddNewPlayer: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
onAddNewPlayer: (input: NewPlayerInput) => Promise<void>
onRemoveParticipant: (id: string) => Promise<void>
onSetClass: (participantId: string, classId: string | null) => Promise<void>
}) {
@ -1088,6 +1129,19 @@ function ParticipantsCard({
)
}
// Splitter et fritekst-navn i for-/etternavn -- samme "første ord =
// fornavn, resten = etternavn"-heuristikk som migrasjon 052/074 sin
// backfill, brukt her til å FORHÅNDSUTFYLLE opprett-skjemaet fra
// søkefeltet (fortsatt redigerbart -- kun et fornuftig utgangspunkt).
function splitName(fullName: string): { first: string; last: string } {
const trimmed = fullName.trim()
const spaceIndex = trimmed.indexOf(" ")
if (spaceIndex === -1) return { first: trimmed, last: "" }
return { first: trimmed.slice(0, spaceIndex), last: trimmed.slice(spaceIndex + 1).trim() }
}
const MAX_VISIBLE_MATCHES = 8
function AddParticipantControl({
pool,
alreadyIn,
@ -1097,20 +1151,31 @@ function AddParticipantControl({
pool: ApiPlayer[]
alreadyIn: Set<string>
onAddExisting: (playerId: string) => Promise<void>
onAddNew: (name: string, handicap?: number, gender?: "m" | "f" | "x") => Promise<void>
onAddNew: (input: NewPlayerInput) => Promise<void>
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [creating, setCreating] = useState(false)
const [firstName, setFirstName] = useState("")
const [lastName, setLastName] = useState("")
const [newHandicap, setNewHandicap] = useState("")
const [newGender, setNewGender] = useState<"m" | "f" | "x" | "">("")
const [showMoreDetails, setShowMoreDetails] = useState(false)
const [birthDate, setBirthDate] = useState("")
const [email, setEmail] = useState("")
const [clubMemberNumber, setClubMemberNumber] = useState("")
const [club, setClub] = useState("")
const [country, setCountry] = useState("")
const [paid, setPaid] = useState(false)
const [comment, setComment] = useState("")
const trimmed = query.trim()
const available = useMemo(() => pool.filter((p) => !alreadyIn.has(p.id)), [pool, alreadyIn])
const matches = useMemo(() => {
if (!trimmed) return []
if (!trimmed) return available
const q = trimmed.toLowerCase()
return pool.filter((p) => !alreadyIn.has(p.id) && p.display_name.toLowerCase().includes(q))
}, [pool, trimmed, alreadyIn])
return available.filter((p) => p.display_name.toLowerCase().includes(q))
}, [available, trimmed])
const exactMatch = useMemo(
() => pool.some((p) => p.display_name.toLowerCase() === trimmed.toLowerCase()),
[pool, trimmed],
@ -1120,8 +1185,18 @@ function AddParticipantControl({
function reset() {
setQuery("")
setCreating(false)
setFirstName("")
setLastName("")
setNewHandicap("")
setNewGender("")
setShowMoreDetails(false)
setBirthDate("")
setEmail("")
setClubMemberNumber("")
setClub("")
setCountry("")
setPaid(false)
setComment("")
}
async function handleAddExisting(playerId: string) {
@ -1130,11 +1205,30 @@ function AddParticipantControl({
setOpen(false)
}
function startCreating() {
const { first, last } = splitName(trimmed)
setFirstName(first)
setLastName(last)
setCreating(true)
}
async function handleCreate() {
if (trimmed.length < 2) return
if (firstName.trim().length === 0) return
const hcpValue = newHandicap.trim() === "" ? undefined : Number(newHandicap.replace(",", "."))
const hcp = hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined
await onAddNew(trimmed, hcp, newGender || undefined)
await onAddNew({
firstName,
lastName,
gender: newGender || undefined,
handicapIndex: hcp,
birthDate: birthDate || undefined,
email: email || undefined,
clubMemberNumber: clubMemberNumber || undefined,
club: club || undefined,
country: country || undefined,
paid,
comment: comment || undefined,
})
reset()
setOpen(false)
}
@ -1158,7 +1252,7 @@ function AddParticipantControl({
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="Søk eller skriv navn…"
placeholder="Søk blant eksisterende, eller skriv navnet på en ny…"
value={query}
onChange={(e) => {
setQuery(e.target.value)
@ -1187,9 +1281,21 @@ function AddParticipantControl({
</Button>
</div>
{!creating && (
<>
{matches.length === 0 && available.length === 0 && (
<p className="px-1 text-sm text-muted-foreground">
{pool.length === 0
? "Ingen eksisterende spillere i organisasjonen ennå -- opprett den første under."
: "Alle eksisterende spillere er allerede lagt til -- opprett en ny under."}
</p>
)}
{matches.length === 0 && available.length > 0 && trimmed.length > 0 && (
<p className="px-1 text-sm text-muted-foreground">Ingen treff blant eksisterende spillere.</p>
)}
{matches.length > 0 && (
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
{matches.slice(0, 6).map((p) => (
{matches.slice(0, MAX_VISIBLE_MATCHES).map((p) => (
<li key={p.id}>
<button
type="button"
@ -1203,11 +1309,18 @@ function AddParticipantControl({
))}
</ul>
)}
{matches.length > MAX_VISIBLE_MATCHES && (
<p className="px-1 text-xs text-muted-foreground">
+{matches.length - MAX_VISIBLE_MATCHES} flere -- fortsett å skrive for å begrense listen.
</p>
)}
</>
)}
{showCreate && !creating && (
<button
type="button"
onClick={() => setCreating(true)}
onClick={startCreating}
className="flex min-h-11 items-center gap-2 rounded-xl border border-dashed border-border px-3 py-2.5 text-left text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<UserPlus aria-hidden="true" className="size-4" />
@ -1216,21 +1329,33 @@ function AddParticipantControl({
)}
{showCreate && creating && (
<div className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3">
<div className="flex flex-col gap-3 rounded-xl border border-border bg-background p-3">
<div className="flex gap-2">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-hcp" className="text-xs font-semibold">
HCP (valgfritt)
<Label htmlFor="new-participant-first-name" className="text-xs font-semibold">
Fornavn
</Label>
<Input
id="new-participant-hcp"
inputMode="decimal"
placeholder="F.eks. 18,4"
value={newHandicap}
onChange={(e) => setNewHandicap(e.target.value)}
id="new-participant-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-last-name" className="text-xs font-semibold">
Etternavn
</Label>
<Input
id="new-participant-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
</div>
<div className="flex gap-2">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-gender" className="text-xs font-semibold">
Kjønn (for HCP-beregning)
@ -1247,9 +1372,127 @@ function AddParticipantControl({
<option value="x">Annet</option>
</select>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-hcp" className="text-xs font-semibold">
HCP (valgfritt)
</Label>
<Input
id="new-participant-hcp"
inputMode="decimal"
placeholder="F.eks. 18,4"
value={newHandicap}
onChange={(e) => setNewHandicap(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
<Button type="button" onClick={handleCreate} className="h-10 rounded-xl text-sm font-bold">
Opprett og legg til «{trimmed}»
</div>
{!showMoreDetails ? (
<button
type="button"
onClick={() => setShowMoreDetails(true)}
className="min-h-9 text-left text-xs font-bold text-primary"
>
+ Flere detaljer (fødselsdato, e-post, klubb, betalt m.m.)
</button>
) : (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex gap-2">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-birth-date" className="text-xs font-semibold">
Fødselsdato
</Label>
<Input
id="new-participant-birth-date"
type="date"
value={birthDate}
onChange={(e) => setBirthDate(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-email" className="text-xs font-semibold">
E-postadresse
</Label>
<Input
id="new-participant-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
</div>
<div className="flex gap-2">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-club" className="text-xs font-semibold">
Hjemmeklubb
</Label>
<Input
id="new-participant-club"
value={club}
onChange={(e) => setClub(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="new-participant-club-member-number" className="text-xs font-semibold">
Medlemsnummer i hjemmeklubb
</Label>
<Input
id="new-participant-club-member-number"
value={clubMemberNumber}
onChange={(e) => setClubMemberNumber(e.target.value)}
className="h-10 rounded-xl text-sm"
/>
</div>
</div>
<div className="flex flex-col gap-1">
<Label htmlFor="new-participant-country" className="text-xs font-semibold">
Land
</Label>
<Input
id="new-participant-country"
value={country}
onChange={(e) => setCountry(e.target.value)}
className="h-10 w-full rounded-xl text-sm sm:w-1/2"
/>
</div>
<label className="flex min-h-9 items-center gap-2 text-sm font-medium text-foreground">
<input
type="checkbox"
checked={paid}
onChange={(e) => setPaid(e.target.checked)}
className="size-5 shrink-0 accent-primary"
/>
Betalt
</label>
<div className="flex flex-col gap-1">
<Label htmlFor="new-participant-comment" className="text-xs font-semibold">
Kommentar
</Label>
<textarea
id="new-participant-comment"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={2}
className="w-full resize-y rounded-xl border border-border bg-card px-3 py-2 text-sm text-foreground outline-none"
/>
</div>
</div>
)}
<Button
type="button"
onClick={handleCreate}
disabled={firstName.trim().length === 0}
className="h-10 rounded-xl text-sm font-bold"
>
Opprett og legg til «{`${firstName} ${lastName}`.trim()}»
</Button>
</div>
)}