diff --git a/.claude/settings.local.json b/.claude/settings.local.json index a8e4131..752ce26 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -178,7 +178,16 @@ "Bash(python3 -m zipfile -l \"tee-cup-login-screen \\(1\\).zip\")", "Bash(python3 -m zipfile -e \"tee-cup-login-screen \\(1\\).zip\" /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0_dashboard_export/)", "Bash(curl -s -o /dev/null -w 'teecup.teeoff.no/dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)", - "Bash(grep -n \"Selve\\\\s*$\\\\|skjerm-/skrive-flyten IKKE testet\" /opt/teecup/CLAUDE.md)" + "Bash(grep -n \"Selve\\\\s*$\\\\|skjerm-/skrive-flyten IKKE testet\" /opt/teecup/CLAUDE.md)", + "Bash(python3 -m zipfile -l \"tee-cup-login-screen \\(2\\).zip\")", + "Bash(python3 -m py_compile /opt/teecup/app/routers/tournaments.py)", + "Bash(cp .env .env.scratch)", + "Bash(sed -i -e 's#^TEECUP_DB_NAME=.*#TEECUP_DB_NAME=teecup_scratch#' -e 's#^TEECUP_DEV_LOG_MAGIC_LINKS=.*#TEECUP_DEV_LOG_MAGIC_LINKS=true#' .env.scratch)", + "Bash(echo \"TEECUP_DEV_LOG_MAGIC_LINKS=true\")", + "Bash(break)", + "Bash(rm -f /opt/teecup/.env.scratch /tmp/roster_cookies.txt)", + "Bash(curl -s -o /dev/null -w 'dashboard: %{http_code}\\\\n' https://teecup.teeoff.no/dashboard)", + "Bash(curl -s -o /dev/null -w 'health: %{http_code}\\\\n' https://teecup.teeoff.no/health)" ], "additionalDirectories": [ "/opt/teeoff/deploy", diff --git a/CLAUDE.md b/CLAUDE.md index 62ccbe5..6074293 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -329,12 +329,52 @@ Ferdig og verifisert: dato), ett-org-visningen viste riktig uten unødvendig bytter-UI. Første gang en HEL skrive-flyt (ikke bare lesing) er bevist ende-til-ende fra frontend mot ekte produksjonsdata. +- **Lag/roster-skjerm LIVE (2026-07-18):** tredje V0-skjerm + (`/tournaments/[id]`), samme re-eksport-mønster som dashboard-runden — + diffet mot live-treet, tok kun inn `tournament-detail.tsx` og et + `Link`-basert `tournament-card.tsx` (navigasjon fra dashbordet). + **URL-design bevisst avvikende fra V0s forslag:** V0s genererte side leste + aldri `params.id` og hadde ingen organization_id i det hele tatt — holdt + derfor V0s flate `/tournaments/[id]`-struktur (i stedet for en nøstet + `/orgs/[orgId]/tournaments/[id]`, som ville krevd manuell ombygging ved + HVER fremtidig V0-reeksport) og la `org`+`name` til som søkeparametre i + `tournament-card.tsx` sin lenke — API-et krever organization_id på alle + team-/roster-kall (RLS). + **Reelt hull funnet FØR integrering, ikke etter:** V0-skjermen bygger inn + "fjern spiller"/"gjør til kaptein"-handlinger, men backend hadde KUN + GET/POST på `team_roster` — ingen DELETE eller PATCH. Spurte bruker + eksplisitt (samme mønster som andre scope-avklaringer denne økten) — svar: + bygg de to endepunktene nå. Lagt til i `app/routers/tournaments.py`: + `PATCH .../roster/{roster_id}` (bevisst enkel — setter/fjerner + `is_captain` på NØYAKTIG denne raden, håndhever IKKE "kun én kaptein per + lag", siden kaptein fortsatt bare er et merke, ikke en egen + autorisasjonsrolle) og `DELETE .../roster/{roster_id}` (204, idempotent + `NOT_FOUND` ved dobbel sletting — ikke krasj). + **`tournament-detail.tsx` sitt datalag skrevet om fra V0s mock:** henter + lag + roster (roster-radene bærer allerede `display_name`/ + `handicap_index_snapshot` fra APIet, så V0s separate `poolById`-oppslag + ble fjernet som overflødig) og organisasjonens spillerpool + (`GET /orgs/{id}/players`, brukt til type-ahead ved "legg til spiller"). + Alle fem mutasjonene (opprett lag, legg til eksisterende spiller, opprett + ny spiller inline + rostre, endre kaptein, fjern fra roster) kablet mot + ekte endepunkter — presentasjonskomponentene (kort, type-ahead, + fargevelger, bekreft-fjerning) beholdt uendret fra V0. + **Verifisert:** de to nye endepunktene testet mot en fersk + `teecup_scratch` (PATCH setter kaptein + riktig `NOT_FOUND` på ugyldig id, + DELETE gir 204 + idempotent `NOT_FOUND` ved gjentak, `test_isolation.sql` + fortsatt 12/12), ekte typesjekket frontend-build (5 ruter), redeploy av + BEGGE containere (backend-endepunktene er nye), `teecup.teeoff.no/dashboard` + → 200, `teeoff.no` upåvirket. Selve skrive-flyten på `/tournaments/[id]` + (opprett lag/roster) ikke testet med ekte data i denne runden — venter på + brukeren, samme mønster som dashboard-rundens skrive-test. Neste steg: -1. Flere V0-skjermer (lag/roster, økt/program, blind draw, scorekort, - leaderboard) — samme mønster: design i V0 (fortsett i samme prosjekt), - FORVENT en full re-eksport neste gang også — diff mot live-treet i et - scratch-område før noe pakkes ut over eksisterende filer (se - dashboard-runden over for hvorfor dette er nødvendig hver gang). +1. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) — + samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full + re-eksport hver gang — diff mot live-treet i et scratch-område før noe + pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn + handlinger backend ikke støtter ennå FØR integrering (se + dashboard-/roster-rundene over for hvorfor begge er nødvendige hver + gang). 2. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet. 3. Kommunikasjon (chat/feed) — ikke startet. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index a206b21..9a37f5f 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -37,6 +37,8 @@ | i18n-forberedelse (nb/en) | ✅ | ADR-015. `preferred_locale` + ekte engelsk e-postmal. Se egen seksjon under. | | Frontend: innlogging + verifisering, LIVE | ✅ | ADR-016. Next.js på `teecup.teeoff.no`, ekte magic-link-flyt bevist med reell e-post. Se egen seksjon under. | | Frontend: dashboard (org-bytter/-opprettelse + turneringsliste), LIVE | ✅ | `/dashboard`. Kablet mot `/auth/me`, `/orgs`, `/orgs/{id}/tournaments`. Skrive-flyt bekreftet med ekte data (org "Tjøme Gents" + turnering opprettet av bruker). | +| Frontend: lag/roster-skjerm, LIVE | ✅ | `/tournaments/[id]`. To nye backend-endepunkter bygget samtidig (`PATCH`/`DELETE` roster). Skrive-flyt ikke testet med ekte data ennå. | +| Roster: endre kaptein / fjern spiller (`PATCH`/`DELETE`) | ✅ | `app/routers/tournaments.py`. Bevisst ingen "kun én kaptein"-håndhevelse ennå — se «Brukerroller»-punktet under. | --- @@ -300,6 +302,10 @@ - **Mangler:** «tilskuer» som begrep. Henger sammen med hvem som ser den offentlige feeden (se Kommunikasjon). Kaptein-rollen bør kanskje gi spesifikke rettigheter (sette oppstilling), ikke bare være et flagg. +- **Nytt 2026-07-18:** `PATCH .../roster/{id}` (sette/fjerne kaptein) håndhever + bevisst IKKE «kun én kaptein per lag» — flere spillere kan i dag merkes + kaptein samtidig på samme lag. Bør revurderes samtidig med resten av dette + punktet, ikke løses isolert i roster-endepunktet. ### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16) - **Status:** ❓ trenger beslutning — direkte oppfølger av «Brukerroller» over. diff --git a/app/routers/tournaments.py b/app/routers/tournaments.py index 021a92f..4fb6077 100644 --- a/app/routers/tournaments.py +++ b/app/routers/tournaments.py @@ -252,6 +252,65 @@ async def add_roster_entry( return RosterEntry(**dict(row)) +class RosterEntryUpdate(BaseModel): + is_captain: bool + + +@router.patch( + "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}", + response_model=RosterEntry, +) +async def update_roster_entry( + team_id: str, + roster_id: str, + body: RosterEntryUpdate, + organization_id: str = Depends(get_authorized_org), +) -> RosterEntry: + # Bevisst enkelt: setter/fjerner kapteinmerket på NØYAKTIG denne raden, + # håndhever ikke "kun én kaptein per lag" -- kaptein er i dag bare et + # merke, ikke en egen autorisasjonsrolle (se FEATURE_BACKLOG). + async with org_connection(organization_id) as conn: + row = await conn.fetchrow( + """ + WITH updated AS ( + UPDATE team_roster + SET is_captain = $1 + WHERE id = $2 AND team_id = $3 + RETURNING id, player_id, handicap_index_snapshot, is_captain + ) + SELECT updated.id::text, updated.player_id::text, + updated.handicap_index_snapshot::float AS handicap_index_snapshot, + updated.is_captain, p.display_name + FROM updated JOIN player p ON p.id = updated.player_id + """, + body.is_captain, + roster_id, + team_id, + ) + if row is None: + raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.") + return RosterEntry(**dict(row)) + + +@router.delete( + "/orgs/{organization_id}/teams/{team_id}/roster/{roster_id}", + status_code=204, +) +async def delete_roster_entry( + team_id: str, + roster_id: str, + organization_id: str = Depends(get_authorized_org), +) -> None: + async with org_connection(organization_id) as conn: + deleted = await conn.fetchval( + "DELETE FROM team_roster WHERE id = $1 AND team_id = $2 RETURNING id", + roster_id, + team_id, + ) + if deleted is None: + raise app_error(404, "NOT_FOUND", "Roster-oppføringen finnes ikke.") + + # --------------------------------------------------------------------------- # Økter (ADR-007: turnering = ordnet sekvens av økter) # --------------------------------------------------------------------------- diff --git a/frontend/app/tournaments/[id]/page.tsx b/frontend/app/tournaments/[id]/page.tsx new file mode 100644 index 0000000..629ee03 --- /dev/null +++ b/frontend/app/tournaments/[id]/page.tsx @@ -0,0 +1,28 @@ +import { TournamentDetail } from "@/components/tournament-detail" + +export default async function TournamentPage({ + params, + searchParams, +}: { + params: Promise<{ id: string }> + searchParams: Promise<{ org?: string; name?: string }> +}) { + const { id } = await params + const { org, name } = await searchParams + const tournamentName = name?.trim() || "Turnering" + + // org-parameteren følger alltid med fra dashbordets TournamentCard-lenke + // (se components/tournament-card.tsx) -- API-et krever organization_id på + // alle team-/roster-endepunkter (RLS, se ADR-001/003). + if (!org) { + return ( +
+

+ Mangler organisasjon i lenken. Gå tilbake til dashbordet og prøv igjen. +

+
+ ) + } + + return +} diff --git a/frontend/components/dashboard.tsx b/frontend/components/dashboard.tsx index 48e380e..7b78cfd 100644 --- a/frontend/components/dashboard.tsx +++ b/frontend/components/dashboard.tsx @@ -328,7 +328,9 @@ function OrganizationView({ /> ) : tournaments.length > 0 ? ( - tournaments.map((t) => ) + tournaments.map((t) => ( + + )) ) : ( )} diff --git a/frontend/components/tournament-card.tsx b/frontend/components/tournament-card.tsx index 15451b0..c739284 100644 --- a/frontend/components/tournament-card.tsx +++ b/frontend/components/tournament-card.tsx @@ -1,3 +1,4 @@ +import Link from "next/link" import { Calendar, ChevronRight } from "lucide-react" import { TournamentStatusBadge, type TournamentStatus } from "@/components/tournament-status-badge" @@ -29,12 +30,18 @@ function formatDateRange(start?: string, end?: string) { return from ?? to ?? null } -export function TournamentCard({ tournament }: { tournament: Tournament }) { +export function TournamentCard({ + tournament, + orgId, +}: { + tournament: Tournament + orgId: string +}) { const dateRange = formatDateRange(tournament.startDate, tournament.endDate) return ( - + ) } diff --git a/frontend/components/tournament-detail.tsx b/frontend/components/tournament-detail.tsx new file mode 100644 index 0000000..18f7951 --- /dev/null +++ b/frontend/components/tournament-detail.tsx @@ -0,0 +1,761 @@ +"use client" + +import type React from "react" +import { useEffect, useMemo, useState } from "react" +import Link from "next/link" +import { + ArrowLeft, + Check, + MoreVertical, + Plus, + Star, + Trash2, + UserPlus, + Users, + X, +} from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { cn } from "@/lib/utils" + +// --- Types (matcher API-kontrakten i app/routers/tournaments.py/players.py) - + +type ApiPlayer = { + id: string + display_name: string + handicap_index: number | null +} + +type ApiRosterEntry = { + id: string + player_id: string + display_name: string + handicap_index_snapshot: number | null + is_captain: boolean +} + +type Team = { + id: string + name: string + color: string + roster: ApiRosterEntry[] +} + +// Team colors er DATA brukt på scorekort senere -- bevisst atskilt fra +// app-ens merkevare-grønn/oransje. +const TEAM_COLORS: { value: string; label: string }[] = [ + { value: "#2563eb", label: "Blå" }, + { value: "#dc2626", label: "Rød" }, + { value: "#7c3aed", label: "Lilla" }, + { value: "#0891b2", label: "Turkis" }, + { value: "#db2777", label: "Rosa" }, + { value: "#475569", label: "Skifer" }, +] + +// --- Component ------------------------------------------------------------- + +export function TournamentDetail({ + organizationId, + tournamentId, + tournamentName, +}: { + organizationId: string + tournamentId: string + tournamentName: string +}) { + const [teams, setTeams] = useState<[Team | null, Team | null]>([null, null]) + const [pool, setPool] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + async function load() { + try { + const [teamsRes, poolRes] = await Promise.all([ + fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { + credentials: "include", + }), + fetch(`/orgs/${organizationId}/players`, { credentials: "include" }), + ]) + if (!teamsRes.ok || !poolRes.ok) throw new Error("load failed") + const teamsData: { id: string; name: string; color: string | null }[] = + await teamsRes.json() + const poolData: ApiPlayer[] = await poolRes.json() + + const withRosters = await Promise.all( + teamsData.map(async (t) => { + const rosterRes = await fetch(`/orgs/${organizationId}/teams/${t.id}/roster`, { + credentials: "include", + }) + const roster: ApiRosterEntry[] = rosterRes.ok ? await rosterRes.json() : [] + return { id: t.id, name: t.name, color: t.color ?? TEAM_COLORS[0].value, roster } + }), + ) + if (cancelled) return + setTeams([withRosters[0] ?? null, withRosters[1] ?? null]) + setPool(poolData) + } catch { + if (!cancelled) setError("Klarte ikke å laste lag og spillere. Prøv å laste siden på nytt.") + } finally { + if (!cancelled) setLoading(false) + } + } + void load() + return () => { + cancelled = true + } + }, [organizationId, tournamentId]) + + async function createTeam(slot: 0 | 1, name: string, color: string) { + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/teams`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ name, color }), + }) + if (!res.ok) throw new Error(`create team: ${res.status}`) + const created: { id: string; name: string; color: string | null } = await res.json() + setTeams((prev) => { + const next: [Team | null, Team | null] = [prev[0], prev[1]] + next[slot] = { id: created.id, name: created.name, color: created.color ?? color, roster: [] } + return next + }) + } catch { + setError("Klarte ikke å opprette laget. Prøv igjen.") + } + } + + async function addExistingPlayer(teamId: string, playerId: string) { + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ player_id: playerId, is_captain: false }), + }) + if (!res.ok) throw new Error(`add roster: ${res.status}`) + const entry: ApiRosterEntry = await res.json() + setTeams((prev) => + prev.map((t) => (t && t.id === teamId ? { ...t, roster: [...t.roster, entry] } : t)) as [ + Team | null, + Team | null, + ], + ) + } catch { + setError("Klarte ikke å legge til spilleren. Prøv igjen.") + } + } + + async function addNewPlayer(teamId: string, name: string, handicap?: number) { + setError(null) + try { + 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 }), + }) + if (!playerRes.ok) throw new Error(`create player: ${playerRes.status}`) + const player: ApiPlayer = await playerRes.json() + setPool((prev) => [...prev, player]) + await addExistingPlayer(teamId, player.id) + } catch { + setError("Klarte ikke å opprette spilleren. Prøv igjen.") + } + } + + async function toggleCaptain(teamId: string, rosterId: string, current: boolean) { + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ is_captain: !current }), + }) + if (!res.ok) throw new Error(`toggle captain: ${res.status}`) + const updated: ApiRosterEntry = await res.json() + setTeams((prev) => + prev.map((t) => + t && t.id === teamId + ? { ...t, roster: t.roster.map((r) => (r.id === updated.id ? updated : r)) } + : t, + ) as [Team | null, Team | null], + ) + } catch { + setError("Klarte ikke å endre kaptein. Prøv igjen.") + } + } + + async function removePlayer(teamId: string, rosterId: string) { + setError(null) + try { + const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/roster/${rosterId}`, { + method: "DELETE", + credentials: "include", + }) + if (!res.ok && res.status !== 204) throw new Error(`remove roster: ${res.status}`) + setTeams((prev) => + prev.map((t) => + t && t.id === teamId ? { ...t, roster: t.roster.filter((r) => r.id !== rosterId) } : t, + ) as [Team | null, Team | null], + ) + } catch { + setError("Klarte ikke å fjerne spilleren. Prøv igjen.") + } + } + + // Hvilket lag er en gitt spiller (fra pool) allerede rostret på, om noen? + function findPlayerTeam(playerId: string): Team | null { + for (const t of teams) { + if (t && t.roster.some((r) => r.player_id === playerId)) return t + } + return null + } + + return ( +
+
+
+ +
+
+ +
+
+

Lag og spillere

+

+ Ryder Cup-format: nøyaktig to lag. Sett opp begge lagene og fyll troppene før + turneringen kan starte. +

+
+ + {error && ( +

+ {error} +

+ )} + + {loading ? ( +
+ + ) : ( +
+ {([0, 1] as const).map((slot) => { + const team = teams[slot] + if (team) { + const otherTeam = teams[slot === 0 ? 1 : 0] + return ( + + ) + } + return + })} +
+ )} +
+
+ ) +} + +// --- Create team (State A) ------------------------------------------------- + +function CreateTeamCard({ + slot, + onCreate, +}: { + slot: 0 | 1 + onCreate: (slot: 0 | 1, name: string, color: string) => void +}) { + const [name, setName] = useState("") + const [color, setColor] = useState(TEAM_COLORS[slot === 0 ? 0 : 1].value) + const valid = name.trim().length >= 2 + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!valid) return + onCreate(slot, name.trim(), color) + } + + return ( +
+
+
+
+
+ + Lag {slot + 1} + + Opprett lag +
+
+ +
+ + setName(e.target.value)} + className="h-12 rounded-2xl text-base" + /> +
+ +
+ +
+ {TEAM_COLORS.map((c) => { + const selected = c.value === color + return ( + + ) + })} +
+
+ + +
+ ) +} + +// --- Team panel with roster (State B) -------------------------------------- + +function TeamPanel({ + team, + otherTeam, + pool, + findPlayerTeam, + onToggleCaptain, + onRemovePlayer, + onAddExisting, + onAddNew, +}: { + team: Team + otherTeam: Team | null + pool: ApiPlayer[] + findPlayerTeam: (playerId: string) => Team | null + onToggleCaptain: (teamId: string, rosterId: string, current: boolean) => void + onRemovePlayer: (teamId: string, rosterId: string) => void + onAddExisting: (teamId: string, playerId: string) => void + onAddNew: (teamId: string, name: string, handicap?: number) => void +}) { + const [confirmingId, setConfirmingId] = useState(null) + + return ( +
+
+
+ +
    + {team.roster.length === 0 && ( +
  • + Ingen spillere ennå. Legg til nedenfor. +
  • + )} + {team.roster.map((entry) => { + const confirming = confirmingId === entry.id + + if (confirming) { + return ( +
  • + + Fjern {entry.display_name} fra laget? + +
    + + +
    +
  • + ) + } + + return ( +
  • +
    + + {entry.display_name} + + {entry.is_captain && ( + + + )} +
    + + {formatHandicap(entry.handicap_index_snapshot)} + + + + + + onToggleCaptain(team.id, entry.id, entry.is_captain)} + className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold" + > + + setConfirmingId(entry.id)} + className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive" + > + + + +
  • + ) + })} +
+ +
+ +
+
+ ) +} + +// --- Add player type-ahead ------------------------------------------------- + +function AddPlayerControl({ + team, + pool, + findPlayerTeam, + onAddExisting, + onAddNew, +}: { + team: Team + otherTeam: Team | null + pool: ApiPlayer[] + findPlayerTeam: (playerId: string) => Team | null + onAddExisting: (teamId: string, playerId: string) => void + onAddNew: (teamId: string, name: string, handicap?: number) => void +}) { + const [open, setOpen] = useState(false) + const [query, setQuery] = useState("") + const [creating, setCreating] = useState(false) + const [newHandicap, setNewHandicap] = useState("") + + const trimmed = query.trim() + + const matches = useMemo(() => { + if (!trimmed) return [] + const q = trimmed.toLowerCase() + return pool + .filter((p) => p.display_name.toLowerCase().includes(q)) + .map((p) => { + const onTeam = findPlayerTeam(p.id) + return { + player: p, + onThisTeam: onTeam?.id === team.id, + onOtherTeam: onTeam && onTeam.id !== team.id ? onTeam : null, + } + }) + .filter((m) => !m.onThisTeam) + }, [pool, trimmed, findPlayerTeam, team.id]) + + const exactMatch = useMemo( + () => pool.some((p) => p.display_name.toLowerCase() === trimmed.toLowerCase()), + [pool, trimmed], + ) + + const showCreate = trimmed.length >= 2 && !exactMatch + + function reset() { + setQuery("") + setCreating(false) + setNewHandicap("") + } + + function handleAddExisting(playerId: string) { + onAddExisting(team.id, playerId) + reset() + } + + function handleCreate() { + if (trimmed.length < 2) return + const hcpValue = newHandicap.trim() === "" ? undefined : Number(newHandicap.replace(",", ".")) + const hcp = hcpValue !== undefined && !Number.isNaN(hcpValue) ? hcpValue : undefined + onAddNew(team.id, trimmed, hcp) + reset() + } + + if (!open) { + return ( + + ) + } + + return ( +
+
+ { + setQuery(e.target.value) + setCreating(false) + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + setOpen(false) + reset() + } + }} + className="h-11 flex-1 rounded-2xl text-base" + /> + +
+ + {trimmed.length > 0 && ( +
+
    + {matches.map(({ player, onOtherTeam }) => { + if (onOtherTeam) { + return ( +
  • +
    + + {player.display_name} + + + allerede på {onOtherTeam.name} + +
    + + {formatHandicap(player.handicap_index)} + +
  • + ) + } + return ( +
  • + +
  • + ) + })} + + {matches.length === 0 && !showCreate && ( +
  • + Ingen treff. +
  • + )} + + {showCreate && !creating && ( +
  • + +
  • + )} + + {showCreate && creating && ( +
  • + + Ny spiller: {trimmed} + +
    +
    + + setNewHandicap(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleCreate() + } + }} + className="h-11 rounded-xl text-base" + /> +
    + +
    +
  • + )} +
+
+ )} +
+ ) +} + +// --- Helpers --------------------------------------------------------------- + +function formatHandicap(handicap: number | null | undefined) { + if (handicap === null || handicap === undefined) return "—" + return handicap.toFixed(1) +}