Rotårsak for "banen er allerede importert"-forvirringen rundt Tjøme-baner: teeoffs egen hull-data hadde en duplisert hcp-indeks (13 to ganger, 14 manglet). Manglende validering lot dette komme gjennom og feile stille på en UNIQUE-constraint, oversatt til den misvisende DUPLICATE-meldingen. Legger til eksplisitt 1-18-validering før lagring, med en ærlig feilkode/ -melding, i både teeoff- og GolfAPI-import-stiene og alle fire frontend- kallstedene. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
712 lines
29 KiB
TypeScript
712 lines
29 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 på 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) må 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 på 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) {
|
|
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 (
|
|
<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>
|
|
)
|
|
}
|