teecup/frontend/components/order-of-merit-detail.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

739 lines
28 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, ChevronDown, 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"
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 }
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 [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())
}
useEffect(() => {
void loadOom()
void loadLinks()
void loadTournaments()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [organizationId, oomId])
useEffect(() => {
if (oom?.kind === "player") void loadLeaderboard()
// 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 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} />
<LeaderboardSection kind={oom.kind} 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">
<SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
Sum
</SettingsSegment>
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
Snitt
</SettingsSegment>
</div>
</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>
<Button type="submit" disabled={saving} className="h-11 rounded-xl font-bold">
{saving ? "Lagrer…" : "Lagre"}
</Button>
</form>
)}
</section>
)
}
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({
kind,
resultType,
entries,
}: {
kind: OomKind
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>
{kind === "team" ? (
<div className="rounded-2xl border border-dashed border-border bg-muted/40 px-4 py-6 text-center">
<Users aria-hidden="true" className="mx-auto mb-2 size-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Lag-resultatliste er ikke bygget ennå.</p>
</div>
) : entries === null ? (
<p className="text-sm text-muted-foreground">Laster</p>
) : (
<LeaderboardTable entries={entries} resultType={resultType} />
)}
</section>
)
}
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>
)
}