teecup/frontend/components/tournament-program.tsx
Erol Haagenrud 8a95ef8af2 Update Todos
Diffe zip 9 mot live-treet

Integrere rediger/slett i tournament-program.tsx (behold ekte create-flyt, IKKE V0s regresjon)

Ekte typesjekket frontend-build

Rulle ut live

Oppdatere .md-filer + slette zip 9

Rediger/slett-UI for økter er live. Kort oppsummert:

Fanget en reell regresjon i selve V0-eksporten før noe ble tatt inn: denne runden hadde utilsiktet fjernet bane-feltet fra «Legg til økt»-skjemaet (nye økter ville fått en hardkodet mock-bane). Diffet mot live-treet som vanlig, tok kun inn de nye rediger/slett-delene, lot det ekte opprett-skjemaet (med ekte bane-søk/teeoff-import) stå urørt.

Bane-bytte i redigeringsskjemaet er bevisst enklere enn opprett-skjemaets — kun velg blant eksisterende baner eller hent fra teeoff, ingen «opprett ny bane»-snarvei, som unngår at samme forvekslingsfelle fra Tjøme-hendelsen kan skje igjen.

Verifisert med ekte PATCH/DELETE-kall (inkludert at 409-feilmeldingen fra backend vises riktig i UI-et), test_isolation.sql 12/12, typesjekket build.

.md-filene oppdatert, zip 9 slettet.

Alt fra "bygg i rekkefølgen ting brukes"-serien pluss denne rundens opprydninger er nå live og konsistente.
2026-07-19 08:17:12 +02:00

1535 lines
54 KiB
TypeScript

"use client"
import type React from "react"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import {
ArrowLeft,
CalendarClock,
Check,
ChevronDown,
Clock,
Flag,
MapPin,
MoreVertical,
Pencil,
Plus,
Sparkles,
Trash2,
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
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.")
}
}
async function updateSession(sessionId: string, patch: Record<string, unknown>): Promise<boolean> {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(patch),
})
if (!res.ok) throw new Error(`update session: ${res.status}`)
const updated: ApiSession = await res.json()
setSessions((prev) => prev.map((s) => (s.id === sessionId ? updated : s)))
return true
} catch {
setError("Klarte ikke å oppdatere økten. Prøv igjen.")
return false
}
}
async function deleteSession(sessionId: string): Promise<string | null> {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}`, {
method: "DELETE",
credentials: "include",
})
if (res.status === 204) {
setSessions((prev) => prev.filter((s) => s.id !== sessionId))
return null
}
const body = await res.json().catch(() => null)
return body?.detail?.message ?? "Klarte ikke å slette økten. Prøv igjen."
} catch {
return "Klarte ikke å slette økten. Prøv igjen."
}
}
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
const leaderboardHref = `/tournaments/${tournamentId}/leaderboard?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>
<Link
href={leaderboardHref}
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Leaderboard
</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}
courses={courses}
organizationId={organizationId}
isLast={index === sorted.length - 1}
href={`/tournaments/${tournamentId}/sessions/${session.id}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
onCourseImported={(course) => setCourses((prev) => [...prev, course])}
onSave={(patch) => updateSession(session.id, patch)}
onDelete={() => deleteSession(session.id)}
/>
))}
</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,
courses,
organizationId,
isLast,
href,
onCourseImported,
onSave,
onDelete,
}: {
session: ApiSession
course: ApiCourse | null
courses: ApiCourse[]
organizationId: string
isLast: boolean
href: string
onCourseImported: (course: ApiCourse) => void
onSave: (patch: Record<string, unknown>) => Promise<boolean>
onDelete: () => Promise<string | null>
}) {
const [mode, setMode] = useState<"view" | "editing" | "confirmingDelete">("view")
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const title = session.name?.trim() || `Økt ${session.sequence}`
async function handleDelete() {
setDeleting(true)
const message = await onDelete()
setDeleting(false)
if (message) {
setDeleteError(message)
} else {
setMode("view")
}
}
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>
{mode === "editing" ? (
<EditSessionForm
session={session}
course={course}
courses={courses}
organizationId={organizationId}
onCourseImported={onCourseImported}
onCancel={() => setMode("view")}
onSave={async (patch) => {
const ok = await onSave(patch)
if (ok) setMode("view")
}}
/>
) : (
<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 items-start justify-between gap-3">
<Link href={href} className="flex min-w-0 flex-1 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>
</Link>
<DropdownMenu>
<DropdownMenuTrigger
aria-label={`Handlinger for ${title}`}
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<MoreVertical aria-hidden="true" className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 rounded-2xl p-1.5">
<DropdownMenuItem
onClick={() => setMode("editing")}
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
>
<Pencil aria-hidden="true" className="size-4" />
Rediger
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setDeleteError(null)
setMode("confirmingDelete")
}}
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
Slett
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<Link href={href} 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>
)}
</Link>
{mode === "confirmingDelete" &&
(deleteError ? (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-muted/50 p-4">
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{deleteError}</p>
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
className="h-9 rounded-xl"
onClick={() => setMode("view")}
>
Lukk
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 p-4">
<p className="text-sm leading-relaxed text-foreground text-pretty">
Er du sikker at du vil slette denne økten? Dette kan ikke angres.
</p>
<div className="flex justify-end gap-2">
<Button
type="button"
size="sm"
variant="destructive"
className="h-9 rounded-xl"
disabled={deleting}
onClick={handleDelete}
>
{deleting ? "Sletter…" : "Slett"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-9 rounded-xl"
onClick={() => setMode("view")}
>
Avbryt
</Button>
</div>
</div>
))}
</div>
)}
</li>
)
}
// --- Edit session form -------------------------------------------------------
function EditSessionForm({
session,
course,
courses,
organizationId,
onCourseImported,
onSave,
onCancel,
}: {
session: ApiSession
course: ApiCourse | null
courses: ApiCourse[]
organizationId: string
onCourseImported: (course: ApiCourse) => void
onSave: (patch: Record<string, unknown>) => Promise<void>
onCancel: () => void
}) {
const [name, setName] = useState(session.name ?? "")
const [points, setPoints] = useState(String(session.points_per_match))
const [startHole, setStartHole] = useState(String(session.start_hole))
const [scheduledAt, setScheduledAt] = useState(session.scheduled_at?.slice(0, 16) ?? "")
const [teeInterval, setTeeInterval] = useState(
session.tee_interval_minutes ? String(session.tee_interval_minutes) : "",
)
const [courseId, setCourseId] = useState(session.course_id)
const [courseName, setCourseName] = useState(course?.name ?? "")
const [submitting, setSubmitting] = useState(false)
const hasTime = scheduledAt.trim() !== ""
const uid = session.id
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const pointsValue = Number(points.replace(",", ".")) || session.points_per_match
const startHoleValue = Math.min(18, Math.max(1, Number(startHole) || 1))
const intervalValue = hasTime && teeInterval.trim() !== "" ? Number(teeInterval) : undefined
setSubmitting(true)
await onSave({
name: name.trim() || null,
points_per_match: pointsValue,
start_hole: startHoleValue,
scheduled_at: hasTime ? scheduledAt : null,
tee_interval_minutes: intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
course_id: courseId,
})
setSubmitting(false)
}
return (
<form
onSubmit={handleSubmit}
className="mb-1 flex flex-1 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">
<Pencil 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">
Rediger økt {session.sequence}
</span>
<span className="text-base font-bold text-foreground">
{session.name?.trim() || `Økt ${session.sequence}`}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-name-${uid}`} className="text-sm font-semibold">
Navn <span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id={`edit-name-${uid}`}
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">Bane</Label>
<EditCourseField
organizationId={organizationId}
courses={courses}
currentCourseId={courseId}
currentCourseName={courseName}
onCourseImported={onCourseImported}
onChange={(id, name) => {
setCourseId(id)
setCourseName(name)
}}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={`edit-points-${uid}`} className="text-sm font-semibold">
Poeng per match
</Label>
<Input
id={`edit-points-${uid}`}
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={`edit-starthole-${uid}`} className="text-sm font-semibold">
Starthull
</Label>
<Input
id={`edit-starthole-${uid}`}
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={`edit-time-${uid}`} className="text-sm font-semibold">
Startdato og -klokkeslett{" "}
<span className="font-normal text-muted-foreground">(valgfritt)</span>
</Label>
<Input
id={`edit-time-${uid}`}
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={`edit-interval-${uid}`} className="text-sm font-semibold">
Minutter mellom hver flight
</Label>
<Input
id={`edit-interval-${uid}`}
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="flex gap-3">
<Button type="submit" disabled={submitting} className="h-12 flex-1 rounded-2xl text-base font-bold shadow-sm">
{submitting ? "Lagrer…" : "Lagre"}
</Button>
<Button
type="button"
variant="ghost"
onClick={onCancel}
className="h-12 rounded-2xl px-6 text-base font-semibold"
>
Avbryt
</Button>
</div>
</form>
)
}
// --- Bane-bytte i redigeringsskjemaet (kun eksisterende baner -- PATCH sin
// course_id må være en ekte, allerede opprettet bane, ikke en "opprett
// ny"-tekststreng slik opprett-skjemaet tillater) -------------------------
function EditCourseField({
organizationId,
courses,
currentCourseId,
currentCourseName,
onCourseImported,
onChange,
}: {
organizationId: string
courses: ApiCourse[]
currentCourseId: string
currentCourseName: string
onCourseImported: (course: ApiCourse) => void
onChange: (courseId: string, courseName: string) => void
}) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
if (!open) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1.5 text-sm font-semibold text-secondary-foreground">
<MapPin aria-hidden="true" className="size-4 text-primary" />
{currentCourseName || "Ukjent bane"}
</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-9 rounded-xl"
onClick={() => {
setQuery("")
setOpen(true)
}}
>
Bytt bane
</Button>
</div>
)
}
const q = query.trim().toLowerCase()
const matches = courses.filter((c) => c.name.toLowerCase().includes(q))
function select(c: ApiCourse) {
onChange(c.id, c.name)
setOpen(false)
}
return (
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background/50 p-3">
<div className="flex items-center gap-2">
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Søk blant organisasjonens baner…"
className="h-11 flex-1 rounded-xl text-base"
/>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Lukk banesøk"
className="flex size-9 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
<X aria-hidden="true" className="size-4" />
</button>
</div>
<ul className="flex max-h-48 flex-col overflow-auto rounded-xl border border-border">
{matches.map((c) => (
<li key={c.id} className="border-b border-border last:border-b-0">
<button
type="button"
onClick={() => select(c)}
className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60"
>
<span className="flex items-center gap-2">
<MapPin aria-hidden="true" className="size-4 text-muted-foreground" />
{c.name}
</span>
{c.id === currentCourseId && <Check aria-hidden="true" className="size-4 text-primary" />}
</button>
</li>
))}
{matches.length === 0 && (
<li className="px-3 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
)}
</ul>
{!officialSearchOpen ? (
<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>
) : (
<OfficialCourseSearch
organizationId={organizationId}
onClose={() => setOfficialSearchOpen(false)}
onImported={(newCourse) => {
onCourseImported(newCourse)
select(newCourse)
setOfficialSearchOpen(false)
}}
/>
)}
</div>
)
}
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",
})
}