teecup/frontend/components/order-of-merit-detail.tsx
Erol Haagenrud f740814833 Order of Merit: offentlig/delt visning (ADR-076)
Siste av de tre gjenstående OOM-punktene fra ADR-043 (lag-OOM ADR-074,
eclectic på tvers av turneringer ADR-075, offentlig visning her).

Migrasjon 076: SECURITY DEFINER-bro public_order_of_merit_by_id, samme
anti-enumerering som public_org_by_slug/public_tournament_by_code. Ny
/public/order-of-merits/{id}-endepunkt (gjenbruker den eksisterende
leaderboard-beregningen direkte). Ny uautentisert frontend-side
modellert på public-club.tsx, "Del offentlig lenke" i innstillinger.

83/83 backend-tester, 45/45 vitest, scratch-verifisert i egen inkognito-
nettleserkontekst (ingen session-cookie). Migrasjon vist og bekreftet
før kjøring mot ekte teecup_db. Rullet ut.

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

1066 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
// Order of Merit-detalj (ADR-043 [OOM]): lenkede turneringer -> innstillinger
// -> resultatliste, i den rekkefølgen -- resultater gir ikke mening før
// turneringer er lenket. Håndkodet (ingen V0-credits denne runden), men
// bevisst bygget til samme visuelle presisjon som resten av appen sine
// V0-eksporter (samme tokens/kort-mønster).
import type React from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Check, ChevronDown, Link2, Medal, Plus, Settings, Trash2, Trophy, Users } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
type OomKind = "player" | "team"
type OomResultType = "points" | "stableford" | "gross" | "net" | "money"
type OomAggregationMode = "sum" | "average" | "eclectic"
// Eclectic-aggregering (ADR-075) krever kind='player' og en resultattype med
// per-hull-granularitet (stableford/brutto/netto) -- håndhevet server-side
// (order_of_merit.py). Kind/resultattype er låst etter opprettelse her, så
// ingen reset-logikk trengs (i motsetning til order-of-merit-list.tsx sitt
// opprettelsesskjema).
function eclecticAllowed(kind: OomKind, resultType: OomResultType): boolean {
return kind === "player" && (resultType === "stableford" || resultType === "gross" || resultType === "net")
}
type ApiOrderOfMerit = {
id: string
name: string
kind: OomKind
result_type: OomResultType
aggregation_mode: OomAggregationMode
count_best_n: number | null
min_results_required: number | null
birth_year_from: number | null
birth_year_to: number | null
public_visible: boolean
}
type ApiLink = {
id: string
tournament_id: string
tournament_name: string
points_table: number[] | null
money_pool_total: number | null
money_payout_table: { position: number; amount: number }[] | null
}
type ApiLeaderboardEntry = {
player_id: string
player_name: string
results_counted: number
results_available: number
value: number | null
position: string | null
eligible: boolean
ineligible_reason: string | null
}
type ApiTournament = { id: string; name: string; format_type: string }
// Lag-OOM (ADR-043 Beslutning C, ADR-074) -- sesong-par satt opp DIREKTE i
// OOM-en, ikke turnering-lag. Kun relevant når oom.kind === "team".
type ApiOomTeamMember = { id: string; player_id: string; player_name: string }
type ApiOomTeam = { id: string; name: string; members: ApiOomTeamMember[] }
type ApiPlayer = { id: string; display_name: string }
const KIND_LABELS: Record<OomKind, string> = { player: "Spiller", team: "Lag" }
const RESULT_TYPE_LABELS: Record<OomResultType, string> = {
points: "Poeng etter plassering",
stableford: "Stableford-sum",
gross: "Bruttoscore-sum",
net: "Nettoscore-sum",
money: "Pengeliste",
}
function formatValue(value: number | null, resultType: OomResultType): string {
if (value === null) return ""
if (resultType === "money") return `${Math.round(value).toLocaleString("nb-NO")} kr`
return Number.isInteger(value) ? String(value) : value.toFixed(1)
}
export function OrderOfMeritDetail({
organizationId,
oomId,
orgName,
}: {
organizationId: string
oomId: string
orgName: string
}) {
const [oom, setOom] = useState<ApiOrderOfMerit | null>(null)
const [links, setLinks] = useState<ApiLink[] | null>(null)
const [leaderboard, setLeaderboard] = useState<ApiLeaderboardEntry[] | null>(null)
const [allTournaments, setAllTournaments] = useState<ApiTournament[]>([])
const [teams, setTeams] = useState<ApiOomTeam[] | null>(null)
const [players, setPlayers] = useState<ApiPlayer[]>([])
const [error, setError] = useState<string | null>(null)
const [notFound, setNotFound] = useState(false)
async function loadOom() {
const res = await fetch(`/orgs/${organizationId}/order-of-merits`, { credentials: "include" })
if (!res.ok) return
const all: ApiOrderOfMerit[] = await res.json()
const mine = all.find((o) => o.id === oomId)
if (!mine) {
setNotFound(true)
return
}
setOom(mine)
}
async function loadLinks() {
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments`, { credentials: "include" })
if (res.ok) setLinks(await res.json())
}
async function loadLeaderboard() {
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/leaderboard`, { credentials: "include" })
if (res.ok) setLeaderboard(await res.json())
else setLeaderboard([])
}
async function loadTournaments() {
const res = await fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" })
if (res.ok) setAllTournaments(await res.json())
}
async function loadTeams() {
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams`, { credentials: "include" })
if (res.ok) setTeams(await res.json())
}
async function loadPlayers() {
const res = await fetch(`/orgs/${organizationId}/players`, { credentials: "include" })
if (res.ok) setPlayers(await res.json())
}
useEffect(() => {
void loadOom()
void loadLinks()
void loadTournaments()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId, oomId])
useEffect(() => {
if (!oom) return
void loadLeaderboard()
if (oom.kind === "team") {
void loadTeams()
void loadPlayers()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [oom?.kind, links])
async function linkTournament(input: {
tournament_id: string
points_table?: number[]
money_pool_total?: number
money_payout_table?: { position: number; amount: number }[]
}) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(input),
})
if (!res.ok) {
const body = await res.json().catch(() => null)
setError(body?.detail?.message ?? "Klarte ikke å lenke turneringen.")
return
}
await loadLinks()
}
async function unlinkTournament(linkId: string) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments/${linkId}`, {
method: "DELETE",
credentials: "include",
})
if (res.status !== 204) {
setError("Klarte ikke å fjerne lenken.")
return
}
setLinks((prev) => (prev ?? []).filter((l) => l.id !== linkId))
}
async function createTeam(name: string) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ name }),
})
if (!res.ok) {
setError("Klarte ikke å opprette laget.")
return
}
await loadTeams()
}
async function deleteTeam(teamId: string) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}`, {
method: "DELETE",
credentials: "include",
})
if (res.status !== 204) {
setError("Klarte ikke å slette laget.")
return
}
setTeams((prev) => (prev ?? []).filter((t) => t.id !== teamId))
await loadLeaderboard()
}
async function addMember(teamId: string, playerId: string) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}/members`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ player_id: playerId }),
})
if (!res.ok) {
setError("Klarte ikke å legge til spilleren.")
return
}
await loadTeams()
await loadLeaderboard()
}
async function removeMember(teamId: string, memberId: string) {
setError(null)
const res = await fetch(
`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}/members/${memberId}`,
{ method: "DELETE", credentials: "include" },
)
if (res.status !== 204) {
setError("Klarte ikke å fjerne spilleren.")
return
}
await loadTeams()
await loadLeaderboard()
}
async function saveSettings(patch: Record<string, unknown>) {
setError(null)
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(patch),
})
if (!res.ok) {
setError("Klarte ikke å lagre innstillingene.")
return
}
const updated: ApiOrderOfMerit = await res.json()
setOom(updated)
}
async function deleteOom() {
if (!confirm("Slette denne Order of Merit-en? Dette kan ikke angres.")) return
const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}`, {
method: "DELETE",
credentials: "include",
})
if (res.status === 204) {
window.location.href = `/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`
}
}
if (notFound) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center gap-3 bg-background px-5 text-center">
<p className="text-sm font-medium text-muted-foreground">Fant ikke denne Order of Merit-en.</p>
<Link href={`/organizations/${organizationId}/order-of-merit`} className="text-sm font-semibold text-primary underline-offset-2 hover:underline">
Tilbake til listen
</Link>
</div>
)
}
if (!oom) {
return (
<div className="flex min-h-[100dvh] flex-col items-center justify-center bg-background">
<div aria-hidden="true" className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary" />
</div>
)
}
const linkedTournamentIds = new Set((links ?? []).map((l) => l.tournament_id))
const linkableTournaments = allTournaments.filter(
(t) => t.format_type === "individual" && !linkedTournamentIds.has(t.id),
)
return (
<div className="flex min-h-[100dvh] flex-col bg-background">
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
<div className="mx-auto flex w-full max-w-2xl items-center gap-3 px-5 py-4">
<Link
href={`/organizations/${organizationId}/order-of-merit?name=${encodeURIComponent(orgName)}`}
aria-label="Tilbake til Order of Merit-listen"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Order of Merit
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{oom.name}</h1>
</div>
<Button
type="button"
variant="ghost"
onClick={deleteOom}
className="h-10 shrink-0 rounded-xl px-3 text-sm font-semibold text-destructive hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
Slett
</Button>
</div>
</header>
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-5 py-6 sm:py-8">
{error && (
<p role="alert" className="text-sm font-medium text-destructive">
{error}
</p>
)}
<LinkedTournamentsSection
resultType={oom.result_type}
links={links}
linkableTournaments={linkableTournaments}
onLink={linkTournament}
onUnlink={unlinkTournament}
/>
<SettingsSection oom={oom} onSave={saveSettings} />
{oom.kind === "team" && (
<TeamsSection
teams={teams}
players={players}
onCreateTeam={createTeam}
onDeleteTeam={deleteTeam}
onAddMember={addMember}
onRemoveMember={removeMember}
/>
)}
<LeaderboardSection resultType={oom.result_type} entries={leaderboard} />
</main>
</div>
)
}
// --- B. Lenkede turneringer --------------------------------------------------
function LinkedTournamentsSection({
resultType,
links,
linkableTournaments,
onLink,
onUnlink,
}: {
resultType: OomResultType
links: ApiLink[] | null
linkableTournaments: ApiTournament[]
onLink: (input: {
tournament_id: string
points_table?: number[]
money_pool_total?: number
money_payout_table?: { position: number; amount: number }[]
}) => Promise<void>
onUnlink: (linkId: string) => void
}) {
const [adding, setAdding] = useState(false)
return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Trophy aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Lenkede turneringer</h2>
</div>
{links === null ? (
<p className="text-sm text-muted-foreground">Laster</p>
) : links.length === 0 ? (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen turneringer lenket ennå.
</p>
) : (
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
{links.map((l) => (
<li key={l.id} className="flex items-center gap-3 px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-foreground">{l.tournament_name}</span>
{resultType === "points" && l.points_table && (
<span className="text-xs text-muted-foreground">Poeng: {l.points_table.join("-")}</span>
)}
{resultType === "money" && l.money_pool_total !== null && (
<span className="text-xs text-muted-foreground">
Premiepott: {Math.round(l.money_pool_total).toLocaleString("nb-NO")} kr
</span>
)}
</div>
<Button
type="button"
variant="ghost"
onClick={() => onUnlink(l.id)}
className="h-10 shrink-0 rounded-xl px-3 text-xs font-bold text-muted-foreground hover:text-destructive"
>
Fjern
</Button>
</li>
))}
</ul>
)}
{!adding ? (
<Button
type="button"
variant="outline"
onClick={() => setAdding(true)}
disabled={linkableTournaments.length === 0}
className="h-11 self-start rounded-xl text-sm font-bold"
>
<Plus aria-hidden="true" className="size-4" />
Lenk turnering
</Button>
) : (
<LinkTournamentForm
resultType={resultType}
candidates={linkableTournaments}
onSubmit={async (input) => {
await onLink(input)
setAdding(false)
}}
onCancel={() => setAdding(false)}
/>
)}
{linkableTournaments.length === 0 && !adding && (
<p className="text-xs text-muted-foreground">
Ingen flere individuelle turneringer å lenke -- alle er allerede lenket, eller organisasjonen har ingen ennå.
</p>
)}
</section>
)
}
function LinkTournamentForm({
resultType,
candidates,
onSubmit,
onCancel,
}: {
resultType: OomResultType
candidates: ApiTournament[]
onSubmit: (input: {
tournament_id: string
points_table?: number[]
money_pool_total?: number
money_payout_table?: { position: number; amount: number }[]
}) => Promise<void>
onCancel: () => void
}) {
const [tournamentId, setTournamentId] = useState(candidates[0]?.id ?? "")
const [pointsRows, setPointsRows] = useState<string[]>(["10", "6", "3"])
const [moneyPool, setMoneyPool] = useState("")
const [moneyRows, setMoneyRows] = useState<string[]>(["", "", ""])
const [submitting, setSubmitting] = useState(false)
function updateRow(setter: React.Dispatch<React.SetStateAction<string[]>>, index: number, value: string) {
setter((prev) => prev.map((v, i) => (i === index ? value : v)))
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!tournamentId || submitting) return
setSubmitting(true)
try {
if (resultType === "points") {
const points_table = pointsRows.map((v) => Number(v) || 0)
await onSubmit({ tournament_id: tournamentId, points_table })
} else if (resultType === "money") {
const money_payout_table = moneyRows.map((v, i) => ({ position: i + 1, amount: Number(v) || 0 }))
await onSubmit({ tournament_id: tournamentId, money_pool_total: Number(moneyPool) || 0, money_payout_table })
} else {
await onSubmit({ tournament_id: tournamentId })
}
} finally {
setSubmitting(false)
}
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 rounded-2xl border border-border bg-background p-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="link-tournament" className="text-xs font-semibold">
Turnering
</Label>
<select
id="link-tournament"
value={tournamentId}
onChange={(e) => setTournamentId(e.target.value)}
className="h-11 rounded-xl border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
>
{candidates.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
{resultType === "points" && (
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold">Poeng per plassering</span>
{pointsRows.map((v, i) => (
<div key={i} className="flex items-center gap-2">
<span className="w-20 shrink-0 text-sm text-muted-foreground">{i + 1}. plass</span>
<Input
inputMode="numeric"
value={v}
onChange={(e) => updateRow(setPointsRows, i, e.target.value)}
className="h-10 flex-1 rounded-lg text-sm"
/>
</div>
))}
<Button
type="button"
variant="ghost"
onClick={() => setPointsRows((prev) => [...prev, "0"])}
className="h-9 self-start rounded-lg text-xs font-bold text-muted-foreground"
>
<Plus aria-hidden="true" className="size-4" />
Legg til plassering
</Button>
</div>
)}
{resultType === "money" && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="money-pool" className="text-xs font-semibold">
Premiepott (kr)
</Label>
<Input
id="money-pool"
inputMode="numeric"
value={moneyPool}
onChange={(e) => setMoneyPool(e.target.value)}
className="h-10 rounded-lg text-sm"
/>
</div>
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold">Utbetaling per plassering (kr)</span>
{moneyRows.map((v, i) => (
<div key={i} className="flex items-center gap-2">
<span className="w-20 shrink-0 text-sm text-muted-foreground">{i + 1}. plass</span>
<Input
inputMode="numeric"
value={v}
onChange={(e) => updateRow(setMoneyRows, i, e.target.value)}
className="h-10 flex-1 rounded-lg text-sm"
/>
</div>
))}
<Button
type="button"
variant="ghost"
onClick={() => setMoneyRows((prev) => [...prev, ""])}
className="h-9 self-start rounded-lg text-xs font-bold text-muted-foreground"
>
<Plus aria-hidden="true" className="size-4" />
Legg til plassering
</Button>
</div>
</div>
)}
<div className="flex gap-2">
<Button type="submit" disabled={!tournamentId || submitting} className="h-10 flex-1 rounded-xl text-sm font-bold">
{submitting ? "Lenker…" : "Lenk"}
</Button>
<Button type="button" variant="ghost" onClick={onCancel} className="h-10 rounded-xl text-sm font-semibold text-muted-foreground">
Avbryt
</Button>
</div>
</form>
)
}
// --- C. Innstillinger ---------------------------------------------------------
function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch: Record<string, unknown>) => Promise<void> }) {
const [open, setOpen] = useState(false)
const [name, setName] = useState(oom.name)
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>(oom.aggregation_mode)
const [bestN, setBestN] = useState(oom.count_best_n?.toString() ?? "")
const [minResults, setMinResults] = useState(oom.min_results_required?.toString() ?? "")
const [birthYearFrom, setBirthYearFrom] = useState(oom.birth_year_from?.toString() ?? "")
const [birthYearTo, setBirthYearTo] = useState(oom.birth_year_to?.toString() ?? "")
const [publicVisible, setPublicVisible] = useState(oom.public_visible)
const [saving, setSaving] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setSaving(true)
try {
await onSave({
name: name.trim(),
aggregation_mode: aggregationMode,
count_best_n: bestN.trim() ? Number(bestN) : null,
min_results_required: minResults.trim() ? Number(minResults) : null,
birth_year_from: birthYearFrom.trim() ? Number(birthYearFrom) : null,
birth_year_to: birthYearTo.trim() ? Number(birthYearTo) : null,
public_visible: publicVisible,
})
} finally {
setSaving(false)
}
}
return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex min-h-11 items-center gap-2.5 text-left"
>
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Settings aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="flex-1 text-base font-bold text-foreground">Innstillinger</h2>
<ChevronDown aria-hidden="true" className={cn("size-5 text-muted-foreground transition-transform", open && "rotate-180")} />
</button>
{open && (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<p className="text-xs text-muted-foreground">
Type ({KIND_LABELS[oom.kind]}) og resultattype ({RESULT_TYPE_LABELS[oom.result_type]}) kan ikke endres etter opprettelse.
</p>
<div className="flex flex-col gap-1.5">
<Label htmlFor="settings-name" className="text-sm font-semibold">
Navn
</Label>
<Input id="settings-name" value={name} onChange={(e) => setName(e.target.value)} className="h-11 rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-semibold text-foreground">Aggregering</span>
<div className={`grid grid-cols-2 gap-2 ${eclecticAllowed(oom.kind, oom.result_type) ? "sm:grid-cols-3" : ""}`}>
<SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
Sum
</SettingsSegment>
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
Snitt
</SettingsSegment>
{eclecticAllowed(oom.kind, oom.result_type) && (
<SettingsSegment selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
Eclectic
</SettingsSegment>
)}
</div>
{aggregationMode === "eclectic" && (
<p className="text-xs text-muted-foreground">
Eclectic krever at alle lenkede turneringer spilles samme bane -- lenking av en turnering en annen
bane avvises lenge denne modusen er valgt.
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="best-n" className="text-sm font-semibold">
Behold de N beste
</Label>
<Input
id="best-n"
inputMode="numeric"
value={bestN}
onChange={(e) => setBestN(e.target.value)}
placeholder="Tom = tell alle resultater"
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="min-results" className="text-sm font-semibold">
Minimum antall resultater
</Label>
<Input
id="min-results"
inputMode="numeric"
value={minResults}
onChange={(e) => setMinResults(e.target.value)}
className="h-11 rounded-xl"
/>
</div>
<div className="flex flex-col gap-1.5">
<span className="text-sm font-semibold text-foreground">Aldersgrense</span>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="birth-from" className="text-xs text-muted-foreground">
Fødselsår fra
</Label>
<Input id="birth-from" inputMode="numeric" value={birthYearFrom} onChange={(e) => setBirthYearFrom(e.target.value)} className="h-11 rounded-xl" />
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="birth-to" className="text-xs text-muted-foreground">
Fødselsår til
</Label>
<Input id="birth-to" inputMode="numeric" value={birthYearTo} onChange={(e) => setBirthYearTo(e.target.value)} className="h-11 rounded-xl" />
</div>
</div>
</div>
<label className="flex min-h-11 items-center justify-between gap-3 rounded-xl border border-border bg-background px-4 py-3">
<span className="text-sm font-semibold text-foreground">Offentlig synlig</span>
<Switch checked={publicVisible} onCheckedChange={setPublicVisible} />
</label>
{oom.public_visible ? (
<PublicLinkChip oomId={oom.id} />
) : publicVisible ? (
<p className="text-xs text-muted-foreground">Lenken blir aktiv når du lagrer.</p>
) : null}
<Button type="submit" disabled={saving} className="h-11 rounded-xl font-bold">
{saving ? "Lagrer…" : "Lagre"}
</Button>
</form>
)}
</section>
)
}
function PublicLinkChip({ oomId }: { oomId: string }) {
const [copied, setCopied] = useState(false)
async function handleCopy() {
try {
const url = `${window.location.origin}/order-of-merit/${oomId}`
await navigator.clipboard.writeText(url)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
// stille -- ikke kritisk
}
}
return (
<button
type="button"
onClick={handleCopy}
className="flex min-h-11 items-center justify-center gap-1.5 rounded-xl border border-border bg-card px-3 py-2.5 text-sm font-bold text-foreground transition-colors hover:bg-accent/50"
>
{copied ? (
<Check aria-hidden="true" className="size-4 text-primary" />
) : (
<Link2 aria-hidden="true" className="size-4 text-muted-foreground" />
)}
{copied ? "Lenke kopiert" : "Del offentlig lenke"}
</button>
)
}
function SettingsSegment({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
className={cn(
"flex min-h-11 items-center justify-center rounded-xl border px-3 py-2.5 text-sm font-semibold transition-colors",
selected ? "border-primary bg-primary/10 text-foreground" : "border-border bg-background text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
{children}
</button>
)
}
// --- D. Resultatliste ----------------------------------------------------------
function LeaderboardSection({
resultType,
entries,
}: {
resultType: OomResultType
entries: ApiLeaderboardEntry[] | null
}) {
return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Medal aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Resultatliste</h2>
</div>
{entries === null ? (
<p className="text-sm text-muted-foreground">Laster</p>
) : (
<LeaderboardTable entries={entries} resultType={resultType} />
)}
</section>
)
}
// --- C.5 Lag (kun kind === "team", ADR-043 Beslutning C, ADR-074) ----------
// Sesong-par satt opp DIREKTE i OOM-en -- IKKE turnering-lag. Samme
// liste+skjema-mønster som "Lenkede turneringer" over.
function TeamsSection({
teams,
players,
onCreateTeam,
onDeleteTeam,
onAddMember,
onRemoveMember,
}: {
teams: ApiOomTeam[] | null
players: ApiPlayer[]
onCreateTeam: (name: string) => Promise<void>
onDeleteTeam: (teamId: string) => Promise<void>
onAddMember: (teamId: string, playerId: string) => Promise<void>
onRemoveMember: (teamId: string, memberId: string) => Promise<void>
}) {
const [adding, setAdding] = useState(false)
const [name, setName] = useState("")
const [submitting, setSubmitting] = useState(false)
async function handleCreate(e: React.FormEvent) {
e.preventDefault()
if (!name.trim() || submitting) return
setSubmitting(true)
try {
await onCreateTeam(name.trim())
setName("")
setAdding(false)
} finally {
setSubmitting(false)
}
}
return (
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
<div className="flex items-center gap-2.5">
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
</div>
<h2 className="text-base font-bold text-foreground">Lag</h2>
</div>
{teams === null ? (
<p className="text-sm text-muted-foreground">Laster</p>
) : teams.length === 0 ? (
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
Ingen lag opprettet ennå -- lag er sesong-par satt opp direkte her, ikke koblet til noe turnering-lag.
</p>
) : (
<ul className="flex flex-col gap-3">
{teams.map((t) => (
<TeamCard
key={t.id}
team={t}
players={players}
onDelete={() => onDeleteTeam(t.id)}
onAddMember={(playerId) => onAddMember(t.id, playerId)}
onRemoveMember={(memberId) => onRemoveMember(t.id, memberId)}
/>
))}
</ul>
)}
{!adding ? (
<Button
type="button"
variant="outline"
onClick={() => setAdding(true)}
className="h-11 self-start rounded-xl text-sm font-bold"
>
<Plus aria-hidden="true" className="size-4" />
Nytt lag
</Button>
) : (
<form onSubmit={handleCreate} className="flex items-center gap-2 rounded-2xl border border-border bg-background p-3">
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Lagnavn"
className="h-10 flex-1 rounded-lg text-sm"
/>
<Button type="submit" disabled={!name.trim() || submitting} className="h-10 rounded-lg text-sm font-bold">
{submitting ? "Oppretter…" : "Opprett"}
</Button>
<Button
type="button"
variant="ghost"
onClick={() => {
setAdding(false)
setName("")
}}
className="h-10 rounded-lg text-sm font-semibold text-muted-foreground"
>
Avbryt
</Button>
</form>
)}
</section>
)
}
function TeamCard({
team,
players,
onDelete,
onAddMember,
onRemoveMember,
}: {
team: ApiOomTeam
players: ApiPlayer[]
onDelete: () => Promise<void>
onAddMember: (playerId: string) => Promise<void>
onRemoveMember: (memberId: string) => Promise<void>
}) {
const memberPlayerIds = new Set(team.members.map((m) => m.player_id))
const availablePlayers = players.filter((p) => !memberPlayerIds.has(p.id))
const [selectedPlayerId, setSelectedPlayerId] = useState("")
return (
<li className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-bold text-foreground">{team.name}</span>
<Button
type="button"
variant="ghost"
onClick={onDelete}
className="h-9 shrink-0 rounded-lg px-3 text-xs font-bold text-muted-foreground hover:text-destructive"
>
<Trash2 aria-hidden="true" className="size-4" />
Slett lag
</Button>
</div>
{team.members.length > 0 && (
<ul className="flex flex-col divide-y divide-border rounded-xl border border-border">
{team.members.map((m) => (
<li key={m.id} className="flex items-center justify-between gap-3 px-3 py-2">
<span className="text-sm text-foreground">{m.player_name}</span>
<Button
type="button"
variant="ghost"
onClick={() => onRemoveMember(m.id)}
className="h-8 shrink-0 rounded-lg px-2 text-xs font-semibold text-muted-foreground hover:text-destructive"
>
Fjern
</Button>
</li>
))}
</ul>
)}
{availablePlayers.length > 0 && (
<div className="flex items-center gap-2">
<select
value={selectedPlayerId}
onChange={(e) => setSelectedPlayerId(e.target.value)}
className="h-10 flex-1 rounded-lg border border-border bg-card px-3 text-sm font-medium text-foreground outline-none"
>
<option value="">Velg spiller</option>
{availablePlayers.map((p) => (
<option key={p.id} value={p.id}>
{p.display_name}
</option>
))}
</select>
<Button
type="button"
variant="outline"
disabled={!selectedPlayerId}
onClick={() => {
void onAddMember(selectedPlayerId)
setSelectedPlayerId("")
}}
className="h-10 shrink-0 rounded-lg text-sm font-semibold"
>
Legg til
</Button>
</div>
)}
</li>
)
}
function LeaderboardTable({ entries, resultType }: { entries: ApiLeaderboardEntry[]; resultType: OomResultType }) {
const eligible = entries.filter((e) => e.eligible)
const ineligible = entries.filter((e) => !e.eligible)
if (eligible.length === 0 && ineligible.length === 0) {
return <p className="text-sm leading-relaxed text-muted-foreground text-pretty">Ingen resultater ennå -- lenk minst én turnering over.</p>
}
return (
<div className="flex flex-col gap-4">
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border">
{eligible.map((e) => {
const isLeader = e.position === "1"
return (
<li
key={e.player_id}
className={cn("flex items-center gap-3 px-4 py-3", isLeader ? "bg-gold text-gold-foreground" : "bg-card")}
>
<span className={cn("w-9 shrink-0 text-center text-sm font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
{e.position}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<span className={cn("truncate text-sm font-bold", isLeader ? "text-gold-foreground" : "text-foreground")}>{e.player_name}</span>
{e.results_counted !== e.results_available && (
<span className={cn("text-xs", isLeader ? "text-gold-foreground/80" : "text-muted-foreground")}>
{e.results_counted} av {e.results_available} resultater talt
</span>
)}
</div>
<span className={cn("shrink-0 text-base font-extrabold tabular-nums", isLeader ? "text-gold-foreground" : "text-foreground")}>
{formatValue(e.value, resultType)}
</span>
</li>
)
})}
</ul>
{ineligible.length > 0 && (
<div className="flex flex-col gap-2">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Ikke kvalifisert</h3>
<ul className="flex flex-col divide-y divide-border overflow-hidden rounded-2xl border border-border opacity-70">
{ineligible.map((e) => (
<li key={e.player_id} className="flex items-center gap-3 px-4 py-3">
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold text-muted-foreground">{e.player_name}</span>
{e.ineligible_reason && <span className="text-xs text-muted-foreground">{e.ineligible_reason}</span>}
</div>
</li>
))}
</ul>
</div>
)}
</div>
)
}