teecup/frontend/components/course-template-editor.tsx
Erol Haagenrud aee1662de2 Fiks turneringsoppsett-rekkefølge, koble til TeeOff-import, og bygg delt bane-mal-bibliotek
Klasser vises nå etter bane-/rundeoppsett (var meningsløst å velge
standardutslag før banen fantes). TeeOff-import koblet inn i
individuelle turneringer. Ny mal-basert baneoppretting i
turneringsmodulen (fra bunnen av / TeeOff / offentlig custom-bane),
med personal_course gjenbrukt som et delt, plattform-omfattende
bane-bibliotek på tvers av org- og single-runde-modulen (ADR-042,
migrasjon 054).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 11:34:28 +02:00

698 lines
28 KiB
TypeScript

"use client"
// Kompakt hull-/utslag-editor for org-turneringsmodulen (2026-08-04) --
// "opprett manuell bane" der var frem til nå KUN et navnefelt (ingen vei til
// hull/par/hcp-indeks/utslag fantes i det hele tatt, POST /orgs/{id}/courses
// tok bare imot `{name}`). Speiler single-runde-modulens `OwnCreateStep`
// (new-round.tsx) i FUNKSJON, men i et kompakt, kort-innebygd formspråk som
// matcher resten av turneringsmodulens skjemaer (h-10/h-11, text-sm) i
// stedet for enkeltrunde-modulens store veiviser-stil.
//
// Ren UI + lokal validering -- kalleren avgjør hvor dataene faktisk
// persisteres (org-`course` via POST /orgs/{id}/courses, som nå valgfritt
// tar imot samme `holes`/`tees`-form i tillegg til navn), matcher
// prosjektets etablerte "hold komponenten dum"-adaptermønster.
//
// `CourseTemplatePicker` under er selve inngangspunktet brukt fra
// tournament-program.tsx/individual-tournament-detail.tsx: tilbyr "fra
// bunnen av", "TeeOff-bane som mal" og "offentlig custom-bane som mal"
// (søk mot GET /personal-courses -- allerede, litt overraskende, et
// PLATTFORM-OMFATTENDE bibliotek uansett org/bruker, se rounds.py).
import { useEffect, useState } from "react"
import { Plus, Trash2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export type ApiCourseHole = { hole_number: number; par: number; stroke_index: number }
export type ApiCourseTeeRating = { gender: "m" | "f"; course_rating: number; slope_rating: number; par: number }
export type ApiCourseTee = { name: string; ratings: ApiCourseTeeRating[] }
export type CourseTemplateValues = { name: string; holes: ApiCourseHole[]; tees: ApiCourseTee[] }
export type ApiCourseSummary = { id: string; name: string }
function blankHoles(): ApiCourseHole[] {
return Array.from({ length: 18 }, (_, i) => ({ hole_number: i + 1, par: 4, stroke_index: i + 1 }))
}
function blankTees(): ApiCourseTee[] {
return [{ name: "", ratings: [{ gender: "m", course_rating: 70, slope_rating: 113, par: 72 }] }]
}
type TeeRow = {
key: string
name: string
men: boolean
women: boolean
menRating: { courseRating: string; slope: string; par: string }
womenRating: { courseRating: string; slope: string; par: string }
}
function teesToRows(tees: ApiCourseTee[]): TeeRow[] {
return tees.map((t, i) => {
const m = t.ratings.find((r) => r.gender === "m")
const f = t.ratings.find((r) => r.gender === "f")
return {
key: `${i}-${t.name}`,
name: t.name,
men: !!m,
women: !!f,
menRating: m
? { courseRating: String(m.course_rating), slope: String(m.slope_rating), par: String(m.par) }
: { courseRating: "", slope: "", par: "" },
womenRating: f
? { courseRating: String(f.course_rating), slope: String(f.slope_rating), par: String(f.par) }
: { courseRating: "", slope: "", par: "" },
}
})
}
/** Kompakt skjema: 18-hulls par/stroke-indeks-rutenett + utslag med rating
* per kjønn. Forhåndsutfylt fra `initial` når banen opprettes som mal-basert
* (fra TeeOff eller en offentlig custom-bane). */
export function CourseTemplateEditor({
initial,
submitLabel,
submitting,
error,
onSubmit,
onCancel,
}: {
initial?: CourseTemplateValues
submitLabel: string
submitting: boolean
error: string | null
onSubmit: (values: CourseTemplateValues) => void
onCancel: () => void
}) {
const [name, setName] = useState(initial?.name ?? "")
const [holes, setHoles] = useState<ApiCourseHole[]>(initial?.holes ?? blankHoles())
const [tees, setTees] = useState<TeeRow[]>(teesToRows(initial?.tees ?? blankTees()))
function updateHole(index: number, field: "par" | "stroke_index", value: number) {
setHoles((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h)))
}
function updateTee(key: string, patch: Partial<TeeRow>) {
setTees((prev) => prev.map((t) => (t.key === key ? { ...t, ...patch } : t)))
}
function addTee() {
setTees((prev) => [
...prev,
{ key: `new-${Date.now()}`, name: "", men: false, women: false, menRating: { courseRating: "", slope: "", par: "" }, womenRating: { courseRating: "", slope: "", par: "" } },
])
}
function removeTee(key: string) {
setTees((prev) => (prev.length > 1 ? prev.filter((t) => t.key !== key) : prev))
}
const indexesValid = new Set(holes.map((h) => h.stroke_index)).size === 18
const teesValid = tees.every(
(t) =>
t.name.trim() !== "" &&
(t.men || t.women) &&
(!t.men || (t.menRating.courseRating.trim() && t.menRating.slope.trim() && t.menRating.par.trim())) &&
(!t.women || (t.womenRating.courseRating.trim() && t.womenRating.slope.trim() && t.womenRating.par.trim())),
)
const canSubmit = name.trim() !== "" && indexesValid && teesValid && !submitting
// Bevisst IKKE et <form onSubmit>: denne komponenten rendres ofte inne i
// et YTRE skjema (NewRoundForm/CreateSessionCard), og nestede <form>-
// elementer er ugyldig HTML -- funnet ved en ekte scratch-verifisering
// 2026-08-04 (submit-knappen submittet i praksis det ytre runde-/økt-
// skjemaet i stedet, en reell side-navigasjon som vasket bort ?org=...
// fra URL-en). Samme feilklasse/fiks som OfficialCourseSearch fra før.
function handleSubmit() {
if (!canSubmit) return
const parseDecimal = (v: string) => Number(v.replace(",", "."))
onSubmit({
name: name.trim(),
holes,
tees: tees.map((t) => ({
name: t.name.trim(),
ratings: [
...(t.men
? [{ gender: "m" as const, course_rating: parseDecimal(t.menRating.courseRating), slope_rating: Number(t.menRating.slope), par: Number(t.menRating.par) }]
: []),
...(t.women
? [{ gender: "f" as const, course_rating: parseDecimal(t.womenRating.courseRating), slope_rating: Number(t.womenRating.slope), par: Number(t.womenRating.par) }]
: []),
],
})),
})
}
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-background p-4">
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<div className="flex flex-col gap-1.5">
<Label htmlFor="ct-name" className="text-xs font-semibold">
Navn banen
</Label>
<Input id="ct-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="F.eks. Hjemmebanen" className="h-10 rounded-xl text-sm" />
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold">Hull -- par og stroke-indeks</span>
{!indexesValid && (
<p role="alert" className="text-xs font-medium text-destructive">
Hver stroke-indeks (1-18) brukes nøyaktig én gang.
</p>
)}
<div className="max-h-72 overflow-y-auto rounded-xl border border-border">
<div className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 border-b border-border bg-muted/60 px-3 py-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
<span className="w-12">Hull</span>
<span>Par</span>
<span>SI</span>
</div>
{holes.map((h, index) => (
<div key={h.hole_number} className="grid grid-cols-[auto_1fr_1fr] items-center gap-2 border-b border-border px-3 py-1.5 last:border-b-0">
<span className="w-12 text-sm font-semibold text-foreground">{h.hole_number}</span>
<select
aria-label={`Par for hull ${h.hole_number}`}
value={h.par}
onChange={(e) => updateHole(index, "par", Number(e.target.value))}
className="h-9 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{[3, 4, 5, 6].map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<select
aria-label={`Stroke-indeks for hull ${h.hole_number}`}
value={h.stroke_index}
onChange={(e) => updateHole(index, "stroke_index", Number(e.target.value))}
className="h-9 rounded-lg border border-border bg-card px-2 text-sm font-medium text-foreground outline-none"
>
{Array.from({ length: 18 }, (_, i) => i + 1).map((si) => (
<option key={si} value={si}>
{si}
</option>
))}
</select>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-3">
<span className="text-xs font-semibold">Utslag</span>
{tees.map((t, index) => (
<div key={t.key} className="flex flex-col gap-3 rounded-xl border border-border bg-card p-3">
<div className="flex items-end gap-2">
<div className="flex flex-1 flex-col gap-1.5">
<Label htmlFor={`ct-tee-${t.key}`} className="text-xs font-semibold">
Navn utslag {index + 1}
</Label>
<Input id={`ct-tee-${t.key}`} value={t.name} onChange={(e) => updateTee(t.key, { name: e.target.value })} placeholder="F.eks. Gul" className="h-10 rounded-xl text-sm" />
</div>
{tees.length > 1 && (
<Button type="button" variant="ghost" onClick={() => removeTee(t.key)} className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold text-muted-foreground">
<Trash2 aria-hidden="true" className="size-4" />
</Button>
)}
</div>
<RatingRow
label="Herre"
enabled={t.men}
rating={t.menRating}
onToggle={(men) => updateTee(t.key, { men })}
onChange={(menRating) => updateTee(t.key, { menRating })}
idPrefix={`ct-men-${t.key}`}
/>
<RatingRow
label="Dame"
enabled={t.women}
rating={t.womenRating}
onToggle={(women) => updateTee(t.key, { women })}
onChange={(womenRating) => updateTee(t.key, { womenRating })}
idPrefix={`ct-women-${t.key}`}
/>
</div>
))}
<Button type="button" variant="outline" onClick={addTee} className="h-10 self-start rounded-xl text-xs font-bold">
<Plus aria-hidden="true" className="size-4" />
Legg til utslag
</Button>
</div>
<div className="flex gap-2">
<Button type="button" onClick={handleSubmit} disabled={!canSubmit} className="h-11 flex-1 rounded-xl text-sm font-bold">
{submitting ? "Lagrer…" : submitLabel}
</Button>
<Button type="button" variant="ghost" onClick={onCancel} className="h-11 rounded-xl text-sm font-bold text-muted-foreground">
Avbryt
</Button>
</div>
</div>
)
}
function RatingRow({
label,
enabled,
rating,
onToggle,
onChange,
idPrefix,
}: {
label: string
enabled: boolean
rating: { courseRating: string; slope: string; par: string }
onToggle: (enabled: boolean) => void
onChange: (rating: { courseRating: string; slope: string; par: string }) => void
idPrefix: string
}) {
return (
<div className="flex flex-col gap-2 rounded-lg border border-border bg-background/60 p-2.5">
<label className="flex items-center gap-2 text-xs font-semibold text-foreground">
<input
type="checkbox"
checked={enabled}
onChange={(e) => onToggle(e.target.checked)}
className="size-4 shrink-0 accent-primary"
/>
{label}-rating
</label>
{enabled && (
<div className="grid grid-cols-3 gap-2">
<Input
aria-label={`${label} course rating`}
inputMode="decimal"
value={rating.courseRating}
onChange={(e) => onChange({ ...rating, courseRating: e.target.value })}
placeholder="CR"
className="h-9 rounded-lg text-sm"
/>
<Input
aria-label={`${label} slope`}
inputMode="numeric"
value={rating.slope}
onChange={(e) => onChange({ ...rating, slope: e.target.value })}
placeholder="Slope"
className="h-9 rounded-lg text-sm"
/>
<Input
aria-label={`${label} par`}
inputMode="numeric"
value={rating.par}
onChange={(e) => onChange({ ...rating, par: e.target.value })}
placeholder="Par"
className="h-9 rounded-lg text-sm"
/>
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Mal-velger: "fra bunnen av" / "TeeOff-bane som mal" / "offentlig custom-
// bane som mal". Brukt som selve "opprett bane"-inngangen i org-
// turneringsmodulen (erstatter det tidligere bare-navnefeltet).
// ---------------------------------------------------------------------------
type ApiPublicCourse = { id: string; name: string; is_mine: boolean; created_by_display_name: string }
type ApiPublicCourseDetail = {
id: string
name: string
holes: ApiCourseHole[]
full_tees: ApiCourseTee[]
}
type ApiOfficialFacility = { slug: string; name: string; city: string | null; county: string | null }
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean }
type ApiOfficialTeeOption = { name: string; genders: ("m" | "f")[] }
type ApiOfficialCourseDetail = { id: number; name: string; holes: { hole_number: number; par: number; stroke_index: number }[]; tees: (ApiOfficialTeeOption & { ratings?: ApiCourseTeeRating[] })[] }
type PickerStep =
| { kind: "choose" }
| { kind: "official-search" }
| { kind: "official-facility"; facility: ApiOfficialFacility }
| { kind: "public-search" }
| { kind: "editor"; initial?: CourseTemplateValues; templatePersonalCourseId?: string }
export function CourseTemplatePicker({
organizationId,
onCreated,
onCancel,
}: {
organizationId: string
onCreated: (course: ApiCourseSummary) => void
onCancel: () => void
}) {
const [step, setStep] = useState<PickerStep>({ kind: "choose" })
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
async function submitCourse(values: CourseTemplateValues, templatePersonalCourseId?: string) {
setSubmitting(true)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ ...values, template_personal_course_id: templatePersonalCourseId ?? null }),
})
if (!res.ok) throw new Error(`create course: ${res.status}`)
const created: ApiCourseSummary = await res.json()
onCreated(created)
} catch {
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
} finally {
setSubmitting(false)
}
}
if (step.kind === "choose") {
return (
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-background p-4">
<span className="text-xs font-semibold">Opprett manuell bane</span>
<div className="flex flex-col gap-2 sm:flex-row">
<Button type="button" variant="outline" onClick={() => setStep({ kind: "editor" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
Fra bunnen av
</Button>
<Button type="button" variant="outline" onClick={() => setStep({ kind: "official-search" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
TeeOff-bane som mal
</Button>
<Button type="button" variant="outline" onClick={() => setStep({ kind: "public-search" })} className="h-11 flex-1 rounded-xl text-sm font-bold">
Offentlig bane som mal
</Button>
</div>
<Button type="button" variant="ghost" onClick={onCancel} className="h-9 self-start rounded-xl text-xs font-semibold text-muted-foreground">
Avbryt
</Button>
</div>
)
}
if (step.kind === "official-search") {
return (
<OfficialFacilitySearch
organizationId={organizationId}
onPickFacility={(facility) => setStep({ kind: "official-facility", facility })}
onClose={() => setStep({ kind: "choose" })}
/>
)
}
if (step.kind === "official-facility") {
return (
<OfficialCourseList
organizationId={organizationId}
facility={step.facility}
onBack={() => setStep({ kind: "official-search" })}
onPicked={(values) => setStep({ kind: "editor", initial: values })}
/>
)
}
if (step.kind === "public-search") {
return (
<PublicCourseSearch
onPickDirect={(values, sourceId) => submitCourse(values, sourceId)}
onPickCustomize={(values, sourceId) => setStep({ kind: "editor", initial: values, templatePersonalCourseId: sourceId })}
onClose={() => setStep({ kind: "choose" })}
/>
)
}
return (
<CourseTemplateEditor
initial={step.initial}
submitLabel="Opprett bane"
submitting={submitting}
error={error}
onSubmit={(values) => submitCourse(values, step.templatePersonalCourseId)}
onCancel={() => setStep({ kind: "choose" })}
/>
)
}
function OfficialFacilitySearch({
organizationId,
onPickFacility,
onClose,
}: {
organizationId: string
onPickFacility: (facility: ApiOfficialFacility) => void
onClose: () => void
}) {
const [query, setQuery] = useState("")
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
const [searching, setSearching] = useState(false)
const [error, setError] = useState<string | null>(null)
async function runSearch() {
setSearching(true)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
if (!res.ok) throw new Error(`search: ${res.status}`)
setFacilities(await res.json())
} catch {
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
setFacilities([])
} finally {
setSearching(false)
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">TeeOff-bane som mal</span>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
<X aria-hidden="true" className="size-4" />
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<div className="flex items-center gap-2">
<Input
autoFocus
placeholder="Søk anleggsnavn…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault()
runSearch()
}
}}
className="h-11 flex-1 rounded-xl text-base"
/>
<Button type="button" onClick={() => runSearch()} disabled={searching} className="h-11 shrink-0 rounded-xl font-bold">
{searching ? "Søker…" : "Søk"}
</Button>
</div>
{facilities && (
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
{facilities.map((f) => (
<li key={f.slug} className="border-b border-border last:border-b-0">
<button type="button" onClick={() => onPickFacility(f)} className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60">
<span className="text-sm font-semibold text-foreground">{f.name}</span>
{(f.city || f.county) && <span className="text-xs text-muted-foreground">{[f.city, f.county].filter(Boolean).join(", ")}</span>}
</button>
</li>
))}
{facilities.length === 0 && <li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>}
</ul>
)}
</div>
)
}
function OfficialCourseList({
organizationId,
facility,
onBack,
onPicked,
}: {
organizationId: string
facility: ApiOfficialFacility
onBack: () => void
onPicked: (values: CourseTemplateValues) => void
}) {
const [courses, setCourses] = useState<ApiOfficialCourseOption[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [loadingCourseId, setLoadingCourseId] = useState<number | null>(null)
useEffect(() => {
let cancelled = false
fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : { courses: [] }))
.then((detail: { courses: ApiOfficialCourseOption[] }) => {
if (!cancelled) setCourses(detail.courses)
})
.catch(() => {
if (!cancelled) setError("Klarte ikke å hente baner for dette anlegget.")
})
return () => {
cancelled = true
}
}, [organizationId, facility.slug])
async function pick(course: ApiOfficialCourseOption) {
// Henter full bane-/utslagdata via samme offisiell-import-endepunkt som
// "vanlig" import bruker -- men i STEDET for å lagre importen som en
// ferdig org-bane, brukes svaret KUN til å forhåndsutfylle det
// redigerbare skjemaet (ingen kobling til TeeOff bevares etterpå, se
// migrasjon 054 sin kommentar om "engangs-kopi, ingen vedvarende
// kobling"). Trygt å kalle -- import er idempotent (courses.py).
setLoadingCourseId(course.teeoff_course_id)
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ facility_slug: facility.slug, teeoff_course_id: course.teeoff_course_id }),
})
if (!res.ok) throw new Error(`import: ${res.status}`)
const imported: { id: string; name: string } = await res.json()
const [holesRes, teesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/courses/${imported.id}/holes`, { credentials: "include" }),
fetch(`/orgs/${organizationId}/courses/${imported.id}/tees`, { credentials: "include" }),
])
const holes: ApiCourseHole[] = holesRes.ok ? await holesRes.json() : []
const teesRaw: { name: string; ratings: ApiCourseTeeRating[] }[] = teesRes.ok ? await teesRes.json() : []
onPicked({ name: imported.name, holes, tees: teesRaw.map((t) => ({ name: t.name, ratings: t.ratings })) })
} catch {
setError("Klarte ikke å hente banedata fra teeoff. Prøv igjen.")
} finally {
setLoadingCourseId(null)
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<button type="button" onClick={onBack} className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground">
{facility.name}
</button>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
{courses === null ? (
<p className="text-sm text-muted-foreground">Laster baner</p>
) : (
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
{courses.map((c) => (
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
<button
type="button"
disabled={loadingCourseId !== null}
onClick={() => pick(c)}
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
>
{c.name}
{loadingCourseId === c.teeoff_course_id && <span className="text-xs font-normal text-muted-foreground">Henter</span>}
</button>
</li>
))}
{courses.length === 0 && <li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen 18-hulls baner hos dette anlegget ennå.</li>}
</ul>
)}
</div>
)
}
function PublicCourseSearch({
onPickDirect,
onPickCustomize,
onClose,
}: {
onPickDirect: (values: CourseTemplateValues, sourcePersonalCourseId: string) => void
onPickCustomize: (values: CourseTemplateValues, sourcePersonalCourseId: string) => void
onClose: () => void
}) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<ApiPublicCourse[]>([])
const [loadingId, setLoadingId] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
const timer = setTimeout(() => {
fetch(`/personal-courses?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
.then((res) => (res.ok ? res.json() : []))
.then((data: ApiPublicCourse[]) => {
if (!cancelled) setResults(data)
})
.catch(() => {})
}, 250)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [query])
async function fetchDetail(course: ApiPublicCourse): Promise<CourseTemplateValues | null> {
setLoadingId(course.id)
setError(null)
try {
const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
if (!res.ok) throw new Error(`detail: ${res.status}`)
const detail: ApiPublicCourseDetail = await res.json()
return { name: detail.name, holes: detail.holes, tees: detail.full_tees }
} catch {
setError("Klarte ikke å hente banedetaljer.")
return null
} finally {
setLoadingId(null)
}
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-bold text-foreground">Offentlig bane som mal</span>
<Button type="button" variant="ghost" size="icon" onClick={onClose} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
<X aria-hidden="true" className="size-4" />
</Button>
</div>
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
<Input autoFocus value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Søk etter banenavn…" className="h-11 rounded-xl text-sm" />
<ul className="flex max-h-72 flex-col gap-2 overflow-auto">
{results.map((c) => (
<li key={c.id} className="flex flex-col gap-2 rounded-xl border border-border p-3">
<div className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{c.name}</span>
<span className="text-xs text-muted-foreground">{c.is_mine ? "Opprettet av deg" : `Opprettet av ${c.created_by_display_name}`}</span>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
disabled={loadingId !== null}
onClick={async () => {
const values = await fetchDetail(c)
if (values) onPickDirect(values, c.id)
}}
className="h-9 flex-1 rounded-lg text-xs font-bold"
>
Bruk direkte
</Button>
<Button
type="button"
variant="outline"
disabled={loadingId !== null}
onClick={async () => {
const values = await fetchDetail(c)
if (values) onPickCustomize(values, c.id)
}}
className="h-9 flex-1 rounded-lg text-xs font-bold"
>
Tilpass før bruk
</Button>
</div>
</li>
))}
{results.length === 0 && (
<li className="px-3 py-4 text-center text-sm text-muted-foreground">
{query.trim() ? `Ingen offentlige baner matcher «${query}».` : "Skriv for å søke."}
</li>
)}
</ul>
</div>
)
}