teecup/frontend/components/ny-runde/create-course-form.tsx

385 lines
14 KiB
TypeScript
Raw Permalink Normal View History

"use client"
import type { ApiGender, Course } from "@/lib/ny-runde/types"
import { createOwnCourse } from "@/lib/ny-runde/api"
import { cn } from "@/lib/utils"
import { Plus, Trash2 } from "lucide-react"
import { useMemo, useState } from "react"
import { Field, NativeSelect, TextInput, Toggle, useAutoFocus } from "./primitives"
interface DraftRating {
cr: string
slope: string
par: string
}
interface DraftTee {
id: string
name: string
menEnabled: boolean
womenEnabled: boolean
men: DraftRating
women: DraftRating
}
interface DraftHole {
par: number
si: number | 0 // 0 = ikke satt
}
let teeCounter = 0
function newTee(): DraftTee {
teeCounter += 1
return {
id: `draft-tee-${teeCounter}`,
name: "",
menEnabled: true,
womenEnabled: false,
men: { cr: "", slope: "", par: "" },
women: { cr: "", slope: "", par: "" },
}
}
// seed.tees har KUN genders (ikke CR/Slope-tall) -- et forhåndsutfylt
// utslag fra en mal kan derfor bare forhåndsutfylle navn+hvilke(t) kjønn
// som er aktive, ikke selve ratingtallene (de er ikke kjent klient-side).
function seedToDraft(seed?: Course): { holes: DraftHole[]; tees: DraftTee[]; name: string } {
if (!seed) {
return {
name: "",
holes: Array.from({ length: 18 }, () => ({ par: 4, si: 0 })),
tees: [newTee()],
}
}
const holes: DraftHole[] = seed.templateHoles?.length
? seed.templateHoles.map((h) => ({ par: h.par, si: h.stroke_index }))
: Array.from({ length: 18 }, () => ({ par: 4, si: 0 }))
const tees: DraftTee[] = seed.templateTees?.length
? seed.templateTees.map((t) => {
teeCounter += 1
const men = t.ratings.find((r) => r.gender === "m")
const women = t.ratings.find((r) => r.gender === "f")
return {
id: `draft-tee-${teeCounter}`,
name: t.name,
menEnabled: !!men,
womenEnabled: !!women,
men: men ? { cr: String(men.course_rating), slope: String(men.slope_rating), par: String(men.par) } : { cr: "", slope: "", par: "" },
women: women ? { cr: String(women.course_rating), slope: String(women.slope_rating), par: String(women.par) } : { cr: "", slope: "", par: "" },
}
})
: [newTee()]
return { name: `${seed.name} (kopi)`, holes, tees }
}
function ratingComplete(r: DraftRating) {
return r.cr.trim() !== "" && r.slope.trim() !== "" && r.par.trim() !== ""
}
export function CreateCourseForm({
seed,
onCancel,
onCreate,
}: {
seed?: Course
onCancel: () => void
onCreate: (course: Course) => void
}) {
const initial = useMemo(() => seedToDraft(seed), [seed])
const [name, setName] = useState(initial.name)
const [holes, setHoles] = useState<DraftHole[]>(initial.holes)
const [tees, setTees] = useState<DraftTee[]>(initial.tees)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const nameRef = useAutoFocus<HTMLInputElement>(true)
const siValues = holes.map((h) => h.si)
const siError = useMemo(() => {
const filled = siValues.filter((v) => v >= 1 && v <= 18)
const set = new Set(filled)
const complete = set.size === 18 && filled.length === 18
return complete ? null : "Hver stroke-indeks (118) må brukes nøyaktig én gang."
}, [siValues])
const teesValid = tees.every((t) => {
if (!t.name.trim()) return false
if (!t.menEnabled && !t.womenEnabled) return false
if (t.menEnabled && !ratingComplete(t.men)) return false
if (t.womenEnabled && !ratingComplete(t.women)) return false
return true
})
const canSubmit = name.trim() !== "" && !siError && tees.length >= 1 && teesValid && !submitting
function setHole(i: number, patch: Partial<DraftHole>) {
setHoles((prev) => prev.map((h, idx) => (idx === i ? { ...h, ...patch } : h)))
}
function setTee(id: string, patch: Partial<DraftTee>) {
setTees((prev) => prev.map((t) => (t.id === id ? { ...t, ...patch } : t)))
}
function setTeeRating(id: string, gender: "men" | "women", patch: Partial<DraftRating>) {
setTees((prev) => prev.map((t) => (t.id === id ? { ...t, [gender]: { ...t[gender], ...patch } } : t)))
}
async function submit() {
if (!canSubmit) return
setSubmitting(true)
setError(null)
try {
const toRatings = (t: DraftTee) => {
const ratings: { gender: ApiGender; course_rating: number; slope_rating: number; par: number }[] = []
if (t.menEnabled) ratings.push({ gender: "m", course_rating: Number.parseFloat(t.men.cr), slope_rating: Number.parseInt(t.men.slope, 10), par: Number.parseInt(t.men.par, 10) })
if (t.womenEnabled) ratings.push({ gender: "f", course_rating: Number.parseFloat(t.women.cr), slope_rating: Number.parseInt(t.women.slope, 10), par: Number.parseInt(t.women.par, 10) })
return ratings
}
// Kun personlige baner (ikke offisielle teeoff-baner) kan "forkes" --
// seed.id er et syntetisk teeoff-N-id for offisielle baner, en ekte
// UUID for personlige (se courseFromOfficial/fetchOwnCourse i api.ts).
const forkedFromId = seed && !seed.id.startsWith("teeoff-") ? seed.id : null
const course = await createOwnCourse({
name: name.trim(),
holes: holes.map((h, i) => ({ hole_number: i + 1, par: h.par, stroke_index: h.si })),
tees: tees.map((t) => ({ name: t.name.trim(), ratings: toRatings(t) })),
forkedFromId,
})
onCreate(course)
} catch {
setError("Klarte ikke å opprette banen. Sjekk at hull og utslag er fylt ut riktig.")
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-5">
<Field label="Navn på banen" htmlFor="cc-name">
<TextInput
id="cc-name"
ref={nameRef}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="F.eks. Skogsbanen"
maxLength={120}
/>
</Field>
{error ? (
<p role="alert" className="text-xs text-[var(--nr-danger)]">
{error}
</p>
) : null}
{/* Hull */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Hull</h3>
<span className="text-xs text-[var(--nr-muted)]">Par og stroke-indeks for 18 hull</span>
</div>
<div className="overflow-hidden rounded-xl border border-[var(--nr-border)]">
<div className="grid grid-cols-[auto_1fr_1fr] gap-px bg-[var(--nr-border)] text-xs">
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Hull</div>
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Par</div>
<div className="bg-[var(--nr-surface-2)] px-3 py-2 font-medium text-[var(--nr-muted)]">Stroke-indeks</div>
{holes.map((h, i) => (
<HoleRow key={i} index={i} hole={h} onChange={(patch) => setHole(i, patch)} />
))}
</div>
</div>
{siError ? (
<p role="alert" className="text-xs text-[var(--nr-danger)]">
{siError}
</p>
) : (
<p className="text-xs text-[var(--nr-ok)]">Alle stroke-indekser 118 er satt.</p>
)}
</div>
{/* Utslag */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--nr-ink)]">Utslag</h3>
<button
type="button"
onClick={() => setTees((p) => [...p, newTee()])}
className="inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-3 text-[13px] font-medium text-[var(--nr-accent)] transition-colors hover:bg-[var(--nr-surface-2)] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-[var(--nr-accent-ring)]/40"
>
<Plus className="h-4 w-4" aria-hidden="true" />
Legg til utslag
</button>
</div>
{tees.map((t, i) => (
<div key={t.id} className="rounded-xl border border-[var(--nr-border)] bg-[var(--nr-surface-2)] p-3">
<div className="flex items-end gap-2">
<Field label="Navn på utslag" htmlFor={`${t.id}-name`} className="flex-1">
<TextInput
id={`${t.id}-name`}
value={t.name}
onChange={(e) => setTee(t.id, { name: e.target.value })}
placeholder={`Utslag ${i + 1}`}
invalid={t.name.trim() === ""}
className="bg-[var(--nr-surface)]"
/>
</Field>
<button
type="button"
onClick={() => tees.length > 1 && setTees((p) => p.filter((x) => x.id !== t.id))}
disabled={tees.length <= 1}
aria-label={`Fjern ${t.name.trim() || `utslag ${i + 1}`}`}
className="mb-0.5 flex h-11 w-11 items-center justify-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] text-[var(--nr-muted)] transition-colors hover:text-[var(--nr-danger)] disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="mt-3 flex flex-col gap-3">
<RatingBlock
title="Herre-rating"
enabled={t.menEnabled}
onToggle={(v) => setTee(t.id, { menEnabled: v })}
rating={t.men}
onChange={(patch) => setTeeRating(t.id, "men", patch)}
idPrefix={`${t.id}-men`}
/>
<RatingBlock
title="Dame-rating"
enabled={t.womenEnabled}
onToggle={(v) => setTee(t.id, { womenEnabled: v })}
rating={t.women}
onChange={(patch) => setTeeRating(t.id, "women", patch)}
idPrefix={`${t.id}-women`}
/>
{!t.menEnabled && !t.womenEnabled ? (
<p role="alert" className="text-xs text-[var(--nr-danger)]">
Minst én rating (herre eller dame) være aktiv for dette utslaget.
</p>
) : null}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between gap-3 pt-1">
<button
type="button"
onClick={onCancel}
className="inline-flex min-h-11 items-center rounded-lg border border-[var(--nr-border)] bg-[var(--nr-surface)] px-4 text-sm font-medium text-[var(--nr-ink)] transition-colors hover:bg-[var(--nr-surface-2)]"
>
Avbryt
</button>
<button
type="button"
onClick={() => void submit()}
disabled={!canSubmit}
className="inline-flex min-h-11 items-center rounded-lg bg-[var(--nr-accent)] px-5 text-sm font-semibold text-[var(--nr-accent-ink)] transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
>
{submitting ? "Lagrer…" : "Lagre bane"}
</button>
</div>
</div>
)
}
function HoleRow({
index,
hole,
onChange,
}: {
index: number
hole: DraftHole
onChange: (patch: Partial<DraftHole>) => void
}) {
return (
<>
<div className="flex items-center bg-[var(--nr-surface)] px-3 py-1.5 text-sm font-medium tabular-nums text-[var(--nr-muted)]">
{index + 1}
</div>
<div className="bg-[var(--nr-surface)] px-2 py-1.5">
<NativeSelect
aria-label={`Par for hull ${index + 1}`}
value={hole.par}
onChange={(e) => onChange({ par: Number.parseInt(e.target.value, 10) })}
className="h-9 bg-[var(--nr-surface-2)]"
>
{[3, 4, 5, 6].map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</NativeSelect>
</div>
<div className="bg-[var(--nr-surface)] px-2 py-1.5">
<NativeSelect
aria-label={`Stroke-indeks for hull ${index + 1}`}
value={hole.si || ""}
onChange={(e) => onChange({ si: Number.parseInt(e.target.value, 10) })}
className="h-9 bg-[var(--nr-surface-2)]"
>
<option value=""></option>
{Array.from({ length: 18 }, (_, i) => i + 1).map((si) => (
<option key={si} value={si}>
{si}
</option>
))}
</NativeSelect>
</div>
</>
)
}
function RatingBlock({
title,
enabled,
onToggle,
rating,
onChange,
idPrefix,
}: {
title: string
enabled: boolean
onToggle: (v: boolean) => void
rating: DraftRating
onChange: (patch: Partial<DraftRating>) => void
idPrefix: string
}) {
return (
<div className={cn("rounded-lg border p-3", enabled ? "border-[var(--nr-accent-ring)] bg-[var(--nr-surface)]" : "border-[var(--nr-border)] bg-[var(--nr-surface)]")}>
<Toggle checked={enabled} onChange={onToggle} label={title} />
{enabled ? (
<div className="mt-3 grid grid-cols-3 gap-2">
<Field label="Course Rating" htmlFor={`${idPrefix}-cr`}>
<TextInput
id={`${idPrefix}-cr`}
inputMode="decimal"
value={rating.cr}
onChange={(e) => onChange({ cr: e.target.value })}
placeholder="72.1"
invalid={rating.cr.trim() === ""}
className="bg-[var(--nr-surface-2)]"
/>
</Field>
<Field label="Slope" htmlFor={`${idPrefix}-slope`}>
<TextInput
id={`${idPrefix}-slope`}
inputMode="numeric"
value={rating.slope}
onChange={(e) => onChange({ slope: e.target.value.replace(/[^\d]/g, "") })}
placeholder="128"
invalid={rating.slope.trim() === ""}
className="bg-[var(--nr-surface-2)]"
/>
</Field>
<Field label="Par" htmlFor={`${idPrefix}-par`}>
<TextInput
id={`${idPrefix}-par`}
inputMode="numeric"
value={rating.par}
onChange={(e) => onChange({ par: e.target.value.replace(/[^\d]/g, "") })}
placeholder="72"
invalid={rating.par.trim() === ""}
className="bg-[var(--nr-surface-2)]"
/>
</Field>
</div>
) : null}
</div>
)
}