teecup/frontend/components/order-of-merit-list.tsx
Erol Haagenrud fea701091c Bygg Order of Merit: sesong-sammenlagt spillerrangering på tvers av turneringer
Ny funksjon (ADR-043, migrasjon 055): organisatorer kan opprette en
Order of Merit, lenke individuelle turneringer til den, og få en
sesong-sammenlagt rangering regnet ut ved lesing -- fem resultattyper
(poeng etter plassering, Stableford-, brutto- og nettoscore-sum,
pengeliste), sum/snitt-aggregering, med valgfri behold-N-beste,
minimum antall resultater og aldersgrense. Motor i handicap_engine.py
(9 nye enhetstester), ny app/routers/order_of_merit.py, og to nye
håndkodede frontend-sider under /organizations/[id]/order-of-merit.

Eclectic-aggregering og lag-OOM sin faktiske resultatberegning er
bevisst ikke bygget denne runden (skjema/CRUD for lag finnes, men
leaderboardet avviser kind='team' med en tydelig feilmelding).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:19:30 +02:00

306 lines
12 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"
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" }
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 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 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)
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={() => setKind("player")}>
Spiller
</SegmentButton>
<SegmentButton selected={kind === "team"} onClick={() => setKind("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={() => setResultType(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">
<SegmentButton selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
Sum
</SegmentButton>
<SegmentButton selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
Snitt
</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>
)
}