"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(initial?.holes ?? blankHoles()) const [tees, setTees] = useState(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) { 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
: denne komponenten rendres ofte inne i // et YTRE skjema (NewRoundForm/CreateSessionCard), og nestede - // 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 (
{error &&

{error}

}
setName(e.target.value)} placeholder="F.eks. Hjemmebanen" className="h-10 rounded-xl text-sm" />
Hull -- par og stroke-indeks {!indexesValid && (

Hver stroke-indeks (1-18) må brukes nøyaktig én gang.

)}
Hull Par SI
{holes.map((h, index) => (
{h.hole_number}
))}
Utslag {tees.map((t, index) => (
updateTee(t.key, { name: e.target.value })} placeholder="F.eks. Gul" className="h-10 rounded-xl text-sm" />
{tees.length > 1 && ( )}
updateTee(t.key, { men })} onChange={(menRating) => updateTee(t.key, { menRating })} idPrefix={`ct-men-${t.key}`} /> updateTee(t.key, { women })} onChange={(womenRating) => updateTee(t.key, { womenRating })} idPrefix={`ct-women-${t.key}`} />
))}
) } 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 (
{enabled && (
onChange({ ...rating, courseRating: e.target.value })} placeholder="CR" className="h-9 rounded-lg text-sm" /> onChange({ ...rating, slope: e.target.value })} placeholder="Slope" className="h-9 rounded-lg text-sm" /> onChange({ ...rating, par: e.target.value })} placeholder="Par" className="h-9 rounded-lg text-sm" />
)}
) } // --------------------------------------------------------------------------- // 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({ kind: "choose" }) const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(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 (
Opprett manuell bane
) } if (step.kind === "official-search") { return ( setStep({ kind: "official-facility", facility })} onClose={() => setStep({ kind: "choose" })} /> ) } if (step.kind === "official-facility") { return ( setStep({ kind: "official-search" })} onPicked={(values) => setStep({ kind: "editor", initial: values })} /> ) } if (step.kind === "public-search") { return ( submitCourse(values, sourceId)} onPickCustomize={(values, sourceId) => setStep({ kind: "editor", initial: values, templatePersonalCourseId: sourceId })} onClose={() => setStep({ kind: "choose" })} /> ) } return ( 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(null) const [searching, setSearching] = useState(false) const [error, setError] = useState(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 (
TeeOff-bane som mal
{error &&

{error}

}
setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault() runSearch() } }} className="h-11 flex-1 rounded-xl text-base" />
{facilities && (
    {facilities.map((f) => (
  • ))} {facilities.length === 0 &&
  • Ingen treff.
  • }
)}
) } function OfficialCourseList({ organizationId, facility, onBack, onPicked, }: { organizationId: string facility: ApiOfficialFacility onBack: () => void onPicked: (values: CourseTemplateValues) => void }) { const [courses, setCourses] = useState(null) const [error, setError] = useState(null) const [loadingCourseId, setLoadingCourseId] = useState(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) { const body = await res.json().catch(() => null) const code = body?.detail?.code if (code === "EXTERNAL_DATA_INCOMPLETE") { setError("Denne banen mangler nok data i teeoff til å importeres ennå.") } else if (code === "EXTERNAL_DATA_INVALID") { setError( body?.detail?.message ?? "Banedataen fra teeoff er ugyldig (f.eks. duplisert hull-indeks) -- dette må rettes hos teeoff før banen kan importeres.", ) } else { setError("Klarte ikke å hente banedata fra teeoff. Prøv igjen.") } return } 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 (
{error &&

{error}

} {courses === null ? (

Laster baner…

) : (
    {courses.map((c) => (
  • ))} {courses.length === 0 &&
  • Ingen 18-hulls baner hos dette anlegget ennå.
  • }
)}
) } 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([]) const [loadingId, setLoadingId] = useState(null) const [error, setError] = useState(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 { 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 (
Offentlig bane som mal
{error &&

{error}

} setQuery(e.target.value)} placeholder="Søk etter banenavn…" className="h-11 rounded-xl text-sm" />
    {results.map((c) => (
  • {c.name} {c.is_mine ? "Opprettet av deg" : `Opprettet av ${c.created_by_display_name}`}
  • ))} {results.length === 0 && (
  • {query.trim() ? `Ingen offentlige baner matcher «${query}».` : "Skriv for å søke."}
  • )}
) }