"use client" // Hull-for-hull-registrering for en frittstående runde (ADR-033). Én // deltaker om gangen velges via fanene øverst -- hver har sitt eget sett // med 18 round_hole-rader (opprettet ved runde-/deltaker-opprettelse). // PATCH-endepunktet erstatter ALLE felt på hullet ved hver kall (ikke et // ekte delvis-PATCH) -- derfor sendes alltid hele det gjeldende hullet, // kun ETT felt endret, aldri bare det isolerte feltet som ble trykket på. import type React from "react" import { useCallback, useEffect, useState } from "react" import Link from "next/link" import { useRouter } from "next/navigation" import { ArrowLeft, Check, ChevronLeft, ChevronRight, Plus, Trophy, UserPlus, X } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Wordmark } from "@/components/wordmark" import { cn } from "@/lib/utils" type Gender = "m" | "f" | "x" type ApiParticipant = { id: string user_id: string | null guest_name: string | null is_owner: boolean gender: Gender handicap_index_snapshot: number | null course_handicap_snapshot: number | null counts_for_handicap: boolean score_differential: number | null } type ApiRound = { id: string course_source: string course_name_snapshot: string tee_name_snapshot: string played_at: string start_hole: number holes_planned: number completed_at: string | null participants: ApiParticipant[] } type TeeShotResult = "fairway" | "left" | "right" type ApproachResult = "hit" | "long" | "short" | "left" | "right" type ApiHole = { hole_number: number par: number stroke_index: number played: boolean score: number | null putts: number | null club_off_tee: string | null tee_shot_result: TeeShotResult | null approach_result: ApproachResult | null chip_count: number | null bunker_shot_count: number | null penalty_strokes: number | null first_putt_distance_m: number | null } function holeOrder(startHole: number): number[] { return Array.from({ length: 18 }, (_, i) => ((startHole - 1 + i) % 18) + 1) } function participantLabel(p: ApiParticipant): string { return p.is_owner ? "Deg" : p.guest_name ?? "Gjest" } export function RoundDetail({ roundId }: { roundId: string }) { const router = useRouter() const [round, setRound] = useState(null) const [error, setError] = useState(null) const [activeParticipantId, setActiveParticipantId] = useState(null) const [holesByParticipant, setHolesByParticipant] = useState>({}) const [currentHole, setCurrentHole] = useState(1) const [showAddGuest, setShowAddGuest] = useState(false) const [completing, setCompleting] = useState(false) const loadRound = useCallback(async () => { try { const res = await fetch(`/rounds/${roundId}`, { credentials: "include" }) if (res.status === 401) { router.replace("/") return } if (res.status === 403 || res.status === 404) { setError("Denne runden finnes ikke, eller du har ikke tilgang til den.") return } if (!res.ok) throw new Error(`round: ${res.status}`) const data: ApiRound = await res.json() setRound(data) setActiveParticipantId((prev) => prev ?? data.participants.find((p) => p.is_owner)?.id ?? data.participants[0]?.id ?? null) setCurrentHole((prev) => prev || data.start_hole) } catch { setError("Klarte ikke å hente runden. Prøv igjen om litt.") } }, [roundId, router]) useEffect(() => { void loadRound() }, [loadRound]) const loadHoles = useCallback( async (participantId: string) => { const res = await fetch(`/rounds/${roundId}/participants/${participantId}/holes`, { credentials: "include" }) if (!res.ok) return const data: ApiHole[] = await res.json() setHolesByParticipant((prev) => ({ ...prev, [participantId]: data })) }, [roundId], ) useEffect(() => { if (activeParticipantId && !holesByParticipant[activeParticipantId]) { void loadHoles(activeParticipantId) } }, [activeParticipantId, holesByParticipant, loadHoles]) async function handleUpdateHole(patch: Partial) { if (!activeParticipantId || !round) return const holes = holesByParticipant[activeParticipantId] const existing = holes?.find((h) => h.hole_number === currentHole) if (!existing) return const merged: ApiHole = { ...existing, ...patch } const res = await fetch(`/rounds/${roundId}/participants/${activeParticipantId}/holes/${currentHole}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ played: merged.played, score: merged.score, putts: merged.putts, club_off_tee: merged.club_off_tee, tee_shot_result: merged.tee_shot_result, approach_result: merged.approach_result, chip_count: merged.chip_count, bunker_shot_count: merged.bunker_shot_count, penalty_strokes: merged.penalty_strokes, first_putt_distance_m: merged.first_putt_distance_m, }), }) if (!res.ok) return const updated: ApiHole = await res.json() setHolesByParticipant((prev) => ({ ...prev, [activeParticipantId]: (prev[activeParticipantId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)), })) } async function handleAddGuest(name: string, gender: Gender, hcp: number | null) { const res = await fetch(`/rounds/${roundId}/participants`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ guest_name: name, gender, handicap_index: hcp }), }) if (!res.ok) { setError("Klarte ikke å legge til spilleren. Sjekk at banen har en rating for valgt kjønn.") return } setShowAddGuest(false) await loadRound() } async function handleRemoveGuest(participantId: string) { if (!confirm("Fjerne denne spilleren fra runden?")) return const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, { method: "DELETE", credentials: "include" }) if (!res.ok) return if (activeParticipantId === participantId) setActiveParticipantId(round?.participants.find((p) => p.is_owner)?.id ?? null) await loadRound() } async function handleComplete() { if (!confirm("Fullføre runden? Du kan fortsatt se den, men ikke lenger endre registrerte hull.")) return setCompleting(true) try { const res = await fetch(`/rounds/${roundId}/complete`, { method: "POST", credentials: "include" }) if (!res.ok) throw new Error() setRound(await res.json()) } catch { setError("Klarte ikke å fullføre runden. Prøv igjen.") } finally { setCompleting(false) } } if (error) { return (

{error}

Tilbake til egne runder
) } if (!round) { return (
) } const activeParticipant = round.participants.find((p) => p.id === activeParticipantId) ?? null const holes = activeParticipantId ? holesByParticipant[activeParticipantId] : undefined const currentHoleData = holes?.find((h) => h.hole_number === currentHole) ?? null const isCompleted = round.completed_at !== null const order = holeOrder(round.start_hole) return (

{round.course_name_snapshot}

{round.tee_name_snapshot} · {round.holes_planned} hull · {formatDate(round.played_at)}

{isCompleted && }
{round.participants.map((p) => ( ))} {!isCompleted && !showAddGuest && ( )}
{showAddGuest && setShowAddGuest(false)} onAdd={handleAddGuest} />} {activeParticipant && ( <> {currentHoleData ? ( ) : (
)}
)} {!isCompleted && ( )}
) } // --- Fullført-sammendrag ----------------------------------------------------- function CompletedSummary({ round }: { round: ApiRound }) { return (
{round.participants.map((p) => (
{participantLabel(p)} {p.counts_for_handicap && p.score_differential !== null ? `Differensial: ${p.score_differential.toFixed(1)}` : "Telte ikke mot HCP"}
))}
) } // --- Legg til gjest ----------------------------------------------------------- function AddGuestForm({ onCancel, onAdd, }: { onCancel: () => void onAdd: (name: string, gender: Gender, hcp: number | null) => void }) { const [name, setName] = useState("") const [gender, setGender] = useState("m") const [hcp, setHcp] = useState("") const valid = name.trim().length > 0 function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!valid) return onAdd(name.trim(), gender, hcp.trim() ? Number(hcp) : null) setName("") setHcp("") } return (
Legg til spiller
setName(e.target.value)} className="h-11 rounded-xl text-base" />
setHcp(e.target.value)} className="h-11 rounded-xl text-base" />
) } // --- Hull-navigasjon ---------------------------------------------------------- function HoleStrip({ order, holes, currentHole, onSelect, }: { order: number[] holes: ApiHole[] | undefined currentHole: number onSelect: (n: number) => void }) { return (
{order.map((n) => { const h = holes?.find((x) => x.hole_number === n) return ( ) })}
) } // --- Hull-panel ----------------------------------------------------------- function HolePanel({ hole, readOnly, onChange, }: { hole: ApiHole readOnly: boolean onChange: (patch: Partial) => void }) { const [showDetails, setShowDetails] = useState(false) const gir = hole.approach_result === "hit" && hole.score !== null && hole.putts !== null ? hole.score - hole.putts <= hole.par - 2 : null return (

Hull {hole.hole_number}

· Par {hole.par} · Idx {hole.stroke_index}
{gir !== null && ( {gir ? "GIR ✓" : "Ikke GIR"} )}
Slag onChange({ score: n, played: true })} />
Putter onChange({ putts: n })} />
{showDetails && (
onChange({ club_off_tee: e.target.value || null })} placeholder="F.eks. Driver" className="h-11 rounded-xl text-base" />
{hole.par >= 4 && (
Utslag
{(["left", "fairway", "right"] as const).map((v) => ( onChange({ tee_shot_result: v })} > {v === "fairway" ? "Fairway" : v === "left" ? "Venstre" : "Høyre"} ))}
)}
Innspill
{(["left", "short", "hit", "long", "right"] as const).map((v) => ( onChange({ approach_result: v })} > {v === "hit" ? "Traff" : v === "long" ? "Langt" : v === "short" ? "Kort" : v === "left" ? "Venstre" : "Høyre"} ))}
onChange({ chip_count: n })} /> onChange({ bunker_shot_count: n })} /> onChange({ penalty_strokes: n })} />
onChange({ first_putt_distance_m: e.target.value === "" ? null : Number(e.target.value) })} className="h-11 rounded-xl text-base" />
)}
) } function ChoiceButton({ active, disabled, onClick, children, }: { active: boolean disabled: boolean onClick: () => void children: React.ReactNode }) { return ( ) } function SmallStepper({ label, value, disabled, onChange, }: { label: string value: number | null disabled: boolean onChange: (n: number) => void }) { const current = value ?? 0 return (
{label}
{current}
) } // Tallvelger, samme mønster som StrokePicker i session-scorecard.tsx: rask // direkte-trykk for de vanligste verdiene, med en "flere"-utvidelse for // resten -- ikke en +/- stepper (for mange klikk for typiske slagtall). function NumberPicker({ value, min, max, disabled, onSelect, }: { value: number | null min: number max: number disabled: boolean onSelect: (n: number) => void }) { const splitAt = Math.min(min + 8, max) const [showHigh, setShowHigh] = useState(value !== null && value > splitAt) useEffect(() => { setShowHigh(value !== null && value > splitAt) // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]) const low = Array.from({ length: splitAt - min + 1 }, (_, i) => min + i) const high = Array.from({ length: max - splitAt }, (_, i) => splitAt + 1 + i) const numbers = showHigh ? high : low return (
{numbers.map((n) => ( ))} {!showHigh && high.length > 0 && ( )}
{showHigh && ( )}
) } function formatDate(iso: string) { const date = new Date(iso) if (Number.isNaN(date.getTime())) return iso return new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "short", year: "numeric" }).format(date) }