"use client" import type React from "react" import { useEffect, useMemo, useRef, useState } from "react" import { Download, Printer, Crown } from "lucide-react" import { cn } from "@/lib/utils" import { fetchCupLeaderboard, fetchIndividualLeaderboard, fetchMatches, fetchPlayers, fetchSessions, fetchTeams, fetchTournament, fetchTournamentParticipants, type ApiLeaderboardEntry, type ApiMatch, type ApiPlayer, type ApiSession, type ApiTeam, type ApiTournament, type ApiTournamentParticipant, } from "@/lib/print-data" /** * Print-friendly RESULTATLISTE -- ekte data (ADR-097). Videreført fra * V0-mockupen: individuell rankert leaderboard, eller Cup-sluttresultat * (poeng per økt + match for match). Resultater vises med FORM (sirkel = * under par, firkant = over par) så arket er lesbart i sort/hvitt. */ type SizeKey = "A4" | "Letter" type Orientation = "portrait" | "landscape" const PAPER: Record = { A4: { label: "A4", sub: "210 × 297 mm", w: 210, h: 297 }, Letter: { label: "Letter", sub: "216 × 279 mm", w: 216, h: 279 }, } const MM_TO_PX = 96 / 25.4 const INK = "#171717" const INK_MUTED = "#5b5b5b" const HAIRLINE = "#d9d9d9" const HAIRLINE_STRONG = "#a3a3a3" const BAND = "#f2f2f0" function Segmented({ options, value, onChange, ariaLabel, }: { options: { key: T; label: string; sub?: string }[] value: T onChange: (v: T) => void ariaLabel: string }) { return (
{options.map((o) => { const active = o.key === value return ( ) })}
) } function ToggleRow({ label, hint, checked, onCheckedChange }: { label: string; hint?: string; checked: boolean; onCheckedChange: (v: boolean) => void }) { return ( ) } function PanelSection({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
) } // Form bærer betydning (sirkel/firkant/tekst), ikke farge -- leselig i sort/hvitt. function Shape({ label, tone, size = "md" }: { label: string; tone: "under" | "even" | "over" | null; size?: "md" | "sm" }) { const dim = size === "md" ? "h-[24px] min-w-[32px] text-[13px]" : "h-[19px] min-w-[26px] text-[11px]" if (tone === "under") { return ( {label} ) } if (tone === "over") { return ( {label} ) } return ( {label} ) } function StatusPill({ text }: { text: string }) { return ( {text} ) } const STATUS_LABEL: Record = { dsq: "DSQ", dns: "DNS", rtd: "RTD", dnf: "DNF" } type EnrichedEntry = ApiLeaderboardEntry & { club: string; hcpLabel: string } function SheetIndividual({ tournamentName, period, method, entries, fields, }: { tournamentName: string period: string method: string entries: EnrichedEntry[] fields: { hcp: boolean; rounds: boolean; club: boolean; classes: boolean; cut: boolean } }) { const maxRounds = Math.max(0, ...entries.map((e) => e.rounds.length)) const firstCutIndex = entries.findIndex((e) => e.cut) const gridCols = ["36px", "minmax(0,1fr)", fields.club ? "120px" : null, fields.hcp ? "48px" : null, ...(fields.rounds ? Array(maxRounds).fill("40px") : []), "56px"] .filter(Boolean) .join(" ") const totalCell = (e: EnrichedEntry) => { if (e.status !== "active") return if (e.cut) return const label = e.total_label ?? "–" const tone = label.startsWith("-") || label.startsWith("−") ? "under" : label.startsWith("+") ? "over" : "even" return } const HeaderRow = () => (
Plass Spiller {fields.club && Hjemmeklubb} {fields.hcp && HCP} {fields.rounds && Array.from({ length: maxRounds }).map((_, i) => ( R{i + 1} ))} Total
) const Row = ({ e }: { e: EnrichedEntry }) => (
{e.position ?? "–"} {e.player_name} {fields.club && ( {e.club} )} {fields.hcp && ( {e.hcpLabel} )} {fields.rounds && Array.from({ length: maxRounds }).map((_, i) => { const r = e.rounds[i] return ( {r ? : } ) })} {totalCell(e)}
) let body: React.ReactNode if (!fields.classes) { body = (
{entries.map((e, idx) => (
{fields.cut && idx === firstCutIndex && firstCutIndex >= 0 && (
Cut
)}
))}
) } else { const classNames = Array.from(new Set(entries.map((e) => e.class_name ?? "Ingen klasse"))) body = (
{classNames.map((cls) => (
{cls}
{entries.filter((e) => (e.class_name ?? "Ingen klasse") === cls).map((e) => ( ))}
))}
) } return (

{tournamentName}

Resultatliste
{method} {period}
{body}
Under par Par Over par Spesialstatus
) } type CupMatchRow = { no: number; session: string; nord: string; sor: string; result: string; winner: "nord" | "sor" | null } function SheetCup({ tournamentName, meta, teamNord, teamSor, sessions, matches, fields, }: { tournamentName: string meta: string teamNord: { name: string; color: string; points: number } teamSor: { name: string; color: string; points: number } sessions: { name: string; points: { nord: number; sor: number } }[] matches: CupMatchRow[] fields: { sessions: boolean; matches: boolean } }) { const nordLeads = teamNord.points > teamSor.points const sorLeads = teamSor.points > teamNord.points const fmt = (n: number) => n.toLocaleString("nb-NO", { minimumFractionDigits: 1, maximumFractionDigits: 1 }) const TeamScore = ({ name, color, pts, leads }: { name: string; color: string; pts: number; leads: boolean }) => (
{leads && ( Leder )} {name} {fmt(pts)} poeng
) return (

{tournamentName}

Sluttresultat
{meta}
{fields.sessions && (

Poeng per økt

{sessions.map((s) => ( ))}
Økt {teamNord.name} {teamSor.name}
{s.name} {fmt(s.points.nord)} {fmt(s.points.sor)}
Totalt {fmt(teamNord.points)} {fmt(teamSor.points)}
)} {fields.matches && (

Match for match

{matches.map((m) => ( ))}
# {teamNord.name} Resultat {teamSor.name}
{m.no} {m.nord} {m.result} {m.sor}
)}
) } function SheetFooter() { return (
Side 1 av 1 Generert {new Date().toLocaleString("no-NO")} TeeCup
) } const SCORING_METHOD_LABEL: Record = { stroke_gross: "Bruttoslagspill", stroke_net: "Nettoslagspill", stableford: "Stableford", copenhagen: "Københavner", bbb: "High-low-high", } export function PrintResultlist({ organizationId, tournamentId, initialSize = "A4", initialOrientation = "portrait", }: { organizationId: string tournamentId: string initialSize?: SizeKey initialOrientation?: Orientation }) { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [tournament, setTournament] = useState(null) const [entries, setEntries] = useState([]) const [cupData, setCupData] = useState<{ teamNord: { name: string; color: string; points: number }; teamSor: { name: string; color: string; points: number }; sessions: { name: string; points: { nord: number; sor: number } }[]; matches: CupMatchRow[] } | null>(null) const [downloading, setDownloading] = useState(false) const [size, setSize] = useState(initialSize) const [orientation, setOrientation] = useState(initialOrientation) const [aFields, setAFields] = useState({ hcp: false, rounds: true, club: true, classes: false, cut: true }) const [bFields, setBFields] = useState({ sessions: true, matches: true }) useEffect(() => { let cancelled = false async function load() { setLoading(true) setError(null) try { const t = await fetchTournament(organizationId, tournamentId) if (cancelled) return setTournament(t) if (!t) throw new Error("Fant ikke turneringen.") if (t.format_type === "individual") { const [leaderboard, tournamentParticipants, players] = await Promise.all([ fetchIndividualLeaderboard(organizationId, tournamentId), fetchTournamentParticipants(organizationId, tournamentId), fetchPlayers(organizationId), ]) const tpById = new Map(tournamentParticipants.map((tp) => [tp.id, tp])) const playerById = new Map(players.map((p) => [p.id, p])) const enriched: EnrichedEntry[] = leaderboard.map((e) => { const tp = tpById.get(e.tournament_participant_id) const player = tp ? playerById.get(tp.player_id) : undefined return { ...e, club: player?.club ?? "—", hcpLabel: tp?.handicap_index_snapshot != null ? tp.handicap_index_snapshot.toFixed(1).replace(".", ",") : "–", } }) if (cancelled) return setEntries(enriched) } else { const [cup, sessions, teams] = await Promise.all([ fetchCupLeaderboard(organizationId, tournamentId), fetchSessions(organizationId, tournamentId), fetchTeams(organizationId, tournamentId), ]) const [teamA, teamB] = cup.teams const teamById = new Map(teams.map((tm) => [tm.id, tm])) const allMatches = ( await Promise.all(sessions.map(async (s: ApiSession) => ({ session: s, matches: await fetchMatches(organizationId, s.id) }))) ).flatMap(({ session, matches }) => matches.map((m: ApiMatch) => ({ session, m })), ) const matchRows: CupMatchRow[] = allMatches.map(({ session, m }, i) => { const nordNames = m.participants.filter((p) => p.team_side === "a").map((p) => p.player_name).join(" / ") const sorNames = m.participants.filter((p) => p.team_side === "b").map((p) => p.player_name).join(" / ") return { no: i + 1, session: session.name, nord: nordNames || (teamById.get(m.team_a_id)?.name ?? "Lag A"), sor: sorNames || (teamById.get(m.team_b_id)?.name ?? "Lag B"), result: m.status_text ?? "Ikke spilt", winner: m.leading_side === "a" ? "nord" : m.leading_side === "b" ? "sor" : null, } }) if (cancelled) return setCupData({ teamNord: { name: teamA?.team_name ?? "Lag A", color: teamA?.color ?? "#1f5fbf", points: teamA?.points ?? 0 }, teamSor: { name: teamB?.team_name ?? "Lag B", color: teamB?.color ?? "#d1561b", points: teamB?.points ?? 0 }, sessions: cup.sessions.map((s) => ({ name: s.name ?? `Økt ${s.sequence}`, points: { nord: teamA ? (s.points_by_team[teamA.team_id] ?? 0) : 0, sor: teamB ? (s.points_by_team[teamB.team_id] ?? 0) : 0 }, })), matches: matchRows, }) } } catch { if (!cancelled) setError("Klarte ikke å laste resultatlisten. Prøv igjen.") } finally { if (!cancelled) setLoading(false) } } void load() return () => { cancelled = true } }, [organizationId, tournamentId]) const paper = PAPER[size] const natW = (orientation === "portrait" ? paper.w : paper.h) * MM_TO_PX const natH = (orientation === "portrait" ? paper.h : paper.w) * MM_TO_PX const areaRef = useRef(null) const [scale, setScale] = useState(0.4) useEffect(() => { const el = areaRef.current if (!el) return const compute = () => { const pad = 56 const availW = el.clientWidth - pad const availH = el.clientHeight - pad if (availW <= 0 || availH <= 0) return setScale(Math.max(0.12, Math.min(availW / natW, availH / natH, 1.6))) } compute() const ro = new ResizeObserver(compute) ro.observe(el) return () => ro.disconnect() }, [natW, natH]) const pageCss = useMemo( () => `@page { size: ${orientation === "portrait" ? `${paper.w}mm ${paper.h}mm` : `${paper.h}mm ${paper.w}mm`}; margin: 0; }`, [paper.w, paper.h, orientation], ) async function downloadPdf() { setDownloading(true) try { const res = await fetch(`/orgs/${organizationId}/print/pdf`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ kind: "resultlist", tournament_id: tournamentId, paper: size, orientation }), }) if (!res.ok) throw new Error() const blob = await res.blob() const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = "teecup-resultatliste.pdf" document.body.appendChild(a) a.click() a.remove() URL.revokeObjectURL(url) } catch { setError("Klarte ikke å generere PDF-en. Prøv igjen.") } finally { setDownloading(false) } } const ready = !loading return (

Skriv ut resultatliste

{error ?? (loading ? "Laster …" : "Klar")}

{loading ? (

Laster resultatliste …

) : tournament?.format_type === "individual" ? (
) : cupData && tournament ? (
) : (

{error ?? "Ingen data funnet."}

)}
) }