teecup/frontend/components/print-scorecard.tsx

780 lines
29 KiB
TypeScript
Raw Permalink Normal View History

2026-08-21 13:54:01 +02:00
"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 é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
}