"use client" // Hull-for-hull-registrering for en frittstående runde (ADR-033). // Presentasjon fra V0, datalag skrevet om fra mock til ekte fetch/PATCH. // // VIKTIG kontraktsdetalj (bekreftet i scratch): hull-PATCH-endepunktet er // IKKE et ekte delvis-PATCH -- det skriver ALLE felt ved hvert kall. Derfor // slår updateStat() alltid sammen med gjeldende hull-data FØR den sender, // aldri kun det ene feltet som ble endret. import type React from "react" import { useCallback, useEffect, useState } from "react" import Link from "next/link" import { ArrowLeft, CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, Flag, MapPin, Minus, Plus, Trophy, X, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { cn } from "@/lib/utils" // --- Types ----------------------------------------------------------------- type Gender = "male" | "female" | "other" type ApiGender = "m" | "f" | "x" type TeeShot = "left" | "fairway" | "right" type Approach = "left" | "short" | "hit" | "long" | "right" type StatLevel = "strokes_only" | "strokes_and_putts" | "full" type PuttBucket = "<1m" | "<2m" | "<3m" | "<5m" | "<8m" | "8m+" type Player = { id: string name: string gender: Gender hcp: number | null isSelf: boolean countsForHandicap: boolean scoreDifferential: number | null statLevel: StatLevel } type HoleStat = { played: boolean strokes: number | null putts: number | null club: string teeShot: TeeShot | null approach: Approach | null chip: number bunker: number penalty: number firstPuttBucket: PuttBucket | null anywayStrokes: number | null } type Hole = { holeNumber: number par: number index: number } function emptyStat(): HoleStat { return { played: false, strokes: null, putts: null, club: "", teeShot: null, approach: null, chip: 0, bunker: 0, penalty: 0, firstPuttBucket: null, anywayStrokes: null, } } function statKey(playerId: string, holeNumber: number) { return `${playerId}:${holeNumber}` } function apiGenderToUi(g: ApiGender): Gender { return g === "m" ? "male" : g === "f" ? "female" : "other" } function uiGenderToApi(g: Gender): ApiGender { return g === "male" ? "m" : g === "female" ? "f" : "x" } const dateFormatter = new Intl.DateTimeFormat("no-NO", { day: "numeric", month: "long", year: "numeric" }) function formatDate(value: string) { const parsed = new Date(value) if (Number.isNaN(parsed.getTime())) return value return dateFormatter.format(parsed) } // --- API-typer --------------------------------------------------------------- type ApiParticipant = { id: string user_id: string | null guest_name: string | null is_owner: boolean gender: ApiGender handicap_index_snapshot: number | null course_handicap_snapshot: number | null counts_for_handicap: boolean score_differential: number | null stat_level: StatLevel } type ApiRound = { id: 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 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: TeeShot | null approach_result: Approach | null chip_count: number | null bunker_shot_count: number | null penalty_strokes: number | null first_putt_distance_bucket: PuttBucket | null anyway_strokes: number | null } function apiHoleToStat(h: ApiHole): HoleStat { return { played: h.played, strokes: h.score, putts: h.putts, club: h.club_off_tee ?? "", teeShot: h.tee_shot_result, approach: h.approach_result, chip: h.chip_count ?? 0, bunker: h.bunker_shot_count ?? 0, penalty: h.penalty_strokes ?? 0, firstPuttBucket: h.first_putt_distance_bucket, anywayStrokes: h.anyway_strokes, } } function statToPatchBody(s: HoleStat) { return { played: s.played, score: s.strokes, putts: s.putts, club_off_tee: s.club.trim() === "" ? null : s.club, tee_shot_result: s.teeShot, approach_result: s.approach, chip_count: s.chip, bunker_shot_count: s.bunker, penalty_strokes: s.penalty, first_putt_distance_bucket: s.firstPuttBucket, anyway_strokes: s.anywayStrokes, } } function playerLabel(p: ApiParticipant): string { return p.is_owner ? "Deg" : p.guest_name ?? "Gjest" } // --- Component ------------------------------------------------------------- export function RoundDetail({ roundId }: { roundId: string }) { const [round, setRound] = useState(null) const [error, setError] = useState(null) const [activePlayerId, setActivePlayerId] = useState(null) const [holesByParticipant, setHolesByParticipant] = useState>({}) // Eierens egen kølle-bag (personlig profil) -- brukt til å tilby et // knapp-utvalg for "Kølle brukt ved utslaget" i stedet for fritekst, kun // for eieren selv (gjester har ingen profil å hente dette fra). const [ownBagClubs, setOwnBagClubs] = useState([]) // `null` betyr "ikke satt ennå" -- MÅ være null, ikke f.eks. 1, siden 1 // er en gyldig, truthy hullverdi og ville gjort `prev || start_hole` // lenger ned til en no-op (funnet 2026-07-24: runden åpnet alltid på // hull 1 uansett faktisk starthull). const [currentHole, setCurrentHole] = useState(null) const [detailsOpen, setDetailsOpen] = useState(false) 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 === 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) setActivePlayerId((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]) useEffect(() => { void loadRound() }, [loadRound]) useEffect(() => { let cancelled = false fetch("/auth/me", { credentials: "include" }) .then((res) => (res.ok ? res.json() : null)) .then((data: { bag_clubs: string[] } | null) => { if (!cancelled && data) setOwnBagClubs(data.bag_clubs) }) .catch(() => {}) return () => { cancelled = true } }, []) 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 (activePlayerId && !holesByParticipant[activePlayerId]) { void loadHoles(activePlayerId) } }, [activePlayerId, holesByParticipant, loadHoles]) const completed = round?.completed_at != null const readOnly = completed const players: Player[] = round?.participants.map((p) => ({ id: p.id, name: playerLabel(p), gender: apiGenderToUi(p.gender), hcp: p.handicap_index_snapshot, isSelf: p.is_owner, countsForHandicap: p.counts_for_handicap, scoreDifferential: p.score_differential, statLevel: p.stat_level, })) ?? [] const activePlayer = players.find((p) => p.id === activePlayerId) ?? players[0] ?? null const apiHoles = activePlayerId ? holesByParticipant[activePlayerId] : undefined const holes: Hole[] = (apiHoles ?? []).map((h) => ({ holeNumber: h.hole_number, par: h.par, index: h.stroke_index })) // Alltid en tallverdi å regne videre på selv rett etter mount, FØR // loadRound() har satt currentHole fra round.start_hole. const activeHole = currentHole ?? round?.start_hole ?? 1 const hole = holes.find((h) => h.holeNumber === activeHole) ?? null // Navigasjonsrekkefølge starter på øktens starthull og går rundt (18 // hull, sirkulært) -- ikke bare stigende 1..18, som ville vist feil // hull i fokus for en runde som starter et annet sted enn hull 1. const holeOrder = round ? Array.from({ length: 18 }, (_, i) => ((round.start_hole - 1 + i) % 18) + 1) : [] const orderedHoles = holeOrder.map((n) => holes.find((h) => h.holeNumber === n)).filter((h): h is Hole => h !== undefined) const currentApiHole = apiHoles?.find((h) => h.hole_number === activeHole) ?? null const currentStat = currentApiHole ? apiHoleToStat(currentApiHole) : emptyStat() const showGir = currentStat.played && currentStat.strokes !== null && currentStat.putts !== null && hole !== null && currentStat.strokes - currentStat.putts <= hole.par - 2 async function updateStat(patch: Partial) { if (readOnly || !activePlayerId || !currentApiHole) return const merged: HoleStat = { ...currentStat, ...patch } const res = await fetch(`/rounds/${roundId}/participants/${activePlayerId}/holes/${activeHole}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(statToPatchBody(merged)), }) if (!res.ok) return const updated: ApiHole = await res.json() setHolesByParticipant((prev) => ({ ...prev, [activePlayerId]: (prev[activePlayerId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)), })) } function holeIsPlayed(holeNumber: number) { return apiHoles?.find((h) => h.hole_number === holeNumber)?.played ?? false } function goPrev() { const i = holeOrder.indexOf(activeHole) setCurrentHole(holeOrder[(i - 1 + 18) % 18]) setDetailsOpen(false) } function goNext() { const i = holeOrder.indexOf(activeHole) setCurrentHole(holeOrder[(i + 1) % 18]) setDetailsOpen(false) } async function addGuest(name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) { const res = await fetch(`/rounds/${roundId}/participants`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ guest_name: name, gender: uiGenderToApi(gender), handicap_index: hcp, stat_level: statLevel, }), }) if (!res.ok) { setError("Klarte ikke å legge til spilleren. Sjekk at banen har en rating for valgt kjønn.") return } setShowAddGuest(false) const created: ApiParticipant = await res.json() await loadRound() setActivePlayerId(created.id) } async function removeGuest(id: string) { if (!confirm("Fjerne denne spilleren fra runden?")) return const res = await fetch(`/rounds/${roundId}/participants/${id}`, { method: "DELETE", credentials: "include" }) if (!res.ok) return if (activePlayerId === id) setActivePlayerId(round?.participants.find((p) => p.is_owner)?.id ?? null) await loadRound() } async function updateStatLevel(participantId: string, statLevel: StatLevel) { const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ stat_level: statLevel }), }) if (!res.ok) return const updated: ApiParticipant = await res.json() setRound((prev) => prev ? { ...prev, participants: prev.participants.map((p) => (p.id === updated.id ? updated : p)) } : prev, ) } async function finishRound() { 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 || !activePlayer) { return (
) } return (
{/* Sticky header */}
{error && (

{error}

)} {/* Completed summary banner */} {completed && } {/* Participant tabs */} setShowAddGuest((v) => !v)} addOpen={showAddGuest} readOnly={readOnly} /> {!readOnly && ( updateStatLevel(activePlayer.id, v)} /> )} {showAddGuest && !readOnly && setShowAddGuest(false)} />} {/* Hole navigation */} {holes.length === 0 ? (
) : ( <> { setCurrentHole(n) setDetailsOpen(false) }} /> {/* Current hole panel */} {hole && (

Hull {hole.holeNumber} · Par {hole.par} · Idx {hole.index}

{showGir && ( GIR )}
updateStat({ strokes: v, played: true })} readOnly={readOnly} /> {activePlayer.statLevel !== "strokes_only" && ( updateStat({ putts: v })} readOnly={readOnly} /> )} {activePlayer.statLevel === "full" && (
{detailsOpen && (
{activePlayer.isSelf && ownBagClubs.length > 0 ? (
{ownBagClubs.map((club) => { const selected = currentStat.club === club return ( ) })}
) : ( updateStat({ club: e.target.value })} placeholder="F.eks. Driver, 3-jern" disabled={readOnly} className="h-12 rounded-2xl text-base" /> )}
{hole.par !== 3 && ( updateStat({ teeShot: v as TeeShot })} readOnly={readOnly} /> )} updateStat({ approach: v as Approach })} readOnly={readOnly} />
updateStat({ chip: v })} readOnly={readOnly} /> updateStat({ bunker: v })} readOnly={readOnly} /> updateStat({ penalty: v })} readOnly={readOnly} />
updateStat({ firstPuttBucket: v as PuttBucket })} readOnly={readOnly} /> updateStat({ anywayStrokes: v })} readOnly={readOnly} />
)}
)}
)} )} {!completed && ( )}
) } // --- Completed banner ------------------------------------------------------ function CompletedBanner({ players }: { players: Player[] }) { return (
Ferdigspilt

Runde fullført

    {players.map((player) => { const counts = player.countsForHandicap && player.scoreDifferential !== null return (
  • {player.name} {counts ? ( Differensial {player.scoreDifferential!.toFixed(1).replace(".", ",")} ) : ( Telte ikke mot HCP )}
  • ) })}
) } // --- Player tabs ----------------------------------------------------------- function PlayerTabs({ players, activePlayerId, onSelect, onRemove, onAdd, addOpen, readOnly, }: { players: Player[] activePlayerId: string onSelect: (id: string) => void onRemove: (id: string) => void onAdd: () => void addOpen: boolean readOnly: boolean }) { return (
{players.map((player) => { const active = player.id === activePlayerId const canRemove = !player.isSelf && !readOnly return (
{canRemove && ( )}
) })} {!readOnly && ( )}
) } // --- Add guest form -------------------------------------------------------- function AddGuestForm({ onAdd, onCancel, }: { onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void onCancel: () => void }) { const [name, setName] = useState("") const [gender, setGender] = useState("male") const [hcp, setHcp] = useState("") const [statLevel, setStatLevel] = useState("strokes_only") function handleSubmit(e: React.FormEvent) { e.preventDefault() const trimmed = name.trim() if (!trimmed) return const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", ".")) onAdd(trimmed, gender, parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null, statLevel) } return (
setName(e.target.value)} placeholder="Gjestens navn" className="h-12 rounded-2xl text-base" />
setGender(v as Gender)} readOnly={false} />
setHcp(e.target.value)} placeholder="F.eks. 18" className="h-12 rounded-2xl text-base" />
setStatLevel(v as StatLevel)} readOnly={false} />
) } // --- Hole navigation ------------------------------------------------------- function HoleNav({ holes, currentHole, isPlayed, onSelect, }: { holes: Hole[] currentHole: number isPlayed: (holeNumber: number) => boolean onSelect: (holeNumber: number) => void }) { return ( ) } // --- Statistikknivå-velger --------------------------------------------------- // "Hullet er spilt" fantes tidligere som egen avkrysning, men var reelt // overflødig -- score settes allerede automatisk til "spilt" idet et // slagtall velges (se onChange på Slag-NumberPicker under). Fjernet // 2026-07-24 på brukerens eksplisitte bekreftelse. function StatLevelPicker({ value, onChange }: { value: StatLevel; onChange: (v: StatLevel) => void }) { const options: { value: StatLevel; label: string }[] = [ { value: "strokes_only", label: "Kun slag" }, { value: "strokes_and_putts", label: "Slag og putter" }, { value: "full", label: "All statistikk" }, ] return (
Statistikk
{options.map((opt) => ( ))}
) } // --- Number picker --------------------------------------------------------- function NumberPicker({ label, value, directValues, expandValues, expandLabel, onChange, readOnly, }: { label: string value: number | null directValues: number[] expandValues: number[] expandLabel: string onChange: (value: number) => void readOnly: boolean }) { const valueInExpand = value !== null && expandValues.includes(value) const [expanded, setExpanded] = useState(false) const showExpanded = expanded || valueInExpand const visibleValues = showExpanded ? [...directValues, ...expandValues] : directValues return (
{label}
{visibleValues.map((n) => { const selected = value === n return ( ) })} {!showExpanded && !readOnly && ( )}
) } // --- Choice row (segmented buttons) ---------------------------------------- function ChoiceRow({ label, options, value, onChange, readOnly, }: { label: string options: { value: string; label: string }[] value: string | null onChange: (value: string) => void readOnly: boolean }) { return (
{label}
{options.map((opt) => { const selected = value === opt.value return ( ) })}
) } // --- Stepper (+/-) --------------------------------------------------------- function Stepper({ label, value, onChange, readOnly }: { label: string; value: number; onChange: (value: number) => void; readOnly: boolean }) { return (
{label}
{value}
) }