teecup/frontend/components/ny-runde/step3-players.tsx
Erol Haagenrud a0163eaa16 Ny runde-veiviseren: tre fikser (overleggsmeny, HCP-overstyring, statistikk-default)
- "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>
2026-08-21 08:05:05 +02:00

636 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
)
}