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:
parent
25b50d4e5b
commit
a8a2c602ce
5 changed files with 497 additions and 41 deletions
38
074_player_extended_fields.sql
Normal file
38
074_player_extended_fields.sql
Normal 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;
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
63
CHANGELOG.md
63
CHANGELOG.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,27 +1281,46 @@ function AddParticipantControl({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{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) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddExisting(p.id)}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
|
||||
>
|
||||
{p.display_name}
|
||||
<Plus aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!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, MAX_VISIBLE_MATCHES).map((p) => (
|
||||
<li key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddExisting(p.id)}
|
||||
className="flex min-h-11 w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/50"
|
||||
>
|
||||
{p.display_name}
|
||||
<Plus aria-hidden="true" className="size-4 shrink-0 text-primary" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</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>
|
||||
</div>
|
||||
<Button type="button" onClick={handleCreate} className="h-10 rounded-xl text-sm font-bold">
|
||||
Opprett og legg til «{trimmed}»
|
||||
|
||||
{!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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue