teecup/frontend/components/tournament-program.tsx

1061 lines
38 KiB
TypeScript

"use client"
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import {
ArrowLeft,
CalendarClock,
ChevronDown,
Clock,
Flag,
Plus,
Sparkles,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) -
// 'scramble' finnes IKKE som egen verdi i API-et -- skjemaets CHECK-constraint
// (001_initial_schema.sql) og handicap_engine.py sin Format-enum krever
// scramble_2/scramble_4 (antall spillere per side er del av selve formatet,
// ikke en egen bryter).
type SessionFormat = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles"
type HoleConfig = "full_18" | "front_9" | "back_9"
type ScoringMode = "stroke" | "hole_result"
// Formater der ENHETEN som deler én Playing Handicap er SIDEN, ikke spilleren
// -- avgjør om et manuelt HCP-prosent-overstyr skal sendes som "combined"
// eller "per_player" (se app/handicap.py sin _SIDE_IS_UNIT).
const SIDE_IS_UNIT_FORMAT = new Set<SessionFormat>(["foursome", "greensome", "scramble_2", "scramble_4"])
type ApiCourse = {
id: string
name: string
source: string
}
type ApiSession = {
id: string
sequence: number
name: string | null
format: SessionFormat
hole_config: HoleConfig
course_id: string
points_per_match: number
allowance_override: Record<string, unknown> | null
scoring_mode: ScoringMode
scheduled_at: string | null
tee_interval_minutes: number | null
start_hole: number
locked_team_ids: string[]
revealed: boolean
}
// --- Norwegian display labels ----------------------------------------------
const FORMAT_LABELS: Record<SessionFormat, string> = {
foursome: "Foursome",
greensome: "Greensome",
scramble_2: "Scramble (2 spillere)",
scramble_4: "Scramble (4 spillere)",
fourball: "Fourball",
singles: "Singel",
}
const HOLE_LABELS: Record<HoleConfig, string> = {
full_18: "18 hull",
front_9: "Front 9",
back_9: "Back 9",
}
const SCORING_LABELS: Record<ScoringMode, string> = {
stroke: "Slag for slag",
hole_result: "Kun hullresultat",
}
const FORMAT_OPTIONS = Object.keys(FORMAT_LABELS) as SessionFormat[]
const HOLE_OPTIONS = Object.keys(HOLE_LABELS) as HoleConfig[]
const SCORING_OPTIONS = Object.keys(SCORING_LABELS) as ScoringMode[]
// --- Component -------------------------------------------------------------
export function TournamentProgram({
organizationId,
tournamentId,
tournamentName,
}: {
organizationId: string
tournamentId: string
tournamentName: string
}) {
const [sessions, setSessions] = useState<ApiSession[]>([])
const [courses, setCourses] = useState<ApiCourse[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const [sessionsRes, coursesRes] = await Promise.all([
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, {
credentials: "include",
}),
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
])
if (!sessionsRes.ok || !coursesRes.ok) throw new Error("load failed")
const sessionsData: ApiSession[] = await sessionsRes.json()
const coursesData: ApiCourse[] = await coursesRes.json()
if (cancelled) return
setSessions(sessionsData)
setCourses(coursesData)
} catch {
if (!cancelled) setError("Klarte ikke å laste programmet. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId])
const coursesById = useMemo(() => {
const map = new Map<string, ApiCourse>()
for (const c of courses) map.set(c.id, c)
return map
}, [courses])
const sorted = useMemo(() => [...sessions].sort((a, b) => a.sequence - b.sequence), [sessions])
const nextSequence = useMemo(
() => (sessions.length === 0 ? 1 : Math.max(...sessions.map((s) => s.sequence)) + 1),
[sessions],
)
async function ensureCourseId(existingId: string | null, newName: string): Promise<string> {
if (existingId) return existingId
const res = await fetch(`/orgs/${organizationId}/courses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name: newName }),
})
if (!res.ok) throw new Error(`create course: ${res.status}`)
const created: ApiCourse = await res.json()
setCourses((prev) => [...prev, created])
return created.id
}
async function addSession(input: {
name?: string
format: SessionFormat
holeConfig: HoleConfig
scoringMode: ScoringMode
pointsPerMatch: number
scheduledAt?: string
teeIntervalMinutes?: number
startHole: number
courseId: string | null
newCourseName: string
allowanceOverride: Record<string, unknown> | null
}) {
setError(null)
try {
const courseId = await ensureCourseId(input.courseId, input.newCourseName)
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
sequence: nextSequence,
name: input.name ?? null,
format: input.format,
hole_config: input.holeConfig,
course_id: courseId,
points_per_match: input.pointsPerMatch,
allowance_override: input.allowanceOverride,
scoring_mode: input.scoringMode,
scheduled_at: input.scheduledAt ?? null,
tee_interval_minutes: input.teeIntervalMinutes ?? null,
start_hole: input.startHole,
}),
})
if (!res.ok) throw new Error(`create session: ${res.status}`)
const created: ApiSession = await res.json()
setSessions((prev) => [...prev, created])
} catch {
setError("Klarte ikke å opprette økten. Prøv igjen.")
}
}
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-5 py-4">
<Link
href={detailHref}
aria-label="Tilbake til turnering"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Turnering
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
{tournamentName}
</h1>
</div>
</div>
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
<Link
href={detailHref}
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Lag og spillere
</Link>
<Link
href={programHref}
aria-current="page"
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
>
Program
</Link>
</nav>
</header>
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
<div className="mb-5 flex flex-col gap-1">
<h2 className="text-base font-bold text-foreground">Program</h2>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Sett opp turneringens økter i rekkefølge. Hver økt er én runde med et gitt format og
tidspunkt.
</p>
</div>
{error && (
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
{error}
</p>
)}
{loading ? (
<div className="flex justify-center py-16">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
) : (
<>
{sorted.length === 0 ? (
<EmptyState />
) : (
<ol className="flex flex-col gap-3">
{sorted.map((session, index) => (
<SessionCard
key={session.id}
session={session}
course={coursesById.get(session.course_id) ?? null}
isLast={index === sorted.length - 1}
/>
))}
</ol>
)}
<div className="mt-6">
<CreateSessionCard
nextSequence={nextSequence}
courses={courses}
organizationId={organizationId}
onCourseImported={(course) => setCourses((prev) => [...prev, course])}
onCreate={addSession}
/>
</div>
</>
)}
</main>
</div>
)
}
// --- Session card (timeline item) ------------------------------------------
function SessionCard({
session,
course,
isLast,
}: {
session: ApiSession
course: ApiCourse | null
isLast: boolean
}) {
const title = session.name?.trim() || `Økt ${session.sequence}`
return (
<li className="relative flex gap-4">
<div className="flex flex-col items-center">
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary text-lg font-extrabold tabular-nums text-primary-foreground shadow-sm">
{session.sequence}
</div>
{!isLast && <div aria-hidden="true" className="mt-1 w-0.5 flex-1 rounded-full bg-border" />}
</div>
<div className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5">
<div className="flex flex-col gap-1">
<h3 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h3>
<div className="flex flex-wrap items-center gap-2">
<MetaBadge>{FORMAT_LABELS[session.format]}</MetaBadge>
<MetaBadge>{HOLE_LABELS[session.hole_config]}</MetaBadge>
<MetaBadge>{SCORING_LABELS[session.scoring_mode]}</MetaBadge>
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
{course && <MetaBadge>{course.name}</MetaBadge>}
</div>
</div>
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
{session.scheduled_at ? (
<div className="flex items-start gap-2 text-sm text-foreground">
<CalendarClock aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
<span className="text-pretty">
Første utslag {formatDateTime(session.scheduled_at)}
{session.tee_interval_minutes
? `, ${session.tee_interval_minutes} min mellom hver`
: ""}
</span>
</div>
) : (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock aria-hidden="true" className="size-4 shrink-0" />
<span>Tidspunkt ikke satt</span>
</div>
)}
{session.start_hole !== 1 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Flag aria-hidden="true" className="size-4 shrink-0" />
<span>Starter hull {session.start_hole}</span>
</div>
)}
</div>
</div>
</li>
)
}
function MetaBadge({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
{children}
</span>
)
}
// --- Empty state -----------------------------------------------------------
function EmptyState() {
return (
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-10 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<CalendarClock aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-base font-bold text-foreground">Ingen økter ennå</h3>
<p className="mx-auto max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">
Legg til den første økten nedenfor for å bygge programmet.
</p>
</div>
<ChevronDown aria-hidden="true" className="size-5 animate-bounce text-muted-foreground" />
</div>
)
}
// --- Create session form -----------------------------------------------------
function CreateSessionCard({
nextSequence,
courses,
organizationId,
onCourseImported,
onCreate,
}: {
nextSequence: number
courses: ApiCourse[]
organizationId: string
onCourseImported: (course: ApiCourse) => void
onCreate: (input: {
name?: string
format: SessionFormat
holeConfig: HoleConfig
scoringMode: ScoringMode
pointsPerMatch: number
scheduledAt?: string
teeIntervalMinutes?: number
startHole: number
courseId: string | null
newCourseName: string
allowanceOverride: Record<string, unknown> | null
}) => Promise<void>
}) {
const [name, setName] = useState("")
const [format, setFormat] = useState<SessionFormat>("foursome")
const [holeConfig, setHoleConfig] = useState<HoleConfig>("full_18")
const [scoringMode, setScoringMode] = useState<ScoringMode>("stroke")
const [points, setPoints] = useState("1")
const [scheduledAt, setScheduledAt] = useState("")
const [teeInterval, setTeeInterval] = useState("")
const [startHole, setStartHole] = useState("1")
const [submitting, setSubmitting] = useState(false)
const [courseQuery, setCourseQuery] = useState("")
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [useHandicap, setUseHandicap] = useState(true)
const [useCourseHandicap, setUseCourseHandicap] = useState(true)
const [useMatchplayHandicap, setUseMatchplayHandicap] = useState(true)
const [hcpPercent, setHcpPercent] = useState("")
const trimmedCourseQuery = courseQuery.trim()
const courseMatches = useMemo(() => {
if (!trimmedCourseQuery || selectedCourseId) return []
const q = trimmedCourseQuery.toLowerCase()
return courses.filter((c) => c.name.toLowerCase().includes(q))
}, [courses, trimmedCourseQuery, selectedCourseId])
const exactCourseMatch = courses.some(
(c) => c.name.toLowerCase() === trimmedCourseQuery.toLowerCase(),
)
const showCreateCourse = trimmedCourseQuery.length >= 2 && !exactCourseMatch && !selectedCourseId
const hasTime = scheduledAt.trim() !== ""
const courseChosen = selectedCourseId !== null || (showCreateCourse && trimmedCourseQuery.length >= 2)
const valid = courseChosen
function buildAllowanceOverride(): Record<string, unknown> | null {
const percentValue = hcpPercent.trim() === "" ? undefined : Number(hcpPercent.replace(",", "."))
const hasPercent = percentValue !== undefined && !Number.isNaN(percentValue)
if (useHandicap && useCourseHandicap && useMatchplayHandicap && !hasPercent) {
// Alt på standard -- ikke send noe, backend bruker allerede riktige
// defaults når allowance_override er null (se app/handicap.py).
return null
}
const override: Record<string, unknown> = {
use_handicap: useHandicap,
use_course_handicap: useCourseHandicap,
use_matchplay_handicap: useMatchplayHandicap,
}
if (hasPercent) {
const fraction = percentValue! / 100
override.strategy = SIDE_IS_UNIT_FORMAT.has(format)
? { type: "combined", percentage: fraction }
: { type: "per_player", percentage: fraction }
}
return override
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!valid || submitting) return
const pointsValue = Number(points.replace(",", ".")) || 1
const startHoleValue = Math.min(18, Math.max(1, Number(startHole) || 1))
const intervalValue = hasTime && teeInterval.trim() !== "" ? Number(teeInterval) : undefined
setSubmitting(true)
await onCreate({
name: name.trim() || undefined,
format,
holeConfig,
scoringMode,
pointsPerMatch: pointsValue,
scheduledAt: hasTime ? scheduledAt : undefined,
teeIntervalMinutes: intervalValue && !Number.isNaN(intervalValue) ? intervalValue : undefined,
startHole: startHoleValue,
courseId: selectedCourseId,
newCourseName: trimmedCourseQuery,
allowanceOverride: buildAllowanceOverride(),
})
setSubmitting(false)
setName("")
setFormat("foursome")
setHoleConfig("full_18")
setScoringMode("stroke")
setPoints("1")
setScheduledAt("")
setTeeInterval("")
setStartHole("1")
setCourseQuery("")
setSelectedCourseId(null)
setOfficialSearchOpen(false)
setAdvancedOpen(false)
setUseHandicap(true)
setUseCourseHandicap(true)
setUseMatchplayHandicap(true)
setHcpPercent("")
}
return (
<form
onSubmit={handleSubmit}
className="flex flex-col gap-6 rounded-3xl border border-dashed border-border bg-card/50 p-5 sm:p-6"
>
<div className="flex items-center gap-3">
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
<Plus aria-hidden="true" className="size-5 text-primary" />
</div>
<div className="flex flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Økt {nextSequence}
</span>
<span className="text-base font-bold text-foreground">Legg til økt</span>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="session-name" className="text-sm font-semibold">
Navn <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="session-name"
placeholder="F.eks. Foursome dag 1"
value={name}
onChange={(e) => setName(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">Format</Label>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{FORMAT_OPTIONS.map((opt) => (
<SegmentButton key={opt} selected={format === opt} onClick={() => setFormat(opt)}>
{FORMAT_LABELS[opt]}
</SegmentButton>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">Bane</Label>
{selectedCourseId ? (
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-background px-4 py-3">
<span className="text-sm font-semibold text-foreground">
{courses.find((c) => c.id === selectedCourseId)?.name}
</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
setSelectedCourseId(null)
setCourseQuery("")
}}
className="size-8 rounded-lg text-muted-foreground"
aria-label="Fjern valgt bane"
>
<X aria-hidden="true" className="size-4" />
</Button>
</div>
) : (
<div className="flex flex-col gap-2">
<Input
placeholder="Søk eller skriv banenavn…"
value={courseQuery}
onChange={(e) => setCourseQuery(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
{trimmedCourseQuery.length > 0 && (
<div className="overflow-hidden rounded-2xl border border-border bg-background">
<ul className="flex max-h-48 flex-col overflow-auto">
{courseMatches.map((c) => (
<li key={c.id} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => {
setSelectedCourseId(c.id)
setCourseQuery("")
}}
className="flex w-full items-center px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60"
>
{c.name}
</button>
</li>
))}
{courseMatches.length === 0 && !showCreateCourse && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
)}
{showCreateCourse && (
<li className={courseMatches.length > 0 ? "border-t border-border" : ""}>
<button
type="button"
onClick={() => setSelectedCourseId(null)}
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-semibold text-primary transition-colors hover:bg-accent/60"
>
<Plus aria-hidden="true" className="size-4" />
Opprett ny bane: «{trimmedCourseQuery}»
</button>
</li>
)}
</ul>
</div>
)}
<button
type="button"
onClick={() => setOfficialSearchOpen(true)}
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
>
Hent bane fra teeoff i stedet
</button>
{officialSearchOpen && (
<OfficialCourseSearch
organizationId={organizationId}
onClose={() => setOfficialSearchOpen(false)}
onImported={(course) => {
onCourseImported(course)
setSelectedCourseId(course.id)
setOfficialSearchOpen(false)
}}
/>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">Hullomfang</Label>
<div className="grid grid-cols-3 gap-2">
{HOLE_OPTIONS.map((opt) => (
<SegmentButton key={opt} selected={holeConfig === opt} onClick={() => setHoleConfig(opt)}>
{HOLE_LABELS[opt]}
</SegmentButton>
))}
</div>
</div>
<div className="flex flex-col gap-2">
<Label className="text-sm font-semibold">Scoringsmodus</Label>
<div className="grid grid-cols-2 gap-2">
{SCORING_OPTIONS.map((opt) => (
<SegmentButton key={opt} selected={scoringMode === opt} onClick={() => setScoringMode(opt)}>
{SCORING_LABELS[opt]}
</SegmentButton>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="session-points" className="text-sm font-semibold">
Poeng per match
</Label>
<Input
id="session-points"
inputMode="decimal"
type="number"
step="0.5"
min="0"
value={points}
onChange={(e) => setPoints(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="session-starthole" className="text-sm font-semibold">
Starthull
</Label>
<Input
id="session-starthole"
inputMode="numeric"
type="number"
min="1"
max="18"
value={startHole}
onChange={(e) => setStartHole(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="session-time" className="text-sm font-semibold">
Startdato og -klokkeslett{" "}
<span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="session-time"
type="datetime-local"
value={scheduledAt}
onChange={(e) => setScheduledAt(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
{hasTime && (
<div className="flex flex-col gap-2">
<Label htmlFor="session-interval" className="text-sm font-semibold">
Minutter mellom hver flight
</Label>
<Input
id="session-interval"
inputMode="numeric"
type="number"
min="1"
placeholder="F.eks. 10"
value={teeInterval}
onChange={(e) => setTeeInterval(e.target.value)}
className="h-12 rounded-2xl text-base"
/>
</div>
)}
<div className="rounded-2xl border border-border bg-background/50">
<button
type="button"
onClick={() => setAdvancedOpen((v) => !v)}
aria-expanded={advancedOpen}
className="flex w-full items-center justify-between gap-2 rounded-2xl px-4 py-3.5 text-left transition-colors hover:bg-accent/40"
>
<span className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Sparkles aria-hidden="true" className="size-4 text-muted-foreground" />
Avanserte handicap-innstillinger
</span>
<ChevronDown
aria-hidden="true"
className={cn("size-5 text-muted-foreground transition-transform", advancedOpen && "rotate-180")}
/>
</button>
{advancedOpen && (
<div className="flex flex-col gap-1 border-t border-border px-4 py-2">
<ToggleRow label="Bruk handicap" checked={useHandicap} onCheckedChange={setUseHandicap} />
<ToggleRow
label="Bruk course handicap-justering"
checked={useCourseHandicap}
onCheckedChange={setUseCourseHandicap}
/>
<ToggleRow
label="Bruk matchplay-handicap"
checked={useMatchplayHandicap}
onCheckedChange={setUseMatchplayHandicap}
/>
<div className="flex flex-col gap-2 py-3">
<Label htmlFor="hcp-percent" className="text-sm font-semibold">
HCP-prosent <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id="hcp-percent"
inputMode="decimal"
placeholder="Standard for format"
value={hcpPercent}
onChange={(e) => setHcpPercent(e.target.value)}
className="h-11 rounded-xl text-base"
/>
</div>
</div>
)}
</div>
<Button type="submit" disabled={!valid || submitting} className="h-12 rounded-2xl text-base font-bold shadow-sm">
<Plus aria-hidden="true" className="size-5" />
{submitting ? "Legger til…" : "Legg til økt"}
</Button>
</form>
)
}
// --- Offisiell bane fra teeoff (ADR-019) ------------------------------------
type ApiOfficialFacility = {
slug: string
name: string
city: string | null
county: string | null
}
type ApiOfficialCourseOption = {
teeoff_course_id: number
name: string
is_main_course: boolean
}
function OfficialCourseSearch({
organizationId,
onClose,
onImported,
}: {
organizationId: string
onClose: () => void
onImported: (course: ApiCourse) => void
}) {
const [query, setQuery] = useState("")
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
const [selectedFacility, setSelectedFacility] = useState<ApiOfficialFacility | null>(null)
const [courseOptions, setCourseOptions] = useState<ApiOfficialCourseOption[] | null>(null)
const [searching, setSearching] = useState(false)
const [importing, setImporting] = 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)
}
}
async function pickFacility(facility: ApiOfficialFacility) {
setSelectedFacility(facility)
setError(null)
setCourseOptions(null)
try {
const res = await fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, {
credentials: "include",
})
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
setCourseOptions(detail.courses)
} catch {
setError("Klarte ikke å hente baner for dette anlegget.")
setCourseOptions([])
}
}
async function importCourse(course: ApiOfficialCourseOption) {
if (!selectedFacility) return
setImporting(true)
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: selectedFacility.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 === "DUPLICATE") {
setError("Denne banen er allerede importert til organisasjonen.")
} else {
setError("Klarte ikke å importere banen. Prøv igjen.")
}
return
}
onImported(await res.json())
} catch {
setError("Klarte ikke å importere banen. Prøv igjen.")
} finally {
setImporting(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">Hent bane fra teeoff</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>}
{!selectedFacility ? (
<>
{/* Bevisst IKKE et eget <form> her -- denne komponenten rendres
inne i CreateSessionCard sitt <form onSubmit={handleSubmit}>,
og nestede <form>-elementer er ugyldig HTML. Nettleseren
slår da sammen de to skjemaene i den faktiske DOM-en, som
gjorde at "Søk"-knappen i praksis submittet det YTRE
økt-skjemaet i stedet -- en ekte native side-navigasjon som
vasket bort ?org=...-parameteren fra URL-en (funnet ved en
reell feilrapport fra bruker mot produksjon). */}
<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={() => pickFacility(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>
)}
</>
) : (
<>
<button
type="button"
onClick={() => {
setSelectedFacility(null)
setCourseOptions(null)
}}
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
>
{selectedFacility.name}
</button>
{courseOptions === null ? (
<p className="text-sm text-muted-foreground">Laster baner</p>
) : (
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
{courseOptions.map((c) => (
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
<button
type="button"
disabled={importing}
onClick={() => importCourse(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}
{importing && <span className="text-xs font-normal text-muted-foreground">Importerer</span>}
</button>
</li>
))}
{courseOptions.length === 0 && (
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
Ingen 18-hulls baner å importere hos dette anlegget ennå.
</li>
)}
</ul>
)}
</>
)}
</div>
)
}
// --- Small UI helpers ------------------------------------------------------
function SegmentButton({
selected,
onClick,
children,
}: {
selected: boolean
onClick: () => void
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
className={cn(
"flex items-center justify-center rounded-2xl border px-3 py-3 text-sm font-semibold transition-colors",
selected
? "border-primary bg-primary/10 text-foreground"
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
{children}
</button>
)
}
function ToggleRow({
label,
checked,
onCheckedChange,
}: {
label: string
checked: boolean
onCheckedChange: (checked: boolean) => void
}) {
return (
<div className="flex items-center justify-between gap-4 border-b border-border py-3 last:border-b-0">
<span className="text-sm font-medium text-foreground text-pretty">{label}</span>
<Switch checked={checked} onCheckedChange={onCheckedChange} />
</div>
)
}
// --- Helpers ---------------------------------------------------------------
function formatPoints(points: number) {
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
}
function formatDateTime(iso: string) {
const date = new Date(iso)
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleString("no-NO", {
weekday: "short",
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
})
}