385 lines
14 KiB
TypeScript
385 lines
14 KiB
TypeScript
|
|
"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 (1–18) 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 1–18 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) må 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>
|
|||
|
|
)
|
|||
|
|
}
|