775 lines
27 KiB
TypeScript
775 lines
27 KiB
TypeScript
|
|
"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<ApiRound | null>(null)
|
|||
|
|
const [error, setError] = useState<string | null>(null)
|
|||
|
|
const [activeParticipantId, setActiveParticipantId] = useState<string | null>(null)
|
|||
|
|
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
|
|||
|
|
const [currentHole, setCurrentHole] = useState<number>(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<ApiHole>) {
|
|||
|
|
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 (
|
|||
|
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-4 bg-background px-5 text-center">
|
|||
|
|
<p className="text-sm font-medium text-destructive">{error}</p>
|
|||
|
|
<Link href="/rounds" className="text-sm font-semibold text-primary underline underline-offset-2">
|
|||
|
|
Tilbake til egne runder
|
|||
|
|
</Link>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!round) {
|
|||
|
|
return (
|
|||
|
|
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
|
|||
|
|
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 (
|
|||
|
|
<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-2xl items-center justify-between gap-4 px-5 py-4">
|
|||
|
|
<Link
|
|||
|
|
href="/rounds"
|
|||
|
|
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
|||
|
|
>
|
|||
|
|
<ArrowLeft aria-hidden="true" className="size-4" />
|
|||
|
|
Egne runder
|
|||
|
|
</Link>
|
|||
|
|
<Wordmark compact />
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
|
|||
|
|
<div className="mb-5 flex flex-col gap-1">
|
|||
|
|
<h1 className="text-xl font-extrabold tracking-tight text-foreground text-balance">
|
|||
|
|
{round.course_name_snapshot}
|
|||
|
|
</h1>
|
|||
|
|
<p className="text-sm text-muted-foreground">
|
|||
|
|
{round.tee_name_snapshot} · {round.holes_planned} hull · {formatDate(round.played_at)}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{isCompleted && <CompletedSummary round={round} />}
|
|||
|
|
|
|||
|
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|||
|
|
{round.participants.map((p) => (
|
|||
|
|
<button
|
|||
|
|
key={p.id}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setActiveParticipantId(p.id)}
|
|||
|
|
aria-pressed={activeParticipantId === p.id}
|
|||
|
|
className={cn(
|
|||
|
|
"flex h-11 items-center gap-2 rounded-xl border px-3 text-sm font-bold transition-colors",
|
|||
|
|
activeParticipantId === p.id
|
|||
|
|
? "border-primary bg-primary text-primary-foreground"
|
|||
|
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{participantLabel(p)}
|
|||
|
|
{!p.is_owner && !isCompleted && (
|
|||
|
|
<span
|
|||
|
|
role="button"
|
|||
|
|
tabIndex={0}
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
void handleRemoveGuest(p.id)
|
|||
|
|
}}
|
|||
|
|
onKeyDown={(e) => {
|
|||
|
|
if (e.key === "Enter") {
|
|||
|
|
e.stopPropagation()
|
|||
|
|
void handleRemoveGuest(p.id)
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
aria-label={`Fjern ${p.guest_name}`}
|
|||
|
|
className="-mr-1 flex size-6 items-center justify-center rounded-full hover:bg-black/10"
|
|||
|
|
>
|
|||
|
|
<X aria-hidden="true" className="size-3.5" />
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
{!isCompleted && !showAddGuest && (
|
|||
|
|
<Button
|
|||
|
|
type="button"
|
|||
|
|
variant="outline"
|
|||
|
|
size="icon"
|
|||
|
|
onClick={() => setShowAddGuest(true)}
|
|||
|
|
className="size-11 shrink-0 rounded-xl"
|
|||
|
|
aria-label="Legg til spiller"
|
|||
|
|
>
|
|||
|
|
<UserPlus aria-hidden="true" className="size-4" />
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{showAddGuest && <AddGuestForm onCancel={() => setShowAddGuest(false)} onAdd={handleAddGuest} />}
|
|||
|
|
|
|||
|
|
{activeParticipant && (
|
|||
|
|
<>
|
|||
|
|
<HoleStrip order={order} holes={holes} currentHole={currentHole} onSelect={setCurrentHole} />
|
|||
|
|
|
|||
|
|
{currentHoleData ? (
|
|||
|
|
<HolePanel
|
|||
|
|
hole={currentHoleData}
|
|||
|
|
readOnly={isCompleted}
|
|||
|
|
onChange={handleUpdateHole}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<div className="flex justify-center py-10">
|
|||
|
|
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="mt-4 flex items-center gap-2">
|
|||
|
|
<Button
|
|||
|
|
type="button"
|
|||
|
|
variant="outline"
|
|||
|
|
onClick={() => setCurrentHole(order[(order.indexOf(currentHole) - 1 + 18) % 18])}
|
|||
|
|
className="h-12 flex-1 rounded-xl text-sm font-bold"
|
|||
|
|
>
|
|||
|
|
<ChevronLeft aria-hidden="true" className="size-4" />
|
|||
|
|
Forrige
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setCurrentHole(order[(order.indexOf(currentHole) + 1) % 18])}
|
|||
|
|
className="h-12 flex-1 rounded-xl text-sm font-bold"
|
|||
|
|
>
|
|||
|
|
Neste
|
|||
|
|
<ChevronRight aria-hidden="true" className="size-4" />
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{!isCompleted && (
|
|||
|
|
<Button
|
|||
|
|
type="button"
|
|||
|
|
disabled={completing}
|
|||
|
|
onClick={handleComplete}
|
|||
|
|
className="mt-8 h-14 w-full rounded-2xl text-base font-bold shadow-sm"
|
|||
|
|
>
|
|||
|
|
<Check aria-hidden="true" className="size-5" />
|
|||
|
|
{completing ? "Fullfører…" : "Fullfør runde"}
|
|||
|
|
</Button>
|
|||
|
|
)}
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Fullført-sammendrag -----------------------------------------------------
|
|||
|
|
|
|||
|
|
function CompletedSummary({ round }: { round: ApiRound }) {
|
|||
|
|
return (
|
|||
|
|
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-primary/30 bg-primary/5 p-4">
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<Trophy aria-hidden="true" className="size-5 text-primary" />
|
|||
|
|
<span className="text-base font-bold text-foreground">Runde fullført</span>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
{round.participants.map((p) => (
|
|||
|
|
<div key={p.id} className="flex items-center justify-between gap-2 text-sm">
|
|||
|
|
<span className="font-semibold text-foreground">{participantLabel(p)}</span>
|
|||
|
|
<span className="text-muted-foreground">
|
|||
|
|
{p.counts_for_handicap && p.score_differential !== null
|
|||
|
|
? `Differensial: ${p.score_differential.toFixed(1)}`
|
|||
|
|
: "Telte ikke mot HCP"}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- 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<Gender>("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 (
|
|||
|
|
<form onSubmit={handleSubmit} className="mb-4 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4">
|
|||
|
|
<div className="flex items-center justify-between">
|
|||
|
|
<span className="text-sm font-bold text-foreground">Legg til spiller</span>
|
|||
|
|
<Button type="button" variant="ghost" size="icon" onClick={onCancel} className="size-8 rounded-lg text-muted-foreground" aria-label="Lukk">
|
|||
|
|
<X aria-hidden="true" className="size-4" />
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<Label htmlFor="guest-name" className="text-sm font-semibold">
|
|||
|
|
Navn
|
|||
|
|
</Label>
|
|||
|
|
<Input id="guest-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} className="h-11 rounded-xl text-base" />
|
|||
|
|
</div>
|
|||
|
|
<div className="grid grid-cols-2 gap-3">
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<Label htmlFor="guest-gender" className="text-sm font-semibold">
|
|||
|
|
Kjønn
|
|||
|
|
</Label>
|
|||
|
|
<select
|
|||
|
|
id="guest-gender"
|
|||
|
|
value={gender}
|
|||
|
|
onChange={(e) => setGender(e.target.value as Gender)}
|
|||
|
|
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-medium text-foreground"
|
|||
|
|
>
|
|||
|
|
<option value="f">Dame</option>
|
|||
|
|
<option value="m">Herre</option>
|
|||
|
|
<option value="x">Annet</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<Label htmlFor="guest-hcp" className="text-sm font-semibold">
|
|||
|
|
HCP (valgfritt)
|
|||
|
|
</Label>
|
|||
|
|
<Input
|
|||
|
|
id="guest-hcp"
|
|||
|
|
inputMode="decimal"
|
|||
|
|
value={hcp}
|
|||
|
|
onChange={(e) => setHcp(e.target.value)}
|
|||
|
|
className="h-11 rounded-xl text-base"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<Button type="submit" disabled={!valid} className="h-11 rounded-xl text-sm font-bold">
|
|||
|
|
<Plus aria-hidden="true" className="size-4" />
|
|||
|
|
Legg til
|
|||
|
|
</Button>
|
|||
|
|
</form>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Hull-navigasjon ----------------------------------------------------------
|
|||
|
|
|
|||
|
|
function HoleStrip({
|
|||
|
|
order,
|
|||
|
|
holes,
|
|||
|
|
currentHole,
|
|||
|
|
onSelect,
|
|||
|
|
}: {
|
|||
|
|
order: number[]
|
|||
|
|
holes: ApiHole[] | undefined
|
|||
|
|
currentHole: number
|
|||
|
|
onSelect: (n: number) => void
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div className="mb-4 flex gap-1.5 overflow-x-auto pb-1">
|
|||
|
|
{order.map((n) => {
|
|||
|
|
const h = holes?.find((x) => x.hole_number === n)
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={n}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => onSelect(n)}
|
|||
|
|
aria-pressed={currentHole === n}
|
|||
|
|
className={cn(
|
|||
|
|
"flex size-11 shrink-0 flex-col items-center justify-center rounded-xl border text-sm font-bold tabular-nums transition-colors",
|
|||
|
|
currentHole === n
|
|||
|
|
? "border-primary bg-primary text-primary-foreground"
|
|||
|
|
: h?.played
|
|||
|
|
? "border-primary/40 bg-primary/10 text-foreground"
|
|||
|
|
: "border-border bg-card text-foreground hover:bg-accent/50",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{n}
|
|||
|
|
</button>
|
|||
|
|
)
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// --- Hull-panel -----------------------------------------------------------
|
|||
|
|
|
|||
|
|
function HolePanel({
|
|||
|
|
hole,
|
|||
|
|
readOnly,
|
|||
|
|
onChange,
|
|||
|
|
}: {
|
|||
|
|
hole: ApiHole
|
|||
|
|
readOnly: boolean
|
|||
|
|
onChange: (patch: Partial<ApiHole>) => 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 (
|
|||
|
|
<div className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-4 shadow-sm shadow-black/5 sm:p-5">
|
|||
|
|
<div className="flex items-baseline justify-between gap-2">
|
|||
|
|
<div className="flex items-baseline gap-2">
|
|||
|
|
<h2 className="text-2xl font-extrabold tracking-tight text-foreground">Hull {hole.hole_number}</h2>
|
|||
|
|
<span className="text-lg font-bold text-muted-foreground">
|
|||
|
|
· Par {hole.par} · Idx {hole.stroke_index}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
{gir !== null && (
|
|||
|
|
<span
|
|||
|
|
className={cn(
|
|||
|
|
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-bold",
|
|||
|
|
gir ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{gir ? "GIR ✓" : "Ikke GIR"}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<label className="flex items-center gap-2.5 text-sm font-semibold text-foreground">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={hole.played}
|
|||
|
|
disabled={readOnly}
|
|||
|
|
onChange={(e) => onChange({ played: e.target.checked })}
|
|||
|
|
className="size-5 rounded border-border"
|
|||
|
|
/>
|
|||
|
|
Hullet er spilt
|
|||
|
|
</label>
|
|||
|
|
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<span className="text-sm font-semibold text-foreground">Slag</span>
|
|||
|
|
<NumberPicker value={hole.score} min={1} max={20} disabled={readOnly} onSelect={(n) => onChange({ score: n, played: true })} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<span className="text-sm font-semibold text-foreground">Putter</span>
|
|||
|
|
<NumberPicker value={hole.putts} min={0} max={10} disabled={readOnly} onSelect={(n) => onChange({ putts: n })} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setShowDetails((v) => !v)}
|
|||
|
|
className="self-start text-sm font-semibold text-primary"
|
|||
|
|
>
|
|||
|
|
{showDetails ? "Skjul detaljer" : "Flere detaljer (kølle, retning, chip, bunker …)"}
|
|||
|
|
</button>
|
|||
|
|
|
|||
|
|
{showDetails && (
|
|||
|
|
<div className="flex flex-col gap-4 border-t border-border pt-4">
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<Label htmlFor="club-off-tee" className="text-sm font-semibold">
|
|||
|
|
Kølle brukt ved utslaget
|
|||
|
|
</Label>
|
|||
|
|
<Input
|
|||
|
|
id="club-off-tee"
|
|||
|
|
disabled={readOnly}
|
|||
|
|
value={hole.club_off_tee ?? ""}
|
|||
|
|
onChange={(e) => onChange({ club_off_tee: e.target.value || null })}
|
|||
|
|
placeholder="F.eks. Driver"
|
|||
|
|
className="h-11 rounded-xl text-base"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{hole.par >= 4 && (
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<span className="text-sm font-semibold text-foreground">Utslag</span>
|
|||
|
|
<div className="grid grid-cols-3 gap-2">
|
|||
|
|
{(["left", "fairway", "right"] as const).map((v) => (
|
|||
|
|
<ChoiceButton
|
|||
|
|
key={v}
|
|||
|
|
active={hole.tee_shot_result === v}
|
|||
|
|
disabled={readOnly}
|
|||
|
|
onClick={() => onChange({ tee_shot_result: v })}
|
|||
|
|
>
|
|||
|
|
{v === "fairway" ? "Fairway" : v === "left" ? "Venstre" : "Høyre"}
|
|||
|
|
</ChoiceButton>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<span className="text-sm font-semibold text-foreground">Innspill</span>
|
|||
|
|
<div className="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
|||
|
|
{(["left", "short", "hit", "long", "right"] as const).map((v) => (
|
|||
|
|
<ChoiceButton
|
|||
|
|
key={v}
|
|||
|
|
active={hole.approach_result === v}
|
|||
|
|
disabled={readOnly}
|
|||
|
|
onClick={() => onChange({ approach_result: v })}
|
|||
|
|
>
|
|||
|
|
{v === "hit" ? "Traff" : v === "long" ? "Langt" : v === "short" ? "Kort" : v === "left" ? "Venstre" : "Høyre"}
|
|||
|
|
</ChoiceButton>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-3 gap-3">
|
|||
|
|
<SmallStepper label="Chip" value={hole.chip_count} disabled={readOnly} onChange={(n) => onChange({ chip_count: n })} />
|
|||
|
|
<SmallStepper label="Bunker" value={hole.bunker_shot_count} disabled={readOnly} onChange={(n) => onChange({ bunker_shot_count: n })} />
|
|||
|
|
<SmallStepper label="Straffeslag" value={hole.penalty_strokes} disabled={readOnly} onChange={(n) => onChange({ penalty_strokes: n })} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<Label htmlFor="first-putt" className="text-sm font-semibold">
|
|||
|
|
Avstand første putt (m)
|
|||
|
|
</Label>
|
|||
|
|
<Input
|
|||
|
|
id="first-putt"
|
|||
|
|
inputMode="decimal"
|
|||
|
|
disabled={readOnly}
|
|||
|
|
value={hole.first_putt_distance_m ?? ""}
|
|||
|
|
onChange={(e) => onChange({ first_putt_distance_m: e.target.value === "" ? null : Number(e.target.value) })}
|
|||
|
|
className="h-11 rounded-xl text-base"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ChoiceButton({
|
|||
|
|
active,
|
|||
|
|
disabled,
|
|||
|
|
onClick,
|
|||
|
|
children,
|
|||
|
|
}: {
|
|||
|
|
active: boolean
|
|||
|
|
disabled: boolean
|
|||
|
|
onClick: () => void
|
|||
|
|
children: React.ReactNode
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
disabled={disabled}
|
|||
|
|
onClick={onClick}
|
|||
|
|
aria-pressed={active}
|
|||
|
|
className={cn(
|
|||
|
|
"flex h-12 items-center justify-center rounded-xl border text-sm font-bold transition-colors disabled:opacity-60",
|
|||
|
|
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-accent/50",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</button>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function SmallStepper({
|
|||
|
|
label,
|
|||
|
|
value,
|
|||
|
|
disabled,
|
|||
|
|
onChange,
|
|||
|
|
}: {
|
|||
|
|
label: string
|
|||
|
|
value: number | null
|
|||
|
|
disabled: boolean
|
|||
|
|
onChange: (n: number) => void
|
|||
|
|
}) {
|
|||
|
|
const current = value ?? 0
|
|||
|
|
return (
|
|||
|
|
<div className="flex flex-col gap-1.5">
|
|||
|
|
<span className="text-xs font-semibold text-muted-foreground">{label}</span>
|
|||
|
|
<div className="flex items-center gap-1.5">
|
|||
|
|
<Button
|
|||
|
|
type="button"
|
|||
|
|
variant="outline"
|
|||
|
|
size="icon"
|
|||
|
|
disabled={disabled || current <= 0}
|
|||
|
|
onClick={() => onChange(Math.max(0, current - 1))}
|
|||
|
|
className="size-9 shrink-0 rounded-lg"
|
|||
|
|
aria-label={`Reduser ${label}`}
|
|||
|
|
>
|
|||
|
|
−
|
|||
|
|
</Button>
|
|||
|
|
<span className="w-6 text-center text-base font-bold tabular-nums text-foreground">{current}</span>
|
|||
|
|
<Button
|
|||
|
|
type="button"
|
|||
|
|
variant="outline"
|
|||
|
|
size="icon"
|
|||
|
|
disabled={disabled}
|
|||
|
|
onClick={() => onChange(current + 1)}
|
|||
|
|
className="size-9 shrink-0 rounded-lg"
|
|||
|
|
aria-label={`Øk ${label}`}
|
|||
|
|
>
|
|||
|
|
+
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 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 (
|
|||
|
|
<div className="flex flex-col gap-2">
|
|||
|
|
<div className="grid grid-cols-5 gap-2">
|
|||
|
|
{numbers.map((n) => (
|
|||
|
|
<button
|
|||
|
|
key={n}
|
|||
|
|
type="button"
|
|||
|
|
disabled={disabled}
|
|||
|
|
onClick={() => onSelect(n)}
|
|||
|
|
aria-pressed={value === n}
|
|||
|
|
className={cn(
|
|||
|
|
"flex h-12 items-center justify-center rounded-xl border text-base font-bold tabular-nums transition-colors active:scale-95 disabled:opacity-60",
|
|||
|
|
value === n
|
|||
|
|
? "border-primary bg-primary text-primary-foreground"
|
|||
|
|
: "border-border bg-background text-foreground hover:bg-accent/50",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
{n}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
{!showHigh && high.length > 0 && (
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setShowHigh(true)}
|
|||
|
|
className="flex h-12 items-center justify-center rounded-xl border border-dashed border-border bg-background text-xs font-bold text-muted-foreground transition-colors hover:bg-accent/50"
|
|||
|
|
>
|
|||
|
|
{splitAt + 1}+
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
{showHigh && (
|
|||
|
|
<button type="button" onClick={() => setShowHigh(false)} className="self-start text-xs font-semibold text-primary">
|
|||
|
|
← Tilbake
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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)
|
|||
|
|
}
|