Backend: migrasjon 027 (unik user_id per runde) Backend: søke-basert deltaker-innsetting (user_id via /people/search) Backend: åpne opp co-player-tilgang (accessible vs owned) + viewer-relative RoundOut Frontend: rename Gjest→Medspiller + søk-UI + viewer-relativ "Deg" Frontend: fiks round-stats.tsx/round-scorecard.tsx samme viewer-bug Typesjekket produksjonsbuild Scratch-verifisere hele funksjonen grundig Oppdatere .md-filer Legge frem utrullingsplan og vente på bekreftelse Live. Søket på "+ Medspiller" fungerer nå, og en lagt-til medspiller har full tilgang til å registrere score for hele flighten mens rundeforvaltning (rediger/slett/legg til/fjern) forblir eierens alene.
2319 lines
92 KiB
TypeScript
2319 lines
92 KiB
TypeScript
"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, useRef, useState } from "react"
|
||
import Link from "next/link"
|
||
import { useRouter } from "next/navigation"
|
||
import {
|
||
ArrowDown,
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
ArrowUp,
|
||
CalendarDays,
|
||
Check,
|
||
ChevronDown,
|
||
Clock,
|
||
Trash2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Flag,
|
||
MapPin,
|
||
Minus,
|
||
Plus,
|
||
Search,
|
||
Settings2,
|
||
Target,
|
||
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
|
||
// Alltid utfylt av API-et -- gjestens navn, eller en levende oppslått
|
||
// visningsnavn for en lenket bruker (eier eller medspiller).
|
||
display_name: string
|
||
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 PersonMatch = {
|
||
id: string
|
||
first_name: string
|
||
last_name: string
|
||
avatar_url: string | null
|
||
home_club: string | null
|
||
}
|
||
|
||
type ApiRound = {
|
||
id: string
|
||
name: string | null
|
||
course_name_snapshot: string
|
||
tee_name_snapshot: string
|
||
played_at: string
|
||
start_hole: number
|
||
holes_planned: number
|
||
started_at: string | null
|
||
completed_at: string | null
|
||
participants: ApiParticipant[]
|
||
}
|
||
|
||
// Utslagstid vises i klokkeslett-format, og tidsbruk beregnes som
|
||
// completed_at - started_at (begge ekte tidsstempler) -- ingen egen
|
||
// lagret varighet, kun utledet ved visning.
|
||
const timeFormatter = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
|
||
function formatTime(value: string) {
|
||
const parsed = new Date(value)
|
||
if (Number.isNaN(parsed.getTime())) return value
|
||
return timeFormatter.format(parsed)
|
||
}
|
||
function formatDuration(startedAt: string, completedAt: string): string | null {
|
||
const start = new Date(startedAt).getTime()
|
||
const end = new Date(completedAt).getTime()
|
||
if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return null
|
||
const totalMinutes = Math.round((end - start) / 60000)
|
||
const hours = Math.floor(totalMinutes / 60)
|
||
const minutes = totalMinutes % 60
|
||
if (hours === 0) return `${minutes} min`
|
||
return `${hours}t ${minutes}min`
|
||
}
|
||
|
||
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
|
||
strokes_received: 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,
|
||
}
|
||
}
|
||
|
||
// Viewer-relativt (ADR-036 fase 3-utvidelsen, 2026-07-26): "Deg" skal vises
|
||
// for DEN SOM SER PÅ, ikke alltid runde-eieren -- en lenket medspiller som
|
||
// åpner runden skal se seg selv som "Deg" og eieren under sitt eget navn.
|
||
function playerLabel(p: ApiParticipant, viewerId: string | null): string {
|
||
return viewerId !== null && p.user_id === viewerId ? "Deg" : p.display_name
|
||
}
|
||
|
||
// --- Component -------------------------------------------------------------
|
||
|
||
export function RoundDetail({ roundId }: { roundId: string }) {
|
||
const router = useRouter()
|
||
const [round, setRound] = useState<ApiRound | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [activePlayerId, setActivePlayerId] = useState<string | null>(null)
|
||
const [holesByParticipant, setHolesByParticipant] = useState<Record<string, ApiHole[]>>({})
|
||
// 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<string[]>([])
|
||
// Den innloggede brukerens egen id (ADR-036 fase 3-utvidelsen,
|
||
// 2026-07-26) -- runden kan nå åpnes av en lenket medspiller, ikke bare
|
||
// eieren, så "hvem er 'Deg'" og "hvem har lov til å forvalte runden"
|
||
// avhenger av DENNE, ikke av `is_owner` alene.
|
||
const [viewerId, setViewerId] = useState<string | null>(null)
|
||
// `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<number | null>(null)
|
||
const [showAddGuest, setShowAddGuest] = useState(false)
|
||
const [completing, setCompleting] = useState(false)
|
||
const [deleting, setDeleting] = useState(false)
|
||
const [showEditRound, setShowEditRound] = useState(false)
|
||
// "Flere detaljer" er kollapset som default (etterspurt av bruker
|
||
// 2026-07-25 -- scorekortet var "veldig dårlig designet", altfor mye
|
||
// stablet oppå hverandre for et enkelt slag-registrering) slik at en
|
||
// rask slag-/putt-registrering ikke krever noe scrolling i det hele tatt.
|
||
const [detailsOpen, setDetailsOpen] = useState(false)
|
||
// Hull-panelet gjenbrukes ved bytte av hull -- "Forrige"/"Neste hull" ligger
|
||
// NEDERST i panelet, så uten dette ville brukeren blitt stående scrollet
|
||
// helt ned (der knappene er) mens det NYE hullets Slag-felt (øverst i
|
||
// panelet) er utenfor skjermen (rapportert av bruker 2026-07-25).
|
||
const holePanelRef = useRef<HTMLElement>(null)
|
||
function scrollToHolePanel() {
|
||
holePanelRef.current?.scrollIntoView({ behavior: "smooth", block: "start" })
|
||
}
|
||
|
||
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: { id: string; bag_clubs: string[] } | null) => {
|
||
if (!cancelled && data) {
|
||
setOwnBagClubs(data.bag_clubs)
|
||
setViewerId(data.id)
|
||
}
|
||
})
|
||
.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, viewerId),
|
||
gender: apiGenderToUi(p.gender),
|
||
hcp: p.handicap_index_snapshot,
|
||
isSelf: viewerId !== null ? p.user_id === viewerId : p.is_owner,
|
||
countsForHandicap: p.counts_for_handicap,
|
||
scoreDifferential: p.score_differential,
|
||
statLevel: p.stat_level,
|
||
})) ?? []
|
||
|
||
// Kun eieren kan forvalte runden (rediger/slett/legge til/fjerne
|
||
// medspillere) -- en lenket medspiller kan se runden og registrere
|
||
// score for hele flighten, men ikke dette (ADR-036 fase 3-utvidelsen).
|
||
const isOwnerViewer = viewerId !== null && (round?.participants.some((p) => p.is_owner && p.user_id === viewerId) ?? false)
|
||
|
||
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
|
||
// (sirkulært over 18 hull), men KUN så mange hull som runden faktisk
|
||
// planlegger (holes_planned) -- en 9-hulls runde 10-18 skal aldri vise
|
||
// hull 1-9 i det hele tatt, og "Forrige" fra hull 10 skal gå til hull
|
||
// 18 (siste i settet), ikke hull 9 (funnet 2026-07-24: brukte tidligere
|
||
// alltid 18, uansett holes_planned).
|
||
const holeOrder = round
|
||
? Array.from({ length: round.holes_planned }, (_, 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<HoleStat>) {
|
||
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 + holeOrder.length) % holeOrder.length])
|
||
scrollToHolePanel()
|
||
}
|
||
function goNext() {
|
||
const i = holeOrder.indexOf(activeHole)
|
||
setCurrentHole(holeOrder[(i + 1) % holeOrder.length])
|
||
scrollToHolePanel()
|
||
}
|
||
|
||
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 addSearchedParticipant(userId: string, statLevel: StatLevel): Promise<string | null> {
|
||
const res = await fetch(`/rounds/${roundId}/participants`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ user_id: userId, stat_level: statLevel }),
|
||
})
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => null)
|
||
return body?.detail?.message ?? "Klarte ikke å legge til spilleren."
|
||
}
|
||
setShowAddGuest(false)
|
||
const created: ApiParticipant = await res.json()
|
||
await loadRound()
|
||
setActivePlayerId(created.id)
|
||
return null
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
async function deleteRound() {
|
||
if (!confirm("Slette denne runden permanent? Dette kan ikke angres, og gjelder for alle spillerne i runden.")) return
|
||
setDeleting(true)
|
||
try {
|
||
const res = await fetch(`/rounds/${roundId}`, { method: "DELETE", credentials: "include" })
|
||
if (!res.ok) throw new Error()
|
||
router.replace("/my-rounds")
|
||
} catch {
|
||
setError("Klarte ikke å slette runden. Prøv igjen.")
|
||
setDeleting(false)
|
||
}
|
||
}
|
||
|
||
// Retter opp feil bane/utslag eller feil antall hull underveis i runden
|
||
// (2026-07-24) -- rører ALDRI allerede registrerte slag/putter/etc.,
|
||
// kun rating-grunnlaget. Backend avviser tydelig (400) hvis en deltaker
|
||
// ville mistet HCP-sporing, uten å skrive noe.
|
||
async function patchRound(body: Record<string, unknown>): Promise<{ ok: true } | { ok: false; message: string }> {
|
||
const res = await fetch(`/rounds/${roundId}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify(body),
|
||
})
|
||
if (!res.ok) {
|
||
const errBody = await res.json().catch(() => null)
|
||
return { ok: false, message: errBody?.detail?.message ?? "Klarte ikke å oppdatere runden. Prøv igjen." }
|
||
}
|
||
const updated: ApiRound = await res.json()
|
||
setRound(updated)
|
||
// Par/stroke-indeks kan ha endret seg for ALLE deltakere -- hent hull-
|
||
// dataen på nytt for alle vi allerede har lastet, ikke bare aktiv spiller.
|
||
const participantIds = Object.keys(holesByParticipant)
|
||
for (const pid of participantIds) {
|
||
await loadHoles(pid)
|
||
}
|
||
return { ok: true }
|
||
}
|
||
|
||
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-base font-medium text-destructive">{error}</p>
|
||
<Link href="/my-rounds" className="text-base font-semibold text-primary underline underline-offset-2">
|
||
Tilbake til egne runder
|
||
</Link>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!round || !activePlayer) {
|
||
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>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||
{/* Sticky header */}
|
||
<header className="sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur">
|
||
<div className="mx-auto flex w-full max-w-3xl items-center gap-3 px-5 py-4">
|
||
<Link
|
||
href="/my-rounds"
|
||
aria-label="Tilbake til egne runder"
|
||
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||
</Link>
|
||
<div className="flex min-w-0 flex-col gap-0.5">
|
||
<h1 className="flex items-center gap-2 truncate text-xl font-extrabold tracking-tight text-foreground">
|
||
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
|
||
<span className="truncate">{round.name?.trim() || round.course_name_snapshot}</span>
|
||
</h1>
|
||
<div className="flex flex-wrap items-center gap-x-4 gap-y-0.5 text-sm font-medium text-muted-foreground">
|
||
{round.name?.trim() && (
|
||
<span className="flex items-center gap-1.5 truncate">
|
||
<MapPin aria-hidden="true" className="size-4 shrink-0" />
|
||
{round.course_name_snapshot}
|
||
</span>
|
||
)}
|
||
<span className="flex items-center gap-1.5">
|
||
<Flag aria-hidden="true" className="size-4 shrink-0" />
|
||
{round.tee_name_snapshot}
|
||
</span>
|
||
<span className="flex items-center gap-1.5 tabular-nums">
|
||
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
|
||
{formatDate(round.played_at)}
|
||
</span>
|
||
{round.started_at && (
|
||
<span className="flex items-center gap-1.5 tabular-nums">
|
||
<Clock aria-hidden="true" className="size-4 shrink-0" />
|
||
{formatTime(round.started_at)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-6 sm:py-8">
|
||
{error && (
|
||
<p role="alert" className="mb-4 text-base font-medium text-destructive">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
{/* Rediger/Fullfør/Slett samlet ØVERST, bevisst tonet ned (samme
|
||
nøytrale trigger-stil som hverandre) -- disse lå tidligere som
|
||
store, fremtredende knapper RETT under "Neste hull"-navigasjonen
|
||
nederst i hull-panelet, som gjorde det altfor lett å trykke feil
|
||
ved et uhell mens man bare skulle bla mellom hull (rapportert av
|
||
bruker 2026-07-25). Ved å flytte dem hit må man aktivt scrolle OPP
|
||
forbi hull-registreringen for å nå dem i det hele tatt. */}
|
||
<div className="mb-5 flex flex-col gap-3">
|
||
<div className="flex flex-wrap gap-2">
|
||
{isOwnerViewer && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowEditRound((v) => !v)}
|
||
aria-expanded={showEditRound}
|
||
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||
>
|
||
<Settings2 aria-hidden="true" className="size-4" />
|
||
Rediger runde
|
||
</button>
|
||
)}
|
||
|
||
{!completed && (
|
||
<button
|
||
type="button"
|
||
disabled={completing}
|
||
onClick={finishRound}
|
||
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-60"
|
||
>
|
||
<Check aria-hidden="true" className="size-4" />
|
||
{completing ? "Fullfører…" : "Fullfør runde"}
|
||
</button>
|
||
)}
|
||
|
||
{isOwnerViewer && (
|
||
<button
|
||
type="button"
|
||
disabled={deleting}
|
||
onClick={deleteRound}
|
||
className="inline-flex min-h-11 items-center gap-1.5 rounded-xl border border-dashed border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-60"
|
||
>
|
||
<Trash2 aria-hidden="true" className="size-4" />
|
||
{deleting ? "Sletter…" : "Slett runde"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{showEditRound && isOwnerViewer && (
|
||
<EditRoundPanel round={round} onPatch={patchRound} onClose={() => setShowEditRound(false)} />
|
||
)}
|
||
</div>
|
||
|
||
{/* Completed summary banner */}
|
||
{completed && (
|
||
<CompletedBanner
|
||
players={players}
|
||
duration={round.started_at && round.completed_at ? formatDuration(round.started_at, round.completed_at) : null}
|
||
roundId={roundId}
|
||
/>
|
||
)}
|
||
|
||
{/* Participant tabs */}
|
||
<PlayerTabs
|
||
players={players}
|
||
activePlayerId={activePlayer.id}
|
||
onSelect={setActivePlayerId}
|
||
onRemove={removeGuest}
|
||
onAdd={() => setShowAddGuest((v) => !v)}
|
||
addOpen={showAddGuest}
|
||
readOnly={readOnly}
|
||
canManage={isOwnerViewer}
|
||
/>
|
||
|
||
{!readOnly && (
|
||
<StatLevelPicker
|
||
value={activePlayer.statLevel}
|
||
onChange={(v) => updateStatLevel(activePlayer.id, v)}
|
||
/>
|
||
)}
|
||
|
||
{/* Skjules når runden er fullført (2026-07-25, rapportert av bruker
|
||
som "veldig mye dobbel informasjon") -- CompletedBanner sin
|
||
"Se full rundestatistikk"-lenke dekker nå akkurat det samme,
|
||
langt grundigere. Fortsatt nyttig som live fremdriftsoversikt
|
||
mens runden pågår. */}
|
||
{!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
|
||
|
||
{showAddGuest && !readOnly && isOwnerViewer && (
|
||
<AddParticipantForm
|
||
onAddGuest={addGuest}
|
||
onAddSearched={addSearchedParticipant}
|
||
onCancel={() => setShowAddGuest(false)}
|
||
/>
|
||
)}
|
||
|
||
{/* Hole navigation */}
|
||
{holes.length === 0 ? (
|
||
<div className="mt-5 flex justify-center py-8">
|
||
<div aria-hidden="true" className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
|
||
</div>
|
||
) : (
|
||
<>
|
||
<HoleNav
|
||
holes={orderedHoles}
|
||
currentHole={activeHole}
|
||
isPlayed={holeIsPlayed}
|
||
onSelect={(n) => {
|
||
setCurrentHole(n)
|
||
scrollToHolePanel()
|
||
}}
|
||
/>
|
||
|
||
{/* Current hole panel */}
|
||
{hole && (
|
||
<section
|
||
ref={holePanelRef}
|
||
className="mt-5 flex scroll-mt-28 flex-col gap-6 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 sm:p-6"
|
||
>
|
||
{/* Høyre kolonne reserverer plass ved siden av hull-headeren
|
||
til en fremtidig avstandsmåling-indikator (ikke bygget
|
||
ennå, men avklart 2026-07-25 at det kommer) -- GIR-merket
|
||
ligger under den reserverte plassen slik at et senere
|
||
avstand-chip her ikke krever noen layout-endring. */}
|
||
<div className="flex items-start justify-between gap-3">
|
||
<h2 className="text-2xl font-extrabold tracking-tight text-foreground text-balance">
|
||
Hull {hole.holeNumber} · Par {hole.par} · Hcp {hole.index}
|
||
</h2>
|
||
<div className="flex shrink-0 flex-col items-end gap-2">
|
||
{showGir && (
|
||
<span className="rounded-full bg-primary px-3 py-1.5 text-sm font-bold text-primary-foreground">
|
||
GIR
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Slag/Putter og (ved statLevel="full") alle detaljene vises
|
||
ALLTID samlet under hverandre -- ikke bak en fane man må
|
||
oppdage og trykke på (funnet 2026-07-25: brukeren huket
|
||
av "All statistikk", men fikk aldri se noe utover
|
||
slag/putter siden det lå bak en fane). */}
|
||
<div className="flex flex-col gap-6">
|
||
<NumberPicker
|
||
label="Slag"
|
||
value={currentStat.strokes}
|
||
directValues={[1, 2, 3, 4, 5, 6, 7, 8, 9]}
|
||
expandValues={[10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]}
|
||
expandLabel="10+"
|
||
parValue={hole.par}
|
||
onChange={(v) => updateStat({ strokes: v, played: true })}
|
||
readOnly={readOnly}
|
||
/>
|
||
|
||
{activePlayer.statLevel !== "strokes_only" && (
|
||
<NumberPicker
|
||
label="Putter"
|
||
value={currentStat.putts}
|
||
directValues={[0, 1, 2, 3, 4, 5, 6]}
|
||
expandValues={[7, 8, 9, 10]}
|
||
expandLabel="7+"
|
||
maxValue={currentStat.strokes ?? undefined}
|
||
onChange={(v) => updateStat({ putts: v })}
|
||
readOnly={readOnly}
|
||
/>
|
||
)}
|
||
|
||
{activePlayer.statLevel === "full" && (
|
||
<>
|
||
{/* Rett under antall putter (etterspurt av bruker 2026-07-25) --
|
||
hører naturlig sammen med Putter-feltet rett over, ikke med
|
||
kølle/retning/chip-gruppen lenger ned. */}
|
||
<ChoiceRow
|
||
label="Avstand første putt"
|
||
options={[
|
||
{ value: "<1m", label: "<1m" },
|
||
{ value: "<2m", label: "<2m" },
|
||
{ value: "<3m", label: "<3m" },
|
||
{ value: "<5m", label: "<5m" },
|
||
{ value: "<8m", label: "<8m" },
|
||
{ value: "8m+", label: "8m+" },
|
||
]}
|
||
value={currentStat.firstPuttBucket}
|
||
onChange={(v) => updateStat({ firstPuttBucket: v as PuttBucket })}
|
||
readOnly={readOnly}
|
||
/>
|
||
|
||
{/* Alt annet lever bak én kollapsbar seksjon, lukket som
|
||
default -- panelet forblir kort for det vanlige
|
||
slag-/putt-registreringstilfellet. */}
|
||
<div className="overflow-hidden rounded-2xl border border-border">
|
||
<button
|
||
type="button"
|
||
onClick={() => setDetailsOpen((v) => !v)}
|
||
aria-expanded={detailsOpen}
|
||
className="flex min-h-14 w-full items-center justify-between gap-2 px-4 py-3 text-left text-base font-bold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
Flere detaljer
|
||
<ChevronDown
|
||
aria-hidden="true"
|
||
className={cn(
|
||
"size-5 shrink-0 text-muted-foreground transition-transform",
|
||
detailsOpen && "rotate-180",
|
||
)}
|
||
/>
|
||
</button>
|
||
{detailsOpen && (
|
||
<div className="flex flex-col gap-6 border-t border-border p-4 sm:p-5">
|
||
<div className="flex flex-col gap-2">
|
||
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
|
||
{activePlayer.isSelf && ownBagClubs.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{ownBagClubs.map((club) => {
|
||
const selected = currentStat.club === club
|
||
return (
|
||
<button
|
||
key={club}
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => updateStat({ club })}
|
||
aria-pressed={selected}
|
||
className={cn(
|
||
"flex min-h-11 items-center justify-center rounded-xl border px-3 text-sm font-bold transition-colors disabled:opacity-100",
|
||
selected
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{club}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<Input
|
||
id="club"
|
||
value={currentStat.club}
|
||
onChange={(e) => updateStat({ club: e.target.value })}
|
||
placeholder="F.eks. Driver, 3-jern"
|
||
disabled={readOnly}
|
||
className="h-12 rounded-2xl text-base"
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{hole.par !== 3 && (
|
||
<DirectionCross
|
||
label="Utslag"
|
||
variant="horizontal"
|
||
centerLabel="Fairway"
|
||
value={currentStat.teeShot}
|
||
onChange={(v) => updateStat({ teeShot: v as TeeShot })}
|
||
readOnly={readOnly}
|
||
/>
|
||
)}
|
||
|
||
<DirectionCross
|
||
label="Innspill"
|
||
variant="full"
|
||
centerLabel="Traff"
|
||
value={currentStat.approach}
|
||
onChange={(v) => updateStat({ approach: v as Approach })}
|
||
readOnly={readOnly}
|
||
/>
|
||
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||
<Stepper label="Chip" value={currentStat.chip} onChange={(v) => updateStat({ chip: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
|
||
<Stepper label="Bunker" value={currentStat.bunker} onChange={(v) => updateStat({ bunker: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
|
||
<Stepper label="Straffeslag" value={currentStat.penalty} onChange={(v) => updateStat({ penalty: v })} readOnly={readOnly} max={currentStat.strokes ?? undefined} />
|
||
</div>
|
||
|
||
<NumberPicker
|
||
label="Anywayslag"
|
||
value={currentStat.anywayStrokes}
|
||
directValues={[0, 1, 2, 3, 4, 5, 6]}
|
||
expandValues={[7, 8, 9, 10]}
|
||
expandLabel="7+"
|
||
maxValue={currentStat.strokes ?? undefined}
|
||
onChange={(v) => updateStat({ anywayStrokes: v })}
|
||
readOnly={readOnly}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Button type="button" variant="outline" onClick={goPrev} className="h-14 rounded-2xl text-base font-bold">
|
||
<ChevronLeft aria-hidden="true" className="size-5" />
|
||
Forrige
|
||
</Button>
|
||
<Button type="button" variant="outline" onClick={goNext} className="h-14 rounded-2xl text-base font-bold">
|
||
Neste hull
|
||
<ChevronRight aria-hidden="true" className="size-5" />
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Completed banner ------------------------------------------------------
|
||
|
||
function CompletedBanner({
|
||
players,
|
||
duration,
|
||
roundId,
|
||
}: {
|
||
players: Player[]
|
||
duration: string | null
|
||
roundId: string
|
||
}) {
|
||
return (
|
||
<div className="mb-6 flex flex-col gap-4 rounded-3xl border border-primary/30 bg-primary/10 p-5 sm:p-6">
|
||
<div className="flex items-center gap-3">
|
||
<div className="flex size-12 shrink-0 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-sm">
|
||
<Trophy aria-hidden="true" className="size-6" />
|
||
</div>
|
||
<div className="flex flex-col">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-primary">Ferdigspilt</span>
|
||
<h2 className="text-xl font-extrabold tracking-tight text-foreground">Runde fullført</h2>
|
||
</div>
|
||
{duration && (
|
||
<div className="ml-auto flex flex-col items-end">
|
||
<span className="text-xs font-medium text-muted-foreground">Tid brukt</span>
|
||
<span className="flex items-center gap-1.5 text-lg font-extrabold tabular-nums text-foreground">
|
||
<Clock aria-hidden="true" className="size-4 text-primary" />
|
||
{duration}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<ul className="flex flex-col divide-y divide-primary/20 overflow-hidden rounded-2xl border border-primary/20 bg-card">
|
||
{players.map((player) => {
|
||
const counts = player.countsForHandicap && player.scoreDifferential !== null
|
||
return (
|
||
<li key={player.id} className="flex items-center justify-between gap-3 px-4 py-3">
|
||
<span className="text-base font-bold text-foreground">{player.name}</span>
|
||
{counts ? (
|
||
<span className="flex items-baseline gap-1.5">
|
||
<span className="text-xs font-medium text-muted-foreground">Differensial</span>
|
||
<span className="text-lg font-extrabold tabular-nums text-foreground">
|
||
{player.scoreDifferential!.toFixed(1).replace(".", ",")}
|
||
</span>
|
||
</span>
|
||
) : (
|
||
<span className="text-sm font-semibold text-muted-foreground">Telte ikke mot HCP</span>
|
||
)}
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
<div className="flex flex-col gap-2 sm:flex-row">
|
||
<Link
|
||
href={`/my-rounds/${roundId}/scorecard`}
|
||
className="flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-2xl bg-primary px-5 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
|
||
>
|
||
Se scorekort
|
||
<ChevronRight aria-hidden="true" className="size-5" />
|
||
</Link>
|
||
<Link
|
||
href={`/my-rounds/${roundId}/stats`}
|
||
className="flex min-h-12 flex-1 items-center justify-center gap-1.5 rounded-2xl border border-primary/40 bg-card px-5 text-base font-bold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
Se full rundestatistikk
|
||
<ChevronRight aria-hidden="true" className="size-5" />
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Score så langt (kompakt linje + utvidbar full oversikt) --------------
|
||
// Etterspurt av bruker 2026-07-25 med referanse til en annen golf-app --
|
||
// samme "Vis full oversikt"-mønster som allerede etablert i
|
||
// session-scorecard.tsx (turnering-scoring), tilpasset til en enkelt
|
||
// spillers frittstående runde (ingen motstander-side å vise her).
|
||
//
|
||
// Utvidet SAMME DAG, etterspurt av bruker: netto-/stableford-sum, putt-/
|
||
// kølle-statistikk-totaler, og grafisk fairway-/innspill-fordeling +
|
||
// gjennomsnittlig score-til-par splittet på treff/bom. Appen har ingen
|
||
// egen "spilleform"-innstilling for frittstående runder -- stableford
|
||
// beregnes derfor alltid ut fra netto score der det er mulig (krever
|
||
// registrert HCP), uavhengig av om brukeren "egentlig" spiller slagspill
|
||
// eller stableford -- rent informativt, ikke en offisiell poengsum.
|
||
|
||
// Standard stableford-poengtabell: netto dobbel bogey eller dårligere = 0,
|
||
// netto par = 2, hvert slag bedre/dårligere enn par gir ett poeng mer/mindre.
|
||
function stablefordPoints(netScore: number, par: number): number {
|
||
return Math.max(0, 2 - (netScore - par))
|
||
}
|
||
|
||
function avgScoreToPar(holes: ApiHole[]): number | null {
|
||
const played = holes.filter((h) => h.played && h.score !== null)
|
||
if (played.length === 0) return null
|
||
const diffSum = played.reduce((sum, h) => sum + ((h.score as number) - h.par), 0)
|
||
return diffSum / played.length
|
||
}
|
||
|
||
function formatSignedAvg(n: number): string {
|
||
const fixed = Math.abs(n).toFixed(2)
|
||
return n > 0 ? `+${fixed}` : n < 0 ? `-${fixed}` : fixed
|
||
}
|
||
|
||
function ScoreSoFar({ holes, order }: { holes: ApiHole[] | undefined; order: number[] }) {
|
||
const [expanded, setExpanded] = useState(false)
|
||
if (!holes || holes.length === 0) return null
|
||
|
||
const orderedHoles = order
|
||
.map((n) => holes.find((h) => h.hole_number === n))
|
||
.filter((h): h is ApiHole => h !== undefined)
|
||
const playedSoFar = orderedHoles.filter((h) => h.played && h.score !== null)
|
||
if (playedSoFar.length === 0) return null
|
||
|
||
const totalStrokes = playedSoFar.reduce((sum, h) => sum + (h.score ?? 0), 0)
|
||
const totalPar = playedSoFar.reduce((sum, h) => sum + h.par, 0)
|
||
const toPar = totalStrokes - totalPar
|
||
const toParLabel = toPar === 0 ? "Par" : toPar > 0 ? `+${toPar}` : `${toPar}`
|
||
|
||
// Netto/stableford krever at deltakeren har en beregnet course handicap
|
||
// (strokes_received er da satt på ALLE hull, ellers på ingen) -- uten
|
||
// det vises rett og slett ikke disse to (f.eks. en gjest uten HCP).
|
||
const withNetto = playedSoFar.filter((h) => h.strokes_received !== null)
|
||
const hasNetto = withNetto.length > 0
|
||
const nettoTotal = hasNetto
|
||
? withNetto.reduce((sum, h) => sum + ((h.score as number) - (h.strokes_received as number)), 0)
|
||
: null
|
||
const stablefordTotal = hasNetto
|
||
? withNetto.reduce(
|
||
(sum, h) => sum + stablefordPoints((h.score as number) - (h.strokes_received as number), h.par),
|
||
0,
|
||
)
|
||
: null
|
||
|
||
const puttsPlayed = orderedHoles.filter((h) => h.putts !== null)
|
||
const puttsTotal = puttsPlayed.length > 0 ? puttsPlayed.reduce((sum, h) => sum + (h.putts as number), 0) : null
|
||
const chipPlayed = orderedHoles.filter((h) => h.chip_count !== null)
|
||
const chipTotal = chipPlayed.length > 0 ? chipPlayed.reduce((sum, h) => sum + (h.chip_count as number), 0) : null
|
||
const bunkerPlayed = orderedHoles.filter((h) => h.bunker_shot_count !== null)
|
||
const bunkerTotal =
|
||
bunkerPlayed.length > 0 ? bunkerPlayed.reduce((sum, h) => sum + (h.bunker_shot_count as number), 0) : null
|
||
const penaltyPlayed = orderedHoles.filter((h) => h.penalty_strokes !== null)
|
||
const penaltyTotal =
|
||
penaltyPlayed.length > 0 ? penaltyPlayed.reduce((sum, h) => sum + (h.penalty_strokes as number), 0) : null
|
||
const anywayPlayed = orderedHoles.filter((h) => h.anyway_strokes !== null)
|
||
const anywayTotal =
|
||
anywayPlayed.length > 0 ? anywayPlayed.reduce((sum, h) => sum + (h.anyway_strokes as number), 0) : null
|
||
|
||
// Fairwaytreff spores kun på hull som ikke er par 3 (samme regel som
|
||
// registrerings-skjemaet -- DirectionCross for Utslag vises ikke der).
|
||
const fairwayTracked = orderedHoles.filter((h) => h.par !== 3 && h.tee_shot_result !== null)
|
||
const fairwayHit = fairwayTracked.filter((h) => h.tee_shot_result === "fairway")
|
||
const fairwayLeft = fairwayTracked.filter((h) => h.tee_shot_result === "left")
|
||
const fairwayRight = fairwayTracked.filter((h) => h.tee_shot_result === "right")
|
||
const fairwayMiss = [...fairwayLeft, ...fairwayRight]
|
||
const avgToParFairwayHit = avgScoreToPar(fairwayHit)
|
||
const avgToParFairwayMiss = avgScoreToPar(fairwayMiss)
|
||
|
||
const approachTracked = orderedHoles.filter((h) => h.approach_result !== null)
|
||
const approachHit = approachTracked.filter((h) => h.approach_result === "hit")
|
||
const approachShort = approachTracked.filter((h) => h.approach_result === "short")
|
||
const approachLong = approachTracked.filter((h) => h.approach_result === "long")
|
||
const approachLeft = approachTracked.filter((h) => h.approach_result === "left")
|
||
const approachRight = approachTracked.filter((h) => h.approach_result === "right")
|
||
const approachMiss = [...approachShort, ...approachLong, ...approachLeft, ...approachRight]
|
||
const avgToParApproachHit = avgScoreToPar(approachHit)
|
||
const avgToParApproachMiss = avgScoreToPar(approachMiss)
|
||
|
||
let running = 0
|
||
|
||
return (
|
||
<div className="mb-4 overflow-hidden rounded-2xl border border-border bg-card">
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded((v) => !v)}
|
||
aria-expanded={expanded}
|
||
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left"
|
||
>
|
||
<span className="text-sm font-semibold text-foreground text-pretty">
|
||
Så langt: {playedSoFar.length} hull · {totalStrokes} slag · {toParLabel} til par
|
||
{hasNetto && ` · Netto ${nettoTotal! > 0 ? "+" : ""}${nettoTotal}`}
|
||
</span>
|
||
<span className="shrink-0 text-sm font-semibold text-primary">{expanded ? "Skjul" : "Vis full oversikt"}</span>
|
||
</button>
|
||
{expanded && (
|
||
<div className="border-t border-border">
|
||
<div className="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
|
||
<StatPill label="Slag" value={String(totalStrokes)} />
|
||
<StatPill label="Til par" value={toParLabel} />
|
||
{hasNetto && <StatPill label="Netto" value={nettoTotal! > 0 ? `+${nettoTotal}` : String(nettoTotal)} />}
|
||
{hasNetto && <StatPill label="Stableford" value={`${stablefordTotal} poeng`} />}
|
||
{puttsTotal !== null && <StatPill label="Putt" value={String(puttsTotal)} />}
|
||
{chipTotal !== null && <StatPill label="Chip" value={String(chipTotal)} />}
|
||
{bunkerTotal !== null && <StatPill label="Bunker" value={String(bunkerTotal)} />}
|
||
{penaltyTotal !== null && <StatPill label="Straffeslag" value={String(penaltyTotal)} />}
|
||
{anywayTotal !== null && <StatPill label="Anywayslag" value={String(anywayTotal)} />}
|
||
</div>
|
||
|
||
<div className="overflow-x-auto border-t border-border">
|
||
<table className="w-full min-w-[420px] text-sm">
|
||
<thead>
|
||
<tr className="border-b border-border text-left text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||
<th className="px-3 py-2">Hull</th>
|
||
<th className="px-3 py-2">Par</th>
|
||
<th className="px-3 py-2">Score</th>
|
||
<th className="px-3 py-2">Netto</th>
|
||
{hasNetto && <th className="px-3 py-2">Stableford</th>}
|
||
<th className="px-3 py-2">Sum</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{orderedHoles.map((h) => {
|
||
const played = h.played && h.score !== null
|
||
if (played) running += h.score as number
|
||
const netto = played && h.strokes_received !== null ? (h.score as number) - h.strokes_received : null
|
||
const points = played && netto !== null ? stablefordPoints(netto, h.par) : null
|
||
return (
|
||
<tr key={h.hole_number} className="border-b border-border last:border-b-0">
|
||
<td className="px-3 py-2 font-bold tabular-nums text-foreground">{h.hole_number}</td>
|
||
<td className="px-3 py-2 tabular-nums text-muted-foreground">{h.par}</td>
|
||
<td className="px-3 py-2 tabular-nums font-semibold text-foreground">{played ? h.score : "–"}</td>
|
||
<td className="px-3 py-2 tabular-nums text-muted-foreground">{netto ?? "–"}</td>
|
||
{hasNetto && (
|
||
<td className="px-3 py-2 tabular-nums text-muted-foreground">{points ?? "–"}</td>
|
||
)}
|
||
<td className="px-3 py-2 tabular-nums font-bold text-foreground">{played ? running : "–"}</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{fairwayTracked.length > 0 && (
|
||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||
<span className="text-sm font-bold text-foreground">Fairwaytreff</span>
|
||
<DistributionBar
|
||
segments={[
|
||
{ label: "Fairway", count: fairwayHit.length, colorClass: "bg-emerald-500" },
|
||
{ label: "Venstre", count: fairwayLeft.length, colorClass: "bg-amber-500" },
|
||
{ label: "Høyre", count: fairwayRight.length, colorClass: "bg-rose-500" },
|
||
]}
|
||
/>
|
||
{(avgToParFairwayHit !== null || avgToParFairwayMiss !== null) && (
|
||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
|
||
{avgToParFairwayHit !== null && (
|
||
<span>
|
||
Til par ved fairwaytreff:{" "}
|
||
<strong className="text-foreground">{formatSignedAvg(avgToParFairwayHit)}</strong>
|
||
</span>
|
||
)}
|
||
{avgToParFairwayMiss !== null && (
|
||
<span>
|
||
Til par ved bom på fairway:{" "}
|
||
<strong className="text-foreground">{formatSignedAvg(avgToParFairwayMiss)}</strong>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{approachTracked.length > 0 && (
|
||
<div className="flex flex-col gap-2 border-t border-border p-4">
|
||
<span className="text-sm font-bold text-foreground">Innspill</span>
|
||
<DistributionBar
|
||
segments={[
|
||
{ label: "Traff", count: approachHit.length, colorClass: "bg-emerald-500" },
|
||
{ label: "Kort", count: approachShort.length, colorClass: "bg-amber-500" },
|
||
{ label: "Langt", count: approachLong.length, colorClass: "bg-orange-500" },
|
||
{ label: "Venstre", count: approachLeft.length, colorClass: "bg-sky-500" },
|
||
{ label: "Høyre", count: approachRight.length, colorClass: "bg-rose-500" },
|
||
]}
|
||
/>
|
||
{(avgToParApproachHit !== null || avgToParApproachMiss !== null) && (
|
||
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-1 text-xs font-semibold text-muted-foreground">
|
||
{avgToParApproachHit !== null && (
|
||
<span>
|
||
Til par ved treff på innspill:{" "}
|
||
<strong className="text-foreground">{formatSignedAvg(avgToParApproachHit)}</strong>
|
||
</span>
|
||
)}
|
||
{avgToParApproachMiss !== null && (
|
||
<span>
|
||
Til par ved bom på innspill:{" "}
|
||
<strong className="text-foreground">{formatSignedAvg(avgToParApproachMiss)}</strong>
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function StatPill({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center gap-0.5 rounded-xl border border-border bg-background px-2 py-2.5 text-center">
|
||
<span className="text-lg font-extrabold tabular-nums text-foreground">{value}</span>
|
||
<span className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Ett rektangel delt i fargede segmenter proporsjonalt med antall --
|
||
// samme visuelle idé som SegmentedBar i tournament-leaderboard.tsx, men
|
||
// generalisert til N kategorier (ikke bare to lag).
|
||
function DistributionBar({ segments }: { segments: { label: string; count: number; colorClass: string }[] }) {
|
||
const total = segments.reduce((sum, s) => sum + s.count, 0)
|
||
if (total === 0) return null
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex h-5 w-full overflow-hidden rounded-full border border-border">
|
||
{segments
|
||
.filter((s) => s.count > 0)
|
||
.map((s) => (
|
||
<div key={s.label} aria-hidden="true" style={{ width: `${(s.count / total) * 100}%` }} className={s.colorClass} />
|
||
))}
|
||
</div>
|
||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||
{segments
|
||
.filter((s) => s.count > 0)
|
||
.map((s) => (
|
||
<span key={s.label} className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||
<span aria-hidden="true" className={cn("inline-block size-2.5 rounded-full", s.colorClass)} />
|
||
{s.label} {Math.round((s.count / total) * 100)}% ({s.count})
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Player tabs -----------------------------------------------------------
|
||
|
||
function PlayerTabs({
|
||
players,
|
||
activePlayerId,
|
||
onSelect,
|
||
onRemove,
|
||
onAdd,
|
||
addOpen,
|
||
readOnly,
|
||
canManage,
|
||
}: {
|
||
players: Player[]
|
||
activePlayerId: string
|
||
onSelect: (id: string) => void
|
||
onRemove: (id: string) => void
|
||
onAdd: () => void
|
||
addOpen: boolean
|
||
readOnly: boolean
|
||
// Kun runde-eieren kan legge til/fjerne medspillere (ADR-036 fase
|
||
// 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt BYTTE
|
||
// mellom faner for å registrere score for hele flighten.
|
||
canManage: boolean
|
||
}) {
|
||
return (
|
||
<div className="-mx-5 overflow-x-auto px-5">
|
||
<div role="tablist" aria-label="Velg spiller" className="flex items-center gap-2">
|
||
{players.map((player) => {
|
||
const active = player.id === activePlayerId
|
||
const canRemove = !player.isSelf && !readOnly && canManage
|
||
return (
|
||
<div
|
||
key={player.id}
|
||
className={cn(
|
||
"flex min-h-12 shrink-0 items-center gap-1 rounded-2xl border pl-4 transition-colors",
|
||
canRemove ? "pr-1.5" : "pr-4",
|
||
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
<button type="button" role="tab" aria-selected={active} onClick={() => onSelect(player.id)} className="text-base font-bold">
|
||
{player.name}
|
||
</button>
|
||
{canRemove && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove(player.id)}
|
||
aria-label={`Fjern ${player.name}`}
|
||
className={cn(
|
||
"flex size-8 shrink-0 items-center justify-center rounded-xl transition-colors",
|
||
active ? "text-primary-foreground/80 hover:bg-primary-foreground/20" : "text-muted-foreground hover:bg-accent",
|
||
)}
|
||
>
|
||
<X aria-hidden="true" className="size-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
{!readOnly && canManage && (
|
||
<button
|
||
type="button"
|
||
onClick={onAdd}
|
||
aria-label="Legg til medspiller"
|
||
aria-expanded={addOpen}
|
||
className="flex min-h-12 shrink-0 items-center gap-1.5 rounded-2xl border border-dashed border-border bg-card px-4 text-base font-semibold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
<Plus aria-hidden="true" className="size-5" />
|
||
Medspiller
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Legg til medspiller: søk etter ekte bruker, eller gjest uten konto ----
|
||
// (ADR-036 fase 3-utvidelsen, 2026-07-26 -- "+ Gjest" søkte tidligere ikke
|
||
// etter spillere i det hele tatt, kun et rent tekstfelt.)
|
||
|
||
function AddParticipantForm({
|
||
onAddGuest,
|
||
onAddSearched,
|
||
onCancel,
|
||
}: {
|
||
onAddGuest: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
|
||
onCancel: () => void
|
||
}) {
|
||
const [mode, setMode] = useState<"search" | "guest">("search")
|
||
const [query, setQuery] = useState("")
|
||
const [results, setResults] = useState<PersonMatch[]>([])
|
||
const [searching, setSearching] = useState(false)
|
||
const [adding, setAdding] = useState(false)
|
||
const [searchError, setSearchError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
const trimmed = query.trim()
|
||
if (trimmed.length < 2) {
|
||
setResults([])
|
||
setSearching(false)
|
||
return
|
||
}
|
||
setSearching(true)
|
||
const handle = setTimeout(() => {
|
||
fetch(`/people/search?q=${encodeURIComponent(trimmed)}`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : []))
|
||
.then((data: PersonMatch[]) => setResults(data))
|
||
.catch(() => setResults([]))
|
||
.finally(() => setSearching(false))
|
||
}, 250)
|
||
return () => clearTimeout(handle)
|
||
}, [query])
|
||
|
||
async function handlePick(person: PersonMatch) {
|
||
setAdding(true)
|
||
setSearchError(null)
|
||
const errorMessage = await onAddSearched(person.id, "strokes_only")
|
||
setAdding(false)
|
||
if (errorMessage) setSearchError(errorMessage)
|
||
}
|
||
|
||
if (mode === "guest") {
|
||
return <AddGuestForm onAdd={onAddGuest} onCancel={onCancel} onBack={() => setMode("search")} />
|
||
}
|
||
|
||
return (
|
||
<div className="mt-3 flex flex-col gap-4 rounded-2xl border border-dashed border-border bg-card/50 p-4 sm:p-5">
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="participant-search" className="text-base font-semibold">
|
||
Søk etter medspiller
|
||
</Label>
|
||
<div className="relative">
|
||
<Search aria-hidden="true" className="pointer-events-none absolute left-3.5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground" />
|
||
<Input
|
||
id="participant-search"
|
||
autoFocus
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder="Navn på medspilleren"
|
||
className="h-12 rounded-2xl pl-11 text-base"
|
||
/>
|
||
</div>
|
||
{query.trim().length > 0 && query.trim().length < 2 && (
|
||
<p className="text-sm text-muted-foreground">Skriv minst 2 tegn for å søke.</p>
|
||
)}
|
||
</div>
|
||
|
||
{searchError && (
|
||
<p role="alert" className="text-sm font-medium text-destructive">
|
||
{searchError}
|
||
</p>
|
||
)}
|
||
|
||
{results.length > 0 && (
|
||
<ul className="flex flex-col gap-2">
|
||
{results.map((person) => (
|
||
<li key={person.id}>
|
||
<button
|
||
type="button"
|
||
disabled={adding}
|
||
onClick={() => handlePick(person)}
|
||
className="flex min-h-14 w-full items-center gap-3 rounded-2xl border border-border bg-card px-3 text-left transition-colors hover:border-primary/60 hover:bg-accent/50 disabled:opacity-60"
|
||
>
|
||
<span
|
||
aria-hidden="true"
|
||
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-bold text-primary"
|
||
>
|
||
{person.first_name[0]}
|
||
{person.last_name[0]}
|
||
</span>
|
||
<span className="flex min-w-0 flex-col">
|
||
<span className="truncate text-base font-bold text-foreground">
|
||
{person.first_name} {person.last_name}
|
||
</span>
|
||
{person.home_club && <span className="truncate text-sm text-muted-foreground">{person.home_club}</span>}
|
||
</span>
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
{!searching && query.trim().length >= 2 && results.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">Fant ingen med det navnet.</p>
|
||
)}
|
||
|
||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||
<button
|
||
type="button"
|
||
onClick={() => setMode("guest")}
|
||
className="text-sm font-semibold text-foreground underline-offset-2 hover:underline"
|
||
>
|
||
Legg til uten konto (gjest)
|
||
</button>
|
||
<Button type="button" variant="ghost" onClick={onCancel} className="h-11 rounded-2xl px-5 text-base font-semibold">
|
||
Avbryt
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Add guest form (fallback for spillere uten TeeCup-konto) --------------
|
||
|
||
function AddGuestForm({
|
||
onAdd,
|
||
onCancel,
|
||
onBack,
|
||
}: {
|
||
onAdd: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||
onCancel: () => void
|
||
onBack?: () => void
|
||
}) {
|
||
const [name, setName] = useState("")
|
||
const [gender, setGender] = useState<Gender>("male")
|
||
const [hcp, setHcp] = useState("")
|
||
const [statLevel, setStatLevel] = useState<StatLevel>("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 (
|
||
<form onSubmit={handleSubmit} className="mt-3 flex flex-col gap-5 rounded-2xl border border-dashed border-border bg-card/50 p-4 sm:p-5">
|
||
{onBack && (
|
||
<button
|
||
type="button"
|
||
onClick={onBack}
|
||
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
|
||
>
|
||
← Søk i stedet
|
||
</button>
|
||
)}
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="guest-name" className="text-base font-semibold">
|
||
Navn
|
||
</Label>
|
||
<Input id="guest-name" autoFocus value={name} onChange={(e) => setName(e.target.value)} placeholder="Gjestens navn" className="h-12 rounded-2xl text-base" />
|
||
</div>
|
||
|
||
<ChoiceRow
|
||
label="Kjønn"
|
||
options={[
|
||
{ value: "male", label: "Mann" },
|
||
{ value: "female", label: "Kvinne" },
|
||
{ value: "other", label: "Annet" },
|
||
]}
|
||
value={gender}
|
||
onChange={(v) => setGender(v as Gender)}
|
||
readOnly={false}
|
||
/>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="guest-hcp" className="text-base font-semibold">
|
||
HCP <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</Label>
|
||
<Input
|
||
id="guest-hcp"
|
||
inputMode="decimal"
|
||
type="number"
|
||
step="0.1"
|
||
value={hcp}
|
||
onChange={(e) => setHcp(e.target.value)}
|
||
placeholder="F.eks. 18"
|
||
className="h-12 rounded-2xl text-base"
|
||
/>
|
||
</div>
|
||
|
||
<ChoiceRow
|
||
label="Statistikk for denne spilleren"
|
||
options={[
|
||
{ value: "strokes_only", label: "Kun slag" },
|
||
{ value: "strokes_and_putts", label: "Slag og putter" },
|
||
{ value: "full", label: "All statistikk" },
|
||
]}
|
||
value={statLevel}
|
||
onChange={(v) => setStatLevel(v as StatLevel)}
|
||
readOnly={false}
|
||
/>
|
||
|
||
<div className="flex gap-3">
|
||
<Button type="submit" className="h-12 flex-1 rounded-2xl text-base font-bold">
|
||
Legg til
|
||
</Button>
|
||
<Button type="button" variant="ghost" onClick={onCancel} className="h-12 rounded-2xl px-6 text-base font-semibold">
|
||
Avbryt
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
)
|
||
}
|
||
|
||
// --- Rediger runde (bane/utslag/antall hull) -------------------------------
|
||
// Retter opp feil bane eller feil antall hull underveis -- rører ALDRI
|
||
// allerede registrerte slag/putter/etc. (backend garanterer dette, se
|
||
// PATCH /rounds/{id}). Kun tilgjengelig før runden er fullført.
|
||
|
||
type ApiFacility = { slug: string; name: string; city: string | null; county: string | null }
|
||
type ApiOfficialCourseOption = { teeoff_course_id: number; name: string; is_main_course: boolean; tees: { name: string }[] }
|
||
type ApiPersonalCourseSummary = { id: string; name: string }
|
||
type ApiPersonalCourseDetail = { id: string; name: string; tees: { name: string }[] }
|
||
|
||
// Konverterer til/fra <input type="datetime-local">-verdiformatet
|
||
// ("YYYY-MM-DDTHH:mm", lokal tid) -- selve lagringen skjer som ekte
|
||
// tidsstempler (UTC), kun visningen er lokal.
|
||
function toDatetimeLocalValue(iso: string | null): string {
|
||
if (!iso) return ""
|
||
const d = new Date(iso)
|
||
if (Number.isNaN(d.getTime())) return ""
|
||
const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000)
|
||
return local.toISOString().slice(0, 16)
|
||
}
|
||
function fromDatetimeLocalValue(value: string): string | null {
|
||
if (!value) return null
|
||
const d = new Date(value)
|
||
return Number.isNaN(d.getTime()) ? null : d.toISOString()
|
||
}
|
||
|
||
function EditRoundPanel({
|
||
round,
|
||
onPatch,
|
||
onClose,
|
||
}: {
|
||
round: ApiRound
|
||
onPatch: (body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onClose: () => void
|
||
}) {
|
||
const isCompleted = round.completed_at !== null
|
||
const [name, setName] = useState(round.name ?? "")
|
||
const [holesPlanned, setHolesPlanned] = useState<9 | 18>(round.holes_planned === 9 ? 9 : 18)
|
||
const [startHole, setStartHole] = useState(String(round.start_hole))
|
||
const [startedAt, setStartedAt] = useState(toDatetimeLocalValue(round.started_at))
|
||
const [completedAt, setCompletedAt] = useState(toDatetimeLocalValue(round.completed_at))
|
||
const [saving, setSaving] = useState(false)
|
||
const [showChangeCourse, setShowChangeCourse] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const hasMetadataChanges =
|
||
name.trim() !== (round.name ?? "") ||
|
||
(!isCompleted && (holesPlanned !== round.holes_planned || Number(startHole) !== round.start_hole)) ||
|
||
toDatetimeLocalValue(round.started_at) !== startedAt ||
|
||
(isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt)
|
||
|
||
async function saveMetadata() {
|
||
setSaving(true)
|
||
setError(null)
|
||
const body: Record<string, unknown> = {}
|
||
// Tom streng betyr "fjern navnet" (backend-kontrakt) -- send den derfor
|
||
// alltid med når feltet faktisk er endret, aldri utelatt for å tømme.
|
||
if (name.trim() !== (round.name ?? "")) body.name = name.trim()
|
||
if (!isCompleted) {
|
||
if (holesPlanned !== round.holes_planned) body.holes_planned = holesPlanned
|
||
if (Number(startHole) !== round.start_hole) body.start_hole = Number(startHole)
|
||
}
|
||
if (toDatetimeLocalValue(round.started_at) !== startedAt) body.started_at = fromDatetimeLocalValue(startedAt)
|
||
if (isCompleted && toDatetimeLocalValue(round.completed_at) !== completedAt) {
|
||
body.completed_at = fromDatetimeLocalValue(completedAt)
|
||
}
|
||
const result = await onPatch(body)
|
||
if (!result.ok) setError(result.message)
|
||
setSaving(false)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-sm font-bold text-foreground">Rediger runde</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>}
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="edit-round-name" className="text-sm font-semibold">
|
||
Navn på runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</Label>
|
||
<Input
|
||
id="edit-round-name"
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder={round.course_name_snapshot}
|
||
maxLength={200}
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
</div>
|
||
|
||
{!isCompleted && (
|
||
<>
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-sm font-semibold text-foreground">Antall hull</span>
|
||
<div className="flex gap-2">
|
||
{([9, 18] as const).map((n) => (
|
||
<button
|
||
key={n}
|
||
type="button"
|
||
onClick={() => setHolesPlanned(n)}
|
||
aria-pressed={holesPlanned === n}
|
||
className={cn(
|
||
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
|
||
holesPlanned === n
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{n} hull
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="edit-start-hole" className="text-sm font-semibold">
|
||
Starthull
|
||
</Label>
|
||
<select
|
||
id="edit-start-hole"
|
||
value={startHole}
|
||
onChange={(e) => setStartHole(e.target.value)}
|
||
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground"
|
||
>
|
||
{Array.from({ length: 18 }, (_, i) => i + 1).map((n) => (
|
||
<option key={n} value={String(n)}>
|
||
Hull {n}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="edit-started-at" className="text-sm font-semibold">
|
||
Utslagstid
|
||
</Label>
|
||
<Input
|
||
id="edit-started-at"
|
||
type="datetime-local"
|
||
value={startedAt}
|
||
onChange={(e) => setStartedAt(e.target.value)}
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
</div>
|
||
|
||
{isCompleted && (
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor="edit-completed-at" className="text-sm font-semibold">
|
||
Fullført-tidspunkt
|
||
</Label>
|
||
<Input
|
||
id="edit-completed-at"
|
||
type="datetime-local"
|
||
value={completedAt}
|
||
onChange={(e) => setCompletedAt(e.target.value)}
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{hasMetadataChanges && (
|
||
<Button type="button" disabled={saving} onClick={saveMetadata} className="h-11 rounded-xl text-sm font-bold">
|
||
{saving ? "Lagrer…" : "Lagre endringer"}
|
||
</Button>
|
||
)}
|
||
|
||
{!isCompleted && (
|
||
<div className="border-t border-border pt-4">
|
||
{showChangeCourse ? (
|
||
<ChangeCourseForm onPatch={onPatch} onCancel={() => setShowChangeCourse(false)} />
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowChangeCourse(true)}
|
||
className="inline-flex min-h-11 items-center gap-1.5 text-sm font-semibold text-primary"
|
||
>
|
||
<Search aria-hidden="true" className="size-4" />
|
||
Bytt bane
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ChangeCourseForm({
|
||
onPatch,
|
||
onCancel,
|
||
}: {
|
||
onPatch: (body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onCancel: () => void
|
||
}) {
|
||
const [source, setSource] = useState<"teeoff" | "custom" | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
|
||
// Teeoff-søk
|
||
const [query, setQuery] = useState("")
|
||
const [facilities, setFacilities] = useState<ApiFacility[] | null>(null)
|
||
const [selectedFacility, setSelectedFacility] = useState<ApiFacility | null>(null)
|
||
const [officialCourses, setOfficialCourses] = useState<ApiOfficialCourseOption[] | null>(null)
|
||
const [selectedOfficialCourse, setSelectedOfficialCourse] = useState<ApiOfficialCourseOption | null>(null)
|
||
|
||
// Egen bane-søk
|
||
const [customQuery, setCustomQuery] = useState("")
|
||
const [customResults, setCustomResults] = useState<ApiPersonalCourseSummary[]>([])
|
||
const [selectedCustomCourse, setSelectedCustomCourse] = useState<ApiPersonalCourseDetail | null>(null)
|
||
|
||
const [teeName, setTeeName] = useState("")
|
||
|
||
useEffect(() => {
|
||
if (source !== "custom") return
|
||
let cancelled = false
|
||
const timer = setTimeout(async () => {
|
||
try {
|
||
const res = await fetch(`/personal-courses?q=${encodeURIComponent(customQuery.trim())}`, { credentials: "include" })
|
||
if (res.ok && !cancelled) setCustomResults(await res.json())
|
||
} catch {
|
||
// Stille -- listen blir bare uendret, ingen kritisk feil å vise her.
|
||
}
|
||
}, 250)
|
||
return () => {
|
||
cancelled = true
|
||
clearTimeout(timer)
|
||
}
|
||
}, [source, customQuery])
|
||
|
||
async function searchFacilities() {
|
||
setError(null)
|
||
try {
|
||
const res = await fetch(`/rounds/official-search?q=${encodeURIComponent(query.trim())}`, { credentials: "include" })
|
||
if (!res.ok) throw new Error()
|
||
setFacilities(await res.json())
|
||
} catch {
|
||
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
|
||
}
|
||
}
|
||
|
||
async function pickFacility(facility: ApiFacility) {
|
||
setSelectedFacility(facility)
|
||
setError(null)
|
||
try {
|
||
const res = await fetch(`/rounds/official-search/${facility.slug}`, { credentials: "include" })
|
||
if (!res.ok) throw new Error()
|
||
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
|
||
setOfficialCourses(detail.courses)
|
||
} catch {
|
||
setError("Klarte ikke å hente baner for dette anlegget.")
|
||
}
|
||
}
|
||
|
||
async function pickCustomCourse(course: ApiPersonalCourseSummary) {
|
||
setError(null)
|
||
try {
|
||
const res = await fetch(`/personal-courses/${course.id}`, { credentials: "include" })
|
||
if (!res.ok) throw new Error()
|
||
setSelectedCustomCourse(await res.json())
|
||
} catch {
|
||
setError("Klarte ikke å hente banedetaljer.")
|
||
}
|
||
}
|
||
|
||
const availableTees = selectedOfficialCourse?.tees ?? selectedCustomCourse?.tees ?? []
|
||
|
||
async function handleSubmit() {
|
||
if (!teeName) return
|
||
setSubmitting(true)
|
||
setError(null)
|
||
const body =
|
||
source === "teeoff" && selectedFacility && selectedOfficialCourse
|
||
? {
|
||
course_source: "teeoff",
|
||
teeoff_facility_slug: selectedFacility.slug,
|
||
teeoff_course_id: selectedOfficialCourse.teeoff_course_id,
|
||
tee_name: teeName,
|
||
}
|
||
: selectedCustomCourse
|
||
? { course_source: "custom", personal_course_id: selectedCustomCourse.id, tee_name: teeName }
|
||
: null
|
||
if (!body) {
|
||
setSubmitting(false)
|
||
return
|
||
}
|
||
const result = await onPatch(body)
|
||
setSubmitting(false)
|
||
if (!result.ok) {
|
||
setError(result.message)
|
||
return
|
||
}
|
||
onCancel()
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
<p className="text-xs leading-relaxed text-muted-foreground text-pretty">
|
||
Endrer kun banen og utslaget videre -- allerede registrerte slag/putter for spilte hull røres ikke.
|
||
</p>
|
||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||
|
||
{source === null && (
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Button type="button" variant="outline" onClick={() => setSource("teeoff")} className="h-11 rounded-xl text-sm font-bold">
|
||
Offisiell bane
|
||
</Button>
|
||
<Button type="button" variant="outline" onClick={() => setSource("custom")} className="h-11 rounded-xl text-sm font-bold">
|
||
Egen bane
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{source === "teeoff" && !selectedFacility && (
|
||
<div className="flex flex-col gap-2">
|
||
<div className="flex gap-2">
|
||
<Input
|
||
autoFocus
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault()
|
||
searchFacilities()
|
||
}
|
||
}}
|
||
placeholder="Søk klubbnavn…"
|
||
className="h-11 flex-1 rounded-xl text-base"
|
||
/>
|
||
<Button type="button" onClick={searchFacilities} className="h-11 shrink-0 rounded-xl text-sm font-bold">
|
||
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-3 py-2.5 text-left 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-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{source === "teeoff" && selectedFacility && !selectedOfficialCourse && (
|
||
<div className="flex flex-col gap-2">
|
||
<button type="button" onClick={() => setSelectedFacility(null)} className="self-start text-sm font-semibold text-muted-foreground">
|
||
← {selectedFacility.name}
|
||
</button>
|
||
{officialCourses === null ? (
|
||
<p className="text-sm text-muted-foreground">Laster baner…</p>
|
||
) : (
|
||
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
|
||
{officialCourses.map((c) => (
|
||
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setSelectedOfficialCourse(c)}
|
||
className="flex w-full items-center justify-between px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60"
|
||
>
|
||
{c.name}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{source === "custom" && !selectedCustomCourse && (
|
||
<div className="flex flex-col gap-2">
|
||
<Input
|
||
autoFocus
|
||
value={customQuery}
|
||
onChange={(e) => setCustomQuery(e.target.value)}
|
||
placeholder="Søk egendefinert bane…"
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
||
{customResults.map((c) => (
|
||
<li key={c.id} className="border-b border-border last:border-b-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => pickCustomCourse(c)}
|
||
className="flex w-full px-3 py-2.5 text-left text-sm font-semibold text-foreground hover:bg-accent/60"
|
||
>
|
||
{c.name}
|
||
</button>
|
||
</li>
|
||
))}
|
||
{customResults.length === 0 && (
|
||
<li className="px-3 py-2.5 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
||
)}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{(selectedOfficialCourse || selectedCustomCourse) && (
|
||
<div className="flex flex-col gap-3">
|
||
<span className="text-sm font-bold text-foreground">
|
||
{selectedOfficialCourse?.name ?? selectedCustomCourse?.name}
|
||
</span>
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-sm font-semibold text-foreground">Utslag</span>
|
||
<div className="flex flex-wrap gap-2">
|
||
{availableTees.map((t) => (
|
||
<button
|
||
key={t.name}
|
||
type="button"
|
||
onClick={() => setTeeName(t.name)}
|
||
aria-pressed={teeName === t.name}
|
||
className={cn(
|
||
"h-10 rounded-lg border px-3 text-sm font-semibold transition-colors",
|
||
teeName === t.name
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{t.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<Button type="button" disabled={submitting || !teeName} onClick={handleSubmit} className="h-11 rounded-xl text-sm font-bold">
|
||
{submitting ? "Bytter bane…" : "Bytt bane"}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<Button type="button" variant="ghost" onClick={onCancel} className="h-10 self-start rounded-xl text-sm font-semibold text-muted-foreground">
|
||
Avbryt
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Hole navigation -------------------------------------------------------
|
||
|
||
function HoleNav({
|
||
holes,
|
||
currentHole,
|
||
isPlayed,
|
||
onSelect,
|
||
}: {
|
||
holes: Hole[]
|
||
currentHole: number
|
||
isPlayed: (holeNumber: number) => boolean
|
||
onSelect: (holeNumber: number) => void
|
||
}) {
|
||
return (
|
||
<nav aria-label="Velg hull" className="-mx-5 mt-5 overflow-x-auto px-5">
|
||
<div className="flex gap-2 pb-1">
|
||
{holes.map((h) => {
|
||
const active = h.holeNumber === currentHole
|
||
const played = isPlayed(h.holeNumber)
|
||
return (
|
||
<button
|
||
key={h.holeNumber}
|
||
type="button"
|
||
onClick={() => onSelect(h.holeNumber)}
|
||
aria-current={active ? "true" : undefined}
|
||
aria-label={`Hull ${h.holeNumber}${played ? ", spilt" : ""}`}
|
||
className={cn(
|
||
"flex size-12 shrink-0 items-center justify-center rounded-2xl border text-base font-bold tabular-nums transition-colors",
|
||
active
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: played
|
||
? "border-primary/40 bg-primary/10 text-foreground hover:bg-primary/20"
|
||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{h.holeNumber}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</nav>
|
||
)
|
||
}
|
||
|
||
// --- 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 (
|
||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Statistikk</span>
|
||
<div className="flex gap-1.5">
|
||
{options.map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => onChange(opt.value)}
|
||
aria-pressed={value === opt.value}
|
||
className={cn(
|
||
"rounded-full px-3 py-1.5 text-xs font-bold transition-colors",
|
||
value === opt.value
|
||
? "bg-primary text-primary-foreground"
|
||
: "bg-muted text-muted-foreground hover:bg-accent",
|
||
)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Number picker ---------------------------------------------------------
|
||
|
||
function NumberPicker({
|
||
label,
|
||
value,
|
||
directValues,
|
||
expandValues,
|
||
expandLabel,
|
||
parValue,
|
||
maxValue,
|
||
onChange,
|
||
readOnly,
|
||
}: {
|
||
label: string
|
||
value: number | null
|
||
directValues: number[]
|
||
expandValues: number[]
|
||
expandLabel: string
|
||
// Merker knappen som tilsvarer hullets par med en liten "par"-bildetekst
|
||
// (kun brukt for Slag-velgeren) -- gjør det tydelig hva som er par uten
|
||
// å måtte huske det fra hull-overskriften.
|
||
parValue?: number
|
||
// Skjuler valg høyere enn dette (f.eks. Putter kan aldri overstige
|
||
// antall slag registrert på hullet) -- ingen vits i å tilby et tall som
|
||
// uansett ville vært selvmotsigende.
|
||
maxValue?: number
|
||
onChange: (value: number) => void
|
||
readOnly: boolean
|
||
}) {
|
||
const cap = (values: number[]) => (maxValue === undefined ? values : values.filter((n) => n <= maxValue))
|
||
const cappedDirect = cap(directValues)
|
||
const cappedExpand = cap(expandValues)
|
||
|
||
const valueInExpand = value !== null && cappedExpand.includes(value)
|
||
const [expanded, setExpanded] = useState(false)
|
||
const showExpanded = expanded || valueInExpand
|
||
|
||
const visibleValues = showExpanded ? [...cappedDirect, ...cappedExpand] : cappedDirect
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-base font-semibold text-foreground">{label}</span>
|
||
{/* Numpad: tre kolonner, knappene fyller bredden -- store trykkflater
|
||
for bruk utendørs, ofte med sol på skjermen (V0-runde 2026-07-24). */}
|
||
<div className="grid max-w-sm grid-cols-3 gap-2.5">
|
||
{visibleValues.map((n) => {
|
||
const selected = value === n
|
||
const isPar = n === parValue
|
||
return (
|
||
<button
|
||
key={n}
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onChange(n)}
|
||
aria-label={isPar ? `${label} ${n}, par` : `${label} ${n}`}
|
||
aria-pressed={selected}
|
||
className={cn(
|
||
"flex h-16 flex-col items-center justify-center gap-0 rounded-2xl border text-2xl font-extrabold leading-none tabular-nums transition-colors disabled:opacity-100",
|
||
selected
|
||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||
readOnly && !selected && "opacity-40",
|
||
)}
|
||
>
|
||
<span>{n}</span>
|
||
{isPar && (
|
||
<span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>
|
||
par
|
||
</span>
|
||
)}
|
||
</button>
|
||
)
|
||
})}
|
||
{!showExpanded && !readOnly && cappedExpand.length > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setExpanded(true)}
|
||
className="col-span-3 flex h-14 items-center justify-center rounded-2xl border border-dashed border-border bg-card text-lg font-bold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
{expandLabel}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Retningskors (D-pad for Utslag / Innspill) -----------------------------
|
||
|
||
function DirButton({
|
||
icon: Icon,
|
||
label,
|
||
selected,
|
||
onClick,
|
||
readOnly,
|
||
}: {
|
||
icon: typeof ArrowUp
|
||
label: string
|
||
selected: boolean
|
||
onClick: () => void
|
||
readOnly: boolean
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={onClick}
|
||
aria-pressed={selected}
|
||
className={cn(
|
||
"flex min-h-16 flex-col items-center justify-center gap-1 rounded-2xl border px-2 py-2 transition-colors disabled:opacity-100",
|
||
selected
|
||
? "border-primary bg-primary text-primary-foreground shadow-sm"
|
||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||
readOnly && !selected && "opacity-40",
|
||
)}
|
||
>
|
||
<Icon aria-hidden="true" className="size-6" />
|
||
<span className="text-sm font-bold">{label}</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
function DirectionCross({
|
||
label,
|
||
value,
|
||
onChange,
|
||
readOnly,
|
||
variant,
|
||
centerLabel,
|
||
}: {
|
||
label: string
|
||
value: string | null
|
||
onChange: (value: string) => void
|
||
readOnly: boolean
|
||
// "horizontal" = kun venstre/senter/høyre (Utslag); "full" = 5-veis (Innspill)
|
||
variant: "horizontal" | "full"
|
||
centerLabel: string
|
||
}) {
|
||
const spacer = <div aria-hidden="true" />
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-base font-semibold text-foreground">{label}</span>
|
||
<div className="grid max-w-sm grid-cols-3 gap-2.5">
|
||
{variant === "full" && (
|
||
<>
|
||
{spacer}
|
||
<DirButton icon={ArrowUp} label="Langt" selected={value === "long"} onClick={() => onChange("long")} readOnly={readOnly} />
|
||
{spacer}
|
||
</>
|
||
)}
|
||
<DirButton icon={ArrowLeft} label="Venstre" selected={value === "left"} onClick={() => onChange("left")} readOnly={readOnly} />
|
||
<DirButton
|
||
icon={Target}
|
||
label={centerLabel}
|
||
selected={value === (variant === "full" ? "hit" : "fairway")}
|
||
onClick={() => onChange(variant === "full" ? "hit" : "fairway")}
|
||
readOnly={readOnly}
|
||
/>
|
||
<DirButton icon={ArrowRight} label="Høyre" selected={value === "right"} onClick={() => onChange("right")} readOnly={readOnly} />
|
||
{variant === "full" && (
|
||
<>
|
||
{spacer}
|
||
<DirButton icon={ArrowDown} label="Kort" selected={value === "short"} onClick={() => onChange("short")} readOnly={readOnly} />
|
||
{spacer}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- 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 (
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-base font-semibold text-foreground">{label}</span>
|
||
<div className="flex flex-wrap gap-2">
|
||
{options.map((opt) => {
|
||
const selected = value === opt.value
|
||
return (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onChange(opt.value)}
|
||
aria-pressed={selected}
|
||
className={cn(
|
||
"flex min-h-12 flex-1 items-center justify-center rounded-2xl border px-4 text-base font-bold transition-colors disabled:opacity-100",
|
||
selected ? "border-primary bg-primary text-primary-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
|
||
readOnly && !selected && "opacity-40",
|
||
)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Stepper (+/-) ---------------------------------------------------------
|
||
|
||
function Stepper({
|
||
label,
|
||
value,
|
||
onChange,
|
||
readOnly,
|
||
max,
|
||
}: {
|
||
label: string
|
||
value: number
|
||
onChange: (value: number) => void
|
||
readOnly: boolean
|
||
// Kan ikke telle høyere enn antall slag registrert på hullet -- f.eks.
|
||
// umulig å ha chippet flere ganger enn totalt antall slag.
|
||
max?: number
|
||
}) {
|
||
const atMax = max !== undefined && value >= max
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-base font-semibold text-foreground">{label}</span>
|
||
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-card p-1.5">
|
||
<button
|
||
type="button"
|
||
disabled={readOnly || value <= 0}
|
||
onClick={() => onChange(Math.max(0, value - 1))}
|
||
aria-label={`Færre ${label}`}
|
||
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
|
||
>
|
||
<Minus aria-hidden="true" className="size-5" />
|
||
</button>
|
||
<span className="min-w-8 text-center text-xl font-extrabold tabular-nums text-foreground">{value}</span>
|
||
<button
|
||
type="button"
|
||
disabled={readOnly || atMax}
|
||
onClick={() => onChange(value + 1)}
|
||
aria-label={`Flere ${label}`}
|
||
className="flex size-11 shrink-0 items-center justify-center rounded-xl border border-border bg-background text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
|
||
>
|
||
<Plus aria-hidden="true" className="size-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|