Update Todos

Diffe zip 9 mot live-treet

Integrere rediger/slett i tournament-program.tsx (behold ekte create-flyt, IKKE V0s regresjon)

Ekte typesjekket frontend-build

Rulle ut live

Oppdatere .md-filer + slette zip 9

Rediger/slett-UI for økter er live. Kort oppsummert:

Fanget en reell regresjon i selve V0-eksporten før noe ble tatt inn: denne runden hadde utilsiktet fjernet bane-feltet fra «Legg til økt»-skjemaet (nye økter ville fått en hardkodet mock-bane). Diffet mot live-treet som vanlig, tok kun inn de nye rediger/slett-delene, lot det ekte opprett-skjemaet (med ekte bane-søk/teeoff-import) stå urørt.

Bane-bytte i redigeringsskjemaet er bevisst enklere enn opprett-skjemaets — kun velg blant eksisterende baner eller hent fra teeoff, ingen «opprett ny bane»-snarvei, som unngår at samme forvekslingsfelle fra Tjøme-hendelsen kan skje igjen.

Verifisert med ekte PATCH/DELETE-kall (inkludert at 409-feilmeldingen fra backend vises riktig i UI-et), test_isolation.sql 12/12, typesjekket build.

.md-filene oppdatert, zip 9 slettet.

Alt fra "bygg i rekkefølgen ting brukes"-serien pluss denne rundens opprydninger er nå live og konsistente.
This commit is contained in:
Erol Haagenrud 2026-07-19 08:17:12 +02:00
parent 459f3f8e14
commit 8a95ef8af2
4 changed files with 542 additions and 47 deletions

View file

@ -270,7 +270,9 @@
"Bash(docker exec teeoff_db psql -U teeoff_admin -d teecup_db -v ON_ERROR_STOP=1 -c ' *)",
"Bash(python3 -m py_compile /opt/teecup/app/routers/tournaments.py /opt/teecup/app/routers/scoring.py)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/patch_cookies.txt)",
"Bash(awk 'NR==499,/^---$/{print NR\": \"$0}' /opt/teecup/FEATURE_BACKLOG.md)"
"Bash(awk 'NR==499,/^---$/{print NR\": \"$0}' /opt/teecup/FEATURE_BACKLOG.md)",
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-9)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/ui_cookies.txt)"
],
"additionalDirectories": [
"/opt/teeoff/deploy",

View file

@ -1040,6 +1040,33 @@ Ferdig og verifisert:
Ingen migrasjon, kun `teecup_api` redeployet, `teeoff.no` upåvirket.
Frontend (rediger-/slett-knapper i program-skjermen) ikke bygget ennå —
kun backend-kapasiteten denne runden.
- **Rediger/slett-UI for økter LIVE (2026-07-19):** V0-utvidelse av den
eksisterende Program-skjermen (ingen ny rute) — "..."-meny (samme
DropdownMenu-mønster som roster-skjermens per-spiller-handlinger) med
"Rediger"/"Slett" per øktkort. Rediger bytter kortet til et inline-skjema
(samme felt som PATCH støtter: navn, poeng, starthull, klokkeslett/
intervall, bane). Slett viser en bekreftelse; treffer den ekte 409-en
(økt har matcher), vises backend sin egen feiltekst i stedet for en
forhåndsberegnet klient-tilstand.
**Reell regresjon funnet OG UNNGÅTT i selve V0-eksporten, ikke i
etterkant:** denne rundens V0-prompt handlet kun om rediger/slett, men
eksporten hadde samtidig (utilsiktet) FJERNET bane-feltet fra "Legg til
økt"-skjemaet helt — nye økter ville stille blitt satt til en hardkodet
mock-bane. Fanget under diff-mot-live-treet FØR noe ble tatt inn (samme
rutine som alltid) — `CreateSessionCard` (med sitt ekte bane-søk/
teeoff-import) beholdt fullstendig urørt; kun de nye rediger/slett-
delene ble hentet inn.
**Bane-bytte i redigeringsskjemaet er bevisst ENKLERE enn opprett-
skjemaets bane-felt** — kun velg blant organisasjonens eksisterende
baner eller hent fra teeoff, INGEN "opprett ny bane: X"-snarvei her
(PATCH sin `course_id` må være en ekte, allerede eksisterende bane, ikke
en tekststreng) — unngår at samme "Tj"-forvekslingsfelle fra forrige
banebytte-hendelse kan gjenta seg via redigeringsveien.
**Verifisert:** ekte PATCH med nøyaktig skjemaets feltform, ekte DELETE
(204 tom økt, 409 med matcher — bekreftet at `{"detail":{"code",
"message"}}`-formen leses riktig av UI-et), `test_isolation.sql` 12/12,
ekte typesjekket build. Rullet ut live, ren frontend-endring, `teeoff.no`
upåvirket.
Neste steg:
1. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.

View file

@ -564,18 +564,23 @@ andre skjermen når den ble generert i en egen prompt.
(`teecup_api`, `teecup_frontend`) bygget og redeployet, live sjekker OK
(`/health`, `/dashboard` → 200), `teeoff.no` upåvirket.
**Nytt 2026-07-18, ✅ BACKEND LIVE (frontend gjenstår):** `PATCH`/`DELETE`
for økter (`app/routers/tournaments.py`) — kunne tidligere verken rettes
eller slettes etter opprettelse. `DELETE` kun for tomme økter (409 hvis den
har matcher). `PATCH` dekker enkle felt fritt, pluss en egen, forsiktig
gren for bane-bytte (finner/flytter tilsvarende tee per allerede tillagt
deltaker, regner om handicap+matchstatus for hele økten etterpå — også for
allerede AVGJORTE matcher, bekreftet eksplisitt av bruker). Se
**Nytt 2026-07-18, ✅ HELT FERDIG (backend + frontend live):** `PATCH`/
`DELETE` for økter (`app/routers/tournaments.py`) — kunne tidligere verken
rettes eller slettes etter opprettelse. `DELETE` kun for tomme økter (409
hvis den har matcher). `PATCH` dekker enkle felt fritt, pluss en egen,
forsiktig gren for bane-bytte (finner/flytter tilsvarende tee per allerede
tillagt deltaker, regner om handicap+matchstatus for hele økten etterpå —
også for allerede AVGJORTE matcher, bekreftet eksplisitt av bruker). Se
CLAUDE.md-status for det fulle scenarioet (verifisert med et 10-hulls
avgjort-match-eksempel) og et urelatert funn (`front_9`/`back_9` +
`stroke`-modus kan aldri få handicap i dag, siden `tee_rating` alltid kun
lages med `full_18`-omfang). Rediger-/slett-UI i program-skjermen ikke
bygget ennå.
lages med `full_18`-omfang).
**Rediger-/slett-UI LIVE 2026-07-19** — utvidelse av den eksisterende
Program-skjermen (ingen ny rute). Fanget en reell regresjon i selve
V0-eksporten før den ble tatt inn: samme runde hadde utilsiktet fjernet
bane-feltet fra "Legg til økt"-skjemaet — kun de nye rediger/slett-delene
ble hentet inn, det ekte opprett-skjemaet urørt. Se CLAUDE.md-status for
detaljer.
---

View file

@ -6,17 +6,28 @@ import Link from "next/link"
import {
ArrowLeft,
CalendarClock,
Check,
ChevronDown,
Clock,
Flag,
MapPin,
MoreVertical,
Pencil,
Plus,
Sparkles,
Trash2,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) -
@ -196,6 +207,43 @@ export function TournamentProgram({
}
}
async function updateSession(sessionId: string, patch: Record<string, unknown>): Promise<boolean> {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(patch),
})
if (!res.ok) throw new Error(`update session: ${res.status}`)
const updated: ApiSession = await res.json()
setSessions((prev) => prev.map((s) => (s.id === sessionId ? updated : s)))
return true
} catch {
setError("Klarte ikke å oppdatere økten. Prøv igjen.")
return false
}
}
async function deleteSession(sessionId: string): Promise<string | null> {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}`, {
method: "DELETE",
credentials: "include",
})
if (res.status === 204) {
setSessions((prev) => prev.filter((s) => s.id !== sessionId))
return null
}
const body = await res.json().catch(() => null)
return body?.detail?.message ?? "Klarte ikke å slette økten. Prøv igjen."
} catch {
return "Klarte ikke å slette økten. Prøv igjen."
}
}
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
const leaderboardHref = `/tournaments/${tournamentId}/leaderboard?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
@ -277,8 +325,13 @@ export function TournamentProgram({
key={session.id}
session={session}
course={coursesById.get(session.course_id) ?? null}
courses={courses}
organizationId={organizationId}
isLast={index === sorted.length - 1}
href={`/tournaments/${tournamentId}/sessions/${session.id}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
onCourseImported={(course) => setCourses((prev) => [...prev, course])}
onSave={(patch) => updateSession(session.id, patch)}
onDelete={() => deleteSession(session.id)}
/>
))}
</ol>
@ -305,16 +358,40 @@ export function TournamentProgram({
function SessionCard({
session,
course,
courses,
organizationId,
isLast,
href,
onCourseImported,
onSave,
onDelete,
}: {
session: ApiSession
course: ApiCourse | null
courses: ApiCourse[]
organizationId: string
isLast: boolean
href: string
onCourseImported: (course: ApiCourse) => void
onSave: (patch: Record<string, unknown>) => Promise<boolean>
onDelete: () => Promise<string | null>
}) {
const [mode, setMode] = useState<"view" | "editing" | "confirmingDelete">("view")
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const title = session.name?.trim() || `Økt ${session.sequence}`
async function handleDelete() {
setDeleting(true)
const message = await onDelete()
setDeleting(false)
if (message) {
setDeleteError(message)
} else {
setMode("view")
}
}
return (
<li className="relative flex gap-4">
<div className="flex flex-col items-center">
@ -324,50 +401,434 @@ function SessionCard({
{!isLast && <div aria-hidden="true" className="mt-1 w-0.5 flex-1 rounded-full bg-border" />}
</div>
<Link
href={href}
className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50"
>
<div className="flex flex-col gap-1">
<h3 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h3>
<div className="flex flex-wrap items-center gap-2">
<MetaBadge>{FORMAT_LABELS[session.format]}</MetaBadge>
<MetaBadge>{HOLE_LABELS[session.hole_config]}</MetaBadge>
<MetaBadge>{SCORING_LABELS[session.scoring_mode]}</MetaBadge>
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
{course && <MetaBadge>{course.name}</MetaBadge>}
{mode === "editing" ? (
<EditSessionForm
session={session}
course={course}
courses={courses}
organizationId={organizationId}
onCourseImported={onCourseImported}
onCancel={() => setMode("view")}
onSave={async (patch) => {
const ok = await onSave(patch)
if (ok) setMode("view")
}}
/>
) : (
<div className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5">
<div className="flex items-start justify-between gap-3">
<Link href={href} className="flex min-w-0 flex-1 flex-col gap-1">
<h3 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h3>
<div className="flex flex-wrap items-center gap-2">
<MetaBadge>{FORMAT_LABELS[session.format]}</MetaBadge>
<MetaBadge>{HOLE_LABELS[session.hole_config]}</MetaBadge>
<MetaBadge>{SCORING_LABELS[session.scoring_mode]}</MetaBadge>
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
{course && <MetaBadge>{course.name}</MetaBadge>}
</div>
</Link>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={`Handlinger for ${title}`}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<MoreVertical aria-hidden="true" className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 rounded-2xl p-1.5">
<DropdownMenuItem
onClick={() => setMode("editing")}
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
>
<Pencil aria-hidden="true" className="size-4" />
Rediger
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setDeleteError(null)
setMode("confirmingDelete")
}}
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
Slett
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
{session.scheduled_at ? (
<div className="flex items-start gap-2 text-sm text-foreground">
<CalendarClock aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<span className="text-pretty">
Første utslag {formatDateTime(session.scheduled_at)}
{session.tee_interval_minutes
? `, ${session.tee_interval_minutes} min mellom hver`
: ""}
</span>
</div>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4 shrink-0" />
<span>Tidspunkt ikke satt</span>
</div>
)}
{session.start_hole !== 1 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Flag aria-hidden="true" className="size-4 shrink-0" />
<span>Starter hull {session.start_hole}</span>
</div>
)}
<Link href={href} className="flex flex-col gap-1.5 border-t border-border pt-3">
{session.scheduled_at ? (
<div className="flex items-start gap-2 text-sm text-foreground">
<CalendarClock aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<span className="text-pretty">
Første utslag {formatDateTime(session.scheduled_at)}
{session.tee_interval_minutes
? `, ${session.tee_interval_minutes} min mellom hver`
: ""}
</span>
</div>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4 shrink-0" />
<span>Tidspunkt ikke satt</span>
</div>
)}
{session.start_hole !== 1 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Flag aria-hidden="true" className="size-4 shrink-0" />
<span>Starter hull {session.start_hole}</span>
</div>
)}
</Link>
{mode === "confirmingDelete" &&
(deleteError ? (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-muted/50 p-4">
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{deleteError}</p>
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
className="h-9 rounded-xl"
onClick={() => setMode("view")}
>
Lukk
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 p-4">
<p className="text-sm leading-relaxed text-foreground text-pretty">
Er du sikker at du vil slette denne økten? Dette kan ikke angres.
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="destructive"
className="h-9 rounded-xl"
disabled={deleting}
onClick={handleDelete}
>
{deleting ? "Sletter…" : "Slett"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-9 rounded-xl"
onClick={() => setMode("view")}
>
Avbryt
</Button>
</div>
</div>
))}
</div>
</Link>
)}
</li>
)
}
// --- Edit session form -------------------------------------------------------
function EditSessionForm({
session,
course,
courses,
organizationId,
onCourseImported,
onSave,
onCancel,
}: {
session: ApiSession
course: ApiCourse | null
courses: ApiCourse[]
organizationId: string
onCourseImported: (course: ApiCourse) => void
onSave: (patch: Record<string, unknown>) => Promise<void>
onCancel: () => void
}) {
const [name, setName] = useState(session.name ?? "")
const [points, setPoints] = useState(String(session.points_per_match))
const [startHole, setStartHole] = useState(String(session.start_hole))
const [scheduledAt, setScheduledAt] = useState(session.scheduled_at?.slice(0, 16) ?? "")
const [teeInterval, setTeeInterval] = useState(
session.tee_interval_minutes ? String(session.tee_interval_minutes) : "",
)
const [courseId, setCourseId] = useState(session.course_id)
const [courseName, setCourseName] = useState(course?.name ?? "")
const [submitting, setSubmitting] = useState(false)
const hasTime = scheduledAt.trim() !== ""
const uid = session.id
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const pointsValue = Number(points.replace(",", ".")) || session.points_per_match
const startHoleValue = Math.min(18, Math.max(1, Number(startHole) || 1))
const intervalValue = hasTime && teeInterval.trim() !== "" ? Number(teeInterval) : undefined
setSubmitting(true)
await onSave({
name: name.trim() || null,
points_per_match: pointsValue,
start_hole: startHoleValue,
scheduled_at: hasTime ? scheduledAt : null,
tee_interval_minutes: intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
course_id: courseId,
})
setSubmitting(false)
}
return (
<form
onSubmit={handleSubmit}
className="mb-1 flex flex-1 flex-col gap-6 rounded-3xl border border-dashed border-border bg-card/50 p-5 sm:p-6"
>
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
<Pencil aria-hidden="true" className="size-5 text-primary" />
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Rediger økt {session.sequence}
</span>
<span className="text-base font-bold text-foreground">
{session.name?.trim() || `Økt ${session.sequence}`}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-name-${uid}`} className="text-sm font-semibold">
Navn <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id={`edit-name-${uid}`}
placeholder="F.eks. Foursome dag 1"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">Bane</Label>
<EditCourseField
organizationId={organizationId}
courses={courses}
currentCourseId={courseId}
currentCourseName={courseName}
onCourseImported={onCourseImported}
onChange={(id, name) => {
setCourseId(id)
setCourseName(name)
}}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-points-${uid}`} className="text-sm font-semibold">
Poeng per match
</Label>
<Input
id={`edit-points-${uid}`}
inputMode="decimal"
type="number"
step="0.5"
min="0"
value={points}
onChange={(e) => setPoints(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-starthole-${uid}`} className="text-sm font-semibold">
Starthull
</Label>
<Input
id={`edit-starthole-${uid}`}
inputMode="numeric"
type="number"
min="1"
max="18"
value={startHole}
onChange={(e) => setStartHole(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-time-${uid}`} className="text-sm font-semibold">
Startdato og -klokkeslett{" "}
<span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id={`edit-time-${uid}`}
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
{hasTime && (
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-interval-${uid}`} className="text-sm font-semibold">
Minutter mellom hver flight
</Label>
<Input
id={`edit-interval-${uid}`}
inputMode="numeric"
type="number"
min="1"
placeholder="F.eks. 10"
value={teeInterval}
onChange={(e) => setTeeInterval(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
)}
<div className="flex gap-3">
<Button type="submit" disabled={submitting} className="h-12 flex-1 rounded-2xl text-base font-bold shadow-sm">
{submitting ? "Lagrer…" : "Lagre"}
</Button>
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="h-12 rounded-2xl px-6 text-base font-semibold"
>
Avbryt
</Button>
</div>
</form>
)
}
// --- Bane-bytte i redigeringsskjemaet (kun eksisterende baner -- PATCH sin
// course_id må være en ekte, allerede opprettet bane, ikke en "opprett
// ny"-tekststreng slik opprett-skjemaet tillater) -------------------------
function EditCourseField({
organizationId,
courses,
currentCourseId,
currentCourseName,
onCourseImported,
onChange,
}: {
organizationId: string
courses: ApiCourse[]
currentCourseId: string
currentCourseName: string
onCourseImported: (course: ApiCourse) => void
onChange: (courseId: string, courseName: string) => void
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
if (!open) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1.5 text-sm font-semibold text-secondary-foreground">
<MapPin aria-hidden="true" className="size-4 text-primary" />
{currentCourseName || "Ukjent bane"}
</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-9 rounded-xl"
onClick={() => {
setQuery("")
setOpen(true)
}}
>
Bytt bane
</Button>
</div>
)
}
const q = query.trim().toLowerCase()
const matches = courses.filter((c) => c.name.toLowerCase().includes(q))
function select(c: ApiCourse) {
onChange(c.id, c.name)
setOpen(false)
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background/50 p-3">
<div className="flex items-center gap-2">
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Søk blant organisasjonens baner…"
className="h-11 flex-1 rounded-xl text-base"
/>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Lukk banesøk"
className="flex size-9 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<X aria-hidden="true" className="size-4" />
</button>
</div>
<ul className="flex max-h-48 flex-col overflow-auto rounded-xl border border-border">
{matches.map((c) => (
<li key={c.id} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => select(c)}
className="flex 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/60"
>
<span className="flex items-center gap-2">
<MapPin aria-hidden="true" className="size-4 text-muted-foreground" />
{c.name}
</span>
{c.id === currentCourseId && <Check aria-hidden="true" className="size-4 text-primary" />}
</button>
</li>
))}
{matches.length === 0 && (
<li className="px-3 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
)}
</ul>
{!officialSearchOpen ? (
<button
type="button"
onClick={() => setOfficialSearchOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
</button>
) : (
<OfficialCourseSearch
organizationId={organizationId}
onClose={() => setOfficialSearchOpen(false)}
onImported={(newCourse) => {
onCourseImported(newCourse)
select(newCourse)
setOfficialSearchOpen(false)
}}
/>
)}
</div>
)
}
function MetaBadge({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">