- "Administrer runde"-dialogen lukker seg nå automatisk ved navigasjon til "Spillere og runde" (var stående åpen ved samme-rute-navigasjon). - HCP kan nå overstyres for en kontokoblet medspiller fra første stund i veiviseren, ikke bare senere via "Runde og runde". Backend sitt add_participant æret aldri en eksplisitt overstyring for user_id, ulikt PATCH-endepunktet (ADR-038-avvik, nå rettet). - Standard statistikknivå for en NY medspiller er nå "Kun slag" i stedet for å arve scoreførerens eget valg. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
636 lines
22 KiB
TypeScript
636 lines
22 KiB
TypeScript
"use client"
|
||
|
||
import { apiGenderOf, lookupKnownGuest, searchAccounts } from "@/lib/ny-runde/api"
|
||
import type { Account, Course, Gender, KnownGuest, Player, StatLevel } from "@/lib/ny-runde/types"
|
||
import { cn } from "@/lib/utils"
|
||
import { ChevronDown, Search, Sparkles, UserPlus, X } from "lucide-react"
|
||
import { useEffect, useMemo, useRef, useState } from "react"
|
||
import { Avatar, Field, NativeSelect, Pill, TextInput, ToggleButton, useDebouncedValue } from "./primitives"
|
||
import type { Tee } from "@/lib/ny-runde/types"
|
||
import { useWizard } from "./wizard-context"
|
||
|
||
const GENDERS: { value: Gender; label: string }[] = [
|
||
{ value: "mann", label: "Mann" },
|
||
{ value: "kvinne", label: "Kvinne" },
|
||
{ value: "annet", label: "Annet" },
|
||
]
|
||
|
||
const STAT: { value: StatLevel; label: string }[] = [
|
||
{ value: "score", label: "Kun slag" },
|
||
{ value: "score_putts", label: "Slag + putter" },
|
||
{ value: "full", label: "Alt" },
|
||
]
|
||
|
||
let keyCounter = 0
|
||
function nextKey() {
|
||
keyCounter += 1
|
||
return `p-${Date.now()}-${keyCounter}`
|
||
}
|
||
|
||
function teeName(course: Course | undefined, teeId?: string) {
|
||
if (!course || !teeId) return "Ikke valgt"
|
||
return course.tees.find((t) => t.id === teeId)?.name ?? "Ikke valgt"
|
||
}
|
||
|
||
function compatibleTees(course: Course | undefined, gender?: Gender) {
|
||
if (!course) return []
|
||
if (!gender) return course.tees // ukjent kjønn (nylig lenket konto): vis alle
|
||
const g = apiGenderOf(gender)
|
||
if (g === "x") return course.tees
|
||
return course.tees.filter((t) => t.genders.includes(g))
|
||
}
|
||
|
||
export function Step3Players() {
|
||
const { state, patch } = useWizard()
|
||
const [adding, setAdding] = useState(false)
|
||
|
||
function updatePlayer(key: string, p: Partial<Player>) {
|
||
patch({ players: state.players.map((pl) => (pl.key === key ? { ...pl, ...p } : pl)) })
|
||
}
|
||
function removePlayer(key: string) {
|
||
patch({ players: state.players.filter((pl) => pl.key !== key) })
|
||
}
|
||
function addPlayer(p: Player) {
|
||
patch({ players: [...state.players, p] })
|
||
setAdding(false)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<div>
|
||
<h2 className="text-lg font-semibold tracking-tight text-[var(--nr-ink)]">Spillere</h2>
|
||
<p className="text-sm text-[var(--nr-muted)]">
|
||
{state.players.length} {state.players.length === 1 ? "spiller" : "spillere"} i runden.
|
||
</p>
|
||
</div>
|
||
|
||
<ul className="flex flex-col gap-2">
|
||
{state.players.map((player) => (
|
||
<li key={player.key}>
|
||
<PlayerCard
|
||
player={player}
|
||
course={state.course}
|
||
onChange={(p) => updatePlayer(player.key, p)}
|
||
onRemove={() => removePlayer(player.key)}
|
||
/>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
{adding ? (
|
||
<AddPlayerPanel
|
||
course={state.course}
|
||
// Standard for EN ANNEN spiller enn den som fører score er "Kun
|
||
// slag", uansett hva scorefører selv har valgt for seg selv
|
||
// (2026-08-20 brukerfunn -- speilet tidligere feilaktig
|
||
// state.statLevel, som normalt er scoreførerens EGET, ofte
|
||
// høyere nivå). Organisatoren kan fortsatt sette et høyere nivå
|
||
// manuelt per medspiller.
|
||
defaultStat="score"
|
||
existing={state.players}
|
||
onCancel={() => setAdding(false)}
|
||
onAdd={addPlayer}
|
||
/>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => setAdding(true)}
|
||
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-xl border border-dashed border-[var(--nr-border-strong)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-accent)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||
>
|
||
<UserPlus className="h-4 w-4" aria-hidden="true" />
|
||
Medspiller
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Player card */
|
||
function PlayerCard({
|
||
player,
|
||
course,
|
||
onChange,
|
||
onRemove,
|
||
}: {
|
||
player: Player
|
||
course?: Course
|
||
onChange: (p: Partial<Player>) => void
|
||
onRemove: () => void
|
||
}) {
|
||
const [open, setOpen] = useState(false)
|
||
const fullName = `${player.firstName}${player.lastName ? ` ${player.lastName}` : ""}`.trim()
|
||
const isOwner = player.kind === "owner"
|
||
const hcpText = player.hcpFromProfile
|
||
? player.hcp != null
|
||
? String(player.hcp)
|
||
: "fra profil"
|
||
: player.hcp != null
|
||
? String(player.hcp)
|
||
: "–"
|
||
|
||
const tees = compatibleTees(course, player.gender)
|
||
|
||
return (
|
||
<div className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface)]">
|
||
<div className="flex items-center gap-3 p-3">
|
||
<Avatar first={player.firstName || "?"} last={player.lastName} />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-1.5">
|
||
<span className="text-sm font-semibold text-[var(--nr-ink)]">{fullName || "Ny spiller"}</span>
|
||
{isOwner ? <Pill tone="accent">Deg · Eier</Pill> : null}
|
||
{player.isGuest ? <Pill tone="neutral">Gjest</Pill> : null}
|
||
</div>
|
||
<p className="truncate text-xs text-[var(--nr-muted)]">
|
||
Utslag: {teeName(course, player.teeId)} · HCP: {hcpText}
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((o) => !o)}
|
||
aria-expanded={open}
|
||
aria-label={open ? `Skjul redigering av ${fullName}` : `Rediger ${fullName}`}
|
||
className="flex h-10 w-10 items-center justify-center rounded-lg border border-[var(--nr-border)] text-[var(--nr-muted)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||
>
|
||
<ChevronDown className={cn("h-4 w-4 transition-transform", open && "rotate-180")} aria-hidden="true" />
|
||
</button>
|
||
{!isOwner ? (
|
||
<button
|
||
type="button"
|
||
onClick={onRemove}
|
||
aria-label={`Fjern ${fullName}`}
|
||
className="flex h-10 w-10 items-center justify-center rounded-lg border border-[var(--nr-border)] text-[var(--nr-muted)] transition-colors hover:text-[var(--nr-danger)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||
>
|
||
<X className="h-4 w-4" aria-hidden="true" />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
{open ? (
|
||
<div className="flex flex-col gap-4 border-t border-[var(--nr-border)] p-4">
|
||
{player.isGuest ? (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Fornavn" htmlFor={`${player.key}-first`}>
|
||
<TextInput
|
||
id={`${player.key}-first`}
|
||
value={player.firstName}
|
||
onChange={(e) => onChange({ firstName: e.target.value })}
|
||
invalid={player.firstName.trim() === ""}
|
||
/>
|
||
</Field>
|
||
<Field label="Etternavn" optional htmlFor={`${player.key}-last`}>
|
||
<TextInput
|
||
id={`${player.key}-last`}
|
||
value={player.lastName}
|
||
onChange={(e) => onChange({ lastName: e.target.value })}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
<Field label="Kjønn">
|
||
<GenderChoice
|
||
value={player.gender ?? "mann"}
|
||
onChange={(g) => {
|
||
const stillOk = compatibleTees(course, g).some((t) => t.id === player.teeId)
|
||
onChange({ gender: g, teeId: stillOk ? player.teeId : compatibleTees(course, g)[0]?.id })
|
||
}}
|
||
/>
|
||
</Field>
|
||
<Field label="E-post" optional htmlFor={`${player.key}-email`}>
|
||
<TextInput
|
||
id={`${player.key}-email`}
|
||
type="email"
|
||
value={player.email ?? ""}
|
||
onChange={(e) => onChange({ email: e.target.value })}
|
||
/>
|
||
</Field>
|
||
</>
|
||
) : null}
|
||
|
||
<Field label="Utslag" htmlFor={`${player.key}-tee`}>
|
||
<NativeSelect
|
||
id={`${player.key}-tee`}
|
||
value={player.teeId ?? ""}
|
||
onChange={(e) => onChange({ teeId: e.target.value })}
|
||
>
|
||
<option value="">Velg utslag</option>
|
||
{tees.map((t) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
</NativeSelect>
|
||
</Field>
|
||
|
||
{player.isGuest ? (
|
||
<Field label="HCP" optional htmlFor={`${player.key}-hcp`}>
|
||
<TextInput
|
||
id={`${player.key}-hcp`}
|
||
inputMode="decimal"
|
||
value={player.hcp != null ? String(player.hcp) : ""}
|
||
onChange={(e) => {
|
||
const v = e.target.value
|
||
onChange({ hcp: v.trim() === "" ? null : Number.parseFloat(v.replace(",", ".")) })
|
||
}}
|
||
className="max-w-[10rem]"
|
||
/>
|
||
</Field>
|
||
) : player.hcpFromProfile && !isOwner ? (
|
||
// Redigerbar fra FØRSTE stund (2026-08-20 brukerfunn) -- var
|
||
// tidligere en read-only visning ("hentes fra profilen"), som
|
||
// tvang organisatoren til å legge til spilleren FØRST og
|
||
// overstyre HCP-en senere via "Runde og spillere" (der
|
||
// overstyring allerede fungerte, samme PATCH-felt). Forhånds-
|
||
// utfylt med profilverdien; urørt betyr fortsatt "bruk
|
||
// profilen", akkurat som før.
|
||
<Field
|
||
label="HCP"
|
||
optional
|
||
htmlFor={`${player.key}-hcp`}
|
||
hint="Hentes fra spillerens profil dersom du lar feltet stå urørt."
|
||
>
|
||
<TextInput
|
||
id={`${player.key}-hcp`}
|
||
inputMode="decimal"
|
||
placeholder={player.hcp == null ? "Henter fra profil…" : undefined}
|
||
value={player.hcp != null ? String(player.hcp) : ""}
|
||
onChange={(e) => {
|
||
const v = e.target.value
|
||
onChange({ hcp: v.trim() === "" ? null : Number.parseFloat(v.replace(",", ".")) })
|
||
}}
|
||
className="max-w-[10rem]"
|
||
/>
|
||
</Field>
|
||
) : null}
|
||
|
||
<Field label="Statistikk">
|
||
<StatChoice value={player.statLevel} onChange={(s) => onChange({ statLevel: s })} />
|
||
</Field>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* Legg til-panel (søk + gjest) */
|
||
function AddPlayerPanel({
|
||
course,
|
||
defaultStat,
|
||
existing,
|
||
onCancel,
|
||
onAdd,
|
||
}: {
|
||
course?: Course
|
||
defaultStat: StatLevel
|
||
existing: Player[]
|
||
onCancel: () => void
|
||
onAdd: (p: Player) => void
|
||
}) {
|
||
const [mode, setMode] = useState<"search" | "guest">("search")
|
||
const [query, setQuery] = useState("")
|
||
const debounced = useDebouncedValue(query, 250)
|
||
const [results, setResults] = useState<Account[]>([])
|
||
|
||
// gjesteskjema
|
||
const [first, setFirst] = useState("")
|
||
const [last, setLast] = useState("")
|
||
const [email, setEmail] = useState("")
|
||
const [gender, setGender] = useState<Gender>("mann")
|
||
const [hcp, setHcp] = useState("")
|
||
const [stat, setStat] = useState<StatLevel>(defaultStat)
|
||
const [teeId, setTeeId] = useState<string | undefined>(() => compatibleTees(course, "mann")[0]?.id)
|
||
const firstRef = useRef<HTMLInputElement>(null)
|
||
|
||
useEffect(() => {
|
||
if (mode === "guest") firstRef.current?.focus()
|
||
}, [mode])
|
||
|
||
const existingAccountIds = new Set(existing.map((p) => p.accountId).filter(Boolean))
|
||
|
||
useEffect(() => {
|
||
const q = debounced.trim()
|
||
if (q.length < 2) {
|
||
setResults([])
|
||
return
|
||
}
|
||
let cancelled = false
|
||
void searchAccounts(q).then((r) => {
|
||
if (!cancelled) setResults(r.filter((a) => !existingAccountIds.has(a.id)))
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [debounced])
|
||
|
||
function switchToGuest() {
|
||
const parts = query.trim().split(/\s+/)
|
||
setFirst(parts[0] ?? "")
|
||
setLast(parts.slice(1).join(" "))
|
||
setMode("guest")
|
||
}
|
||
|
||
function addAccount(acc: Account) {
|
||
onAdd({
|
||
key: nextKey(),
|
||
kind: "account",
|
||
accountId: acc.id,
|
||
firstName: acc.firstName,
|
||
lastName: acc.lastName,
|
||
gender: undefined, // ukjent klient-side inntil profilen lastes
|
||
isGuest: false,
|
||
hcp: acc.handicap,
|
||
hcpFromProfile: true,
|
||
teeId: course?.tees[0]?.id,
|
||
statLevel: defaultStat,
|
||
})
|
||
}
|
||
|
||
function addGuest() {
|
||
if (first.trim() === "") return
|
||
onAdd({
|
||
key: nextKey(),
|
||
kind: "guest",
|
||
firstName: first.trim(),
|
||
lastName: last.trim(),
|
||
gender,
|
||
isGuest: true,
|
||
email: email.trim() || undefined,
|
||
hcp: hcp.trim() === "" ? null : Number.parseFloat(hcp.replace(",", ".")),
|
||
teeId,
|
||
statLevel: stat,
|
||
})
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-xl border border-[var(--nr-accent-ring)] bg-[var(--nr-surface)] p-4">
|
||
<div className="mb-3 flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Legg til medspiller</h3>
|
||
<button
|
||
type="button"
|
||
onClick={onCancel}
|
||
aria-label="Lukk"
|
||
className="flex h-9 w-9 items-center justify-center rounded-lg text-[var(--nr-muted)] hover:bg-[var(--nr-surface-2)]"
|
||
>
|
||
<X className="h-4 w-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
|
||
{mode === "search" ? (
|
||
<div className="flex flex-col gap-3">
|
||
<Field label="Navn på medspilleren" htmlFor="add-search" hint="Skriv minst 2 tegn for å søke.">
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--nr-faint)]" aria-hidden="true" />
|
||
<TextInput
|
||
id="add-search"
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="Søk etter en konto…"
|
||
className="pl-9"
|
||
autoComplete="off"
|
||
/>
|
||
</div>
|
||
</Field>
|
||
|
||
{debounced.trim().length >= 2 ? (
|
||
results.length > 0 ? (
|
||
<ul className="flex flex-col gap-2">
|
||
{results.map((a) => (
|
||
<li key={a.id}>
|
||
<button
|
||
type="button"
|
||
onClick={() => addAccount(a)}
|
||
className="flex min-h-14 w-full items-center gap-3 rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-3 py-2 text-left transition-colors hover:border-[var(--nr-border-strong)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
|
||
>
|
||
<Avatar first={a.firstName} last={a.lastName} />
|
||
<span className="flex-1">
|
||
<span className="block text-sm font-medium text-[var(--nr-ink)]">{a.firstName} {a.lastName}</span>
|
||
<span className="block text-xs text-[var(--nr-muted)]">{a.handicap != null ? `HCP ${a.handicap}` : "Ingen HCP registrert"}</span>
|
||
</span>
|
||
<UserPlus className="h-4 w-4 text-[var(--nr-accent)]" aria-hidden="true" />
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : (
|
||
<p className="text-sm text-[var(--nr-muted)]">Ingen kontoer matcher søket.</p>
|
||
)
|
||
) : null}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={switchToGuest}
|
||
className="text-left text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
|
||
>
|
||
Legg til uten konto (gjest)
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<GuestForm
|
||
course={course}
|
||
first={first}
|
||
setFirst={setFirst}
|
||
last={last}
|
||
setLast={setLast}
|
||
email={email}
|
||
setEmail={setEmail}
|
||
gender={gender}
|
||
setGender={setGender}
|
||
hcp={hcp}
|
||
setHcp={setHcp}
|
||
stat={stat}
|
||
setStat={setStat}
|
||
teeId={teeId}
|
||
setTeeId={setTeeId}
|
||
firstRef={firstRef}
|
||
onBackToSearch={() => setMode("search")}
|
||
onSubmit={addGuest}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function GuestForm({
|
||
course,
|
||
first,
|
||
setFirst,
|
||
last,
|
||
setLast,
|
||
email,
|
||
setEmail,
|
||
gender,
|
||
setGender,
|
||
hcp,
|
||
setHcp,
|
||
stat,
|
||
setStat,
|
||
teeId,
|
||
setTeeId,
|
||
firstRef,
|
||
onBackToSearch,
|
||
onSubmit,
|
||
}: {
|
||
course?: Course
|
||
first: string
|
||
setFirst: (v: string) => void
|
||
last: string
|
||
setLast: (v: string) => void
|
||
email: string
|
||
setEmail: (v: string) => void
|
||
gender: Gender
|
||
setGender: (v: Gender) => void
|
||
hcp: string
|
||
setHcp: (v: string) => void
|
||
stat: StatLevel
|
||
setStat: (v: StatLevel) => void
|
||
teeId: string | undefined
|
||
setTeeId: (v: string | undefined) => void
|
||
firstRef: React.RefObject<HTMLInputElement | null>
|
||
onBackToSearch: () => void
|
||
onSubmit: () => void
|
||
}) {
|
||
const tees = compatibleTees(course, gender)
|
||
// kjent-gjest-oppslag på e-post (scoped til DENNE brukeren server-side)
|
||
const debouncedEmail = useDebouncedValue(email, 350)
|
||
const [match, setMatch] = useState<KnownGuest | null>(null)
|
||
|
||
useEffect(() => {
|
||
const e = debouncedEmail.trim()
|
||
if (e.length < 5 || !e.includes("@")) {
|
||
setMatch(null)
|
||
return
|
||
}
|
||
let cancelled = false
|
||
void lookupKnownGuest(e).then((g) => {
|
||
if (!cancelled) setMatch(g)
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [debouncedEmail])
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onBackToSearch}
|
||
className="self-start text-[13px] font-medium text-[var(--nr-accent)] underline-offset-2 hover:underline"
|
||
>
|
||
← Søk etter konto i stedet
|
||
</button>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field label="Fornavn" htmlFor="g-first">
|
||
<TextInput id="g-first" ref={firstRef} value={first} onChange={(e) => setFirst(e.target.value)} invalid={first.trim() === ""} />
|
||
</Field>
|
||
<Field label="Etternavn" optional htmlFor="g-last">
|
||
<TextInput id="g-last" value={last} onChange={(e) => setLast(e.target.value)} />
|
||
</Field>
|
||
</div>
|
||
|
||
<Field label="E-post" optional htmlFor="g-email">
|
||
<TextInput id="g-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="navn@example.com" />
|
||
</Field>
|
||
|
||
{match ? (
|
||
<div className="flex items-start gap-3 rounded-lg border border-[var(--nr-accent-ring)] bg-[var(--nr-accent-soft)] p-3">
|
||
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-[var(--nr-accent)]" aria-hidden="true" />
|
||
<div className="flex-1">
|
||
<p className="text-[13px] text-[var(--nr-ink)]">
|
||
Vi fant {match.firstName} {match.lastName} fra en tidligere runde du satte opp.
|
||
</p>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setFirst(match.firstName)
|
||
setLast(match.lastName)
|
||
const matchedGender = match.gender === "m" ? "mann" : match.gender === "f" ? "kvinne" : null
|
||
if (matchedGender) {
|
||
const stillOk = compatibleTees(course, matchedGender).some((t) => t.id === teeId)
|
||
setGender(matchedGender)
|
||
if (!stillOk) setTeeId(compatibleTees(course, matchedGender)[0]?.id)
|
||
}
|
||
if (match.hcp != null) setHcp(String(match.hcp))
|
||
}}
|
||
className="mt-1 text-[13px] font-semibold text-[var(--nr-accent)] underline-offset-2 hover:underline"
|
||
>
|
||
Fyll inn automatisk
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<Field label="Kjønn">
|
||
<GenderChoice
|
||
value={gender}
|
||
onChange={(g) => {
|
||
const stillOk = compatibleTees(course, g).some((t) => t.id === teeId)
|
||
setGender(g)
|
||
if (!stillOk) setTeeId(compatibleTees(course, g)[0]?.id)
|
||
}}
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Utslag" htmlFor="g-tee">
|
||
<NativeSelect id="g-tee" value={teeId ?? ""} onChange={(e) => setTeeId(e.target.value || undefined)}>
|
||
<option value="">Velg utslag</option>
|
||
{tees.map((t: Tee) => (
|
||
<option key={t.id} value={t.id}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
</NativeSelect>
|
||
</Field>
|
||
|
||
<Field label="HCP" optional htmlFor="g-hcp">
|
||
<TextInput
|
||
id="g-hcp"
|
||
inputMode="decimal"
|
||
value={hcp}
|
||
onChange={(e) => setHcp(e.target.value)}
|
||
className="max-w-[10rem]"
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Statistikk for denne spilleren">
|
||
<StatChoice value={stat} onChange={setStat} />
|
||
</Field>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onSubmit}
|
||
disabled={first.trim() === ""}
|
||
className="mt-1 inline-flex min-h-11 items-center justify-center rounded-lg bg-[var(--nr-accent)] px-5 text-sm font-semibold text-[var(--nr-accent-ink)] transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
|
||
>
|
||
Legg til gjest
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
function GenderChoice({ value, onChange }: { value: Gender; onChange: (v: Gender) => void }) {
|
||
return (
|
||
<div className="flex gap-2">
|
||
{GENDERS.map((g) => (
|
||
<ToggleButton key={g.value} active={value === g.value} onClick={() => onChange(g.value)} className="flex-1">
|
||
{g.label}
|
||
</ToggleButton>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StatChoice({ value, onChange }: { value: StatLevel; onChange: (v: StatLevel) => void }) {
|
||
return (
|
||
<div className="flex gap-2">
|
||
{STAT.map((s) => (
|
||
<ToggleButton key={s.value} active={value === s.value} onClick={() => onChange(s.value)} className="flex-1">
|
||
{s.label}
|
||
</ToggleButton>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|