teecup/frontend/components/round-header.tsx
Erol Haagenrud a0163eaa16 Ny runde-veiviseren: tre fikser (overleggsmeny, HCP-overstyring, statistikk-default)
- "Administrer runde"-dialogen lukker seg nå automatisk ved navigasjon
  til "Spillere og runde" (var stående åpen ved samme-rute-navigasjon).
- HCP kan nå overstyres for en kontokoblet medspiller fra første stund
  i veiviseren, ikke bare senere via "Runde og runde". Backend sitt
  add_participant æret aldri en eksplisitt overstyring for user_id,
  ulikt PATCH-endepunktet (ADR-038-avvik, nå rettet).
- Standard statistikknivå for en NY medspiller er nå "Kun slag" i
  stedet for å arve scoreførerens eget valg.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:05:05 +02:00

390 lines
15 KiB
TypeScript

"use client"
// Delt header + fane-rad for Score/Scorekort/Leaderboard (ADR-040
// Beslutning C/D, "det store grepet"). Presentasjon fra V0 (zip 26),
// tatt inn UENDRET (rent visuelt/strukturelt, ingen ekte logikk å
// bytte ut -- selve rundedataen og handlingene wires inn av
// `round-page-shell.tsx`, som eier ekte henting/POST/DELETE).
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import Link from "next/link"
import {
ArrowLeft,
CalendarDays,
CheckCircle2,
Clock,
Flag,
MapPin,
Settings2,
Trash2,
Users,
X,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
// --- Types -----------------------------------------------------------------
export type RoundTab = "score" | "scorecard" | "leaderboard"
export type RoundHeaderInfo = {
/** Course name — always known. Shown as heading unless a customName exists. */
courseName: string
/** Optional custom round name; when set it becomes the heading and the
* course name drops to a secondary line. */
customName?: string
teeName: string
date: string
/** Optional tee time, e.g. "09:20". */
teeTime?: string
/** Head-to-head match-play round → shows the outlined "Matchspill" badge. */
isMatchPlay?: boolean
}
type RoundHeaderProps = {
activeTab: RoundTab
round: RoundHeaderInfo
/** Where the back button navigates (out of the round entirely). */
backHref?: string
/** Per-tab destinations. When omitted, tabs render as inert buttons
* (useful for isolated previews). */
tabHrefs?: Record<RoundTab, string>
/** Destination for the round-management screen ("Spillere og runde"). */
manageHref?: string
/** Fullfør runde. Returnerer `null` hvis brukeren avbrøt bekreftelsen
* (dialogen skal da bare forbli åpen, ingen feilmelding), ellers
* `{ ok, message }` -- `ok: false` viser `message` i dialogen i stedet
* for å lukke den (2026-08-04 bugfiks: denne handlingen fulgte
* tidligere aldri opp om `fetch`-kallet faktisk lyktes -- dialogen
* lukket seg uansett utfall, uten feilmelding, se CHANGELOG.md). */
onFinishRound?: () => Promise<{ ok: boolean; message?: string } | null>
/** Når satt: "Fullfør runde"-knappen deaktiveres og denne teksten vises
* i stedet (samme oppsett-ufullstendig-sjekk som round-detail.tsx sin
* egen, uavhengige "Spillere og runde"-fane allerede håndhever). */
finishDisabledReason?: string | null
onDeleteRound?: () => void
className?: string
}
const TABS: { key: RoundTab; label: string }[] = [
{ key: "score", label: "Score" },
{ key: "scorecard", label: "Scorekort" },
{ key: "leaderboard", label: "Leaderboard" },
]
// --- Shared header ---------------------------------------------------------
export function RoundHeader({
activeTab,
round,
backHref = "/rounds",
tabHrefs,
manageHref = "#",
onFinishRound,
finishDisabledReason,
onDeleteRound,
className,
}: RoundHeaderProps) {
const [manageOpen, setManageOpen] = useState(false)
const heading = round.customName ?? round.courseName
// Only show the course name as a secondary line when a custom name replaced it.
const showCourseLine = Boolean(round.customName)
return (
<header
className={cn(
"sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
className,
)}
>
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 py-3 sm:px-5 sm:py-4">
{/* Row 1: back + identity + manage */}
<div className="flex items-start gap-3">
<Link
href={backHref}
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h1 className="flex min-w-0 items-center gap-2 text-lg font-extrabold leading-tight tracking-tight text-foreground text-balance sm:text-xl">
<MapPin aria-hidden="true" className="size-5 shrink-0 text-primary" />
<span className="min-w-0 break-words">{heading}</span>
</h1>
{round.isMatchPlay && (
<span className="inline-flex shrink-0 items-center rounded-full border border-primary/50 px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide text-primary">
Matchspill
</span>
)}
</div>
{/* Secondary line(s): course name (if custom), then tee / date / time */}
<div className="flex flex-col gap-0.5">
{showCourseLine && (
<span className="flex items-center gap-1.5 text-sm font-semibold text-muted-foreground">
<MapPin aria-hidden="true" className="size-4 shrink-0" />
<span className="break-words">{round.courseName}</span>
</span>
)}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm font-medium text-muted-foreground">
<span className="flex items-center gap-1.5">
<Flag aria-hidden="true" className="size-4 shrink-0" />
{round.teeName}
</span>
<span className="flex items-center gap-1.5 tabular-nums">
<CalendarDays aria-hidden="true" className="size-4 shrink-0" />
{round.date}
</span>
{round.teeTime && (
<span className="flex items-center gap-1.5 tabular-nums">
<Clock aria-hidden="true" className="size-4 shrink-0" />
{round.teeTime}
</span>
)}
</div>
</div>
</div>
{/* Quiet, labeled management affordance — secondary to the tabs. */}
<button
type="button"
onClick={() => setManageOpen(true)}
className="flex min-h-11 shrink-0 items-center gap-1.5 rounded-xl border border-border bg-card px-3 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Settings2 aria-hidden="true" className="size-4" />
<span className="hidden sm:inline">Administrer</span>
<span className="sr-only sm:hidden">Administrer runde</span>
</button>
</div>
{/* Row 2: three-tab segmented control */}
<nav aria-label="Rundevisning">
<div className="flex items-center gap-1 rounded-full border border-border bg-muted/60 p-1">
{TABS.map((tab) => {
const active = tab.key === activeTab
const shared =
"flex min-h-11 flex-1 items-center justify-center rounded-full px-3 text-sm font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-base"
const activeCls = "bg-primary text-primary-foreground shadow-sm"
const inactiveCls =
"text-muted-foreground hover:text-foreground hover:bg-background/60"
if (tabHrefs) {
return (
<Link
key={tab.key}
href={tabHrefs[tab.key]}
aria-current={active ? "page" : undefined}
className={cn(shared, active ? activeCls : inactiveCls)}
>
{tab.label}
</Link>
)
}
return (
<button
key={tab.key}
type="button"
aria-current={active ? "page" : undefined}
className={cn(shared, active ? activeCls : inactiveCls)}
>
{tab.label}
</button>
)
})}
</div>
</nav>
</div>
{manageOpen && (
<ManageRoundDialog
manageHref={manageHref}
onClose={() => setManageOpen(false)}
onFinishRound={onFinishRound}
finishDisabledReason={finishDisabledReason}
onDeleteRound={onDeleteRound}
/>
)}
</header>
)
}
// --- Management dialog -----------------------------------------------------
function ManageRoundDialog({
manageHref,
onClose,
onFinishRound,
finishDisabledReason,
onDeleteRound,
}: {
manageHref: string
onClose: () => void
onFinishRound?: () => Promise<{ ok: boolean; message?: string } | null>
finishDisabledReason?: string | null
onDeleteRound?: () => void
}) {
const closeRef = useRef<HTMLButtonElement>(null)
const [confirmDelete, setConfirmDelete] = useState(false)
const [mounted, setMounted] = useState(false)
const [finishing, setFinishing] = useState(false)
const [finishError, setFinishError] = useState<string | null>(null)
async function handleFinish() {
if (!onFinishRound || finishing) return
setFinishing(true)
setFinishError(null)
const result = await onFinishRound()
setFinishing(false)
if (result === null) return // brukeren avbrøt bekreftelsen -- la dialogen stå
if (result.ok) {
onClose()
} else {
setFinishError(result.message ?? "Klarte ikke å fullføre runden. Prøv igjen.")
}
}
// Escape to close + focus the close button on open + lock scroll.
useEffect(() => {
setMounted(true)
closeRef.current?.focus()
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose()
}
document.addEventListener("keydown", onKey)
const prevOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => {
document.removeEventListener("keydown", onKey)
document.body.style.overflow = prevOverflow
}
}, [onClose])
if (!mounted) return null
// Portal to <body> so the fixed overlay isn't trapped by the header's
// backdrop-filter containing block.
return createPortal(
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center">
<div
className="absolute inset-0 bg-foreground/40 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="manage-round-title"
className="relative z-10 flex w-full max-w-md flex-col gap-5 rounded-t-3xl border border-border bg-card p-5 shadow-xl sm:rounded-3xl sm:p-6"
>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<h2
id="manage-round-title"
className="text-xl font-extrabold tracking-tight text-foreground"
>
Administrer runde
</h2>
<p className="text-sm font-medium text-muted-foreground text-pretty">
Spillere, lag og administrasjon for denne runden.
</p>
</div>
<button
ref={closeRef}
type="button"
onClick={onClose}
aria-label="Lukk"
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden="true" className="size-5" />
</button>
</div>
{/* Reach the roster / team setup screen. `onClick={onClose}` (2026-08-20
brukerfunn): denne lenken navigerer ofte til KUN en søkeparameter-
endring på samme rute (?tab=manage, se round-detail.tsx), som ikke
remounter RoundHeader -- uten dette ble dialogen stående åpen over
innholdet under, og måtte lukkes manuelt. */}
<Link
href={manageHref}
onClick={onClose}
className="flex min-h-14 items-center gap-3 rounded-2xl border border-border bg-background px-4 py-3 text-left transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
<Users aria-hidden="true" className="size-5" />
</span>
<span className="flex min-w-0 flex-col">
<span className="text-base font-bold text-foreground">Spillere og runde</span>
<span className="text-sm font-medium text-muted-foreground">
Legg til, fjern eller rediger spillere og lag
</span>
</span>
</Link>
{/* Administrative actions */}
<div className="flex flex-col gap-2">
<Button
onClick={handleFinish}
disabled={finishing || Boolean(finishDisabledReason)}
title={finishDisabledReason ?? undefined}
className="min-h-12 w-full justify-center gap-2 rounded-2xl text-base font-bold"
>
<CheckCircle2 aria-hidden="true" className="size-5" />
{finishing ? "Fullfører…" : "Fullfør runde"}
</Button>
{finishDisabledReason && (
<p className="text-sm font-medium text-muted-foreground text-pretty">
{finishDisabledReason}
</p>
)}
{finishError && (
<p role="alert" className="text-sm font-semibold text-destructive text-pretty">
{finishError}
</p>
)}
{confirmDelete ? (
<div className="flex flex-col gap-2 rounded-2xl border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-semibold text-foreground text-pretty">
Slette runden permanent? Dette kan ikke angres.
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setConfirmDelete(false)}
className="min-h-11 flex-1 justify-center rounded-xl font-bold"
>
Avbryt
</Button>
<Button
variant="destructive"
onClick={() => {
onDeleteRound?.()
onClose()
}}
className="min-h-11 flex-1 justify-center gap-1.5 rounded-xl font-bold"
>
<Trash2 aria-hidden="true" className="size-4" />
Slett runde
</Button>
</div>
</div>
) : (
<Button
variant="destructive"
onClick={() => setConfirmDelete(true)}
className="min-h-12 w-full justify-center gap-2 rounded-2xl text-base font-bold"
>
<Trash2 aria-hidden="true" className="size-5" />
Slett runde
</Button>
)}
</div>
</div>
</div>,
document.body,
)
}