Full skjermovertagende veiviser — trykk på en spillers score-knapp åpner nå et eget fullskjermbilde for akkurat den spilleren på det hullet, med Forrige/Neste gjennom stegene (Slag → Putter → detaljer, avhengig av statistikknivå). Siste steg for siste spiller blir "Ferdig" og går videre til neste hull automatisk. Akkumulert score synlig for alle — hver spiller viser nå "HCP X · E/+N så langt (Y hull)" hele tiden i hovedlisten, ikke bare for én valgt spiller. Hovedsiden er kraftig forenklet: det gamle lange skjemaet er borte, erstattet med én kompakt rad per spiller. Gitt at forrige forsøk så dårlig ut til tross for at logikken var riktig, er dette en større usikkerhet enn vanlig — jeg oppfordrer deg til å teste dette grundig i nettleseren før du stoler på det.
2905 lines
115 KiB
TypeScript
2905 lines
115 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 { Badge } from "@/components/ui/badge"
|
||
import { Button } from "@/components/ui/button"
|
||
import { RoundLeaderboardMini } from "@/components/round-leaderboard"
|
||
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
|
||
// Visningsnavn -- "Deg" for spilleren SOM SER PÅ (viewer-relativt, se
|
||
// playerLabel()), IKKE nødvendigvis den faktiske display_name.
|
||
name: string
|
||
// Den faktiske, urørte display_name -- ALDRI "Deg". Brukt til å
|
||
// forhåndsutfylle "Rediger"-flytens navnefelt for en gjest (2026-07-26).
|
||
rawName: string
|
||
userId: string | null
|
||
guestEmail: string | null
|
||
isOwner: boolean
|
||
gender: Gender
|
||
hcp: number | null
|
||
// Tildelte slag for RUNDEN (course handicap, ikke selve HCP-indeksen) --
|
||
// null når HCP ikke spores for spilleren (2026-07-26).
|
||
courseHandicap: number | null
|
||
teeName: string
|
||
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}`
|
||
}
|
||
|
||
// Til-par-formatering, golfvis (ekte minustegn) -- samme konvensjon som
|
||
// round-scorecard.tsx/round-leaderboard.tsx (2026-07-26, brukt for
|
||
// akkumulert score-så-langt per spiller i den nye scoringsflyten).
|
||
function formatToPar(value: number): string {
|
||
if (value === 0) return "E"
|
||
return value > 0 ? `+${value}` : `−${Math.abs(value)}`
|
||
}
|
||
|
||
function computeRunningToPar(holes: ApiHole[] | undefined): { toPar: number; holesPlayed: number } | null {
|
||
if (!holes) return null
|
||
const played = holes.filter((h) => h.played && h.score !== null)
|
||
if (played.length === 0) return null
|
||
const score = played.reduce((sum, h) => sum + (h.score as number), 0)
|
||
const par = played.reduce((sum, h) => sum + h.par, 0)
|
||
return { toPar: score - par, holesPlayed: played.length }
|
||
}
|
||
|
||
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
|
||
// Valgfritt kontaktfelt, KUN meningsfullt for en gjest (2026-07-26).
|
||
guest_email: 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
|
||
// Per-deltaker utslagssted (2026-07-26) -- kan avvike fra en annen
|
||
// deltakers, redigerbart av eieren kun for DENNE runden.
|
||
tee_name_snapshot: string
|
||
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
|
||
handicap_index: number | null
|
||
}
|
||
|
||
type ApiTeeOption = {
|
||
name: string
|
||
genders: ApiGender[]
|
||
}
|
||
|
||
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)
|
||
// Hvilken spillers "Rediger"-panel som er åpent, om noen (2026-07-26 --
|
||
// panelet ligger nå INNI hvert spillerkort i den vertikale listen, kan
|
||
// derfor gjelde en ANNEN rad enn den aktive/valgte, ulikt tidligere).
|
||
const [editingPlayerId, setEditingPlayerId] = useState<string | null>(null)
|
||
// Hvilken spillers scoringsveiviser (fullskjerm) som er åpen for AKTIVT
|
||
// HULL, om noen (2026-07-26 -- erstatter den tidligere "vis alt inline for
|
||
// aktiv spiller"-flyten, inspirert av en konkurrentapps samlebåndsflyt).
|
||
const [wizardPlayerId, setWizardPlayerId] = useState<string | null>(null)
|
||
// 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],
|
||
)
|
||
|
||
// Laster hull for ALLE deltakere (2026-07-26, ikke bare aktiv spiller) --
|
||
// den nye scoringslisten viser akkumulert score-så-langt for HVER spiller
|
||
// samtidig, ikke kun den ene som tilfeldigvis er "aktiv".
|
||
useEffect(() => {
|
||
if (!round) return
|
||
for (const p of round.participants) {
|
||
if (!holesByParticipant[p.id]) {
|
||
void loadHoles(p.id)
|
||
}
|
||
}
|
||
}, [round, holesByParticipant, loadHoles])
|
||
|
||
// Alltid gjeldende deltaker-id-er tilgjengelig INNI WebSocket-handleren
|
||
// under, uten å måtte koble socket-en til/fra ved hver ny runde-lasting
|
||
// (en vanlig closure over `round` i effektens dependency-array ville
|
||
// krevd det).
|
||
const participantIdsRef = useRef<string[]>([])
|
||
useEffect(() => {
|
||
participantIdsRef.current = round?.participants.map((p) => p.id) ?? []
|
||
}, [round])
|
||
|
||
// Sanntid (2026-07-26, oppfølging av ADR-027): et rent "noe endret seg"-
|
||
// signal -- IKKE selve dataen. Klienten reagerer med de vanlige REST-
|
||
// kallene den uansett gjør ved førstegangslasting. Henter nå ALLE
|
||
// deltakeres hull på nytt (ikke bare én aktiv) siden scoringslisten
|
||
// viser alles akkumulerte score-så-langt samtidig.
|
||
useEffect(() => {
|
||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
|
||
const socket = new WebSocket(`${protocol}//${window.location.host}/ws/rounds/${roundId}/live`)
|
||
socket.onmessage = () => {
|
||
void loadRound()
|
||
for (const id of participantIdsRef.current) {
|
||
void loadHoles(id)
|
||
}
|
||
}
|
||
return () => socket.close()
|
||
}, [roundId, loadRound, loadHoles])
|
||
|
||
const completed = round?.completed_at != null
|
||
const readOnly = completed
|
||
|
||
const players: Player[] =
|
||
round?.participants.map((p) => ({
|
||
id: p.id,
|
||
name: playerLabel(p, viewerId),
|
||
rawName: p.display_name,
|
||
userId: p.user_id,
|
||
guestEmail: p.guest_email,
|
||
isOwner: p.is_owner,
|
||
gender: apiGenderToUi(p.gender),
|
||
hcp: p.handicap_index_snapshot,
|
||
courseHandicap: p.course_handicap_snapshot,
|
||
teeName: p.tee_name_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
|
||
|
||
// Veiviserens mål (2026-07-26) -- HVEM sin score som registreres akkurat
|
||
// nå, uavhengig av `activePlayerId` (som fortsatt kun styrer hvem sin
|
||
// "Så langt i runden"-oversikt lenger ned som vises).
|
||
const wizardPlayer = players.find((p) => p.id === wizardPlayerId) ?? null
|
||
const wizardApiHole = wizardPlayerId ? (holesByParticipant[wizardPlayerId]?.find((h) => h.hole_number === activeHole) ?? null) : null
|
||
const wizardStat = wizardApiHole ? apiHoleToStat(wizardApiHole) : emptyStat()
|
||
|
||
async function updateWizardStat(patch: Partial<HoleStat>) {
|
||
if (readOnly || !wizardPlayerId) return
|
||
const merged: HoleStat = { ...wizardStat, ...patch }
|
||
const res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/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,
|
||
[wizardPlayerId]: (prev[wizardPlayerId] ?? []).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()
|
||
}
|
||
|
||
// Samlebånd-fremdrift (2026-07-26): kalles fra veiviserens siste steg for
|
||
// siste spiller på hullet -- bytter til NESTE spiller i rekkefølgen (samme
|
||
// veiviser, nullstilt til steg 1), eller -- er aktiv spiller den siste --
|
||
// lukker veiviseren og går videre til neste hull, klar for spiller 1 igjen.
|
||
function advanceWizardPlayer() {
|
||
const idx = players.findIndex((p) => p.id === wizardPlayerId)
|
||
const isLast = idx === -1 || idx === players.length - 1
|
||
if (!isLast) {
|
||
const next = players[idx + 1]
|
||
setActivePlayerId(next.id)
|
||
setWizardPlayerId(next.id)
|
||
} else {
|
||
setWizardPlayerId(null)
|
||
setActivePlayerId(players[0]?.id ?? null)
|
||
goNext()
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
// Utslag+HCP+statistikknivå (og navn/kjønn/e-post for en gjest) for en
|
||
// enkelt deltaker (2026-07-26) -- gjelder KUN denne
|
||
// runden, rører aldri spillerens egen profil. Bygget for å dekke at en
|
||
// medspiller kan spille fra et annet utslag enn eieren, og at en foreslått
|
||
// HCP (auto-fylt fra profilen ved søk) kan være feil for akkurat denne dagen.
|
||
async function patchParticipant(
|
||
participantId: string,
|
||
body: Record<string, unknown>,
|
||
): Promise<{ ok: true } | { ok: false; message: string }> {
|
||
const res = await fetch(`/rounds/${roundId}/participants/${participantId}`, {
|
||
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 spilleren. Prøv igjen." }
|
||
}
|
||
const updated: ApiParticipant = await res.json()
|
||
setRound((prev) =>
|
||
prev ? { ...prev, participants: prev.participants.map((p) => (p.id === updated.id ? updated : p)) } : prev,
|
||
)
|
||
return { ok: true }
|
||
}
|
||
|
||
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>
|
||
)}
|
||
|
||
{/* Kompakt leaderboard-forhåndsvisning (2026-07-26) -- kun for
|
||
runder med flere enn én deltaker (komponenten returnerer selv
|
||
null ellers), lenke til hele leaderboardet. */}
|
||
<RoundLeaderboardMini roundId={roundId} />
|
||
|
||
{/* 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}
|
||
/>
|
||
)}
|
||
|
||
{/* Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad)
|
||
-- vertikal, ett kort per spiller. Utslag/HCP/statistikknivå +
|
||
"Rediger" ligger nå INNI hvert kort (samme kort er også selve
|
||
velg-aktiv-spiller-trykkflaten), i stedet for en egen rad under
|
||
listen som kun gjaldt aktiv spiller. */}
|
||
<PlayerList
|
||
roundId={roundId}
|
||
players={players}
|
||
activePlayerId={activePlayer.id}
|
||
onSelect={setActivePlayerId}
|
||
onRemove={removeGuest}
|
||
onAdd={() => setShowAddGuest((v) => !v)}
|
||
addOpen={showAddGuest}
|
||
readOnly={readOnly}
|
||
canManage={isOwnerViewer}
|
||
editingPlayerId={editingPlayerId}
|
||
onToggleEdit={(id) => setEditingPlayerId((prev) => (prev === id ? null : id))}
|
||
onPatchParticipant={patchParticipant}
|
||
onAddGuest={addGuest}
|
||
onAddSearched={addSearchedParticipant}
|
||
/>
|
||
|
||
{/* 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} />}
|
||
|
||
{/* 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}
|
||
{/* Mottatte slag for AKTIV spiller på dette hullet (2026-07-26)
|
||
-- samme allerede hentede strokes_received som ScoreSoFar/
|
||
leaderboardet bruker til netto, bare aldri vist FØR
|
||
scoring her. Golfvis fortegn (ekte minustegn), kun vist
|
||
når spilleren faktisk mottar minst ett slag her. */}
|
||
{currentApiHole?.strokes_received != null && currentApiHole.strokes_received > 0 && (
|
||
<> · −{currentApiHole.strokes_received}</>
|
||
)}
|
||
</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>
|
||
|
||
{/* Registrering (2026-07-26, inspirert av en konkurrentapps
|
||
samlebåndsflyt): én kompakt rad per spiller med
|
||
akkumulert score-så-langt for RUNDEN alltid synlig, og en
|
||
trykkflate som åpner en fokusert, skjermovertagende
|
||
veiviser for AKKURAT denne spilleren og hullet -- i
|
||
stedet for ett langt skjema man skrollet gjennom for én
|
||
spiller om gangen, med manuell fanebytting mellom
|
||
spillere (rapportert av bruker 2026-07-26 som "ingen
|
||
forbedring", "visuelt like overveldende og rotete"). */}
|
||
<ul className="divide-y divide-border overflow-hidden rounded-2xl border border-border">
|
||
{players.map((player) => {
|
||
const playerHoles = holesByParticipant[player.id]
|
||
const playerApiHole = playerHoles?.find((h) => h.hole_number === activeHole) ?? null
|
||
const hasScore = Boolean(playerApiHole?.played) && playerApiHole?.score !== null
|
||
const running = computeRunningToPar(playerHoles)
|
||
return (
|
||
<li key={player.id} className="flex items-center gap-3 p-3 sm:p-4">
|
||
<span className="flex min-w-0 flex-1 flex-col">
|
||
<span className="flex flex-wrap items-center gap-1.5">
|
||
<span className="truncate text-base font-bold text-foreground">{player.name}</span>
|
||
{player.isSelf && (
|
||
<Badge variant="default" className="shrink-0">
|
||
Deg
|
||
</Badge>
|
||
)}
|
||
</span>
|
||
<span className="text-sm text-muted-foreground">
|
||
HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "ikke satt"}
|
||
{running && (
|
||
<>
|
||
{" · "}
|
||
{formatToPar(running.toPar)} så langt ({running.holesPlayed} hull)
|
||
</>
|
||
)}
|
||
</span>
|
||
</span>
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => {
|
||
setActivePlayerId(player.id)
|
||
setWizardPlayerId(player.id)
|
||
}}
|
||
aria-label={
|
||
hasScore
|
||
? `Rediger ${player.name} sin registrering på hull ${activeHole}, ${playerApiHole?.score} slag`
|
||
: `Registrer score for ${player.name} på hull ${activeHole}`
|
||
}
|
||
className={cn(
|
||
"flex size-14 shrink-0 items-center justify-center rounded-full border-2 text-xl font-extrabold tabular-nums transition-colors disabled:opacity-60",
|
||
hasScore
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-dashed border-border text-muted-foreground hover:border-primary/60 hover:text-primary",
|
||
)}
|
||
>
|
||
{hasScore ? playerApiHole?.score : "–"}
|
||
</button>
|
||
</li>
|
||
)
|
||
})}
|
||
</ul>
|
||
|
||
<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 hull
|
||
</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>
|
||
|
||
{/* Skjermovertagende scoringsveiviser (2026-07-26) -- rendres HELT
|
||
UTENFOR <main>, øverst i DOM-treet, slik at den dekker hele
|
||
skjermen uavhengig av hvor langt man har skrollet i bakgrunnen. */}
|
||
{wizardPlayer && hole && (
|
||
<ScoringWizard
|
||
players={players}
|
||
player={wizardPlayer}
|
||
hole={hole}
|
||
stat={wizardStat}
|
||
onUpdate={updateWizardStat}
|
||
onClose={() => setWizardPlayerId(null)}
|
||
onAdvance={advanceWizardPlayer}
|
||
readOnly={readOnly}
|
||
ownBagClubs={ownBagClubs}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Scoringsveiviser (2026-07-26) ------------------------------------------
|
||
// Skjermovertagende, ett-spiller-om-gangen registreringsflyt -- inspirert av
|
||
// en konkurrentapps samlebåndsflyt (video studert bilde for bilde), men
|
||
// BEVISST forenklet til TeeCups eget stat_level-drevne steg-antall i stedet
|
||
// for å kopiere konkurrentens nøyaktige skjermrekkefølge/design:
|
||
// - "strokes_only": ett steg (Slag)
|
||
// - "strokes_and_putts": to steg (Slag, Putter+avstand første putt)
|
||
// - "full": tre steg (Slag, Putter+avstand første putt, resten samlet)
|
||
// "Neste"/"Forrige" beveger seg gjennom stegene for ÉN spiller; på siste
|
||
// steg blir knappen "Neste: {navn}" (bytter til neste spiller, nullstiller
|
||
// til steg 1) eller "Ferdig" (er spilleren den siste, lukker veiviseren og
|
||
// varsler forelderen om å gå videre til neste hull).
|
||
|
||
type WizardStep = "strokes" | "putts" | "details"
|
||
|
||
function wizardStepsFor(statLevel: StatLevel): WizardStep[] {
|
||
if (statLevel === "strokes_only") return ["strokes"]
|
||
if (statLevel === "strokes_and_putts") return ["strokes", "putts"]
|
||
return ["strokes", "putts", "details"]
|
||
}
|
||
|
||
function ScoringWizard({
|
||
players,
|
||
player,
|
||
hole,
|
||
stat,
|
||
onUpdate,
|
||
onClose,
|
||
onAdvance,
|
||
readOnly,
|
||
ownBagClubs,
|
||
}: {
|
||
players: Player[]
|
||
player: Player
|
||
hole: Hole
|
||
stat: HoleStat
|
||
onUpdate: (patch: Partial<HoleStat>) => void
|
||
onClose: () => void
|
||
onAdvance: () => void
|
||
readOnly: boolean
|
||
ownBagClubs: string[]
|
||
}) {
|
||
const playerIndex = players.findIndex((p) => p.id === player.id)
|
||
const isLastPlayer = playerIndex === -1 || playerIndex === players.length - 1
|
||
const nextPlayer = !isLastPlayer ? players[playerIndex + 1] : null
|
||
|
||
const steps = wizardStepsFor(player.statLevel)
|
||
const [stepIndex, setStepIndex] = useState(0)
|
||
// Nullstill til steg 1 hver gang veiviseren åpnes for en NY spiller --
|
||
// uten dette ville "Neste: {navn}" hoppet rett til der FORRIGE spiller
|
||
// slapp, i stedet for å starte på Slag igjen for den nye spilleren.
|
||
useEffect(() => {
|
||
setStepIndex(0)
|
||
}, [player.id])
|
||
|
||
const step = steps[Math.min(stepIndex, steps.length - 1)]
|
||
const isFirstStep = stepIndex === 0
|
||
const isLastStep = stepIndex === steps.length - 1
|
||
|
||
const canProceed = step === "strokes" ? stat.strokes !== null : step === "putts" ? stat.putts !== null : true
|
||
|
||
function back() {
|
||
if (isFirstStep) {
|
||
onClose()
|
||
return
|
||
}
|
||
setStepIndex((i) => i - 1)
|
||
}
|
||
function forward() {
|
||
if (!isLastStep) {
|
||
setStepIndex((i) => i + 1)
|
||
return
|
||
}
|
||
onAdvance()
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||
<header className="flex min-h-14 shrink-0 items-center gap-2 border-b border-border px-3">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="Lukk veiviseren"
|
||
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-foreground transition-colors hover:bg-accent"
|
||
>
|
||
<X aria-hidden="true" className="size-5" />
|
||
</button>
|
||
<div className="flex min-w-0 flex-1 flex-col items-center text-center">
|
||
<span className="truncate text-base font-extrabold text-foreground">
|
||
Hull {hole.holeNumber} · Par {hole.par}
|
||
</span>
|
||
<span className="truncate text-sm font-semibold text-muted-foreground">{player.name}</span>
|
||
</div>
|
||
<div className="size-11 shrink-0" aria-hidden="true" />
|
||
</header>
|
||
|
||
{/* Kontekst-rad: alle spillerne, aktiv fremhevet -- samme prinsipp som
|
||
konkurrentappen: man mister aldri oversikten over hvem som allerede
|
||
er ferdig på hullet. Ren informasjon, ikke en snarvei -- bytte
|
||
spiller midt i et steg ville latt uferdige felt henge løst. */}
|
||
<div className="-mx-1 flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5">
|
||
{players.map((p) => (
|
||
<span
|
||
key={p.id}
|
||
className={cn(
|
||
"flex shrink-0 items-center rounded-full border px-3 py-1.5 text-sm font-bold",
|
||
p.id === player.id
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-muted-foreground",
|
||
)}
|
||
>
|
||
{p.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
<main className="flex-1 overflow-y-auto p-5 sm:p-6">
|
||
<div className="mx-auto flex max-w-sm flex-col gap-6">
|
||
{step === "strokes" && (
|
||
<NumberPicker
|
||
label="Slag"
|
||
value={stat.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}
|
||
showGolfTerms
|
||
onChange={(v) => onUpdate({ strokes: v, played: true })}
|
||
readOnly={readOnly}
|
||
/>
|
||
)}
|
||
|
||
{step === "putts" && (
|
||
<>
|
||
<NumberPicker
|
||
label="Putter"
|
||
value={stat.putts}
|
||
directValues={[0, 1, 2, 3, 4, 5, 6]}
|
||
expandValues={[7, 8, 9, 10]}
|
||
expandLabel="7+"
|
||
maxValue={stat.strokes ?? undefined}
|
||
onChange={(v) => onUpdate({ putts: v })}
|
||
readOnly={readOnly}
|
||
/>
|
||
<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={stat.firstPuttBucket}
|
||
onChange={(v) => onUpdate({ firstPuttBucket: v as PuttBucket })}
|
||
readOnly={readOnly}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{step === "details" && (
|
||
<>
|
||
<div className="flex flex-col gap-2">
|
||
<Label className="text-base font-semibold">Kølle brukt ved utslaget</Label>
|
||
{player.isSelf && ownBagClubs.length > 0 ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
{ownBagClubs.map((club) => {
|
||
const selected = stat.club === club
|
||
return (
|
||
<button
|
||
key={club}
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onUpdate({ 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
|
||
value={stat.club}
|
||
onChange={(e) => onUpdate({ 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={stat.teeShot}
|
||
onChange={(v) => onUpdate({ teeShot: v as TeeShot })}
|
||
readOnly={readOnly}
|
||
/>
|
||
)}
|
||
|
||
<DirectionCross
|
||
label="Innspill"
|
||
variant="full"
|
||
centerLabel="Traff"
|
||
value={stat.approach}
|
||
onChange={(v) => onUpdate({ approach: v as Approach })}
|
||
readOnly={readOnly}
|
||
/>
|
||
|
||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||
<Stepper label="Chip" value={stat.chip} onChange={(v) => onUpdate({ chip: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
|
||
<Stepper label="Bunker" value={stat.bunker} onChange={(v) => onUpdate({ bunker: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
|
||
<Stepper label="Straffeslag" value={stat.penalty} onChange={(v) => onUpdate({ penalty: v })} readOnly={readOnly} max={stat.strokes ?? undefined} />
|
||
</div>
|
||
|
||
<NumberPicker
|
||
label="Anywayslag"
|
||
value={stat.anywayStrokes}
|
||
directValues={[0, 1, 2, 3, 4, 5, 6]}
|
||
expandValues={[7, 8, 9, 10]}
|
||
expandLabel="7+"
|
||
maxValue={stat.strokes ?? undefined}
|
||
onChange={(v) => onUpdate({ anywayStrokes: v })}
|
||
readOnly={readOnly}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
<footer className="grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4">
|
||
<Button type="button" variant="outline" onClick={back} className="h-14 rounded-2xl text-base font-bold">
|
||
{isFirstStep ? "Avbryt" : "Forrige"}
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
disabled={!canProceed}
|
||
onClick={forward}
|
||
className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
|
||
>
|
||
{isLastStep ? (isLastPlayer ? "Ferdig" : `Neste: ${nextPlayer?.name}`) : "Neste"}
|
||
</Button>
|
||
</footer>
|
||
</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 -----------------------------------------------------------
|
||
|
||
// --- Spillerliste (2026-07-26, erstatter tidligere horisontal fane-rad) ---
|
||
// Vertikal liste av spillerkort -- hvert kort er BÅDE "velg meg som aktiv
|
||
// spiller" (hovedtrykkflaten) og bærer et eget "Rediger"-panel (utslag/HCP/
|
||
// statistikk, pluss navn/kjønn/e-post for en gjest) som en utvidbar
|
||
// underseksjon. Skalerer til mange spillere uten fane-scroll.
|
||
|
||
function PlayerList({
|
||
roundId,
|
||
players,
|
||
activePlayerId,
|
||
onSelect,
|
||
onRemove,
|
||
onAdd,
|
||
addOpen,
|
||
readOnly,
|
||
canManage,
|
||
editingPlayerId,
|
||
onToggleEdit,
|
||
onPatchParticipant,
|
||
onAddGuest,
|
||
onAddSearched,
|
||
}: {
|
||
roundId: string
|
||
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/redigere medspillere (ADR-036
|
||
// fase 3-utvidelsen, 2026-07-26) -- en lenket medspiller kan fortsatt
|
||
// VELGE kort for å registrere score for hele flighten.
|
||
canManage: boolean
|
||
editingPlayerId: string | null
|
||
onToggleEdit: (id: string) => void
|
||
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onAddGuest: (name: string, gender: Gender, hcp: number | null, statLevel: StatLevel) => void
|
||
onAddSearched: (userId: string, statLevel: StatLevel) => Promise<string | null>
|
||
}) {
|
||
return (
|
||
<div className="flex flex-col gap-3" role="tablist" aria-label="Velg spiller">
|
||
{players.map((player) => {
|
||
const active = player.id === activePlayerId
|
||
const editing = editingPlayerId === player.id
|
||
const canRemove = !player.isSelf && !readOnly && canManage
|
||
const canEdit = canManage && !readOnly
|
||
return (
|
||
<div
|
||
key={player.id}
|
||
className={cn(
|
||
"overflow-hidden rounded-2xl border transition-colors",
|
||
active ? "border-primary" : "border-border",
|
||
)}
|
||
>
|
||
<div className={cn("flex items-stretch gap-1", active ? "bg-primary/10" : "bg-card")}>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={() => onSelect(player.id)}
|
||
className="flex min-h-[68px] flex-1 flex-col items-start justify-center gap-0.5 overflow-hidden rounded-xl px-4 py-2 text-left transition-colors hover:bg-accent/50"
|
||
>
|
||
{/* Fast kort-høyde uansett innhold (2026-07-26, rapportert av
|
||
bruker) -- begge rader er ÉN linje med trunkering (ikke
|
||
wrap), slik at kort med ulikt antall merker/lange
|
||
navn/utslag likevel blir like høye, og listen samlet tar
|
||
minst mulig plass over score-tastaturet lenger ned. */}
|
||
<span className="flex w-full min-w-0 items-center gap-2">
|
||
<span className="min-w-0 truncate text-base font-bold text-foreground">{player.name}</span>
|
||
{player.isSelf && (
|
||
<Badge variant="default" className="shrink-0">
|
||
Deg
|
||
</Badge>
|
||
)}
|
||
{player.isOwner && (
|
||
<Badge variant="outline" className="shrink-0">
|
||
Eier
|
||
</Badge>
|
||
)}
|
||
</span>
|
||
<span className="w-full truncate text-sm text-muted-foreground">
|
||
Utslag: <span className="font-semibold text-foreground">{player.teeName}</span>
|
||
{" · "}
|
||
HCP:{" "}
|
||
<span className="font-semibold text-foreground">
|
||
{player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "ikke satt"}
|
||
</span>
|
||
{/* Tildelte slag (course handicap) -- KUN vist når HCP
|
||
faktisk spores for spilleren (samme betingelse som
|
||
"spilles med hcp" -- er den null, spores ikke HCP her). */}
|
||
{player.courseHandicap !== null && (
|
||
<>
|
||
{" · "}
|
||
Tildelte slag: <span className="font-semibold text-foreground">{player.courseHandicap}</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
</button>
|
||
<div className="flex shrink-0 flex-col items-center justify-center gap-1 py-1.5 pr-1.5">
|
||
{canEdit && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onToggleEdit(player.id)}
|
||
aria-expanded={editing}
|
||
aria-label={`Rediger ${player.name} for denne runden`}
|
||
className={cn(
|
||
"flex min-h-11 min-w-11 items-center justify-center gap-1 rounded-xl px-2.5 text-sm font-semibold transition-colors",
|
||
editing ? "bg-primary text-primary-foreground" : "text-foreground hover:bg-accent",
|
||
)}
|
||
>
|
||
<Settings2 aria-hidden="true" className="size-4" />
|
||
<span className="hidden sm:inline">Rediger</span>
|
||
</button>
|
||
)}
|
||
{canRemove && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onRemove(player.id)}
|
||
aria-label={`Fjern ${player.name}`}
|
||
className="flex min-h-11 min-w-11 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent hover:text-destructive"
|
||
>
|
||
<X aria-hidden="true" className="size-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{editing && canEdit && (
|
||
<EditParticipantPanel
|
||
roundId={roundId}
|
||
player={player}
|
||
onPatch={onPatchParticipant}
|
||
onClose={() => onToggleEdit(player.id)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
|
||
{!readOnly && canManage && (
|
||
addOpen ? (
|
||
<AddParticipantForm onAddGuest={onAddGuest} onAddSearched={onAddSearched} onCancel={onAdd} />
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={onAdd}
|
||
aria-label="Legg til medspiller"
|
||
className="flex min-h-14 items-center justify-center gap-1.5 rounded-2xl border border-dashed border-border bg-card text-base font-semibold text-foreground transition-colors hover:bg-accent/50"
|
||
>
|
||
<Plus aria-hidden="true" className="size-5" />
|
||
Medspiller
|
||
</button>
|
||
)
|
||
)}
|
||
</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>
|
||
<span className="truncate text-sm text-muted-foreground">
|
||
{person.home_club}
|
||
{person.home_club && person.handicap_index !== null && " · "}
|
||
{person.handicap_index !== null && `HCP ${person.handicap_index.toFixed(1).replace(".", ",")}`}
|
||
</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>
|
||
)
|
||
}
|
||
|
||
// --- Rediger utslag+HCP for én deltaker, KUN for denne runden (2026-07-26) -
|
||
|
||
function EditParticipantPanel({
|
||
roundId,
|
||
player,
|
||
onPatch,
|
||
onClose,
|
||
}: {
|
||
roundId: string
|
||
player: Player
|
||
onPatch: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
onClose: () => void
|
||
}) {
|
||
// En "midlertidig spiller" (gjest uten TeeCup-konto) har ikke egen profil
|
||
// -- navn/kjønn/e-post kan derfor redigeres her. En lenket bruker har sin
|
||
// egen konto, og disse feltene gir ingen mening (backend avviser dem også
|
||
// eksplisitt, 2026-07-26).
|
||
const isGuest = player.userId === null
|
||
|
||
const [teeOptions, setTeeOptions] = useState<ApiTeeOption[] | null>(null)
|
||
const [teeName, setTeeName] = useState(player.teeName)
|
||
const [hcp, setHcp] = useState(player.hcp !== null ? String(player.hcp).replace(".", ",") : "")
|
||
const [statLevel, setStatLevel] = useState<StatLevel>(player.statLevel)
|
||
const [guestName, setGuestName] = useState(player.rawName)
|
||
const [gender, setGender] = useState<Gender>(player.gender)
|
||
const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "")
|
||
const [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch(`/rounds/${roundId}/tee-options`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : []))
|
||
.then((data: ApiTeeOption[]) => {
|
||
if (!cancelled) setTeeOptions(data)
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setTeeOptions([])
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId])
|
||
|
||
// Reaktivt filter (2026-07-26): en gjest kan endre eget kjønn her, som da
|
||
// umiddelbart oppdaterer hvilke utslag som er valgbare -- for en lenket
|
||
// bruker er `gender` uendret gjennom hele panelets levetid.
|
||
const apiGender = uiGenderToApi(gender)
|
||
const availableTees = (teeOptions ?? []).filter((t) => t.genders.includes(apiGender))
|
||
|
||
async function save() {
|
||
setSaving(true)
|
||
setError(null)
|
||
const body: Record<string, unknown> = {}
|
||
if (teeName !== player.teeName) body.tee_name = teeName
|
||
const parsedHcp = hcp.trim() === "" ? null : Number(hcp.replace(",", "."))
|
||
const normalizedHcp = parsedHcp !== null && !Number.isNaN(parsedHcp) ? parsedHcp : null
|
||
if (normalizedHcp !== player.hcp) body.handicap_index = normalizedHcp
|
||
if (statLevel !== player.statLevel) body.stat_level = statLevel
|
||
if (isGuest) {
|
||
const trimmedName = guestName.trim()
|
||
if (trimmedName && trimmedName !== player.rawName) body.guest_name = trimmedName
|
||
if (gender !== player.gender) body.gender = apiGender
|
||
const trimmedEmail = guestEmail.trim()
|
||
const normalizedEmail = trimmedEmail === "" ? null : trimmedEmail
|
||
if (normalizedEmail !== (player.guestEmail ?? null)) body.guest_email = normalizedEmail
|
||
}
|
||
if (Object.keys(body).length === 0) {
|
||
setSaving(false)
|
||
onClose()
|
||
return
|
||
}
|
||
const result = await onPatch(player.id, body)
|
||
setSaving(false)
|
||
if (!result.ok) {
|
||
setError(result.message)
|
||
return
|
||
}
|
||
onClose()
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4 border-t border-border bg-muted/30 p-4 sm:p-5">
|
||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||
|
||
{isGuest && (
|
||
<>
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor={`edit-guest-name-${player.id}`} className="text-sm font-semibold">
|
||
Navn
|
||
</Label>
|
||
<Input
|
||
id={`edit-guest-name-${player.id}`}
|
||
value={guestName}
|
||
onChange={(e) => setGuestName(e.target.value)}
|
||
className="h-11 rounded-xl 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={`edit-guest-email-${player.id}`} className="text-sm font-semibold">
|
||
E-post <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</Label>
|
||
<Input
|
||
id={`edit-guest-email-${player.id}`}
|
||
type="email"
|
||
value={guestEmail}
|
||
onChange={(e) => setGuestEmail(e.target.value)}
|
||
placeholder="navn@eksempel.no"
|
||
className="h-11 rounded-xl text-base"
|
||
/>
|
||
</div>
|
||
|
||
<div className="border-t border-border" />
|
||
</>
|
||
)}
|
||
|
||
<p className="text-sm text-muted-foreground">
|
||
Utslag/HCP/statistikk gjelder kun denne runden -- endrer ikke {player.isSelf ? "din egen" : "spillerens"} profil.
|
||
</p>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor={`edit-tee-${player.id}`} className="text-sm font-semibold">
|
||
Utslagssted
|
||
</Label>
|
||
<select
|
||
id={`edit-tee-${player.id}`}
|
||
value={teeName}
|
||
onChange={(e) => setTeeName(e.target.value)}
|
||
disabled={teeOptions === null}
|
||
className="h-11 rounded-xl border border-border bg-background px-3 text-base font-semibold text-foreground disabled:opacity-60"
|
||
>
|
||
{!availableTees.some((t) => t.name === teeName) && <option value={teeName}>{teeName}</option>}
|
||
{availableTees.map((t) => (
|
||
<option key={t.name} value={t.name}>
|
||
{t.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{teeOptions !== null && availableTees.length === 0 && (
|
||
<p className="text-sm text-muted-foreground">Fant ingen utslag med rating for dette kjønnet på denne banen.</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<Label htmlFor={`edit-hcp-${player.id}`} className="text-sm font-semibold">
|
||
HCP for denne runden <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</Label>
|
||
<Input
|
||
id={`edit-hcp-${player.id}`}
|
||
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 justify-end gap-2 pt-1">
|
||
<Button type="button" variant="ghost" onClick={onClose} className="h-11 rounded-xl px-5 text-sm font-semibold">
|
||
Avbryt
|
||
</Button>
|
||
<Button type="button" disabled={saving} onClick={save} className="h-11 rounded-xl px-5 text-sm font-bold">
|
||
{saving ? "Lagrer…" : "Lagre endringer"}
|
||
</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.
|
||
|
||
// --- Number picker ---------------------------------------------------------
|
||
|
||
// Golf-term per slagtall relativt til par (2026-07-26, inspirert av en
|
||
// konkurrentapps taltastatur der HVER knapp er merket kontekstuelt --
|
||
// fjerner behovet for å regne "er 6 slag på et par 4-hull en bogey eller
|
||
// dobbel bogey?" i hodet). Kun brukt for Slag-velgeren.
|
||
function golfTermForScore(n: number, par: number): string | null {
|
||
const diff = n - par
|
||
if (diff <= -3) return "Albatross"
|
||
if (diff === -2) return "Eagle"
|
||
if (diff === -1) return "Birdie"
|
||
if (diff === 0) return "Par"
|
||
if (diff === 1) return "Bogey"
|
||
if (diff === 2) return "Dobbel bogey"
|
||
return null
|
||
}
|
||
|
||
function NumberPicker({
|
||
label,
|
||
value,
|
||
directValues,
|
||
expandValues,
|
||
expandLabel,
|
||
parValue,
|
||
showGolfTerms,
|
||
maxValue,
|
||
onChange,
|
||
readOnly,
|
||
}: {
|
||
label: string
|
||
value: number | null
|
||
directValues: number[]
|
||
expandValues: number[]
|
||
expandLabel: string
|
||
// Hullets par -- brukt til golf-term-bildeteksten når showGolfTerms er satt.
|
||
parValue?: number
|
||
// Viser Eagle/Birdie/Par/Bogey/Dobbel bogey under HVERT tall (relativt til
|
||
// parValue), i stedet for kun å merke selve par-knappen -- kun meningsfullt
|
||
// for Slag-velgeren, ikke Putter/Anywayslag.
|
||
showGolfTerms?: boolean
|
||
// 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 term = showGolfTerms && parValue !== undefined ? golfTermForScore(n, parValue) : n === parValue ? "par" : null
|
||
return (
|
||
<button
|
||
key={n}
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onChange(n)}
|
||
aria-label={term ? `${label} ${n}, ${term}` : `${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>
|
||
{term && (
|
||
<span className={cn("text-[11px] font-bold leading-none", selected ? "text-primary-foreground/80" : "text-muted-foreground")}>
|
||
{term}
|
||
</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>
|
||
)
|
||
}
|