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
|
|
|
|
"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"
|
2026-08-15 17:38:00 +02:00
|
|
|
|
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")
|
|
|
|
|
|
}
|
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
|
|
|
|
|
|
|
|
|
|
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 }
|
|
|
|
|
|
|
2026-08-15 16:52:37 +02:00
|
|
|
|
// 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 }
|
|
|
|
|
|
|
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
|
|
|
|
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[]>([])
|
2026-08-15 16:52:37 +02:00
|
|
|
|
const [teams, setTeams] = useState<ApiOomTeam[] | null>(null)
|
|
|
|
|
|
const [players, setPlayers] = useState<ApiPlayer[]>([])
|
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
|
|
|
|
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())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 16:52:37 +02:00
|
|
|
|
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())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
void loadOom()
|
|
|
|
|
|
void loadLinks()
|
|
|
|
|
|
void loadTournaments()
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [organizationId, oomId])
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-08-15 16:52:37 +02:00
|
|
|
|
if (!oom) return
|
|
|
|
|
|
void loadLeaderboard()
|
|
|
|
|
|
if (oom.kind === "team") {
|
|
|
|
|
|
void loadTeams()
|
|
|
|
|
|
void loadPlayers()
|
|
|
|
|
|
}
|
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
|
|
|
|
// 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))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 16:52:37 +02:00
|
|
|
|
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()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
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} />
|
|
|
|
|
|
|
2026-08-15 16:52:37 +02:00
|
|
|
|
{oom.kind === "team" && (
|
|
|
|
|
|
<TeamsSection
|
|
|
|
|
|
teams={teams}
|
|
|
|
|
|
players={players}
|
|
|
|
|
|
onCreateTeam={createTeam}
|
|
|
|
|
|
onDeleteTeam={deleteTeam}
|
|
|
|
|
|
onAddMember={addMember}
|
|
|
|
|
|
onRemoveMember={removeMember}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<LeaderboardSection resultType={oom.result_type} entries={leaderboard} />
|
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
|
|
|
|
</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>
|
2026-08-15 17:38:00 +02:00
|
|
|
|
<div className={`grid grid-cols-2 gap-2 ${eclecticAllowed(oom.kind, oom.result_type) ? "sm:grid-cols-3" : ""}`}>
|
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
|
|
|
|
<SettingsSegment selected={aggregationMode === "sum"} onClick={() => setAggregationMode("sum")}>
|
|
|
|
|
|
Sum
|
|
|
|
|
|
</SettingsSegment>
|
|
|
|
|
|
<SettingsSegment selected={aggregationMode === "average"} onClick={() => setAggregationMode("average")}>
|
|
|
|
|
|
Snitt
|
|
|
|
|
|
</SettingsSegment>
|
2026-08-15 17:38:00 +02:00
|
|
|
|
{eclecticAllowed(oom.kind, oom.result_type) && (
|
|
|
|
|
|
<SettingsSegment selected={aggregationMode === "eclectic"} onClick={() => setAggregationMode("eclectic")}>
|
|
|
|
|
|
Eclectic
|
|
|
|
|
|
</SettingsSegment>
|
|
|
|
|
|
)}
|
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
|
|
|
|
</div>
|
2026-08-15 17:38:00 +02:00
|
|
|
|
{aggregationMode === "eclectic" && (
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
Eclectic krever at alle lenkede turneringer spilles på samme bane -- lenking av en turnering på en annen
|
|
|
|
|
|
bane avvises så lenge denne modusen er valgt.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
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
|
|
|
|
</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({
|
|
|
|
|
|
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>
|
|
|
|
|
|
|
2026-08-15 16:52:37 +02:00
|
|
|
|
{entries === null ? (
|
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
|
|
|
|
<p className="text-sm text-muted-foreground">Laster…</p>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<LeaderboardTable entries={entries} resultType={resultType} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 16:52:37 +02:00
|
|
|
|
// --- 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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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
|
|
|
|
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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|