teecup/frontend/components/print-resultlist.tsx
Erol Haagenrud 546900c2d6
Some checks failed
Backend-tester / test (push) Successful in 1m8s
Frontend-tester / test (push) Failing after 20s
Før endringer i turneringsoppsettet
2026-08-21 13:54:01 +02:00

704 lines
30 KiB
TypeScript
Raw Permalink 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"
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<SizeKey, { label: string; sub: string; w: number; h: number }> = {
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<T extends string>({
options,
value,
onChange,
ariaLabel,
}: {
options: { key: T; label: string; sub?: string }[]
value: T
onChange: (v: T) => void
ariaLabel: string
}) {
return (
<div role="radiogroup" aria-label={ariaLabel} className="flex flex-wrap gap-1.5">
{options.map((o) => {
const active = o.key === value
return (
<button
key={o.key}
type="button"
role="radio"
aria-checked={active}
onClick={() => onChange(o.key)}
className={cn(
"flex min-h-11 flex-1 flex-col items-start justify-center rounded-xl border px-3 py-2 text-left transition-colors",
active ? "border-primary bg-primary/10 text-foreground" : "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
)}
>
<span className="text-sm font-bold">{o.label}</span>
{o.sub ? <span className="text-[11px] font-medium opacity-70">{o.sub}</span> : null}
</button>
)
})}
</div>
)
}
function ToggleRow({ label, hint, checked, onCheckedChange }: { label: string; hint?: string; checked: boolean; onCheckedChange: (v: boolean) => void }) {
return (
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-xl px-1 py-1.5">
<span className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{label}</span>
{hint ? <span className="text-[11px] text-muted-foreground">{hint}</span> : null}
</span>
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onCheckedChange(!checked)}
className={cn("relative h-6 w-11 shrink-0 rounded-full transition-colors", checked ? "bg-primary" : "bg-muted-foreground/30")}
>
<span className={cn("absolute top-0.5 size-5 rounded-full bg-white shadow transition-transform", checked ? "translate-x-[22px]" : "translate-x-0.5")} />
</button>
</label>
)
}
function PanelSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-2">
<h3 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{title}</h3>
{children}
</section>
)
}
// 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 (
<span style={{ borderColor: INK, color: INK }} className={cn("inline-flex items-center justify-center rounded-full border-[1.5px] px-1 font-bold tabular-nums", dim)}>
{label}
</span>
)
}
if (tone === "over") {
return (
<span style={{ borderColor: INK, color: INK }} className={cn("inline-flex items-center justify-center rounded-[4px] border-[1.5px] px-1 font-bold tabular-nums", dim)}>
{label}
</span>
)
}
return (
<span style={{ color: INK }} className={cn("inline-flex items-center justify-center px-1 font-bold tabular-nums", dim)}>
{label}
</span>
)
}
function StatusPill({ text }: { text: string }) {
return (
<span style={{ borderColor: HAIRLINE_STRONG, color: INK }} className="inline-flex h-[24px] items-center justify-center rounded-[3px] border border-dashed px-2 text-[11px] font-extrabold uppercase tracking-wide">
{text}
</span>
)
}
const STATUS_LABEL: Record<string, string> = { 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 <StatusPill text={STATUS_LABEL[e.status] ?? e.status.toUpperCase()} />
if (e.cut) return <StatusPill text="Kuttet" />
const label = e.total_label ?? ""
const tone = label.startsWith("-") || label.startsWith("") ? "under" : label.startsWith("+") ? "over" : "even"
return <Shape label={label} tone={tone} />
}
const HeaderRow = () => (
<div style={{ gridTemplateColumns: gridCols, color: INK_MUTED, borderColor: HAIRLINE_STRONG }} className="grid items-end gap-x-2 border-b-2 px-2 pb-1 text-[10px] font-bold uppercase tracking-wide">
<span className="text-right">Plass</span>
<span>Spiller</span>
{fields.club && <span>Hjemmeklubb</span>}
{fields.hcp && <span className="text-right">HCP</span>}
{fields.rounds && Array.from({ length: maxRounds }).map((_, i) => (
<span key={i} className="text-center">
R{i + 1}
</span>
))}
<span className="text-center">Total</span>
</div>
)
const Row = ({ e }: { e: EnrichedEntry }) => (
<div style={{ gridTemplateColumns: gridCols, borderColor: HAIRLINE }} className="grid items-center gap-x-2 border-b px-2 py-[5px] text-[12px]">
<span className="text-right text-[13px] font-extrabold tabular-nums" style={{ color: e.position ? INK : INK_MUTED }}>
{e.position ?? ""}
</span>
<span className="truncate font-bold" style={{ color: INK }}>
{e.player_name}
</span>
{fields.club && (
<span className="truncate" style={{ color: INK_MUTED }}>
{e.club}
</span>
)}
{fields.hcp && (
<span className="text-right tabular-nums" style={{ color: INK_MUTED }}>
{e.hcpLabel}
</span>
)}
{fields.rounds &&
Array.from({ length: maxRounds }).map((_, i) => {
const r = e.rounds[i]
return (
<span key={i} className="flex justify-center">
{r ? <Shape label={r.label ?? ""} tone={r.tone} size="sm" /> : <span style={{ color: INK_MUTED }}></span>}
</span>
)
})}
<span className="flex justify-center">{totalCell(e)}</span>
</div>
)
let body: React.ReactNode
if (!fields.classes) {
body = (
<div className="flex flex-col">
<HeaderRow />
{entries.map((e, idx) => (
<div key={e.tournament_participant_id} className="contents">
{fields.cut && idx === firstCutIndex && firstCutIndex >= 0 && (
<div className="my-1 flex items-center gap-2 px-2">
<span style={{ backgroundColor: INK }} className="h-[2px] flex-1" />
<span style={{ color: INK }} className="text-[10px] font-extrabold uppercase tracking-wide">
Cut
</span>
<span style={{ backgroundColor: INK }} className="h-[2px] flex-1" />
</div>
)}
<Row e={e} />
</div>
))}
</div>
)
} else {
const classNames = Array.from(new Set(entries.map((e) => e.class_name ?? "Ingen klasse")))
body = (
<div className="flex flex-col gap-2.5">
{classNames.map((cls) => (
<div key={cls} className="break-inside-avoid">
<div style={{ backgroundColor: BAND, borderColor: HAIRLINE, color: INK }} className="mb-0.5 rounded-[2px] border px-2 py-1 text-[12px] font-extrabold">
{cls}
</div>
<HeaderRow />
{entries.filter((e) => (e.class_name ?? "Ingen klasse") === cls).map((e) => (
<Row key={e.tournament_participant_id} e={e} />
))}
</div>
))}
</div>
)
}
return (
<div style={{ color: INK }} className="flex h-full flex-col">
<header style={{ borderColor: HAIRLINE_STRONG }} className="border-b-2 pb-3">
<div className="flex items-baseline justify-between gap-4">
<h1 className="text-[22px] font-extrabold leading-tight">{tournamentName}</h1>
<span style={{ color: INK_MUTED }} className="text-[12px] font-semibold">
Resultatliste
</span>
</div>
<div style={{ color: INK_MUTED }} className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] font-medium">
<span style={{ color: INK }} className="font-bold">
{method}
</span>
<span>{period}</span>
</div>
</header>
<div className="mt-3 flex-1">{body}</div>
<div style={{ color: INK_MUTED }} className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] font-medium">
<span className="flex items-center gap-1.5">
<Shape label="-1" tone="under" size="sm" /> Under par
</span>
<span className="flex items-center gap-1.5">
<Shape label="E" tone="even" size="sm" /> Par
</span>
<span className="flex items-center gap-1.5">
<Shape label="+1" tone="over" size="sm" /> Over par
</span>
<span className="flex items-center gap-1.5">
<StatusPill text="DSQ" /> Spesialstatus
</span>
</div>
<SheetFooter />
</div>
)
}
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 }) => (
<div style={{ borderColor: leads ? INK : HAIRLINE }} className={cn("relative flex flex-1 flex-col items-center rounded-[4px] border-2 px-4 py-3", leads && "border-[2.5px]")}>
{leads && (
<span style={{ borderColor: INK, color: INK }} className="absolute -top-2.5 flex items-center gap-1 rounded-full border bg-white px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide">
<Crown aria-hidden className="size-3" fill="currentColor" />
Leder
</span>
)}
<span className="flex items-center gap-2">
<span aria-hidden style={{ backgroundColor: color, borderColor: "rgba(0,0,0,0.25)" }} className="h-3.5 w-3.5 rounded-[3px] border" />
<span className="text-[15px] font-extrabold">{name}</span>
</span>
<span className="mt-1 text-[40px] font-extrabold leading-none tabular-nums">{fmt(pts)}</span>
<span style={{ color: INK_MUTED }} className="text-[10px] font-bold uppercase tracking-wide">
poeng
</span>
</div>
)
return (
<div style={{ color: INK }} className="flex h-full flex-col">
<header style={{ borderColor: HAIRLINE_STRONG }} className="border-b-2 pb-3">
<div className="flex items-baseline justify-between gap-4">
<h1 className="text-[22px] font-extrabold leading-tight">{tournamentName}</h1>
<span style={{ color: INK_MUTED }} className="text-[12px] font-semibold">
Sluttresultat
</span>
</div>
<div style={{ color: INK_MUTED }} className="mt-1 text-[12px] font-medium">
{meta}
</div>
</header>
<div className="mt-4 flex items-stretch gap-3">
<TeamScore name={teamNord.name} color={teamNord.color} pts={teamNord.points} leads={nordLeads} />
<div className="flex items-center">
<span style={{ color: INK_MUTED }} className="text-[15px] font-extrabold">
</span>
</div>
<TeamScore name={teamSor.name} color={teamSor.color} pts={teamSor.points} leads={sorLeads} />
</div>
{fields.sessions && (
<div className="mt-4">
<h2 style={{ color: INK }} className="mb-1 text-[12px] font-extrabold uppercase tracking-wide">
Poeng per økt
</h2>
<table className="w-full border-collapse text-[12px]">
<thead>
<tr style={{ color: INK_MUTED, borderColor: HAIRLINE_STRONG }} className="border-b">
<th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">Økt</th>
<th className="px-2 py-1 text-right font-semibold uppercase tracking-wide">{teamNord.name}</th>
<th className="px-2 py-1 text-right font-semibold uppercase tracking-wide">{teamSor.name}</th>
</tr>
</thead>
<tbody>
{sessions.map((s) => (
<tr key={s.name} style={{ borderColor: HAIRLINE }} className="border-b">
<td className="px-2 py-1 font-bold">{s.name}</td>
<td className="px-2 py-1 text-right font-bold tabular-nums">{fmt(s.points.nord)}</td>
<td className="px-2 py-1 text-right font-bold tabular-nums">{fmt(s.points.sor)}</td>
</tr>
))}
<tr style={{ borderColor: INK }} className="border-t-2">
<td className="px-2 py-1 font-extrabold">Totalt</td>
<td className="px-2 py-1 text-right font-extrabold tabular-nums">{fmt(teamNord.points)}</td>
<td className="px-2 py-1 text-right font-extrabold tabular-nums">{fmt(teamSor.points)}</td>
</tr>
</tbody>
</table>
</div>
)}
{fields.matches && (
<div className="mt-4 flex-1">
<h2 style={{ color: INK }} className="mb-1 text-[12px] font-extrabold uppercase tracking-wide">
Match for match
</h2>
<table className="w-full border-collapse text-[12px]">
<thead>
<tr style={{ color: INK_MUTED, borderColor: HAIRLINE_STRONG }} className="border-b">
<th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">#</th>
<th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">{teamNord.name}</th>
<th className="px-2 py-1 text-center font-semibold uppercase tracking-wide">Resultat</th>
<th className="px-2 py-1 text-right font-semibold uppercase tracking-wide">{teamSor.name}</th>
</tr>
</thead>
<tbody>
{matches.map((m) => (
<tr key={m.no} style={{ borderColor: HAIRLINE }} className="border-b">
<td className="px-2 py-1 font-bold tabular-nums" style={{ color: INK_MUTED }}>
{m.no}
</td>
<td className={cn("px-2 py-1", m.winner === "nord" ? "font-extrabold" : "font-medium")}>{m.nord}</td>
<td className="px-2 py-1 text-center font-bold tabular-nums whitespace-nowrap">{m.result}</td>
<td className={cn("px-2 py-1 text-right", m.winner === "sor" ? "font-extrabold" : "font-medium")}>{m.sor}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<SheetFooter />
</div>
)
}
function SheetFooter() {
return (
<footer style={{ borderColor: HAIRLINE, color: INK_MUTED }} className="mt-3 flex items-center justify-between border-t pt-2 text-[10px] font-medium">
<span>Side 1 av 1</span>
<span>Generert {new Date().toLocaleString("no-NO")}</span>
<span className="font-bold" style={{ color: INK }}>
TeeCup
</span>
</footer>
)
}
const SCORING_METHOD_LABEL: Record<string, string> = {
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<string | null>(null)
const [tournament, setTournament] = useState<ApiTournament | null>(null)
const [entries, setEntries] = useState<EnrichedEntry[]>([])
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<SizeKey>(initialSize)
const [orientation, setOrientation] = useState<Orientation>(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<string, ApiTournamentParticipant>(tournamentParticipants.map((tp) => [tp.id, tp]))
const playerById = new Map<string, ApiPlayer>(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<string, ApiTeam>(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<HTMLDivElement>(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 (
<div className="flex min-h-screen flex-col bg-background text-foreground" data-print-ready={ready ? "true" : undefined}>
<style>{pageCss}</style>
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3 lg:px-6 print:hidden">
<div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/15 text-primary">
<Printer className="size-5" aria-hidden />
</div>
<div>
<h1 className="text-base font-extrabold leading-tight">Skriv ut resultatliste</h1>
<p className="text-[12px] text-muted-foreground">{error ?? (loading ? "Laster …" : "Klar")}</p>
</div>
</div>
<button
type="button"
onClick={downloadPdf}
disabled={downloading || loading}
className="inline-flex min-h-10 items-center gap-2 rounded-xl bg-primary px-4 text-sm font-extrabold text-primary-foreground shadow-sm transition-transform hover:brightness-105 active:scale-[0.99] disabled:opacity-60"
>
<Download className="size-4" aria-hidden />
{downloading ? "Genererer …" : "Last ned PDF"}
</button>
</header>
<div className="flex flex-1 flex-col lg:flex-row">
<aside className="w-full shrink-0 border-b border-border bg-card p-4 lg:w-80 lg:border-b-0 lg:border-r lg:p-5 print:hidden">
<div className="flex flex-col gap-6">
<PanelSection title="Papirstørrelse">
<Segmented
ariaLabel="Papirstørrelse"
options={(Object.keys(PAPER) as SizeKey[]).map((k) => ({ key: k, label: PAPER[k].label, sub: PAPER[k].sub }))}
value={size}
onChange={setSize}
/>
</PanelSection>
<PanelSection title="Orientering">
<Segmented
ariaLabel="Orientering"
options={[
{ key: "portrait" as Orientation, label: "Portrett" },
{ key: "landscape" as Orientation, label: "Landskap" },
]}
value={orientation}
onChange={setOrientation}
/>
</PanelSection>
<PanelSection title={tournament?.format_type === "individual" ? "Valgfrie felt" : "Vis på arket"}>
{tournament?.format_type === "individual" ? (
<div className="flex flex-col divide-y divide-border/70">
<ToggleRow label="HCP" checked={aFields.hcp} onCheckedChange={(v) => setAFields((s) => ({ ...s, hcp: v }))} />
<ToggleRow label="Delresultat per runde" checked={aFields.rounds} onCheckedChange={(v) => setAFields((s) => ({ ...s, rounds: v }))} />
<ToggleRow label="Hjemmeklubb" checked={aFields.club} onCheckedChange={(v) => setAFields((s) => ({ ...s, club: v }))} />
<ToggleRow label="Del inn i klasser" checked={aFields.classes} onCheckedChange={(v) => setAFields((s) => ({ ...s, classes: v }))} />
<ToggleRow label="Vis cut-grense" checked={aFields.cut} onCheckedChange={(v) => setAFields((s) => ({ ...s, cut: v }))} />
</div>
) : (
<div className="flex flex-col divide-y divide-border/70">
<ToggleRow label="Poeng per økt" checked={bFields.sessions} onCheckedChange={(v) => setBFields((s) => ({ ...s, sessions: v }))} />
<ToggleRow label="Match for match" checked={bFields.matches} onCheckedChange={(v) => setBFields((s) => ({ ...s, matches: v }))} />
</div>
)}
</PanelSection>
</div>
</aside>
<main ref={areaRef} className="grid flex-1 place-items-center overflow-auto bg-[oklch(0.9_0.01_130)] p-7 dark:bg-[oklch(0.15_0.01_150)] print:block print:bg-transparent print:p-0">
{loading ? (
<p className="text-sm text-muted-foreground">Laster resultatliste </p>
) : tournament?.format_type === "individual" ? (
<div style={{ width: natW * scale, height: natH * scale }} className="shrink-0 print:h-auto print:w-auto">
<div
style={{ width: natW, height: natH, transform: `scale(${scale})`, transformOrigin: "top left", backgroundColor: "#fff", boxShadow: "0 10px 30px rgba(0,0,0,0.28)", padding: `${12 * MM_TO_PX}px` }}
className="overflow-hidden print:!scale-100 print:shadow-none"
>
<SheetIndividual
tournamentName={tournament.name}
period=""
method={SCORING_METHOD_LABEL[tournament.scoring_method ?? ""] ?? tournament.scoring_method ?? ""}
entries={entries}
fields={aFields}
/>
</div>
</div>
) : cupData && tournament ? (
<div style={{ width: natW * scale, height: natH * scale }} className="shrink-0 print:h-auto print:w-auto">
<div
style={{ width: natW, height: natH, transform: `scale(${scale})`, transformOrigin: "top left", backgroundColor: "#fff", boxShadow: "0 10px 30px rgba(0,0,0,0.28)", padding: `${12 * MM_TO_PX}px` }}
className="overflow-hidden print:!scale-100 print:shadow-none"
>
<SheetCup tournamentName={tournament.name} meta="" teamNord={cupData.teamNord} teamSor={cupData.teamSor} sessions={cupData.sessions} matches={cupData.matches} fields={bFields} />
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">{error ?? "Ingen data funnet."}</p>
)}
</main>
</div>
</div>
)
}