"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 Player = { id: string name: string gender: Gender hcp: number | null isSelf: boolean countsForHandicap: boolean scoreDifferential: number | null } type HoleStat = { played: boolean strokes: number | null putts: number | null club: string teeShot: TeeShot | null approach: Approach | null chip: number bunker: number penalty: number firstPuttDistance: string } 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, firstPuttDistance: "", } } 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 } 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_m: 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, firstPuttDistance: h.first_putt_distance_m !== null ? String(h.first_putt_distance_m) : "", } } 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_m: s.firstPuttDistance.trim() === "" ? null : Number(s.firstPuttDistance.replace(",", ".")), } } 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>({}) const [currentHole, setCurrentHole] = useState(1) 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]) 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, })) ?? [] 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 })) const hole = holes.find((h) => h.holeNumber === currentHole) ?? null const totalHoles = 18 const currentApiHole = apiHoles?.find((h) => h.hole_number === currentHole) ?? 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/${currentHole}`, { 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() { setCurrentHole((h) => Math.max(1, h - 1)) setDetailsOpen(false) } function goNext() { setCurrentHole((h) => Math.min(totalHoles, h + 1)) setDetailsOpen(false) } async function addGuest(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: uiGenderToApi(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) 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 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} /> {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({ played: v })} readOnly={readOnly} /> updateStat({ strokes: v, played: true })} readOnly={readOnly} /> updateStat({ putts: v })} readOnly={readOnly} />
{detailsOpen && (
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({ firstPuttDistance: e.target.value })} placeholder="F.eks. 3.5" disabled={readOnly} className="h-12 rounded-2xl text-base" />
)}
)} )} {!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) => void onCancel: () => void }) { const [name, setName] = useState("") const [gender, setGender] = useState("male") const [hcp, setHcp] = useState("") 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) } 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" />
) } // --- Hole navigation ------------------------------------------------------- function HoleNav({ holes, currentHole, isPlayed, onSelect, }: { holes: Hole[] currentHole: number isPlayed: (holeNumber: number) => boolean onSelect: (holeNumber: number) => void }) { return ( ) } // --- Played toggle --------------------------------------------------------- function PlayedToggle({ checked, onChange, readOnly }: { checked: boolean; onChange: (value: boolean) => void; readOnly: boolean }) { return ( ) } // --- 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}
) }