Sesong-"drømmerunde" -- beste resultat per hull på tvers av ALLE lenkede turneringer (stableford/brutto/netto), med samme-bane-håndheving ved lenking og modus-bytte. Gjenbruker eclectic_best_per_hole() uendret fra handicap_engine.py. Frontend: "Eclectic" lagt til i både opprettelses- skjema og innstillinger, gated til gyldige kind/resultattype-kombinasjoner. 80/80 backend-tester, 45/45 vitest, scratch-verifisert i nettleser (lys+mørk). Rullet ut. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
334 lines
13 KiB
TypeScript
334 lines
13 KiB
TypeScript
"use client"
|
|
|
|
// Order of Merit (ADR-043 [OOM]): sesong-sammenlagt rangering av spillere
|
|
// på tvers av flere individuelle turneringer (ADR-037) i samme
|
|
// organisasjon. Håndkodet (ikke V0 -- ingen V0-credits tilgjengelig denne
|
|
// runden), men bevisst bygget til å matche samme visuelle presisjon som
|
|
// V0-eksportene ellers i appen (samme design-tokens, samme kort-/
|
|
// header-mønster som org-members.tsx).
|
|
|
|
import type React from "react"
|
|
import { useEffect, useState } from "react"
|
|
import Link from "next/link"
|
|
import { useRouter } from "next/navigation"
|
|
import { ArrowLeft, ChevronRight, Plus, Trophy, Users } from "lucide-react"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Label } from "@/components/ui/label"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
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), speilet her for å skjule valget når
|
|
// det uansett ville blitt avvist.
|
|
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
|
|
}
|
|
|
|
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",
|
|
}
|
|
const AGGREGATION_LABELS: Record<OomAggregationMode, string> = {
|
|
sum: "Sum",
|
|
average: "Snitt",
|
|
eclectic: "Eclectic (drømmerunde)",
|
|
}
|
|
|
|
export function OrderOfMeritList({ organizationId, orgName }: { organizationId: string; orgName: string }) {
|
|
const router = useRouter()
|
|
const [items, setItems] = useState<ApiOrderOfMerit[] | null>(null)
|
|
const [creating, setCreating] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
async function load() {
|
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits`, { credentials: "include" })
|
|
if (res.ok) setItems(await res.json())
|
|
}
|
|
|
|
useEffect(() => {
|
|
void load()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [organizationId])
|
|
|
|
async function create(input: { name: string; kind: OomKind; result_type: OomResultType; aggregation_mode: OomAggregationMode }) {
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`/orgs/${organizationId}/order-of-merits`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify(input),
|
|
})
|
|
if (!res.ok) throw new Error(`create: ${res.status}`)
|
|
const created: ApiOrderOfMerit = await res.json()
|
|
setCreating(false)
|
|
router.push(`/organizations/${organizationId}/order-of-merit/${created.id}?name=${encodeURIComponent(orgName)}`)
|
|
} catch {
|
|
setError("Klarte ikke å opprette Order of Merit. Prøv igjen.")
|
|
}
|
|
}
|
|
|
|
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}/members?name=${encodeURIComponent(orgName)}`}
|
|
aria-label="Tilbake til organisasjonen"
|
|
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-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">{orgName}</h1>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto w-full max-w-2xl flex-1 px-5 py-6 sm:py-8">
|
|
{error && (
|
|
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="mb-5 flex items-center justify-between gap-3">
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
Sesong-sammenlagt rangering av spillere på tvers av flere turneringer.
|
|
</p>
|
|
{!creating && (
|
|
<Button
|
|
type="button"
|
|
onClick={() => setCreating(true)}
|
|
className="h-11 shrink-0 rounded-2xl font-bold"
|
|
>
|
|
<Plus aria-hidden="true" className="size-4" />
|
|
Ny Order of Merit
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{creating && (
|
|
<div className="mb-6">
|
|
<CreateForm onCreate={create} onCancel={() => setCreating(false)} />
|
|
</div>
|
|
)}
|
|
|
|
{items === null ? (
|
|
<div className="flex justify-center py-16">
|
|
<div
|
|
aria-hidden="true"
|
|
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
|
/>
|
|
</div>
|
|
) : items.length === 0 && !creating ? (
|
|
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-12 text-center">
|
|
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
|
<Trophy aria-hidden="true" className="size-7 text-muted-foreground" />
|
|
</div>
|
|
<h2 className="text-base font-bold text-foreground">Ingen Order of Merit-er ennå</h2>
|
|
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
Sett opp en sesong-sammenlagt rangering på tvers av flere turneringer.
|
|
</p>
|
|
<Button type="button" onClick={() => setCreating(true)} className="mt-2 h-11 rounded-2xl font-bold">
|
|
<Plus aria-hidden="true" className="size-4" />
|
|
Ny Order of Merit
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<ul className="flex flex-col gap-3">
|
|
{items.map((oom) => (
|
|
<li key={oom.id}>
|
|
<Link
|
|
href={`/organizations/${organizationId}/order-of-merit/${oom.id}?name=${encodeURIComponent(orgName)}`}
|
|
className="flex items-center gap-4 rounded-2xl border border-border bg-card p-5 shadow-md shadow-black/8 transition-colors hover:border-primary/50 hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
<div className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-muted">
|
|
{oom.kind === "team" ? (
|
|
<Users aria-hidden="true" className="size-6 text-muted-foreground" />
|
|
) : (
|
|
<Trophy aria-hidden="true" className="size-6 text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
<span className="truncate text-base font-bold text-foreground">{oom.name}</span>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<Badge>{KIND_LABELS[oom.kind]}</Badge>
|
|
<Badge>{RESULT_TYPE_LABELS[oom.result_type]}</Badge>
|
|
<Badge>{AGGREGATION_LABELS[oom.aggregation_mode]}</Badge>
|
|
</div>
|
|
</div>
|
|
<ChevronRight aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Badge({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
|
|
{children}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function SegmentButton({
|
|
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-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
|
)}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function CreateForm({
|
|
onCreate,
|
|
onCancel,
|
|
}: {
|
|
onCreate: (input: { name: string; kind: OomKind; result_type: OomResultType; aggregation_mode: OomAggregationMode }) => void
|
|
onCancel: () => void
|
|
}) {
|
|
const [name, setName] = useState("")
|
|
const [kind, setKind] = useState<OomKind>("player")
|
|
const [resultType, setResultType] = useState<OomResultType>("stableford")
|
|
const [aggregationMode, setAggregationMode] = useState<OomAggregationMode>("sum")
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
const showEclectic = eclecticAllowed(kind, resultType)
|
|
|
|
function selectKind(k: OomKind) {
|
|
setKind(k)
|
|
if (aggregationMode === "eclectic" && !eclecticAllowed(k, resultType)) setAggregationMode("sum")
|
|
}
|
|
function selectResultType(rt: OomResultType) {
|
|
setResultType(rt)
|
|
if (aggregationMode === "eclectic" && !eclecticAllowed(kind, rt)) setAggregationMode("sum")
|
|
}
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
if (!name.trim() || submitting) return
|
|
setSubmitting(true)
|
|
await onCreate({ name: name.trim(), kind, result_type: resultType, aggregation_mode: aggregationMode })
|
|
setSubmitting(false)
|
|
}
|
|
|
|
return (
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="flex flex-col gap-5 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6"
|
|
>
|
|
<h2 className="text-base font-bold text-foreground">Ny Order of Merit</h2>
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
<Label htmlFor="oom-name" className="text-sm font-semibold">
|
|
Navn
|
|
</Label>
|
|
<Input
|
|
id="oom-name"
|
|
autoFocus
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="F.eks. Klubbmesterskap-serien 2026"
|
|
className="h-11 rounded-xl"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-sm font-semibold text-foreground">Type</span>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<SegmentButton selected={kind === "player"} onClick={() => selectKind("player")}>
|
|
Spiller
|
|
</SegmentButton>
|
|
<SegmentButton selected={kind === "team"} onClick={() => selectKind("team")}>
|
|
Lag
|
|
</SegmentButton>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<span className="text-sm font-semibold text-foreground">Resultattype</span>
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
{(Object.keys(RESULT_TYPE_LABELS) as OomResultType[]).map((rt) => (
|
|
<SegmentButton key={rt} selected={resultType === rt} onClick={() => selectResultType(rt)}>
|
|
{RESULT_TYPE_LABELS[rt]}
|
|
</SegmentButton>
|
|
))}
|
|
</div>
|
|
</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 ${showEclectic ? "sm:grid-cols-3" : ""}`}>
|
|
<SegmentButton selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
|
Sum
|
|
</SegmentButton>
|
|
<SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
|
Snitt
|
|
</SegmentButton>
|
|
{showEclectic && (
|
|
<SegmentButton selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
|
|
Eclectic
|
|
</SegmentButton>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button type="submit" disabled={!name.trim() || submitting} className="h-11 flex-1 rounded-xl font-bold">
|
|
{submitting ? "Oppretter…" : "Opprett"}
|
|
</Button>
|
|
<Button type="button" variant="ghost" onClick={onCancel} className="h-11 rounded-xl font-semibold text-muted-foreground">
|
|
Avbryt
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|