teecup/frontend/components/print-scorecard.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

779 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import type React from "react"
import { useEffect, useId, useRef, useState } from "react"
import { Download, Printer } from "lucide-react"
import { cn } from "@/lib/utils"
import {
fetchCourseHoles,
fetchMatches,
fetchRoundGroups,
fetchRoundParticipants,
fetchRounds,
fetchSessions,
fetchTeams,
fetchTournament,
fetchTournamentParticipants,
mockHoleLength,
type ApiCourseHole,
type ApiMatch,
type ApiRound,
type ApiRoundGroup,
type ApiRoundParticipant,
type ApiSession,
type ApiTeam,
type ApiTournament,
type ApiTournamentParticipant,
} from "@/lib/print-data"
/**
* Print-friendly, tradisjonelt golf-SCOREKORT -- ekte data (ADR-097).
*
* Videreført fra V0-mockupen (samme sheet-mekanikk: A4/Letter/A5,
* portrett/landskap, per-felt-toggles, én sheet PER UTSLAGSGRUPPE
* (individuell) eller PER MATCH (Cup) -- en organisator skriver ut kort for
* HELE feltet på én gang, ikke ett enkelt eksempelkort.
*
* Hullengde finnes ikke i datamodellen ennå (bevisst utsatt, se
* FEATURE_BACKLOG.md) -- mockHoleLength() brukes akkurat som i selve
* V0-utskriftspromptene, ikke lenger her.
*/
const PAPER = {
A4: { w: 210, h: 297 },
Letter: { w: 216, h: 279 },
A5: { w: 148, h: 210 },
} as const
type PaperKey = keyof typeof PAPER
type Orientation = "portrait" | "landscape"
type PerSheet = 1 | 2
type SheetPlayer = {
name: string
hcpLabel: string
phcp: number | null
teeName: string | null
className: string | null
teamName?: string
teamColor?: string | null
}
type Sheet = { title: string; sublabel: string; players: SheetPlayer[] }
type FieldOpts = { netto: boolean; strokes: boolean; putts: boolean; stableford: boolean; matchplay: boolean }
function strokesOnHole(phcp: number, idx: number): number {
const base = Math.floor(phcp / 18)
const extra = phcp % 18
return base + (idx <= extra ? 1 : 0)
}
function ReceivedDots({ count }: { count: number }) {
if (count <= 0) return null
if (count >= 3) return <span className="text-[7px] font-bold leading-none text-neutral-500">{count}</span>
return (
<span className="flex gap-0.5">
{Array.from({ length: count }).map((_, i) => (
<span key={i} className="size-[3px] rounded-full bg-neutral-600" />
))}
</span>
)
}
function GridCell({
children,
wide,
shaded,
header,
className,
}: {
children?: React.ReactNode
wide?: boolean
shaded?: boolean
header?: boolean
className?: string
}) {
return (
<td
className={cn(
"border border-neutral-400 text-center align-middle",
shaded && "bg-neutral-100",
header ? "font-bold" : "",
className,
)}
>
{children}
</td>
)
}
function LabelCell({ children, shaded }: { children: React.ReactNode; shaded?: boolean }) {
return (
<td className={cn("border border-neutral-400 px-2 text-left align-middle text-[11px]", shaded && "bg-neutral-100")}>
{children}
</td>
)
}
// -------------------------- selve sheet-rendering --------------------------
function ScorecardSheet({ sheet, holes, fields, dense }: { sheet: Sheet; holes: ApiCourseHole[]; fields: FieldOpts; dense: boolean }) {
const splitInOut = holes.length > 9
const out = splitInOut ? holes.slice(0, 9) : holes
const inn = splitInOut ? holes.slice(9) : []
const outPar = out.reduce((s, h) => s + h.par, 0)
const inPar = inn.reduce((s, h) => s + h.par, 0)
const totalPar = outPar + inPar
const W_LABEL = 150
const W_HOLE = 42
const W_SUM = 50
const sumCols = splitInOut ? 3 : 1
const colUnits = W_LABEL + holes.length * W_HOLE + sumCols * W_SUM
const pctLabel = `${(W_LABEL / colUnits) * 100}%`
const pctHole = `${(W_HOLE / colUnits) * 100}%`
const pctSum = `${(W_SUM / colUnits) * 100}%`
function HoleCells({ render }: { render: (kind: "hole" | "out" | "in" | "total", hole?: ApiCourseHole) => React.ReactNode }) {
if (!splitInOut) {
return (
<>
{holes.map((h) => (
<GridCell key={`h-${h.hole_number}`}>{render("hole", h)}</GridCell>
))}
<GridCell wide shaded>
{render("total")}
</GridCell>
</>
)
}
return (
<>
{out.map((h) => (
<GridCell key={`o-${h.hole_number}`}>{render("hole", h)}</GridCell>
))}
<GridCell wide shaded>
{render("out")}
</GridCell>
{inn.map((h) => (
<GridCell key={`i-${h.hole_number}`}>{render("hole", h)}</GridCell>
))}
<GridCell wide shaded>
{render("in")}
</GridCell>
<GridCell wide shaded>
{render("total")}
</GridCell>
</>
)
}
function PlayerRow({ player }: { player: SheetPlayer }) {
const sub = (
<>
HCP {player.hcpLabel} · {player.teeName ?? "—"}
{player.className ? <> · Kl. {player.className}</> : null}
{player.teamName ? (
<>
{" · "}
<span className="inline-flex items-center gap-1">
<span className="inline-block size-2 rounded-sm" style={{ backgroundColor: player.teamColor ?? "#a3a3a3" }} />
{player.teamName}
</span>
</>
) : null}
</>
)
return (
<>
<tr>
<LabelCell>
<div className="flex flex-col leading-tight">
<span className="font-bold text-neutral-900">{player.name}</span>
<span className="text-[9px] text-neutral-500">{sub}</span>
</div>
</LabelCell>
<HoleCells
render={(kind, hole) => {
if (kind !== "hole") return null
if (fields.strokes && hole && player.phcp != null) {
const s = strokesOnHole(player.phcp, hole.stroke_index)
return (
<div className="relative h-full w-full" style={{ minHeight: 24 }}>
<span className="absolute left-0.5 top-0.5">
<ReceivedDots count={s} />
</span>
</div>
)
}
return <div style={{ minHeight: 24 }} />
}}
/>
</tr>
{fields.netto ? (
<tr>
<LabelCell shaded>
<span className="text-[10px] italic text-neutral-600">Netto</span>
</LabelCell>
<HoleCells render={() => <div style={{ minHeight: 16 }} />} />
</tr>
) : null}
{fields.putts ? (
<tr>
<LabelCell shaded>
<span className="text-[10px] italic text-neutral-600">Putts</span>
</LabelCell>
<HoleCells render={() => <div style={{ minHeight: 16 }} />} />
</tr>
) : null}
{fields.stableford ? (
<tr>
<LabelCell shaded>
<span className="text-[10px] italic text-neutral-600">Poeng</span>
</LabelCell>
<HoleCells render={() => <div style={{ minHeight: 16 }} />} />
</tr>
) : null}
</>
)
}
return (
<div className={cn("flex h-full flex-col text-neutral-900", dense ? "gap-2 p-4" : "gap-3 p-6")}>
<header className="flex items-end justify-between border-b-2 border-neutral-800 pb-2">
<div className="flex flex-col">
<span className={cn("font-extrabold leading-none", dense ? "text-sm" : "text-lg")}>{sheet.title}</span>
<span className={cn("text-neutral-600", dense ? "text-[11px]" : "text-sm")}>{sheet.sublabel}</span>
</div>
<div className="text-right text-[11px] text-neutral-600">
<div>Offisielt scorekort fylles ut for hånd</div>
</div>
</header>
<table className="w-full border-collapse text-[11px]" style={{ tableLayout: "fixed" }}>
<colgroup>
<col style={{ width: pctLabel }} />
{holes.map((h) => (
<col key={`c-${h.hole_number}`} style={{ width: pctHole }} />
))}
{splitInOut ? <col style={{ width: pctSum }} /> : null}
{splitInOut ? <col style={{ width: pctSum }} /> : null}
<col style={{ width: pctSum }} />
</colgroup>
<tbody>
<tr>
<LabelCell shaded>
<span className="font-bold uppercase tracking-wide text-neutral-700">Hull</span>
</LabelCell>
<HoleCells
render={(kind, hole) => {
if (kind === "hole") return <span className="font-bold">{hole!.hole_number}</span>
if (kind === "out") return <span className="font-extrabold">Ut</span>
if (kind === "in") return <span className="font-extrabold">Inn</span>
return <span className="font-extrabold">{splitInOut ? "Tot" : "Sum"}</span>
}}
/>
</tr>
<tr>
<LabelCell shaded>
<span className="font-bold text-neutral-700">Par</span>
</LabelCell>
<HoleCells
render={(kind, hole) => {
if (kind === "hole") return hole!.par
if (kind === "out") return <span className="font-bold">{outPar}</span>
if (kind === "in") return <span className="font-bold">{inPar}</span>
return <span className="font-bold">{totalPar}</span>
}}
/>
</tr>
<tr>
<LabelCell shaded>
<span className="text-[10px] text-neutral-600">Indeks</span>
</LabelCell>
<HoleCells render={(kind, hole) => (kind === "hole" ? <span className="text-neutral-500">{hole!.stroke_index}</span> : null)} />
</tr>
<tr>
<LabelCell shaded>
<span className="text-[9px] text-neutral-500">Meter</span>
</LabelCell>
<HoleCells
render={(kind, hole) => (kind === "hole" ? <span className="text-[9px] text-neutral-400">{mockHoleLength(hole!.par, hole!.hole_number)}</span> : null)}
/>
</tr>
{sheet.players.map((p, i) => (
<PlayerRow key={i} player={p} />
))}
{fields.matchplay ? (
<tr>
<LabelCell shaded>
<span className="text-[10px] font-bold text-neutral-700">Match-status</span>
<div className="text-[8px] text-neutral-500">f.eks. «2 opp», «AS»</div>
</LabelCell>
<HoleCells render={() => <div style={{ minHeight: 20 }} />} />
</tr>
) : null}
</tbody>
</table>
{fields.strokes ? (
<p className="text-[9px] text-neutral-500">
Mottatte slag: <span className="mx-0.5 inline-block size-[3px] rounded-full bg-neutral-600 align-middle" /> = ett slag,{" "}
<span className="mx-0.5 inline-block size-[3px] rounded-full bg-neutral-600 align-middle" />
<span className="mr-0.5 inline-block size-[3px] rounded-full bg-neutral-600 align-middle" /> = to slag (tall ved 3+).
</p>
) : null}
<div className="mt-auto grid grid-cols-2 gap-x-8 gap-y-3 border-t border-neutral-300 pt-3">
{sheet.players.map((p, i) => (
<div key={i} className="flex items-end gap-2">
<div className="flex-1">
<div className="h-5 border-b border-neutral-500" />
<span className="text-[9px] text-neutral-600">Spillerens signatur {p.name}</span>
</div>
<div className="w-24">
<div className="h-5 border-b border-neutral-500" />
<span className="text-[9px] text-neutral-600">Dato</span>
</div>
</div>
))}
<div className="col-span-2 flex items-end gap-2">
<div className="flex-1">
<div className="h-5 border-b border-neutral-500" />
<span className="text-[9px] text-neutral-600">Markørens signatur</span>
</div>
<div className="w-24">
<div className="h-5 border-b border-neutral-500" />
<span className="text-[9px] text-neutral-600">Dato</span>
</div>
</div>
</div>
</div>
)
}
function ScaledSheet({ paper, orientation, children }: { paper: PaperKey; orientation: Orientation; children: React.ReactNode }) {
const containerRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)
const base = PAPER[paper]
const wMm = orientation === "landscape" ? base.h : base.w
const hMm = orientation === "landscape" ? base.w : base.h
const PX_PER_MM = 3.7795
const sheetWpx = wMm * PX_PER_MM
const sheetHpx = hMm * PX_PER_MM
useEffect(() => {
const el = containerRef.current
if (!el) return
const ro = new ResizeObserver(() => setScale(Math.min(1, el.clientWidth / sheetWpx)))
ro.observe(el)
return () => ro.disconnect()
}, [sheetWpx])
return (
<div ref={containerRef} className="w-full print:contents">
<div style={{ height: sheetHpx * scale }} className="mx-auto print:hidden">
<div
className="origin-top-left overflow-hidden bg-white shadow-lg ring-1 ring-neutral-300"
style={{ width: sheetWpx, minHeight: sheetHpx, transform: `scale(${scale})` }}
>
{children}
</div>
</div>
<div className="hidden print:block print:break-after-page" style={{ width: `${wMm}mm`, minHeight: `${hMm}mm` }}>
{children}
</div>
</div>
)
}
function PanelSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-2">
<h3 className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{title}</h3>
{children}
</section>
)
}
function Segmented<T extends string>({
label,
value,
onChange,
options,
}: {
label: string
value: T
onChange: (v: T) => void
options: { value: T; label: string; disabled?: boolean }[]
}) {
return (
<div role="radiogroup" aria-label={label} className="flex flex-wrap gap-1 rounded-xl bg-muted p-1">
{options.map((o) => {
const active = o.value === value
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={active}
disabled={o.disabled}
onClick={() => onChange(o.value)}
className={cn(
"min-h-9 flex-1 rounded-lg px-3 text-sm font-bold transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
active ? "bg-card text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
o.disabled && "cursor-not-allowed opacity-40 hover:text-muted-foreground",
)}
>
{o.label}
</button>
)
})}
</div>
)
}
function Toggle({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center justify-between gap-3 rounded-xl border border-border bg-card px-3 py-2.5">
<span className="flex flex-col">
<span className="text-sm font-bold text-foreground">{label}</span>
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
</span>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={cn(
"relative h-6 w-11 shrink-0 rounded-full transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
checked ? "bg-primary" : "bg-muted-foreground/30",
)}
>
<span className={cn("absolute top-0.5 size-5 rounded-full bg-card shadow transition-all", checked ? "left-[22px]" : "left-0.5")} />
</button>
</label>
)
}
// -------------------------- data-lasting + hovedkomponent --------------------------
export function PrintScorecard({
organizationId,
tournamentId,
roundId,
sessionId,
initialPaper = "A4",
initialOrientation = "landscape",
}: {
organizationId: string
tournamentId: string
roundId?: string
sessionId?: string
initialPaper?: PaperKey
initialOrientation?: Orientation
}) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [tournament, setTournament] = useState<ApiTournament | null>(null)
const [holes, setHoles] = useState<ApiCourseHole[]>([])
const [sheets, setSheets] = useState<Sheet[]>([])
const [contextLabel, setContextLabel] = useState("")
const [paper, setPaper] = useState<PaperKey>(initialPaper)
const [orientation, setOrientation] = useState<Orientation>(initialOrientation)
const [perSheet, setPerSheet] = useState<PerSheet>(1)
const [fields, setFields] = useState<FieldOpts>({
netto: false,
strokes: true,
putts: false,
stableford: false,
matchplay: sessionId != null,
})
const [downloading, setDownloading] = useState(false)
const styleId = useId()
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const t = await fetchTournament(organizationId, tournamentId)
if (cancelled) return
setTournament(t)
if (roundId) {
const [rounds, groupsRes, roundParticipants, tournamentParticipants] = await Promise.all([
fetchRounds(organizationId, tournamentId),
fetchRoundGroups(organizationId, tournamentId, roundId),
fetchRoundParticipants(organizationId, tournamentId, roundId),
fetchTournamentParticipants(organizationId, tournamentId),
])
const round = rounds.find((r: ApiRound) => r.id === roundId) ?? null
if (!round) throw new Error("Fant ikke runden.")
const courseHoles = await fetchCourseHoles(organizationId, round.course_id)
const playedHoles = filterHolesByConfig(courseHoles, round.hole_config)
const rpById = new Map<string, ApiRoundParticipant>(roundParticipants.map((rp) => [rp.id, rp]))
const tpById = new Map<string, ApiTournamentParticipant>(tournamentParticipants.map((tp) => [tp.id, tp]))
const builtSheets: Sheet[] = groupsRes.groups.map((g: ApiRoundGroup, i: number) => ({
title: t?.name ?? "Turnering",
sublabel: `${round.name} · ${round.course_name} · Gruppe ${g.sequence ?? i + 1}`,
players: g.participants.map((gp) => {
const rp = rpById.get(gp.round_participant_id)
const tp = rp ? tpById.get(rp.tournament_participant_id) : undefined
return {
name: gp.player_name,
hcpLabel: tp?.handicap_index_snapshot != null ? tp.handicap_index_snapshot.toFixed(1).replace(".", ",") : "",
phcp: rp?.playing_handicap ?? null,
teeName: rp?.tee_name ?? null,
className: tp?.class_name ?? null,
}
}),
}))
if (cancelled) return
setHoles(playedHoles)
setContextLabel(`${round.name} · Individuell slagspill`)
setSheets(builtSheets)
} else if (sessionId) {
const [sessions, matches, teams] = await Promise.all([
fetchSessions(organizationId, tournamentId),
fetchMatches(organizationId, sessionId),
fetchTeams(organizationId, tournamentId),
])
const session = sessions.find((s: ApiSession) => s.id === sessionId) ?? null
if (!session) throw new Error("Fant ikke økten.")
const courseHoles = await fetchCourseHoles(organizationId, session.course_id)
const playedHoles = filterHolesByConfig(courseHoles, session.hole_config)
const teamById = new Map<string, ApiTeam>(teams.map((tm) => [tm.id, tm]))
const builtSheets: Sheet[] = matches.map((m: ApiMatch) => {
const teamA = teamById.get(m.team_a_id)
const teamB = teamById.get(m.team_b_id)
const players: SheetPlayer[] = m.participants.map((mp) => ({
name: mp.player_name,
hcpLabel: mp.playing_handicap != null ? String(mp.playing_handicap) : "",
phcp: mp.playing_handicap,
teeName: mp.tee_name,
className: null,
teamName: mp.team_side === "a" ? teamA?.name : teamB?.name,
teamColor: mp.team_side === "a" ? teamA?.color : teamB?.color,
}))
return {
title: t?.name ?? "Turnering",
sublabel: `${session.name} · ${session.format} · Match ${m.sequence}`,
players,
}
})
if (cancelled) return
setHoles(playedHoles)
setContextLabel(`${session.name} · Cup-format · Matchplay`)
setSheets(builtSheets)
} else {
throw new Error("Mangler runde- eller økt-ID.")
}
} catch {
if (!cancelled) setError("Klarte ikke å laste data for scorekortet. Prøv igjen.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId, roundId, sessionId])
function choosePaper(p: PaperKey) {
setPaper(p)
if (p === "A5") {
setPerSheet(1)
setOrientation("landscape")
}
}
function choosePerSheet(n: PerSheet) {
setPerSheet(n)
if (n === 2) setOrientation("portrait")
}
const twoUp = perSheet === 2
const dense = twoUp || paper === "A5"
const base = PAPER[paper]
const pageSize = `${base.w}mm ${base.h}mm ${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: "scorecard",
tournament_id: tournamentId,
round_id: roundId ?? null,
session_id: sessionId ?? null,
paper,
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-scorekort.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="min-h-screen bg-background text-foreground" data-print-ready={ready ? "true" : undefined}>
<style>{`@media print { @page { size: ${pageSize}; margin: 8mm; } }`}</style>
<div id={styleId} className="mx-auto flex max-w-[1400px] flex-col gap-6 p-4 lg:flex-row lg:p-6 print:block print:p-0">
<aside className="flex w-full shrink-0 flex-col gap-5 rounded-2xl border border-border bg-card p-4 lg:w-80 print:hidden">
<div className="flex items-center gap-2">
<span className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Printer aria-hidden="true" className="size-5" />
</span>
<div className="flex flex-col">
<h2 className="text-base font-extrabold leading-none text-foreground">Skriv ut scorekort</h2>
<span className="text-xs text-muted-foreground">{contextLabel || "Fysisk kort til bruk på banen"}</span>
</div>
</div>
{error ? <p className="text-sm font-medium text-destructive">{error}</p> : null}
<PanelSection title="Papir">
<Segmented label="Papirstørrelse" value={paper} onChange={choosePaper} options={[
{ value: "A4", label: "A4" },
{ value: "Letter", label: "Letter" },
{ value: "A5", label: "A5" },
]} />
<Segmented
label="Arkoppsett"
value={String(perSheet) as "1" | "2"}
onChange={(v) => choosePerSheet(Number(v) as PerSheet)}
options={[
{ value: "1", label: "Ett kort" },
{ value: "2", label: "To kort per ark", disabled: paper === "A5" },
]}
/>
<Segmented label="Retning" value={orientation} onChange={setOrientation} options={[
{ value: "landscape", label: "Landskap" },
{ value: "portrait", label: "Portrett" },
]} />
</PanelSection>
<PanelSection title="Valgfrie felt">
<div className="flex flex-col gap-2">
<Toggle label="Nettoscore-rad" hint="Tom rad under bruttoraden" checked={fields.netto} onChange={(v) => setFields((f) => ({ ...f, netto: v }))} />
<Toggle label="Mottatte slag (prikker)" hint="Prikker over hull-cellen" checked={fields.strokes} onChange={(v) => setFields((f) => ({ ...f, strokes: v }))} />
<Toggle label="Putts-rad" checked={fields.putts} onChange={(v) => setFields((f) => ({ ...f, putts: v }))} />
<Toggle label="Stableford-poeng" checked={fields.stableford} onChange={(v) => setFields((f) => ({ ...f, stableford: v }))} />
{sessionId ? (
<Toggle label="Matchplay-status" hint="Statusrad per hull" checked={fields.matchplay} onChange={(v) => setFields((f) => ({ ...f, matchplay: v }))} />
) : null}
</div>
</PanelSection>
<button
type="button"
onClick={downloadPdf}
disabled={downloading || loading || sheets.length === 0}
className={cn(
"mt-1 flex min-h-11 w-full items-center justify-center gap-2 rounded-xl bg-primary px-4 font-bold text-primary-foreground shadow-sm transition-colors",
"hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:opacity-60",
)}
>
<Download aria-hidden="true" className="size-4" />
{downloading ? "Genererer …" : "Last ned PDF"}
</button>
<p className="text-center text-[11px] text-muted-foreground">
{sheets.length > 0 ? `${sheets.length} kort i denne utskriften.` : loading ? "Laster …" : "Ingen grupper/matcher å skrive ut ennå."}
</p>
</aside>
<main className="min-w-0 flex-1 print:w-full">
<div className="rounded-2xl border border-border bg-muted/40 p-4 lg:p-8 print:border-0 print:bg-transparent print:p-0">
{loading ? (
<p className="p-8 text-center text-sm text-muted-foreground">Laster scorekort </p>
) : sheets.length === 0 ? (
<p className="p-8 text-center text-sm text-muted-foreground">Ingen grupper eller matcher funnet for denne runden/økten ennå.</p>
) : twoUp ? (
chunk(sheets, 2).map((pair, i) => (
<div key={i} className="mb-6 last:mb-0">
<ScaledSheet paper={paper} orientation={orientation}>
<TwoUp orientation={orientation}>
<ScorecardSheet sheet={pair[0]} holes={holes} fields={fields} dense />
{pair[1] ? <ScorecardSheet sheet={pair[1]} holes={holes} fields={fields} dense /> : <div />}
</TwoUp>
</ScaledSheet>
</div>
))
) : (
sheets.map((sheet, i) => (
<div key={i} className="mb-6 last:mb-0">
<ScaledSheet paper={paper} orientation={orientation}>
<ScorecardSheet sheet={sheet} holes={holes} fields={fields} dense={dense} />
</ScaledSheet>
</div>
))
)}
</div>
</main>
</div>
</div>
)
}
function TwoUp({ orientation, children }: { orientation: Orientation; children: React.ReactNode[] | React.ReactNode }) {
const stackVertical = orientation === "portrait"
const kids = Array.isArray(children) ? children : [children]
return (
<div className={cn("flex h-full w-full", stackVertical ? "flex-col" : "flex-row")}>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">{kids[0]}</div>
<div
className={cn(
"relative flex shrink-0 items-center justify-center",
stackVertical ? "h-0 w-full border-t border-dashed border-neutral-400" : "h-full w-0 border-l border-dashed border-neutral-400",
)}
>
<span className={cn("absolute bg-white px-1 text-[9px] text-neutral-400", stackVertical ? "left-2" : "top-2 -rotate-90")}>
{"✂"} klipp/brett her
</span>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">{kids[1]}</div>
</div>
)
}
function chunk<T>(arr: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size))
return out
}
function filterHolesByConfig(holes: ApiCourseHole[], config: "full_18" | "front_9" | "back_9"): ApiCourseHole[] {
const sorted = [...holes].sort((a, b) => a.hole_number - b.hole_number)
if (config === "front_9") return sorted.filter((h) => h.hole_number <= 9)
if (config === "back_9") return sorted.filter((h) => h.hole_number > 9)
return sorted
}