Bygg PWA-installasjonsoppfordring (Android beforeinstallprompt + iOS instruksjonsbanner) Design og bygg flere flighter i én frittstående runde (løs gruppering, retning 1) Design og bygg scramble/greensome-statistikk (valgt utslag per spiller) Bygg push-varsler til telefonens OS (VAPID, push_subscription, service worker) Scratch-verifiser backend for alle fire (27/27 sjekker gjort) Browserverifiser alle fire i nettleser mot scratch-backend Rull ut mot ekte systemer (migrasjoner + containere), med bekreftelse Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md Alle fire er ferdigstilt, verifisert og live. Kort oppsummert: PWA-installasjonsoppfordring — ekte beforeinstallprompt-fanging, Android-knapp + iOS-instruksjonsbanner, vist på dashbordet. Bekreftet at Chrome faktisk fyrte eventet i testøkten. Flere flighter i én frittstående runde — løs gruppering av separate runder (delt UUID), "+ Legg til en flight til" gjenbruker hele opprett-flyten forhåndsutfylt, nytt kombinert leaderboard på tvers av flighter. Testet fra knappetrykk til ferdig runde, bekreftet i database. Scramble/greensome: valgt utslag per spiller — nytt felt i hull-registreringen (helt valgfritt), "Utslag brukt"-oppsummering på statistikksiden. Bygget for frittstående runder; org-turneringer bevisst utenfor omfang denne runden (notert i backloggen). Push-varsler til telefonens OS — Web Push/VAPID, ny av/på-bryter i kontoinnstillinger, dekker alle fire varseltyper automatisk. VAPID-nøkler generert og lagt i .env uten å vises i chatten. Én ærlig begrensning: ekte OS-varsel-levering med innvilget tillatelse kunne ikke testes i denne automatiserte nettleserøkten (tillatelsen var forhåndsblokkert av miljøet) — verdt å teste selv på en ekte enhet. 27/27 scratch-sjekker, full nettleser-gjennomgang uten konsollfeil, migrasjoner 035–037 kjørt mot ekte teecup_db, begge containere redeployet og verifisert, teeoff.no upåvirket. Alle tre .md-filene er oppdatert med full detalj.
4666 lines
191 KiB
TypeScript
4666 lines
191 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,
|
||
RefreshCw,
|
||
Search,
|
||
Settings2,
|
||
Target,
|
||
Trophy,
|
||
Users,
|
||
WifiOff,
|
||
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"
|
||
import { enqueueWrite, flushQueue, queueCount } from "@/lib/offline-queue"
|
||
|
||
// --- 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
|
||
// Alltid den faktiske display_name (2026-07-27: fjernet "Deg"-erstatning
|
||
// her -- se playerLabel() -- brøt "roster-kontekst → fullt navn" og ga en
|
||
// "Deg Deg"-duplikat der en egen Badge også viste "Deg" ved siden av).
|
||
name: 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
|
||
// ADR-038 -- manuell opt-out fra faktisk HCP, uavhengig av
|
||
// countsForHandicap (den automatiske kvalifiseringen).
|
||
excludeFromHandicap: boolean
|
||
// ADR-039 -- hvilken side (match/fourball/foursome/greensome/scramble),
|
||
// og den format-relative match-play-enheten (ikke samme som courseHandicap).
|
||
roundSideId: string | null
|
||
playingHandicap: number | null
|
||
}
|
||
|
||
type HoleStat = {
|
||
played: boolean
|
||
strokes: number | null
|
||
putts: number | null
|
||
club: string
|
||
teeShot: TeeShot | null
|
||
approach: Approach | null
|
||
chip: number
|
||
bunker: number
|
||
penalty: number
|
||
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
|
||
exclude_from_handicap: boolean
|
||
// ADR-039 -- hvilken side deltakeren er på (match/fourball/foursome/
|
||
// greensome/scramble), null for slagspill/skins (ingen sider).
|
||
round_side_id: string | null
|
||
// ADR-039 -- format-avledet, RELATIV match-play-enhet, ikke samme som
|
||
// course_handicap_snapshot (den absolutte WHS-verdien).
|
||
playing_handicap: number | null
|
||
}
|
||
|
||
type ApiSide = {
|
||
id: string
|
||
label: string | null
|
||
}
|
||
|
||
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
|
||
play_format: string
|
||
skins_scoring: string | null
|
||
skins_tie_handling: string | null
|
||
started_at: string | null
|
||
completed_at: string | null
|
||
participants: ApiParticipant[]
|
||
sides: ApiSide[]
|
||
setup_complete: boolean
|
||
setup_message: string | null
|
||
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- styrer KUN tredjeparts
|
||
// innsyn (venner/offentligheten), aldri en faktisk lagt-til medspiller.
|
||
visibility_mode: string
|
||
visible_categories: string[]
|
||
// Flere flighter (migrasjon 035) -- null for en vanlig enkeltflight-runde.
|
||
flight_group_id: string | null
|
||
}
|
||
|
||
// Samme faste sett/rekkefølge som friends.py sin Category og friends.tsx
|
||
// sin CATEGORY_OPTIONS -- egen, lokal kopi (samme "liten duplisering
|
||
// fremfor cross-file-import"-mønster som FORMAT_LABELS ellers i appen).
|
||
const VISIBILITY_CATEGORY_OPTIONS = [
|
||
{ code: "spouse", label: "Make" },
|
||
{ code: "close_family", label: "Nær familie" },
|
||
{ code: "extended_family", label: "Storfamilie" },
|
||
{ code: "close_friends", label: "Nære venner" },
|
||
{ code: "golf_friends", label: "Golfvenner" },
|
||
{ code: "colleagues", label: "Kollegaer" },
|
||
{ code: "business", label: "Forretningsforbindelser" },
|
||
{ code: "classmates", label: "Studiekamerater" },
|
||
{ code: "acquaintances", label: "Perifere bekjente" },
|
||
{ code: "other", label: "Ymse" },
|
||
] as const
|
||
|
||
// ADR-039 -- speiler backend sine konstanter i app/routers/rounds.py
|
||
// (_TWO_SIDED_FORMATS/_SIDE_PLAYER_COUNT/SIDE_IS_UNIT) klientside, kun for
|
||
// å vite hvilken oppsett-UI som skal vises. Selve håndhevelsen skjer alltid
|
||
// server-side -- disse styrer bare presentasjon.
|
||
const TWO_SIDED_FORMATS = new Set(["match", "fourball", "foursome", "greensome", "scramble_2", "scramble_4"])
|
||
const SHARED_BALL_FORMATS = new Set(["foursome", "greensome", "scramble_2", "scramble_4"])
|
||
const SIDE_PLAYER_COUNT: Record<string, number> = {
|
||
match: 1,
|
||
fourball: 2,
|
||
foursome: 2,
|
||
greensome: 2,
|
||
scramble_2: 2,
|
||
scramble_4: 4,
|
||
}
|
||
const FORMAT_LABELS: Record<string, string> = {
|
||
stroke: "Slagspill",
|
||
match: "Match",
|
||
skins: "Skins",
|
||
fourball: "Fourball",
|
||
foursome: "Foursome",
|
||
greensome: "Greensome",
|
||
scramble_2: "Scramble (2)",
|
||
scramble_4: "Scramble (4)",
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble)
|
||
// har ÉN score PER SIDE per hull, ikke per spiller -- ingen av de andre
|
||
// detalj-feltene gir entydig mening for en delt ball.
|
||
type ApiSideHole = {
|
||
hole_number: number
|
||
par: number
|
||
stroke_index: number
|
||
played: boolean
|
||
score: number | null
|
||
// Scramble/greensome: hvilken av sidens partnere sitt utslag ble valgt
|
||
// (migrasjon 036) -- helt valgfritt, null = ikke registrert.
|
||
selected_participant_id: string | 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,
|
||
}
|
||
}
|
||
|
||
// Alltid det faktiske visningsnavnet -- ALDRI "Deg" (2026-07-27, compliance-
|
||
// pass mot teecup-scorekort-og-entry-spec.md). "Deg" var tidligere en
|
||
// viewer-relativ erstatning for selve navnet, men det produserte en ekte
|
||
// "Deg Deg"-duplikat der navnet OGSÅ sto ved siden av en egen "Deg"-badge
|
||
// (spillerkort, scoringslisten) -- og brøt spesifikasjonens eksplisitte
|
||
// "roster-kontekst → fullt navn"-regel i skjermer uten noen badge i det
|
||
// hele tatt (veiviserens header/kontekst-rad, "Neste: {navn}", fullført-
|
||
// banneret). `isSelf` (se players-konstruksjonen under) er fortsatt
|
||
// viewer-relativ og brukes alene til å vise "Deg"-badgen der det trengs.
|
||
function playerLabel(p: ApiParticipant): string {
|
||
return 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)
|
||
// ADR-039 -- bumpes hver gang noe som kan ha endret matchstatus/skins-
|
||
// tavlen skjer (hull-registrering, websocket-signal), slik at
|
||
// FormatResultPanel vet den skal hente på nytt.
|
||
const [formatResultRefreshTick, setFormatResultRefreshTick] = useState(0)
|
||
const [showAddGuest, setShowAddGuest] = useState(false)
|
||
// Administrasjon (leaderboard-forhåndsvisning/Rediger/Fullfør/Slett/
|
||
// spillerliste) og selve scoringen er nå to ADSKILTE faner (2026-07-26,
|
||
// etterspurt av bruker etter å ha sammenlignet direkte med en
|
||
// konkurrentapp -- forrige runde flyttet kun ScoreSoFar, men lot alt det
|
||
// ANDRE administrative innholdet stå igjen øverst på samme side som
|
||
// scoringen, som fortsatt gjorde siden "bråkete" før man i det hele tatt
|
||
// når frem til oppgaven). "Score" er default -- den ENESTE fanen de
|
||
// fleste trenger under selve spillingen.
|
||
const [pageTab, setPageTab] = useState<"score" | "manage">("score")
|
||
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)
|
||
// ADR-039 -- delt-ball-formater (foursome/greensome/scramble) scorer PER
|
||
// SIDE, ikke per spiller -- egen, enklere veiviser-tilstand for dette.
|
||
const [wizardSideId, setWizardSideId] = useState<string | null>(null)
|
||
const [holesBySide, setHolesBySide] = useState<Record<string, ApiSideHole[]>>({})
|
||
|
||
// --- Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) ------------
|
||
// Samme kø/synk-mønster som turnering-scorekortet (session-scorecard.tsx)
|
||
// -- portert hit siden frittstående runder er der man oftest står alene
|
||
// ute på banen uten dekning, og ADR-028s kø tidligere kun var koblet til
|
||
// turnering-scorekortet. Enklere her: PATCH-endepunktene erstatter HELE
|
||
// hull-raden per kall (ikke en append-historie), så en køet skriving kan
|
||
// speiles direkte inn i holesByParticipant/holesBySide -- ingen egen
|
||
// verdi-overlay nødvendig, kun et sett med nøkler for selve "lagret
|
||
// lokalt"-indikatoren i veiviseren.
|
||
const [isOnline, setIsOnline] = useState(true)
|
||
const [pendingCount, setPendingCount] = useState(0)
|
||
const [syncing, setSyncing] = useState(false)
|
||
const [pendingParticipantHoles, setPendingParticipantHoles] = useState<Set<string>>(new Set())
|
||
const [pendingSideHoles, setPendingSideHoles] = useState<Set<string>>(new Set())
|
||
// 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)
|
||
// ADR-039 -- sider/deltaker-tildeling kan ha endret seg (SidesPanel
|
||
// kaller denne som onSidesChanged), som igjen kan gjøre FormatResultPanel
|
||
// sitt tidligere "ikke klart"-svar utdatert. Bump ved HVER runde-refetch
|
||
// (billig, og runde-refetch skjer uansett ikke ofte nok til at det er en
|
||
// reell kostnad) i stedet for å spore hver enkelt kallsted som kan
|
||
// påvirke handicap-beregningen.
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
} 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])
|
||
|
||
// ADR-039 -- delt-ball-formater (foursome/greensome/scramble) scorer per
|
||
// SIDE, ikke per deltaker (se holesBySide over).
|
||
const loadSideHoles = useCallback(
|
||
async (sideId: string) => {
|
||
const res = await fetch(`/rounds/${roundId}/sides/${sideId}/holes`, { credentials: "include" })
|
||
if (!res.ok) return
|
||
const data: ApiSideHole[] = await res.json()
|
||
setHolesBySide((prev) => ({ ...prev, [sideId]: data }))
|
||
},
|
||
[roundId],
|
||
)
|
||
|
||
useEffect(() => {
|
||
if (!round) return
|
||
for (const s of round.sides) {
|
||
if (!holesBySide[s.id]) {
|
||
void loadSideHoles(s.id)
|
||
}
|
||
}
|
||
}, [round, holesBySide, loadSideHoles])
|
||
|
||
// --- Offline scoreregistrering, forts. -------------------------------
|
||
// Køer en PATCH i IndexedDB og speiler den samme kroppen optimistisk inn
|
||
// i lokal state -- body sine felt matcher ApiHole/ApiSideHole sine
|
||
// navn 1:1 (se statToPatchBody), så en enkel spread er nok, ingen egen
|
||
// oversettelse. Serveren får siste ord når synk faktisk skjer (bl.a.
|
||
// strokes_received kan derfor være utdatert helt til da).
|
||
async function queueParticipantHoleWrite(
|
||
participantId: string,
|
||
holeNumber: number,
|
||
body: ReturnType<typeof statToPatchBody>,
|
||
) {
|
||
const url = `/rounds/${roundId}/participants/${participantId}/holes/${holeNumber}`
|
||
await enqueueWrite({ url, method: "PATCH", body, matchId: roundId })
|
||
setPendingParticipantHoles((prev) => new Set(prev).add(`${participantId}:${holeNumber}`))
|
||
setPendingCount((c) => c + 1)
|
||
setHolesByParticipant((prev) => ({
|
||
...prev,
|
||
[participantId]: (prev[participantId] ?? []).map((h) => (h.hole_number === holeNumber ? { ...h, ...body } : h)),
|
||
}))
|
||
}
|
||
|
||
async function queueSideHoleWrite(
|
||
sideId: string,
|
||
holeNumber: number,
|
||
body: { played: boolean; score: number | null; selected_participant_id: string | null },
|
||
) {
|
||
const url = `/rounds/${roundId}/sides/${sideId}/holes/${holeNumber}`
|
||
await enqueueWrite({ url, method: "PATCH", body, matchId: roundId })
|
||
setPendingSideHoles((prev) => new Set(prev).add(`${sideId}:${holeNumber}`))
|
||
setPendingCount((c) => c + 1)
|
||
setHolesBySide((prev) => ({
|
||
...prev,
|
||
[sideId]: (prev[sideId] ?? []).map((h) => (h.hole_number === holeNumber ? { ...h, ...body } : h)),
|
||
}))
|
||
}
|
||
|
||
// Sender køede skrivinger og henter deltakerens/sidens hull på nytt fra
|
||
// serveren for hver som faktisk fikk noe synkronisert -- reconciles bl.a.
|
||
// strokes_received, som den optimistiske speilingen over ikke kan regne
|
||
// ut selv. Matcher URL-mønsteret for å vite hvilken type/id det gjaldt
|
||
// (samme kø brukes til begge endepunktstypene).
|
||
async function flushPending() {
|
||
if (syncing) return
|
||
setSyncing(true)
|
||
try {
|
||
const outcomes = await flushQueue(roundId)
|
||
if (outcomes.length === 0) return
|
||
const syncedParticipants = new Set<string>()
|
||
const syncedSides = new Set<string>()
|
||
for (const o of outcomes) {
|
||
const participantMatch = o.entry.url.match(/\/participants\/([^/]+)\/holes\/(\d+)$/)
|
||
if (participantMatch) {
|
||
setPendingParticipantHoles((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(`${participantMatch[1]}:${participantMatch[2]}`)
|
||
return next
|
||
})
|
||
syncedParticipants.add(participantMatch[1])
|
||
continue
|
||
}
|
||
const sideMatch = o.entry.url.match(/\/sides\/([^/]+)\/holes\/(\d+)$/)
|
||
if (sideMatch) {
|
||
setPendingSideHoles((prev) => {
|
||
const next = new Set(prev)
|
||
next.delete(`${sideMatch[1]}:${sideMatch[2]}`)
|
||
return next
|
||
})
|
||
syncedSides.add(sideMatch[1])
|
||
}
|
||
}
|
||
const failed = outcomes.filter((o) => !o.ok)
|
||
if (failed.length > 0) {
|
||
setError(
|
||
`${failed.length} lagret ${failed.length === 1 ? "endring" : "endringer"} kunne ikke synkroniseres: ${failed[0].message}`,
|
||
)
|
||
}
|
||
setPendingCount(await queueCount(roundId))
|
||
for (const id of syncedParticipants) void loadHoles(id)
|
||
for (const id of syncedSides) void loadSideHoles(id)
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
} finally {
|
||
setSyncing(false)
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
setIsOnline(navigator.onLine)
|
||
function handleOnline() {
|
||
setIsOnline(true)
|
||
void flushPending()
|
||
}
|
||
function handleOffline() {
|
||
setIsOnline(false)
|
||
}
|
||
window.addEventListener("online", handleOnline)
|
||
window.addEventListener("offline", handleOffline)
|
||
return () => {
|
||
window.removeEventListener("online", handleOnline)
|
||
window.removeEventListener("offline", handleOffline)
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [roundId])
|
||
|
||
// Sjekk for allerede køede skrivinger fra en tidligere økt (f.eks. siden
|
||
// ble lukket mens offline) -- samme engangs-sjekk som session-scorecard.tsx.
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
queueCount(roundId)
|
||
.then((count) => {
|
||
if (cancelled) return
|
||
setPendingCount(count)
|
||
if (count > 0 && navigator.onLine) void flushPending()
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [roundId])
|
||
|
||
// Alltid gjeldende deltaker-/side-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[]>([])
|
||
const sideIdsRef = useRef<string[]>([])
|
||
useEffect(() => {
|
||
participantIdsRef.current = round?.participants.map((p) => p.id) ?? []
|
||
sideIdsRef.current = round?.sides.map((s) => s.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)
|
||
}
|
||
for (const id of sideIdsRef.current) {
|
||
void loadSideHoles(id)
|
||
}
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
}
|
||
return () => socket.close()
|
||
}, [roundId, loadRound, loadHoles, loadSideHoles])
|
||
|
||
const completed = round?.completed_at != null
|
||
const readOnly = completed
|
||
|
||
const players: Player[] =
|
||
round?.participants.map((p) => ({
|
||
id: p.id,
|
||
name: playerLabel(p),
|
||
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,
|
||
excludeFromHandicap: p.exclude_from_handicap,
|
||
roundSideId: p.round_side_id,
|
||
playingHandicap: p.playing_handicap,
|
||
})) ?? []
|
||
|
||
// 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)
|
||
: []
|
||
|
||
// 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 body = statToPatchBody(merged)
|
||
if (!navigator.onLine) {
|
||
await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
|
||
return
|
||
}
|
||
let res: Response
|
||
try {
|
||
res = await fetch(`/rounds/${roundId}/participants/${wizardPlayerId}/holes/${activeHole}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify(body),
|
||
})
|
||
} catch {
|
||
// Ekte nettverksfeil (ikke bare et avvist svar) -- køordne i stedet
|
||
// for å tape registreringen, se ADR-028.
|
||
await queueParticipantHoleWrite(wizardPlayerId, activeHole, body)
|
||
return
|
||
}
|
||
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)),
|
||
}))
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
// ADR-039 Beslutning C -- delt-ball-formater (foursome/greensome/scramble)
|
||
// scorer PER SIDE, ikke per spiller. Egen, enklere veiviser-tilstand og
|
||
// -oppdatering (kun slagtall, ingen putt/detalj-steg -- gir ikke entydig
|
||
// mening for en delt ball).
|
||
const isSharedBall = round ? SHARED_BALL_FORMATS.has(round.play_format) : false
|
||
const wizardSide = round?.sides.find((s) => s.id === wizardSideId) ?? null
|
||
const wizardSideHole = wizardSideId ? (holesBySide[wizardSideId]?.find((h) => h.hole_number === activeHole) ?? null) : null
|
||
// Par/index for aktivt hull -- samme for begge sider (samme bane), henter
|
||
// fra hvilken som helst av de to som allerede har lastet inn hullene sine.
|
||
const sharedHole: Hole | null = (() => {
|
||
for (const s of round?.sides ?? []) {
|
||
const h = holesBySide[s.id]?.find((x) => x.hole_number === activeHole)
|
||
if (h) return { holeNumber: h.hole_number, par: h.par, index: h.stroke_index }
|
||
}
|
||
return null
|
||
})()
|
||
|
||
// Begge feltene (score OG selected_participant_id) sendes ALLTID sammen
|
||
// (samme "full overwrite hvert kall"-kontrakt som played/score alltid har
|
||
// hatt) -- ellers ville en score-oppdatering stille nullstilt en allerede
|
||
// registrert utslags-valg (migrasjon 036), siden backend ikke gjør en
|
||
// delvis PATCH her.
|
||
async function patchSideHole(score: number | null, selectedParticipantId: string | null) {
|
||
if (readOnly || !wizardSideId) return
|
||
const body = { played: true, score, selected_participant_id: selectedParticipantId }
|
||
if (!navigator.onLine) {
|
||
await queueSideHoleWrite(wizardSideId, activeHole, body)
|
||
return
|
||
}
|
||
let res: Response
|
||
try {
|
||
res = await fetch(`/rounds/${roundId}/sides/${wizardSideId}/holes/${activeHole}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify(body),
|
||
})
|
||
} catch {
|
||
await queueSideHoleWrite(wizardSideId, activeHole, body)
|
||
return
|
||
}
|
||
if (!res.ok) return
|
||
const updated: ApiSideHole = await res.json()
|
||
setHolesBySide((prev) => ({
|
||
...prev,
|
||
[wizardSideId]: (prev[wizardSideId] ?? []).map((h) => (h.hole_number === updated.hole_number ? updated : h)),
|
||
}))
|
||
setFormatResultRefreshTick((n) => n + 1)
|
||
}
|
||
|
||
function updateWizardSideScore(score: number | null) {
|
||
void patchSideHole(score, wizardSideHole?.selected_participant_id ?? null)
|
||
}
|
||
|
||
// Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036) -- helt
|
||
// valgfritt, egen handling fra selve slagregistreringen slik at man kan
|
||
// sette/endre valget uten å røre et allerede registrert slagtall.
|
||
function updateWizardSideSelection(participantId: string | null) {
|
||
void patchSideHole(wizardSideHole?.score ?? null, participantId)
|
||
}
|
||
|
||
function advanceWizardSide() {
|
||
if (!round) return
|
||
const idx = round.sides.findIndex((s) => s.id === wizardSideId)
|
||
const isLast = idx === -1 || idx === round.sides.length - 1
|
||
if (!isLast) {
|
||
setWizardSideId(round.sides[idx + 1].id)
|
||
} else {
|
||
setWizardSideId(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="flex min-h-11 items-center 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>
|
||
)}
|
||
{round.play_format === "match" && (
|
||
<Badge variant="outline" className="shrink-0">
|
||
Matchspill
|
||
</Badge>
|
||
)}
|
||
</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>
|
||
)}
|
||
|
||
{/* To faner: "Score" (default, KUN hull-navigasjon + registrering --
|
||
det eneste de fleste trenger midt i en runde) og "Spillere og
|
||
runde" (alt administrativt, samlet ett sted i stedet for å ligge
|
||
i veien øverst på scoringssiden). */}
|
||
<div className="mb-5 inline-flex gap-1 rounded-full bg-muted p-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPageTab("score")}
|
||
aria-pressed={pageTab === "score"}
|
||
className={cn(
|
||
"min-h-11 rounded-full px-4 text-sm font-bold transition-colors",
|
||
pageTab === "score" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
Score
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPageTab("manage")}
|
||
aria-pressed={pageTab === "manage"}
|
||
className={cn(
|
||
"min-h-11 rounded-full px-4 text-sm font-bold transition-colors",
|
||
pageTab === "manage" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||
)}
|
||
>
|
||
Spillere og runde
|
||
</button>
|
||
</div>
|
||
|
||
{/* Offline scoreregistrering (ADR-028-utvidelse) -- synlig uansett
|
||
fane, siden offline-tilstanden er global for hele siden. */}
|
||
{!isOnline && (
|
||
<div className="mb-4 flex items-center gap-2 rounded-2xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-medium text-amber-900 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
|
||
<WifiOff aria-hidden="true" className="size-4 shrink-0" />
|
||
<span>Du er offline. Endringer lagres lokalt og sendes automatisk når du er tilbake på nett.</span>
|
||
</div>
|
||
)}
|
||
{pendingCount > 0 && (
|
||
<div className="mb-4 flex items-center justify-between gap-2 rounded-2xl border border-border bg-card px-4 py-3 text-sm font-medium text-foreground">
|
||
<span>
|
||
{pendingCount} {pendingCount === 1 ? "endring venter" : "endringer venter"} på synkronisering.
|
||
</span>
|
||
{isOnline && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void flushPending()}
|
||
disabled={syncing}
|
||
className="inline-flex shrink-0 items-center gap-1 font-semibold text-primary disabled:opacity-50"
|
||
>
|
||
<RefreshCw aria-hidden="true" className={cn("size-3.5", syncing && "animate-spin")} />
|
||
Synkroniser nå
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{pageTab === "manage" && (
|
||
<>
|
||
{/* 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} />
|
||
|
||
{/* Løpende matchstatus (to-sidede formater) eller skins-tavle
|
||
(ADR-039) -- avledet ved lesing, aldri lagret. */}
|
||
<FormatResultPanel roundId={roundId} round={round} players={players} refreshTick={formatResultRefreshTick} />
|
||
|
||
{/* Flere flighter i én "vanlig" runde (migrasjon 035) -- løs
|
||
gruppering, se FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md.
|
||
Eieren tar ansvar for å sette opp ALLE flightene i én
|
||
handling ("+ Legg til en flight til" -> gjenbruker hele
|
||
opprett-runde-flyten, forhåndsutfylt fra denne runden). */}
|
||
<FlightGroupPanel roundId={roundId} round={round} isOwnerViewer={isOwnerViewer} />
|
||
|
||
<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 || !round.setup_complete}
|
||
onClick={finishRound}
|
||
title={!round.setup_complete ? round.setup_message ?? undefined : undefined}
|
||
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>
|
||
|
||
{/* Sider (ADR-039 Beslutning A) -- kun for formater som faktisk
|
||
bruker sider (match/fourball/foursome/greensome/scramble).
|
||
Opprett/slett sider + fordel deltakerne. */}
|
||
{TWO_SIDED_FORMATS.has(round.play_format) && (
|
||
<SidesPanel
|
||
roundId={roundId}
|
||
round={round}
|
||
players={players}
|
||
canManage={isOwnerViewer && !readOnly}
|
||
onSidesChanged={loadRound}
|
||
onPatchParticipant={patchParticipant}
|
||
/>
|
||
)}
|
||
|
||
{!round.setup_complete && !completed && (
|
||
<p role="status" className="mb-5 rounded-2xl border border-dashed border-border bg-muted/40 px-4 py-3 text-sm font-medium text-muted-foreground">
|
||
{round.setup_message ?? "Oppsettet er ikke komplett ennå."}
|
||
</p>
|
||
)}
|
||
|
||
{/* 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}
|
||
sides={round.sides}
|
||
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}
|
||
/>
|
||
|
||
{/* Flyttet HIT (2026-07-26, samme redesign-runde) -- lå tidligere
|
||
på selve scoringssiden (først øverst, deretter lenger ned),
|
||
men er detaljert stats-innsyn (fairway/innspill/kølle-
|
||
fordeling for VALGT spiller), ikke selve registrerings-
|
||
oppgaven. Hver spillers rad i scoringslisten viser allerede
|
||
løpende til-par, så dette er nå rendyrket "se mer detaljer"
|
||
-- hører hjemme i administrasjons-fanen, ikke i veien for
|
||
score-registreringen. */}
|
||
{!completed && <ScoreSoFar holes={apiHoles} order={holeOrder} />}
|
||
</>
|
||
)}
|
||
|
||
{pageTab === "score" && (
|
||
<>
|
||
{/* Completed summary banner */}
|
||
{completed && (
|
||
<CompletedBanner
|
||
players={players}
|
||
duration={round.started_at && round.completed_at ? formatDuration(round.started_at, round.completed_at) : null}
|
||
roundId={roundId}
|
||
/>
|
||
)}
|
||
|
||
{/* ADR-039 Beslutning C -- delt-ball-formater scorer PER SIDE, ikke
|
||
per spiller. Vises kun når begge sider faktisk er opprettet
|
||
(SidesPanel i "Spillere og runde" setter dette opp). */}
|
||
{isSharedBall && round.sides.length < 2 ? (
|
||
<p className="mt-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground">
|
||
Opprett begge sidene under "Spillere og runde" før du kan registrere score.
|
||
</p>
|
||
) : isSharedBall ? (
|
||
round.sides.some((s) => !holesBySide[s.id]) ? (
|
||
<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>
|
||
) : (
|
||
<section ref={holePanelRef} className="mt-5 flex scroll-mt-28 flex-col gap-4">
|
||
<SideScorecardGrid
|
||
sides={round.sides}
|
||
players={players}
|
||
holesBySide={holesBySide}
|
||
holeOrder={holeOrder}
|
||
holesPlanned={round.holes_planned}
|
||
currentHole={activeHole}
|
||
readOnly={readOnly}
|
||
onSelectHole={(n) => {
|
||
setCurrentHole(n)
|
||
scrollToHolePanel()
|
||
}}
|
||
onOpenEntry={(sideId, holeNumber) => {
|
||
setCurrentHole(holeNumber)
|
||
setWizardSideId(sideId)
|
||
}}
|
||
/>
|
||
<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>
|
||
)
|
||
) : (
|
||
<>
|
||
{/* Scorekort-grid (§1 i teecup-scorekort-og-entry-spec.md, bygget
|
||
2026-07-27 -- erstatter den forrige rundens ett-hull-om-gangen-
|
||
liste, bruker eksplisitt bekreftet "JA" på at grid-redesignet er
|
||
en egen, større runde). Spillere som rader (sticky venstre
|
||
navnekolonne), hull som horisontalt scrollbare kolonner --
|
||
HELE runden synlig samtidig, ikke bare det aktive hullet. */}
|
||
{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>
|
||
) : (
|
||
<section ref={holePanelRef} className="mt-5 flex scroll-mt-28 flex-col gap-4">
|
||
<ScorecardGrid
|
||
players={players}
|
||
holesByParticipant={holesByParticipant}
|
||
holeOrder={holeOrder}
|
||
holesPlanned={round.holes_planned}
|
||
currentHole={activeHole}
|
||
readOnly={readOnly}
|
||
onSelectHole={(n) => {
|
||
setCurrentHole(n)
|
||
scrollToHolePanel()
|
||
}}
|
||
onOpenEntry={(playerId, holeNumber) => {
|
||
setCurrentHole(holeNumber)
|
||
setActivePlayerId(playerId)
|
||
setWizardPlayerId(playerId)
|
||
}}
|
||
/>
|
||
|
||
{/* Rask sekvensiell hull-navigasjon -- gridet viser allerede
|
||
alle hull som kolonner (tapp en kolonneoverskrift for å
|
||
velge), men disse to gir en rask vei videre uten å måtte
|
||
scrolle bredt for hvert eneste hull. */}
|
||
<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. */}
|
||
{wizardSide && sharedHole && (
|
||
<SideScoreWizard
|
||
sides={round.sides}
|
||
side={wizardSide}
|
||
sideParticipants={players.filter((p) => p.roundSideId === wizardSide.id)}
|
||
hole={sharedHole}
|
||
score={wizardSideHole?.played ? wizardSideHole.score : null}
|
||
selectedParticipantId={wizardSideHole?.selected_participant_id ?? null}
|
||
onSave={updateWizardSideScore}
|
||
onSelectParticipant={updateWizardSideSelection}
|
||
onClose={() => setWizardSideId(null)}
|
||
onAdvance={advanceWizardSide}
|
||
readOnly={readOnly}
|
||
isPending={pendingSideHoles.has(`${wizardSide.id}:${activeHole}`)}
|
||
/>
|
||
)}
|
||
{wizardPlayer && hole && (
|
||
<ScoringWizard
|
||
players={players}
|
||
player={wizardPlayer}
|
||
hole={hole}
|
||
stat={wizardStat}
|
||
onUpdate={updateWizardStat}
|
||
onClose={() => setWizardPlayerId(null)}
|
||
onAdvance={advanceWizardPlayer}
|
||
readOnly={readOnly}
|
||
ownBagClubs={ownBagClubs}
|
||
isPending={pendingParticipantHoles.has(`${wizardPlayer.id}:${activeHole}`)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Scoringsveiviser (2026-07-26, auto-hopp lagt til 2026-07-28) ----------
|
||
// 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": fire steg (Slag, Putter, Avstand første putt, resten samlet)
|
||
// Slag/Putter hopper AUTOMATISK videre i det øyeblikket en verdi velges --
|
||
// ingen "Neste"-trykk nødvendig (etterspurt av bruker 2026-07-28: "Avstand
|
||
// første putt" lå tidligere PÅ SAMME steg som selve putt-tallet, som ville
|
||
// gjort feltet uoppnåelig hvis auto-hopp fyrte idet putt-tallet ble valgt --
|
||
// derfor et eget, eget steg for avstanden, som ELLERS ville vært umulig å
|
||
// nå). "Avstand"/"detaljer" er valgfrie/flerfelts og auto-hopper derfor
|
||
// ikke -- "Neste"/"Forrige"-knappene i bunnen fungerer fortsatt som
|
||
// manuell reserve for disse (og for å rette et allerede registrert steg).
|
||
// 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" | "puttDistance" | "details"
|
||
|
||
function wizardStepsFor(statLevel: StatLevel): WizardStep[] {
|
||
if (statLevel === "strokes_only") return ["strokes"]
|
||
if (statLevel === "strokes_and_putts") return ["strokes", "putts", "puttDistance"]
|
||
return ["strokes", "putts", "puttDistance", "details"]
|
||
}
|
||
|
||
// Hvilket HoleStat-felt (om noen) som "eier" auto-hopp for et gitt steg --
|
||
// null for steg uten noe entydig enkeltfelt å trigge på (flerfelts/valgfritt).
|
||
function autoAdvanceFieldFor(step: WizardStep): "strokes" | "putts" | "firstPuttBucket" | null {
|
||
if (step === "strokes") return "strokes"
|
||
if (step === "putts") return "putts"
|
||
if (step === "puttDistance") return "firstPuttBucket"
|
||
return null
|
||
}
|
||
|
||
function ScoringWizard({
|
||
players,
|
||
player,
|
||
hole,
|
||
stat,
|
||
onUpdate,
|
||
onClose,
|
||
onAdvance,
|
||
readOnly,
|
||
ownBagClubs,
|
||
isPending,
|
||
}: {
|
||
players: Player[]
|
||
player: Player
|
||
hole: Hole
|
||
stat: HoleStat
|
||
onUpdate: (patch: Partial<HoleStat>) => void
|
||
onClose: () => void
|
||
onAdvance: () => void
|
||
readOnly: boolean
|
||
ownBagClubs: string[]
|
||
// Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) -- sann når
|
||
// gjeldende hull for AKTIV spiller ligger i den lokale IndexedDB-køen,
|
||
// ikke bekreftet av serveren ennå.
|
||
isPending: boolean
|
||
}) {
|
||
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
|
||
|
||
// Auto-hopp (2026-07-28): så snart steget sitt eget felt får en verdi,
|
||
// hopp videre av seg selv -- ingen "Neste"-trykk nødvendig. `enteredWith
|
||
// ValueRef` fanger om feltet ALLEREDE hadde en verdi idet vi ankom steget
|
||
// (et allerede utfylt hull skal ikke hoppe videre bare fordi veiviseren
|
||
// åpnes) -- kun en FERSK endring gjort AV BRUKEREN akkurat nå skal utløse
|
||
// hoppet. `firedRef` hindrer at samme hopp trigges to ganger.
|
||
const autoField = autoAdvanceFieldFor(step)
|
||
const enteredWithValueRef = useRef(false)
|
||
const firedRef = useRef(false)
|
||
useEffect(() => {
|
||
firedRef.current = false
|
||
enteredWithValueRef.current = autoField !== null && stat[autoField] !== null
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [step, player.id, hole.holeNumber])
|
||
useEffect(() => {
|
||
if (!autoField || firedRef.current || enteredWithValueRef.current) return
|
||
if (stat[autoField] !== null) {
|
||
firedRef.current = true
|
||
forward()
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [stat.strokes, stat.putts, stat.firstPuttBucket])
|
||
|
||
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>
|
||
|
||
{isPending && (
|
||
<p className="shrink-0 border-b border-border bg-muted/50 px-4 py-1.5 text-center text-xs font-medium text-muted-foreground">
|
||
Lagret lokalt · venter på synk
|
||
</p>
|
||
)}
|
||
|
||
<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}
|
||
/>
|
||
)}
|
||
|
||
{step === "puttDistance" && (
|
||
<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">Hcp spilt til</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>
|
||
)
|
||
}
|
||
|
||
// --- Sider (ADR-039 Beslutning A) -------------------------------------------
|
||
// Kun for match/fourball/foursome/greensome/scramble -- nøyaktig to sider,
|
||
// hver med et forventet spillerantall avhengig av format. Sidene opprettes
|
||
// tomme (POST .../sides), deltakere tildeles/flyttes deretter via PATCH
|
||
// .../participants/{id} med round_side_id.
|
||
|
||
function SidesPanel({
|
||
roundId,
|
||
round,
|
||
players,
|
||
canManage,
|
||
onSidesChanged,
|
||
onPatchParticipant,
|
||
}: {
|
||
roundId: string
|
||
round: ApiRound
|
||
players: Player[]
|
||
canManage: boolean
|
||
onSidesChanged: () => Promise<void>
|
||
onPatchParticipant: (participantId: string, body: Record<string, unknown>) => Promise<{ ok: true } | { ok: false; message: string }>
|
||
}) {
|
||
const [labelDrafts, setLabelDrafts] = useState<Record<number, string>>({})
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const expected = SIDE_PLAYER_COUNT[round.play_format] ?? 1
|
||
const slots: (ApiSide | null)[] = [round.sides[0] ?? null, round.sides[1] ?? null]
|
||
const unassigned = players.filter((p) => p.roundSideId === null)
|
||
|
||
async function createSide(slotIndex: number) {
|
||
setBusy(true)
|
||
setError(null)
|
||
const res = await fetch(`/rounds/${roundId}/sides`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ label: labelDrafts[slotIndex]?.trim() || null }),
|
||
})
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => null)
|
||
setError(body?.detail?.message ?? "Klarte ikke å opprette siden.")
|
||
setBusy(false)
|
||
return
|
||
}
|
||
await onSidesChanged()
|
||
setBusy(false)
|
||
}
|
||
|
||
async function deleteSide(sideId: string) {
|
||
if (!confirm("Slette denne siden? Deltakerne blir liggende i runden, uten side-tilhørighet.")) return
|
||
setBusy(true)
|
||
setError(null)
|
||
const res = await fetch(`/rounds/${roundId}/sides/${sideId}`, { method: "DELETE", credentials: "include" })
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => null)
|
||
setError(body?.detail?.message ?? "Klarte ikke å slette siden.")
|
||
}
|
||
await onSidesChanged()
|
||
setBusy(false)
|
||
}
|
||
|
||
async function assign(participantId: string, sideId: string | null) {
|
||
setBusy(true)
|
||
setError(null)
|
||
const result = await onPatchParticipant(participantId, { round_side_id: sideId })
|
||
if (!result.ok) {
|
||
setError(result.message)
|
||
} else {
|
||
// patchParticipant oppdaterer kun deltaker-listen lokalt -- setup_complete/
|
||
// setup_message/playing_handicap er beregnet SERVER-side og krever en
|
||
// full runde-refetch for å ikke bli stående utdatert (2026-07-28, funnet
|
||
// under browserverifisering: meldingen "ikke tildelt en side" ble
|
||
// stående etter en vellykket tildeling).
|
||
await onSidesChanged()
|
||
}
|
||
setBusy(false)
|
||
}
|
||
|
||
if (!canManage && round.sides.length === 0) return null
|
||
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 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">
|
||
Sider · {expected} spiller{expected !== 1 ? "e" : ""} per side
|
||
</span>
|
||
</div>
|
||
|
||
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
||
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{slots.map((side, slotIndex) => {
|
||
const assignedPlayers = side ? players.filter((p) => p.roundSideId === side.id) : []
|
||
return (
|
||
<div key={slotIndex} className="flex flex-col gap-2 rounded-xl border border-dashed border-border p-3">
|
||
{side ? (
|
||
<>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="text-sm font-bold text-foreground">
|
||
{side.label?.trim() || `Side ${slotIndex === 0 ? "A" : "B"}`}
|
||
</span>
|
||
{canManage && (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => deleteSide(side.id)}
|
||
aria-label="Slett siden"
|
||
className="flex min-h-8 min-w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-60"
|
||
>
|
||
<Trash2 aria-hidden="true" className="size-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{assignedPlayers.length}/{expected} spillere
|
||
{assignedPlayers.some((p) => p.playingHandicap !== null) && (
|
||
<>
|
||
{" · "}
|
||
Match-hcp{" "}
|
||
{assignedPlayers
|
||
.filter((p) => p.playingHandicap !== null)
|
||
.map((p) => p.playingHandicap)
|
||
.join("/")}
|
||
</>
|
||
)}
|
||
</span>
|
||
{assignedPlayers.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">Ingen spillere ennå.</p>
|
||
) : (
|
||
<ul className="flex flex-wrap gap-1.5">
|
||
{assignedPlayers.map((p) => (
|
||
<li
|
||
key={p.id}
|
||
className="inline-flex min-h-8 items-center gap-1.5 rounded-full border border-border bg-background px-2.5 text-sm font-semibold text-foreground"
|
||
>
|
||
{p.name}
|
||
{canManage && (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => assign(p.id, null)}
|
||
aria-label={`Fjern ${p.name} fra siden`}
|
||
className="flex size-5 items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-destructive"
|
||
>
|
||
<X aria-hidden="true" className="size-3" />
|
||
</button>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</>
|
||
) : canManage ? (
|
||
<div className="flex flex-col gap-2">
|
||
<Input
|
||
value={labelDrafts[slotIndex] ?? ""}
|
||
onChange={(e) => setLabelDrafts((prev) => ({ ...prev, [slotIndex]: e.target.value }))}
|
||
placeholder={`Navn på side (valgfritt)`}
|
||
className="h-10 rounded-xl text-sm"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={busy}
|
||
onClick={() => createSide(slotIndex)}
|
||
className="h-10 rounded-xl text-sm font-bold"
|
||
>
|
||
Opprett side
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">Ikke opprettet ennå.</p>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{canManage && unassigned.length > 0 && (
|
||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||
<span className="text-sm font-semibold text-foreground">Ikke tildelt en side</span>
|
||
<ul className="flex flex-col gap-2">
|
||
{unassigned.map((p) => (
|
||
<li key={p.id} className="flex flex-wrap items-center gap-2">
|
||
<span className="text-sm font-semibold text-foreground">{p.name}</span>
|
||
{slots.map((side, slotIndex) => {
|
||
if (!side) return null
|
||
const count = players.filter((x) => x.roundSideId === side.id).length
|
||
const full = count >= expected
|
||
return (
|
||
<button
|
||
key={slotIndex}
|
||
type="button"
|
||
disabled={busy || full}
|
||
onClick={() => assign(p.id, side.id)}
|
||
className="inline-flex min-h-8 items-center rounded-full border border-border bg-card px-3 text-xs font-bold text-foreground transition-colors hover:bg-accent/50 disabled:opacity-40"
|
||
>
|
||
→ {side.label?.trim() || `Side ${slotIndex === 0 ? "A" : "B"}`}
|
||
</button>
|
||
)
|
||
})}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Løpende matchstatus / skins-tavle (ADR-039) ----------------------------
|
||
// Avledet ved LESING (aldri lagret) fra GET .../format-result -- samme
|
||
// filosofi som resten av appen (leaderboard/HCP m.fl.). Slagspill trenger
|
||
// ingen egen visning her (eksisterende leaderboard dekker det).
|
||
|
||
type ApiFormatResult = {
|
||
play_format: string
|
||
ready: boolean
|
||
match_lead: number | null
|
||
match_holes_played: number | null
|
||
match_holes_remaining: number | null
|
||
match_is_closed: boolean | null
|
||
match_is_dormie: boolean | null
|
||
match_status_text: string | null
|
||
hole_results: string[] | null
|
||
skins_won: Record<string, number> | null
|
||
}
|
||
|
||
// match_status_text bruker bokstavelig "(A)"/"(B)" -- disse tilsvarer alltid
|
||
// round.sides[0]/[1] (begge sortert på id, samme rekkefølge som backend sin
|
||
// egen sortering av side-id-er). Erstatt med det faktiske side-navnet.
|
||
function substituteSideLabels(text: string, sides: ApiSide[]): string {
|
||
const labelFor = (idx: number) => sides[idx]?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
|
||
return text.replace(/\(A\)/, `(${labelFor(0)})`).replace(/\(B\)/, `(${labelFor(1)})`)
|
||
}
|
||
|
||
type ApiFlightSummary = {
|
||
round_id: string
|
||
name: string | null
|
||
course_name_snapshot: string
|
||
played_at: string
|
||
started_at: string | null
|
||
completed_at: string | null
|
||
participant_count: number
|
||
is_anchor: boolean
|
||
}
|
||
|
||
// Flere flighter i én "vanlig" runde (migrasjon 035, retning 1 -- løs
|
||
// gruppering av separate `round`-rader, se FEATURE_BACKLOG.md). "+ Legg
|
||
// til en flight til" gjenbruker HELE eksisterende opprett-runde-flyten
|
||
// (/my-rounds/new) i stedet for et eget skjema -- forhåndsutfylt med denne
|
||
// flightens dato/starthull/hullantall/spilleform via URL-parametre, men
|
||
// banen velges på nytt (samme lave friksjon som normal bane-søk ellers).
|
||
function FlightGroupPanel({
|
||
roundId,
|
||
round,
|
||
isOwnerViewer,
|
||
}: {
|
||
roundId: string
|
||
round: ApiRound
|
||
isOwnerViewer: boolean
|
||
}) {
|
||
const router = useRouter()
|
||
const [flights, setFlights] = useState<ApiFlightSummary[] | null>(null)
|
||
const [adding, setAdding] = useState(false)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
fetch(`/rounds/${roundId}/flight-group`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((data: { flight_group_id: string | null; flights: ApiFlightSummary[] } | null) => {
|
||
if (!cancelled) setFlights(data?.flights ?? null)
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setFlights(null)
|
||
})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, round.flight_group_id])
|
||
|
||
async function addFlight() {
|
||
if (!isOwnerViewer) return
|
||
setAdding(true)
|
||
try {
|
||
let groupId = round.flight_group_id
|
||
if (!groupId) {
|
||
groupId = crypto.randomUUID()
|
||
await fetch(`/rounds/${roundId}`, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
credentials: "include",
|
||
body: JSON.stringify({ flight_group_id: groupId }),
|
||
})
|
||
}
|
||
const params = new URLSearchParams({
|
||
flightGroupId: groupId,
|
||
playedAt: round.played_at,
|
||
startHole: String(round.start_hole),
|
||
holes: String(round.holes_planned),
|
||
playFormat: round.play_format,
|
||
})
|
||
router.push(`/my-rounds/new?${params.toString()}`)
|
||
} finally {
|
||
setAdding(false)
|
||
}
|
||
}
|
||
|
||
const others = (flights ?? []).filter((f) => f.round_id !== roundId)
|
||
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="inline-flex items-center gap-1.5 text-sm font-bold text-foreground">
|
||
<Users aria-hidden="true" className="size-4 text-primary" />
|
||
Flighter
|
||
</span>
|
||
{isOwnerViewer && (
|
||
<button
|
||
type="button"
|
||
onClick={addFlight}
|
||
disabled={adding}
|
||
className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border border-dashed border-border px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:opacity-60"
|
||
>
|
||
<Plus aria-hidden="true" className="size-4" />
|
||
{adding ? "Setter opp…" : "Legg til en flight til"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{others.length === 0 ? (
|
||
<p className="text-sm text-muted-foreground">
|
||
Kun denne flighten er satt opp. Legg til en flight til hvis flere spiller sammen med dere samme dag --
|
||
de deler da leaderboard med denne flighten.
|
||
</p>
|
||
) : (
|
||
<>
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{(flights ?? []).map((f) => (
|
||
<li key={f.round_id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||
{f.round_id === roundId ? (
|
||
<span className="text-sm font-semibold text-foreground">
|
||
{f.name || f.course_name_snapshot}{" "}
|
||
<span className="font-normal text-muted-foreground">(denne)</span>
|
||
</span>
|
||
) : (
|
||
<Link
|
||
href={`/my-rounds/${f.round_id}`}
|
||
className="text-sm font-semibold text-primary hover:underline"
|
||
>
|
||
{f.name || f.course_name_snapshot}
|
||
</Link>
|
||
)}
|
||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{f.participant_count} spiller{f.participant_count !== 1 ? "e" : ""}
|
||
{f.completed_at ? " · Ferdig" : ""}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<Link
|
||
href={`/my-rounds/${roundId}/flights`}
|
||
className="inline-flex min-h-11 items-center gap-1.5 self-start text-sm font-bold text-primary hover:underline"
|
||
>
|
||
Se samlet leaderboard for flightene
|
||
<ArrowRight aria-hidden="true" className="size-4" />
|
||
</Link>
|
||
</>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function FormatResultPanel({
|
||
roundId,
|
||
round,
|
||
players,
|
||
refreshTick,
|
||
}: {
|
||
roundId: string
|
||
round: ApiRound
|
||
players: Player[]
|
||
refreshTick: number
|
||
}) {
|
||
const [result, setResult] = useState<ApiFormatResult | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (round.play_format === "stroke") return
|
||
let cancelled = false
|
||
fetch(`/rounds/${roundId}/format-result`, { credentials: "include" })
|
||
.then((res) => (res.ok ? res.json() : null))
|
||
.then((data: ApiFormatResult | null) => {
|
||
if (!cancelled) setResult(data)
|
||
})
|
||
.catch(() => {})
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [roundId, round.play_format, refreshTick])
|
||
|
||
if (round.play_format === "stroke" || !result) return null
|
||
|
||
if (round.play_format === "skins") {
|
||
const won = result.skins_won ?? {}
|
||
const rows = players
|
||
.map((p) => ({ player: p, skins: won[p.id] ?? 0 }))
|
||
.sort((a, b) => b.skins - a.skins)
|
||
const totalAwarded = Object.values(won).reduce((sum, v) => sum + v, 0)
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 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">Skins-tavle</span>
|
||
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{totalAwarded} skin{totalAwarded !== 1 ? "s" : ""} avgjort
|
||
</span>
|
||
</div>
|
||
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-xl border border-border">
|
||
{rows.map(({ player, skins }) => (
|
||
<li key={player.id} className="flex items-center justify-between gap-3 px-4 py-2.5">
|
||
<span className="text-sm font-semibold text-foreground">{player.name}</span>
|
||
<span className="text-base font-extrabold tabular-nums text-foreground">{skins}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// To-sidede formater (match/fourball/foursome/greensome/scramble).
|
||
if (!result.ready) {
|
||
return (
|
||
<div className="mb-5 rounded-2xl border border-dashed border-border bg-card p-4 text-sm text-muted-foreground sm:p-5">
|
||
Venter på at begge sider er komplette og har beregnet handicap før {FORMAT_LABELS[round.play_format] ?? round.play_format}-status kan vises.
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const statusText = result.match_status_text ? substituteSideLabels(result.match_status_text, round.sides) : null
|
||
|
||
return (
|
||
<div className="mb-5 flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 sm:p-5">
|
||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||
<span className="text-sm font-bold text-foreground">{FORMAT_LABELS[round.play_format] ?? round.play_format}</span>
|
||
{result.match_is_closed && (
|
||
<Badge variant="default" className="shrink-0">
|
||
Avgjort
|
||
</Badge>
|
||
)}
|
||
{result.match_is_dormie && !result.match_is_closed && (
|
||
<Badge variant="outline" className="shrink-0">
|
||
Dormie
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
{statusText && <span className="text-2xl font-extrabold tracking-tight text-foreground">{statusText}</span>}
|
||
<span className="text-sm text-muted-foreground">
|
||
{result.match_holes_played} hull spilt
|
||
{result.match_holes_remaining !== null && result.match_holes_remaining > 0 && `, ${result.match_holes_remaining} igjen`}
|
||
</span>
|
||
{result.hole_results && result.hole_results.length > 0 && (
|
||
<ul className="flex flex-wrap gap-1">
|
||
{result.hole_results.map((r, i) => (
|
||
<li
|
||
key={i}
|
||
aria-label={
|
||
r === "halved"
|
||
? `Hull ${i + 1}: delt`
|
||
: `Hull ${i + 1}: ${r === "a" ? round.sides[0]?.label?.trim() || "Side A" : round.sides[1]?.label?.trim() || "Side B"} vant`
|
||
}
|
||
className={cn(
|
||
"flex size-7 items-center justify-center rounded-full text-xs font-extrabold",
|
||
r === "a" && "bg-primary text-primary-foreground",
|
||
r === "b" && "bg-brand-orange text-brand-orange-foreground",
|
||
r === "halved" && "border border-border bg-muted text-muted-foreground",
|
||
)}
|
||
>
|
||
{r === "halved" ? "½" : r.toUpperCase()}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</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,
|
||
sides,
|
||
activePlayerId,
|
||
onSelect,
|
||
onRemove,
|
||
onAdd,
|
||
addOpen,
|
||
readOnly,
|
||
canManage,
|
||
editingPlayerId,
|
||
onToggleEdit,
|
||
onPatchParticipant,
|
||
onAddGuest,
|
||
onAddSearched,
|
||
}: {
|
||
roundId: string
|
||
players: Player[]
|
||
// ADR-039 -- kun brukt til å slå opp side-LABEL for badgen; selve
|
||
// tildelingen skjer i SidesPanel, ikke her.
|
||
sides: ApiSide[]
|
||
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
|
||
// ADR-038 -- full redigering (utslag/HCP/navn/statistikk) er
|
||
// fortsatt eier-only OG sperret etter fullføring. Men enhver
|
||
// lenket deltaker (eier ELLER medspiller, kun sin EGEN rad) skal
|
||
// kunne endre "ekskluder fra faktisk HCP" UANSETT fullført-status
|
||
// -- dekket av en RESTRIKTERT variant av samme panel, kun
|
||
// meningsfullt for en spiller med egen konto (userId).
|
||
const canEditFull = canManage && !readOnly
|
||
const canEditRestricted = (canManage || player.isSelf) && player.userId !== null
|
||
const canEdit = canEditFull || canEditRestricted
|
||
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>
|
||
)}
|
||
{player.roundSideId && (
|
||
<Badge variant="outline" className="shrink-0">
|
||
{(() => {
|
||
const idx = sides.findIndex((s) => s.id === player.roundSideId)
|
||
const side = sides[idx]
|
||
return side?.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
|
||
})()}
|
||
</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 tabular-nums 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 tabular-nums 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}
|
||
restricted={!canEditFull}
|
||
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="flex min-h-11 items-center 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="flex min-h-11 items-center 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))
|
||
// ADR-038 -- ren metadata, aldri sperret av fullført-status (rører aldri
|
||
// AGS/differensial-matematikken selv).
|
||
const [playFormat, setPlayFormat] = useState<"stroke" | "match">(round.play_format === "match" ? "match" : "stroke")
|
||
// ADR-036 Beslutning B (fase 2, 2026-07-28) -- ren metadata, aldri sperret
|
||
// av fullført-status (samme begrunnelse som playFormat over).
|
||
const [visibilityMode, setVisibilityMode] = useState<"private" | "public" | "friends">(
|
||
round.visibility_mode === "public" || round.visibility_mode === "friends" ? round.visibility_mode : "private",
|
||
)
|
||
const [visibleCategories, setVisibleCategories] = useState<string[]>(round.visible_categories)
|
||
const [saving, setSaving] = useState(false)
|
||
const [showChangeCourse, setShowChangeCourse] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const sameCategories =
|
||
visibleCategories.length === round.visible_categories.length &&
|
||
visibleCategories.every((c) => round.visible_categories.includes(c))
|
||
|
||
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) ||
|
||
playFormat !== (round.play_format === "match" ? "match" : "stroke") ||
|
||
visibilityMode !== round.visibility_mode ||
|
||
(visibilityMode === "friends" && !sameCategories)
|
||
|
||
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)
|
||
}
|
||
if (playFormat !== (round.play_format === "match" ? "match" : "stroke")) body.play_format = playFormat
|
||
if (visibilityMode !== round.visibility_mode) body.visibility_mode = visibilityMode
|
||
if (visibilityMode === "friends" && !sameCategories) body.visible_categories = visibleCategories
|
||
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-11 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>
|
||
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-sm font-semibold text-foreground">Spilleform</span>
|
||
<div className="flex gap-2">
|
||
{(
|
||
[
|
||
{ value: "stroke" as const, label: "Slagspill" },
|
||
{ value: "match" as const, label: "Matchspill" },
|
||
]
|
||
).map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => setPlayFormat(opt.value)}
|
||
aria-pressed={playFormat === opt.value}
|
||
className={cn(
|
||
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
|
||
playFormat === opt.value
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Synlighet (ADR-036 Beslutning B, fase 2) -- styrer KUN tredjeparts
|
||
innsyn (venner/offentligheten), aldri en faktisk lagt-til
|
||
medspiller (som alltid ser runden uansett). */}
|
||
<div className="flex flex-col gap-2">
|
||
<span className="text-sm font-semibold text-foreground">Synlighet</span>
|
||
<div className="flex gap-2">
|
||
{(
|
||
[
|
||
{ value: "private" as const, label: "Privat" },
|
||
{ value: "friends" as const, label: "Venner" },
|
||
{ value: "public" as const, label: "Offentlig" },
|
||
]
|
||
).map((opt) => (
|
||
<button
|
||
key={opt.value}
|
||
type="button"
|
||
onClick={() => setVisibilityMode(opt.value)}
|
||
aria-pressed={visibilityMode === opt.value}
|
||
className={cn(
|
||
"h-11 flex-1 rounded-xl border text-base font-bold transition-colors",
|
||
visibilityMode === opt.value
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-background text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
{visibilityMode === "friends" && (
|
||
<div className="flex flex-col gap-2 rounded-xl border border-border bg-background p-3">
|
||
<p className="text-xs font-semibold text-muted-foreground">
|
||
Hvilke av dine venne-kategorier skal kunne se runden?
|
||
</p>
|
||
<div className="flex flex-wrap gap-2">
|
||
{VISIBILITY_CATEGORY_OPTIONS.map(({ code, label }) => {
|
||
const active = visibleCategories.includes(code)
|
||
return (
|
||
<button
|
||
key={code}
|
||
type="button"
|
||
onClick={() =>
|
||
setVisibleCategories((prev) =>
|
||
active ? prev.filter((c) => c !== code) : [...prev, code],
|
||
)
|
||
}
|
||
aria-pressed={active}
|
||
className={cn(
|
||
"min-h-[44px] rounded-full border px-3 text-sm font-semibold transition-colors",
|
||
active
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-muted-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</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,
|
||
restricted,
|
||
onPatch,
|
||
onClose,
|
||
}: {
|
||
roundId: string
|
||
player: Player
|
||
// ADR-038 -- true når viewer IKKE har full redigeringsrett her (en
|
||
// ikke-eier som redigerer sin EGEN rad, ELLER eieren etter at runden er
|
||
// fullført) -- viser da KUN eksklusjons-toggelen, ingen av de andre
|
||
// feltene under.
|
||
restricted: boolean
|
||
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.name)
|
||
const [gender, setGender] = useState<Gender>(player.gender)
|
||
const [guestEmail, setGuestEmail] = useState(player.guestEmail ?? "")
|
||
const [excludeFromHandicap, setExcludeFromHandicap] = useState(player.excludeFromHandicap)
|
||
const [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (restricted) return
|
||
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, restricted])
|
||
|
||
// 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 (!restricted) {
|
||
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.name) 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 (!isGuest && excludeFromHandicap !== player.excludeFromHandicap) {
|
||
body.exclude_from_handicap = excludeFromHandicap
|
||
}
|
||
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>}
|
||
|
||
{restricted && (
|
||
<p className="text-sm text-muted-foreground">
|
||
Runden er fullført, eller du har ikke rett til å endre de andre feltene her -- du kan
|
||
likevel endre om {player.isSelf ? "din egen deltakelse" : "spillerens deltakelse"} skal
|
||
telle mot {player.isSelf ? "ditt" : "spillerens"} faktiske HCP.
|
||
</p>
|
||
)}
|
||
|
||
{!restricted && (
|
||
<>
|
||
{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}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{!isGuest && (
|
||
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-card px-4 py-3">
|
||
<input
|
||
type="checkbox"
|
||
checked={excludeFromHandicap}
|
||
onChange={(e) => setExcludeFromHandicap(e.target.checked)}
|
||
className="mt-1 size-5 shrink-0 accent-primary"
|
||
/>
|
||
<span className="flex flex-col gap-0.5">
|
||
<span className="text-sm font-bold text-foreground">
|
||
Ekskluder fra {player.isSelf ? "mitt" : "spillerens"} faktiske HCP
|
||
</span>
|
||
<span className="text-sm text-muted-foreground">
|
||
F.eks. ved matchspill, eller andre runder som ikke bør telle med i HCP-grunnlaget.
|
||
</span>
|
||
</span>
|
||
</label>
|
||
)}
|
||
|
||
<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 min-h-11 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="flex min-h-11 items-center 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 min-h-11 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 min-h-11 w-full items-center 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-11 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-11 self-start rounded-xl text-sm font-semibold text-muted-foreground">
|
||
Avbryt
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Scorekort-grid (§1 i teecup-scorekort-og-entry-spec.md, 2026-07-27) ---
|
||
// Spillere = rader (sticky venstre navnekolonne), hull = horisontalt
|
||
// scrollbare kolonner (sticky Ut/Inn/Sum til høyre) -- erstatter den
|
||
// forrige ett-hull-om-gangen-listen. Tapp en kolonneoverskrift for å velge
|
||
// "gjeldende" hull (kun navigasjon), tapp en spillers navnecelle eller en
|
||
// score-celle for å åpne `ScoringWizard` for AKKURAT den (spiller, hull)-
|
||
// kombinasjonen -- samme veiviser som før, bare adressert direkte fra
|
||
// gridet i stedet for via ett enkelt "aktivt hull"-panel.
|
||
// Par/stroke-indeks er samme fysiske hull for alle spillere -- hentes fra
|
||
// HVILKEN SOM HELST allerede lastet spillers hull-liste (ikke bare aktiv
|
||
// spiller), robust mot i hvilken rekkefølge deltakernes hull lastes inn.
|
||
|
||
function ScorecardGrid({
|
||
players,
|
||
holesByParticipant,
|
||
holeOrder,
|
||
holesPlanned,
|
||
currentHole,
|
||
readOnly,
|
||
onSelectHole,
|
||
onOpenEntry,
|
||
}: {
|
||
players: Player[]
|
||
holesByParticipant: Record<string, ApiHole[]>
|
||
holeOrder: number[]
|
||
holesPlanned: number
|
||
currentHole: number
|
||
readOnly: boolean
|
||
onSelectHole: (holeNumber: number) => void
|
||
onOpenEntry: (playerId: string, holeNumber: number) => void
|
||
}) {
|
||
const holeMeta: Record<number, { par: number; index: number }> = {}
|
||
for (const list of Object.values(holesByParticipant)) {
|
||
for (const h of list) {
|
||
if (!(h.hole_number in holeMeta)) holeMeta[h.hole_number] = { par: h.par, index: h.stroke_index }
|
||
}
|
||
}
|
||
|
||
// Ut(1-9)/Inn(10-18) er alltid gruppert på FYSISK hullnummer (tradisjonell
|
||
// scorekort-konvensjon), ikke på spillerekkefølgen -- riktig uansett
|
||
// hvilket starthull runden faktisk begynte på. Kun meningsfullt å splitte
|
||
// for en 18-hulls runde; en 9-hulls runde får kun én samlet Sum-kolonne.
|
||
const showSplit = holesPlanned === 18
|
||
const front = holeOrder.filter((n) => n <= 9)
|
||
const back = holeOrder.filter((n) => n >= 10)
|
||
|
||
function sumFor(playerId: string, numbers: number[]): { strokes: number; par: number } | null {
|
||
const list = holesByParticipant[playerId]
|
||
if (!list || numbers.length === 0) return null
|
||
const played = numbers
|
||
.map((n) => list.find((h) => h.hole_number === n))
|
||
.filter((h): h is ApiHole => Boolean(h) && h!.played && h!.score !== null)
|
||
if (played.length === 0) return null
|
||
return {
|
||
strokes: played.reduce((sum, h) => sum + (h.score as number), 0),
|
||
par: played.reduce((sum, h) => sum + h.par, 0),
|
||
}
|
||
}
|
||
|
||
const parRowTotal = holeOrder.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0)
|
||
|
||
return (
|
||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||
<table className="w-full border-separate border-spacing-0 text-sm">
|
||
<colgroup>
|
||
<col className="w-[130px]" />
|
||
{holeOrder.map((n) => (
|
||
<col key={n} className="w-[52px]" />
|
||
))}
|
||
{showSplit && <col className="w-[60px]" />}
|
||
{showSplit && <col className="w-[60px]" />}
|
||
<col className="w-[60px]" />
|
||
</colgroup>
|
||
<thead>
|
||
<tr className="border-b border-border">
|
||
<th
|
||
scope="col"
|
||
className="sticky left-0 z-10 border-r border-border bg-card px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-muted-foreground"
|
||
>
|
||
Spiller
|
||
</th>
|
||
{holeOrder.map((n) => {
|
||
const active = n === currentHole
|
||
return (
|
||
<th key={n} scope="col" className="border-l border-border p-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelectHole(n)}
|
||
aria-current={active ? "true" : undefined}
|
||
aria-label={`Velg hull ${n}`}
|
||
className={cn(
|
||
"flex min-h-11 w-full items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
|
||
active ? "bg-primary text-primary-foreground" : "bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{n}
|
||
</button>
|
||
</th>
|
||
)
|
||
})}
|
||
{showSplit && (
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Ut
|
||
</th>
|
||
)}
|
||
{showSplit && (
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Inn
|
||
</th>
|
||
)}
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Sum
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{/* Hcp- og Par-referanserader (samme mønster som den allerede
|
||
shippede round-scorecard.tsx) -- rene informasjonsrader, ikke
|
||
trykkbare. */}
|
||
<tr className="border-b border-border">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
|
||
Hcp
|
||
</th>
|
||
{holeOrder.map((n) => (
|
||
<td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{holeMeta[n]?.index ?? "–"}
|
||
</td>
|
||
))}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
<td className="border-l border-border bg-muted" />
|
||
</tr>
|
||
<tr className="border-b-2 border-border">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
|
||
Par
|
||
</th>
|
||
{holeOrder.map((n) => (
|
||
<td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{holeMeta[n]?.par ?? "–"}
|
||
</td>
|
||
))}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{front.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
|
||
</td>
|
||
)}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{back.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
|
||
</td>
|
||
)}
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{parRowTotal || "–"}
|
||
</td>
|
||
</tr>
|
||
|
||
{players.map((player) => {
|
||
const list = holesByParticipant[player.id]
|
||
const frontSum = showSplit ? sumFor(player.id, front) : null
|
||
const backSum = showSplit ? sumFor(player.id, back) : null
|
||
const totalSum = sumFor(player.id, holeOrder)
|
||
return (
|
||
<tr key={player.id} className="border-b border-border last:border-b-0">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card p-0 text-left font-normal">
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onOpenEntry(player.id, currentHole)}
|
||
aria-label={`Registrer score for ${player.name} på hull ${currentHole}`}
|
||
className="flex min-h-12 w-[130px] flex-col justify-center gap-0.5 px-3 py-1.5 text-left transition-colors hover:bg-accent/50 disabled:opacity-60"
|
||
>
|
||
<span className="flex items-center gap-1.5">
|
||
<span className="truncate text-sm font-bold text-foreground">{player.name}</span>
|
||
{player.isSelf && (
|
||
<Badge variant="default" className="shrink-0 px-1.5 py-0 text-[10px]">
|
||
Deg
|
||
</Badge>
|
||
)}
|
||
</span>
|
||
<span className="truncate text-xs tabular-nums text-muted-foreground">
|
||
HCP {player.hcp !== null ? player.hcp.toFixed(1).replace(".", ",") : "–"}
|
||
</span>
|
||
</button>
|
||
</th>
|
||
{holeOrder.map((n) => {
|
||
const apiHole = list?.find((h) => h.hole_number === n) ?? null
|
||
const meta = holeMeta[n]
|
||
const hasScore = Boolean(apiHole?.played) && apiHole?.score !== null
|
||
const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : null
|
||
const net =
|
||
hasScore && apiHole?.strokes_received != null ? (apiHole!.score as number) - apiHole!.strokes_received : null
|
||
return (
|
||
<td key={n} className="border-l border-border p-0 text-center">
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onOpenEntry(player.id, n)}
|
||
aria-label={
|
||
hasScore
|
||
? `${player.name}, hull ${n}, ${apiHole?.score} slag${diff !== null && meta ? `, ${golfTermForScore(apiHole!.score as number, meta.par) ?? ""}` : ""}`
|
||
: `Registrer ${player.name} sin score på hull ${n}`
|
||
}
|
||
className="flex min-h-12 w-full flex-col items-center justify-center gap-0.5 py-1 transition-colors hover:bg-accent/40 disabled:opacity-60"
|
||
>
|
||
<ScorecardCell value={hasScore ? (apiHole!.score as number) : null} diff={diff} />
|
||
{net !== null && <span className="text-[10px] font-semibold tabular-nums text-muted-foreground">{net}</span>}
|
||
</button>
|
||
</td>
|
||
)
|
||
})}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
|
||
{frontSum ? frontSum.strokes : "–"}
|
||
</td>
|
||
)}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
|
||
{backSum ? backSum.strokes : "–"}
|
||
</td>
|
||
)}
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center">
|
||
{totalSum ? (
|
||
<span className="flex flex-col items-center leading-tight">
|
||
<span className="text-sm font-extrabold tabular-nums text-foreground">{totalSum.strokes}</span>
|
||
<span className="text-[10px] font-semibold tabular-nums text-muted-foreground">
|
||
{formatToPar(totalSum.strokes - totalSum.par)}
|
||
</span>
|
||
</span>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Golfscore-språket for en LITEN tabellcelle -- samme eksakte klassifisering
|
||
// og Tailwind-klasser som `ScoreMark`/`classify` i round-scorecard.tsx
|
||
// (bekreftet ved å lese kildekoden der direkte, egen kopi her per
|
||
// prosjektets etablerte "lokale komponentmønstre per fil"-konvensjon, se
|
||
// DESIGN_SYSTEM.md). Sirkel under par, "firkant" (`rounded-[4px]`) over
|
||
// par, ren tekst på par, fylt ved 2+ slag fra par.
|
||
function ScorecardCell({ value, diff }: { value: number | null; diff: number | null }) {
|
||
const shared = "flex size-7 items-center justify-center text-sm font-extrabold tabular-nums"
|
||
if (value === null) return <span className={cn(shared, "text-muted-foreground")}>–</span>
|
||
if (diff === null || diff === 0) return <span className={cn(shared, "font-bold text-foreground")}>{value}</span>
|
||
if (diff <= -2) return <span className={cn(shared, "rounded-full bg-primary text-primary-foreground")}>{value}</span>
|
||
if (diff === -1) return <span className={cn(shared, "rounded-full border-2 border-primary bg-primary/10 text-primary")}>{value}</span>
|
||
if (diff === 1) {
|
||
return <span className={cn(shared, "rounded-[4px] border-2 border-brand-orange bg-brand-orange/10 text-brand-orange")}>{value}</span>
|
||
}
|
||
return <span className={cn(shared, "rounded-[4px] bg-brand-orange text-brand-orange-foreground")}>{value}</span>
|
||
}
|
||
|
||
// --- 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
|
||
}
|
||
|
||
// --- Delt-ball-scorekort (ADR-039 Beslutning C) -----------------------------
|
||
// Samme grid-idé som ScorecardGrid, men rader = SIDER (ikke spillere) -- kun
|
||
// ett tall per hull (ingen putt/netto-linje, en delt ball har ingen entydig
|
||
// netto-visning per spiller her).
|
||
|
||
function SideScorecardGrid({
|
||
sides,
|
||
players,
|
||
holesBySide,
|
||
holeOrder,
|
||
holesPlanned,
|
||
currentHole,
|
||
readOnly,
|
||
onSelectHole,
|
||
onOpenEntry,
|
||
}: {
|
||
sides: ApiSide[]
|
||
players: Player[]
|
||
holesBySide: Record<string, ApiSideHole[]>
|
||
holeOrder: number[]
|
||
holesPlanned: number
|
||
currentHole: number
|
||
readOnly: boolean
|
||
onSelectHole: (holeNumber: number) => void
|
||
onOpenEntry: (sideId: string, holeNumber: number) => void
|
||
}) {
|
||
const holeMeta: Record<number, { par: number; index: number }> = {}
|
||
for (const list of Object.values(holesBySide)) {
|
||
for (const h of list) {
|
||
if (!(h.hole_number in holeMeta)) holeMeta[h.hole_number] = { par: h.par, index: h.stroke_index }
|
||
}
|
||
}
|
||
|
||
const showSplit = holesPlanned === 18
|
||
const front = holeOrder.filter((n) => n <= 9)
|
||
const back = holeOrder.filter((n) => n >= 10)
|
||
|
||
function sumFor(sideId: string, numbers: number[]): { strokes: number; par: number } | null {
|
||
const list = holesBySide[sideId]
|
||
if (!list || numbers.length === 0) return null
|
||
const played = numbers
|
||
.map((n) => list.find((h) => h.hole_number === n))
|
||
.filter((h): h is ApiSideHole => Boolean(h) && h!.played && h!.score !== null)
|
||
if (played.length === 0) return null
|
||
return {
|
||
strokes: played.reduce((sum, h) => sum + (h.score as number), 0),
|
||
par: played.reduce((sum, h) => sum + h.par, 0),
|
||
}
|
||
}
|
||
|
||
const parRowTotal = holeOrder.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0)
|
||
|
||
return (
|
||
<div className="overflow-x-auto rounded-2xl border border-border">
|
||
<table className="w-full border-separate border-spacing-0 text-sm">
|
||
<colgroup>
|
||
<col className="w-[130px]" />
|
||
{holeOrder.map((n) => (
|
||
<col key={n} className="w-[52px]" />
|
||
))}
|
||
{showSplit && <col className="w-[60px]" />}
|
||
{showSplit && <col className="w-[60px]" />}
|
||
<col className="w-[60px]" />
|
||
</colgroup>
|
||
<thead>
|
||
<tr className="border-b border-border">
|
||
<th
|
||
scope="col"
|
||
className="sticky left-0 z-10 border-r border-border bg-card px-3 py-2 text-left text-xs font-bold uppercase tracking-wide text-muted-foreground"
|
||
>
|
||
Side
|
||
</th>
|
||
{holeOrder.map((n) => {
|
||
const active = n === currentHole
|
||
return (
|
||
<th key={n} scope="col" className="border-l border-border p-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => onSelectHole(n)}
|
||
aria-current={active ? "true" : undefined}
|
||
aria-label={`Velg hull ${n}`}
|
||
className={cn(
|
||
"flex min-h-11 w-full items-center justify-center text-sm font-extrabold tabular-nums transition-colors",
|
||
active ? "bg-primary text-primary-foreground" : "bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{n}
|
||
</button>
|
||
</th>
|
||
)
|
||
})}
|
||
{showSplit && (
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Ut
|
||
</th>
|
||
)}
|
||
{showSplit && (
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Inn
|
||
</th>
|
||
)}
|
||
<th scope="col" className="border-l border-border bg-muted px-1 py-2 text-xs font-bold text-muted-foreground">
|
||
Sum
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr className="border-b border-border">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
|
||
Hcp
|
||
</th>
|
||
{holeOrder.map((n) => (
|
||
<td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{holeMeta[n]?.index ?? "–"}
|
||
</td>
|
||
))}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
{showSplit && <td className="border-l border-border bg-muted" />}
|
||
<td className="border-l border-border bg-muted" />
|
||
</tr>
|
||
<tr className="border-b-2 border-border">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card px-3 py-1.5 text-left text-xs font-semibold text-muted-foreground">
|
||
Par
|
||
</th>
|
||
{holeOrder.map((n) => (
|
||
<td key={n} className="border-l border-border py-1.5 text-center text-xs font-semibold tabular-nums text-muted-foreground">
|
||
{holeMeta[n]?.par ?? "–"}
|
||
</td>
|
||
))}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{front.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
|
||
</td>
|
||
)}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{back.reduce((sum, n) => sum + (holeMeta[n]?.par ?? 0), 0) || "–"}
|
||
</td>
|
||
)}
|
||
<td className="border-l border-border bg-muted text-center text-xs font-bold tabular-nums text-foreground">
|
||
{parRowTotal || "–"}
|
||
</td>
|
||
</tr>
|
||
|
||
{sides.map((side, sideIndex) => {
|
||
const list = holesBySide[side.id]
|
||
const frontSum = showSplit ? sumFor(side.id, front) : null
|
||
const backSum = showSplit ? sumFor(side.id, back) : null
|
||
const totalSum = sumFor(side.id, holeOrder)
|
||
const assignedNames = players.filter((p) => p.roundSideId === side.id).map((p) => p.name)
|
||
return (
|
||
<tr key={side.id} className="border-b border-border last:border-b-0">
|
||
<th scope="row" className="sticky left-0 z-10 border-r border-border bg-card p-0 text-left font-normal">
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onOpenEntry(side.id, currentHole)}
|
||
aria-label={`Registrer score for ${side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`} på hull ${currentHole}`}
|
||
className="flex min-h-12 w-[130px] flex-col justify-center gap-0.5 px-3 py-1.5 text-left transition-colors hover:bg-accent/50 disabled:opacity-60"
|
||
>
|
||
<span className="truncate text-sm font-bold text-foreground">
|
||
{side.label?.trim() || `Side ${sideIndex === 0 ? "A" : "B"}`}
|
||
</span>
|
||
<span className="truncate text-xs text-muted-foreground">
|
||
{assignedNames.length > 0 ? assignedNames.join(" · ") : "Ingen spillere"}
|
||
</span>
|
||
</button>
|
||
</th>
|
||
{holeOrder.map((n) => {
|
||
const apiHole = list?.find((h) => h.hole_number === n) ?? null
|
||
const meta = holeMeta[n]
|
||
const hasScore = Boolean(apiHole?.played) && apiHole?.score !== null
|
||
const diff = hasScore && meta ? (apiHole!.score as number) - meta.par : null
|
||
return (
|
||
<td key={n} className="border-l border-border p-0 text-center">
|
||
<button
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onOpenEntry(side.id, n)}
|
||
aria-label={
|
||
hasScore
|
||
? `${side.label ?? "Side"}, hull ${n}, ${apiHole?.score} slag`
|
||
: `Registrer score for hull ${n}`
|
||
}
|
||
className="flex min-h-12 w-full items-center justify-center py-1 transition-colors hover:bg-accent/40 disabled:opacity-60"
|
||
>
|
||
<ScorecardCell value={hasScore ? (apiHole!.score as number) : null} diff={diff} />
|
||
</button>
|
||
</td>
|
||
)
|
||
})}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
|
||
{frontSum ? frontSum.strokes : "–"}
|
||
</td>
|
||
)}
|
||
{showSplit && (
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center text-sm font-extrabold tabular-nums text-foreground">
|
||
{backSum ? backSum.strokes : "–"}
|
||
</td>
|
||
)}
|
||
<td className="border-l border-border bg-muted px-1 py-1 text-center">
|
||
{totalSum ? (
|
||
<span className="flex flex-col items-center leading-tight">
|
||
<span className="text-sm font-extrabold tabular-nums text-foreground">{totalSum.strokes}</span>
|
||
<span className="text-[10px] font-semibold tabular-nums text-muted-foreground">
|
||
{formatToPar(totalSum.strokes - totalSum.par)}
|
||
</span>
|
||
</span>
|
||
) : (
|
||
"–"
|
||
)}
|
||
</td>
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// --- Delt-ball-scoreveiviser (ADR-039 Beslutning C) --------------------------
|
||
// Enklere enn ScoringWizard -- kun ett tall (slagtall for hele siden), ingen
|
||
// putt-/detalj-steg. Samme "Neste side" → "Neste hull"-samlebåndsprinsipp.
|
||
|
||
function SideScoreWizard({
|
||
sides,
|
||
side,
|
||
sideParticipants,
|
||
hole,
|
||
score,
|
||
selectedParticipantId,
|
||
onSave,
|
||
onSelectParticipant,
|
||
onClose,
|
||
onAdvance,
|
||
readOnly,
|
||
isPending,
|
||
}: {
|
||
sides: ApiSide[]
|
||
side: ApiSide
|
||
// Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036).
|
||
sideParticipants: Player[]
|
||
hole: Hole
|
||
score: number | null
|
||
selectedParticipantId: string | null
|
||
onSave: (score: number | null) => void
|
||
onSelectParticipant: (participantId: string | null) => void
|
||
onClose: () => void
|
||
onAdvance: () => void
|
||
readOnly: boolean
|
||
// Offline scoreregistrering (ADR-028-utvidelse, 2026-07-28) -- se
|
||
// ScoringWizard sin samme prop for full begrunnelse.
|
||
isPending: boolean
|
||
}) {
|
||
const sideIndex = sides.findIndex((s) => s.id === side.id)
|
||
const isLast = sideIndex === -1 || sideIndex === sides.length - 1
|
||
const nextSide = !isLast ? sides[sideIndex + 1] : null
|
||
|
||
function sideLabel(s: ApiSide, idx: number) {
|
||
return s.label?.trim() || `Side ${idx === 0 ? "A" : "B"}`
|
||
}
|
||
|
||
// Auto-hopp (2026-07-28, samme mønster som ScoringWizard) -- registrer
|
||
// slagtall for siden, hopp umiddelbart videre til neste side/hull. KUN
|
||
// når det ikke finnes noe utslags-valg å ta stilling til også (migrasjon
|
||
// 036) -- ellers ville auto-hoppet revet brukeren videre FØR de rakk å
|
||
// velge hvem sitt utslag som ble brukt (det valgfrie feltet under).
|
||
const autoAdvance = sideParticipants.length === 0
|
||
const enteredWithValueRef = useRef(score !== null)
|
||
const firedRef = useRef(false)
|
||
useEffect(() => {
|
||
firedRef.current = false
|
||
enteredWithValueRef.current = score !== null
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [side.id, hole.holeNumber])
|
||
useEffect(() => {
|
||
if (!autoAdvance || firedRef.current || enteredWithValueRef.current) return
|
||
if (score !== null) {
|
||
firedRef.current = true
|
||
onAdvance()
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [score, autoAdvance])
|
||
|
||
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">{sideLabel(side, sideIndex)}</span>
|
||
</div>
|
||
<div className="size-11 shrink-0" aria-hidden="true" />
|
||
</header>
|
||
|
||
<div className="-mx-1 flex shrink-0 gap-2 overflow-x-auto border-b border-border px-4 py-2.5">
|
||
{sides.map((s, i) => (
|
||
<span
|
||
key={s.id}
|
||
className={cn(
|
||
"flex shrink-0 items-center rounded-full border px-3 py-1.5 text-sm font-bold",
|
||
s.id === side.id
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-muted-foreground",
|
||
)}
|
||
>
|
||
{sideLabel(s, i)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
|
||
{isPending && (
|
||
<p className="shrink-0 border-b border-border bg-muted/50 px-4 py-1.5 text-center text-xs font-medium text-muted-foreground">
|
||
Lagret lokalt · venter på synk
|
||
</p>
|
||
)}
|
||
|
||
<main className="flex-1 overflow-y-auto p-5 sm:p-6">
|
||
<div className="mx-auto flex max-w-sm flex-col gap-6">
|
||
<NumberPicker
|
||
label="Slag"
|
||
value={score}
|
||
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) => onSave(v)}
|
||
readOnly={readOnly}
|
||
/>
|
||
|
||
{/* Scramble/greensome: hvem sitt utslag ble valgt (migrasjon 036,
|
||
FEATURE_BACKLOG.md "Scramble: statistikk over utslag brukt per
|
||
spiller"). Helt valgfritt -- egen handling, rører aldri
|
||
slagtallet. */}
|
||
{sideParticipants.length > 0 && (
|
||
<fieldset className="flex flex-col gap-2">
|
||
<legend className="mb-1 text-sm font-bold text-foreground">
|
||
Hvem sitt utslag ble brukt? <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||
</legend>
|
||
<div className="flex flex-wrap gap-2">
|
||
{sideParticipants.map((p) => (
|
||
<button
|
||
key={p.id}
|
||
type="button"
|
||
disabled={readOnly}
|
||
onClick={() => onSelectParticipant(selectedParticipantId === p.id ? null : p.id)}
|
||
aria-pressed={selectedParticipantId === p.id}
|
||
className={cn(
|
||
"inline-flex min-h-11 items-center rounded-xl border px-4 text-sm font-bold transition-colors disabled:opacity-60",
|
||
selectedParticipantId === p.id
|
||
? "border-primary bg-primary text-primary-foreground"
|
||
: "border-border bg-card text-foreground hover:bg-accent/50",
|
||
)}
|
||
>
|
||
{p.name}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</fieldset>
|
||
)}
|
||
</div>
|
||
</main>
|
||
|
||
<footer className="grid shrink-0 grid-cols-2 gap-3 border-t border-border p-4">
|
||
<Button type="button" variant="outline" onClick={onClose} className="h-14 rounded-2xl text-base font-bold">
|
||
Avbryt
|
||
</Button>
|
||
<Button
|
||
type="button"
|
||
disabled={score === null}
|
||
onClick={onAdvance}
|
||
className="h-14 rounded-2xl text-base font-bold disabled:opacity-40"
|
||
>
|
||
{isLast ? "Ferdig" : `Neste: ${sideLabel(nextSide!, sideIndex + 1)}`}
|
||
</Button>
|
||
</footer>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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>
|
||
)
|
||
}
|