"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" | "eclectic" // Eclectic-aggregering (ADR-075) krever kind='player' og en resultattype med // per-hull-granularitet (stableford/brutto/netto) -- håndhevet server-side // (order_of_merit.py). Kind/resultattype er låst etter opprettelse her, så // ingen reset-logikk trengs (i motsetning til order-of-merit-list.tsx sitt // opprettelsesskjema). function eclecticAllowed(kind: OomKind, resultType: OomResultType): boolean { return kind === "player" && (resultType === "stableford" || resultType === "gross" || resultType === "net") } type ApiOrderOfMerit = { id: string name: string kind: OomKind result_type: OomResultType aggregation_mode: OomAggregationMode count_best_n: number | null min_results_required: number | null birth_year_from: number | null birth_year_to: number | null public_visible: boolean } type ApiLink = { id: string tournament_id: string tournament_name: string points_table: number[] | null money_pool_total: number | null money_payout_table: { position: number; amount: number }[] | null } type ApiLeaderboardEntry = { player_id: string player_name: string results_counted: number results_available: number value: number | null position: string | null eligible: boolean ineligible_reason: string | null } type ApiTournament = { id: string; name: string; format_type: string } // Lag-OOM (ADR-043 Beslutning C, ADR-074) -- sesong-par satt opp DIREKTE i // OOM-en, ikke turnering-lag. Kun relevant når oom.kind === "team". type ApiOomTeamMember = { id: string; player_id: string; player_name: string } type ApiOomTeam = { id: string; name: string; members: ApiOomTeamMember[] } type ApiPlayer = { id: string; display_name: string } const KIND_LABELS: Record = { player: "Spiller", team: "Lag" } const RESULT_TYPE_LABELS: Record = { 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(null) const [links, setLinks] = useState(null) const [leaderboard, setLeaderboard] = useState(null) const [allTournaments, setAllTournaments] = useState([]) const [teams, setTeams] = useState(null) const [players, setPlayers] = useState([]) const [error, setError] = useState(null) const [notFound, setNotFound] = useState(false) async function loadOom() { const res = await fetch(`/orgs/${organizationId}/order-of-merits`, { credentials: "include" }) if (!res.ok) return const all: ApiOrderOfMerit[] = await res.json() const mine = all.find((o) => o.id === oomId) if (!mine) { setNotFound(true) return } setOom(mine) } async function loadLinks() { const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments`, { credentials: "include" }) if (res.ok) setLinks(await res.json()) } async function loadLeaderboard() { const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/leaderboard`, { credentials: "include" }) if (res.ok) setLeaderboard(await res.json()) else setLeaderboard([]) } async function loadTournaments() { const res = await fetch(`/orgs/${organizationId}/tournaments`, { credentials: "include" }) if (res.ok) setAllTournaments(await res.json()) } async function loadTeams() { const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams`, { credentials: "include" }) if (res.ok) setTeams(await res.json()) } async function loadPlayers() { const res = await fetch(`/orgs/${organizationId}/players`, { credentials: "include" }) if (res.ok) setPlayers(await res.json()) } useEffect(() => { void loadOom() void loadLinks() void loadTournaments() // eslint-disable-next-line react-hooks/exhaustive-deps }, [organizationId, oomId]) useEffect(() => { if (!oom) return void loadLeaderboard() if (oom.kind === "team") { void loadTeams() void loadPlayers() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [oom?.kind, links]) async function linkTournament(input: { tournament_id: string points_table?: number[] money_pool_total?: number money_payout_table?: { position: number; amount: number }[] }) { setError(null) const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify(input), }) if (!res.ok) { const body = await res.json().catch(() => null) setError(body?.detail?.message ?? "Klarte ikke å lenke turneringen.") return } await loadLinks() } async function unlinkTournament(linkId: string) { setError(null) const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/tournaments/${linkId}`, { method: "DELETE", credentials: "include", }) if (res.status !== 204) { setError("Klarte ikke å fjerne lenken.") return } setLinks((prev) => (prev ?? []).filter((l) => l.id !== linkId)) } async function createTeam(name: string) { setError(null) const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ name }), }) if (!res.ok) { setError("Klarte ikke å opprette laget.") return } await loadTeams() } async function deleteTeam(teamId: string) { setError(null) const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}`, { method: "DELETE", credentials: "include", }) if (res.status !== 204) { setError("Klarte ikke å slette laget.") return } setTeams((prev) => (prev ?? []).filter((t) => t.id !== teamId)) await loadLeaderboard() } async function addMember(teamId: string, playerId: string) { setError(null) const res = await fetch(`/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}/members`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ player_id: playerId }), }) if (!res.ok) { setError("Klarte ikke å legge til spilleren.") return } await loadTeams() await loadLeaderboard() } async function removeMember(teamId: string, memberId: string) { setError(null) const res = await fetch( `/orgs/${organizationId}/order-of-merits/${oomId}/teams/${teamId}/members/${memberId}`, { method: "DELETE", credentials: "include" }, ) if (res.status !== 204) { setError("Klarte ikke å fjerne spilleren.") return } await loadTeams() await loadLeaderboard() } async function saveSettings(patch: Record) { 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 (

Fant ikke denne Order of Merit-en.

Tilbake til listen
) } if (!oom) { return (
) } const linkedTournamentIds = new Set((links ?? []).map((l) => l.tournament_id)) const linkableTournaments = allTournaments.filter( (t) => t.format_type === "individual" && !linkedTournamentIds.has(t.id), ) return (
{error && (

{error}

)} {oom.kind === "team" && ( )}
) } // --- 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 onUnlink: (linkId: string) => void }) { const [adding, setAdding] = useState(false) return (

Lenkede turneringer

{links === null ? (

Laster…

) : links.length === 0 ? (

Ingen turneringer lenket ennå.

) : (
    {links.map((l) => (
  • {l.tournament_name} {resultType === "points" && l.points_table && ( Poeng: {l.points_table.join("-")} )} {resultType === "money" && l.money_pool_total !== null && ( Premiepott: {Math.round(l.money_pool_total).toLocaleString("nb-NO")} kr )}
  • ))}
)} {!adding ? ( ) : ( { await onLink(input) setAdding(false) }} onCancel={() => setAdding(false)} /> )} {linkableTournaments.length === 0 && !adding && (

Ingen flere individuelle turneringer å lenke -- alle er allerede lenket, eller organisasjonen har ingen ennå.

)}
) } 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 onCancel: () => void }) { const [tournamentId, setTournamentId] = useState(candidates[0]?.id ?? "") const [pointsRows, setPointsRows] = useState(["10", "6", "3"]) const [moneyPool, setMoneyPool] = useState("") const [moneyRows, setMoneyRows] = useState(["", "", ""]) const [submitting, setSubmitting] = useState(false) function updateRow(setter: React.Dispatch>, 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 (
{resultType === "points" && (
Poeng per plassering {pointsRows.map((v, i) => (
{i + 1}. plass updateRow(setPointsRows, i, e.target.value)} className="h-10 flex-1 rounded-lg text-sm" />
))}
)} {resultType === "money" && (
setMoneyPool(e.target.value)} className="h-10 rounded-lg text-sm" />
Utbetaling per plassering (kr) {moneyRows.map((v, i) => (
{i + 1}. plass updateRow(setMoneyRows, i, e.target.value)} className="h-10 flex-1 rounded-lg text-sm" />
))}
)}
) } // --- C. Innstillinger --------------------------------------------------------- function SettingsSection({ oom, onSave }: { oom: ApiOrderOfMerit; onSave: (patch: Record) => Promise }) { const [open, setOpen] = useState(false) const [name, setName] = useState(oom.name) const [aggregationMode, setAggregationMode] = useState(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 (
{open && (

Type ({KIND_LABELS[oom.kind]}) og resultattype ({RESULT_TYPE_LABELS[oom.result_type]}) kan ikke endres etter opprettelse.

setName(e.target.value)} className="h-11 rounded-xl" />
Aggregering
setAggregationMode("sum")}> Sum setAggregationMode("average")}> Snitt {eclecticAllowed(oom.kind, oom.result_type) && ( setAggregationMode("eclectic")}> Eclectic )}
{aggregationMode === "eclectic" && (

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.

)}
setBestN(e.target.value)} placeholder="Tom = tell alle resultater" className="h-11 rounded-xl" />
setMinResults(e.target.value)} className="h-11 rounded-xl" />
Aldersgrense
setBirthYearFrom(e.target.value)} className="h-11 rounded-xl" />
setBirthYearTo(e.target.value)} className="h-11 rounded-xl" />
)}
) } function SettingsSegment({ selected, onClick, children }: { selected: boolean; onClick: () => void; children: React.ReactNode }) { return ( ) } // --- D. Resultatliste ---------------------------------------------------------- function LeaderboardSection({ resultType, entries, }: { resultType: OomResultType entries: ApiLeaderboardEntry[] | null }) { return (

Resultatliste

{entries === null ? (

Laster…

) : ( )}
) } // --- 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 onDeleteTeam: (teamId: string) => Promise onAddMember: (teamId: string, playerId: string) => Promise onRemoveMember: (teamId: string, memberId: string) => Promise }) { 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 (

Lag

{teams === null ? (

Laster…

) : teams.length === 0 ? (

Ingen lag opprettet ennå -- lag er sesong-par satt opp direkte her, ikke koblet til noe turnering-lag.

) : (
    {teams.map((t) => ( onDeleteTeam(t.id)} onAddMember={(playerId) => onAddMember(t.id, playerId)} onRemoveMember={(memberId) => onRemoveMember(t.id, memberId)} /> ))}
)} {!adding ? ( ) : (
setName(e.target.value)} placeholder="Lagnavn" className="h-10 flex-1 rounded-lg text-sm" />
)}
) } function TeamCard({ team, players, onDelete, onAddMember, onRemoveMember, }: { team: ApiOomTeam players: ApiPlayer[] onDelete: () => Promise onAddMember: (playerId: string) => Promise onRemoveMember: (memberId: string) => Promise }) { 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 (
  • {team.name}
    {team.members.length > 0 && (
      {team.members.map((m) => (
    • {m.player_name}
    • ))}
    )} {availablePlayers.length > 0 && (
    )}
  • ) } 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

    Ingen resultater ennå -- lenk minst én turnering over.

    } return (
      {eligible.map((e) => { const isLeader = e.position === "1" return (
    • {e.position}
      {e.player_name} {e.results_counted !== e.results_available && ( {e.results_counted} av {e.results_available} resultater talt )}
      {formatValue(e.value, resultType)}
    • ) })}
    {ineligible.length > 0 && (

    Ikke kvalifisert

      {ineligible.map((e) => (
    • {e.player_name} {e.ineligible_reason && {e.ineligible_reason}}
    • ))}
    )}
    ) }