"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 {count}•
return (
{Array.from({ length: count }).map((_, i) => (
))}
)
}
function GridCell({
children,
wide,
shaded,
header,
className,
}: {
children?: React.ReactNode
wide?: boolean
shaded?: boolean
header?: boolean
className?: string
}) {
return (
{children}
|
)
}
function LabelCell({ children, shaded }: { children: React.ReactNode; shaded?: boolean }) {
return (
{children}
|
)
}
// -------------------------- 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) => (
{render("hole", h)}
))}
{render("total")}
>
)
}
return (
<>
{out.map((h) => (
{render("hole", h)}
))}
{render("out")}
{inn.map((h) => (
{render("hole", h)}
))}
{render("in")}
{render("total")}
>
)
}
function PlayerRow({ player }: { player: SheetPlayer }) {
const sub = (
<>
HCP {player.hcpLabel} · {player.teeName ?? "—"}
{player.className ? <> · Kl. {player.className}> : null}
{player.teamName ? (
<>
{" · "}
{player.teamName}
>
) : null}
>
)
return (
<>
{player.name}
{sub}
{
if (kind !== "hole") return null
if (fields.strokes && hole && player.phcp != null) {
const s = strokesOnHole(player.phcp, hole.stroke_index)
return (
)
}
return
}}
/>
{fields.netto ? (
Netto
} />
) : null}
{fields.putts ? (
Putts
} />
) : null}
{fields.stableford ? (
Poeng
} />
) : null}
>
)
}
return (
{holes.map((h) => (
))}
{splitInOut ? : null}
{splitInOut ? : null}
Hull
{
if (kind === "hole") return {hole!.hole_number}
if (kind === "out") return Ut
if (kind === "in") return Inn
return {splitInOut ? "Tot" : "Sum"}
}}
/>
Par
{
if (kind === "hole") return hole!.par
if (kind === "out") return {outPar}
if (kind === "in") return {inPar}
return {totalPar}
}}
/>
Indeks
(kind === "hole" ? {hole!.stroke_index} : null)} />
Meter
(kind === "hole" ? {mockHoleLength(hole!.par, hole!.hole_number)} : null)}
/>
{sheet.players.map((p, i) => (
))}
{fields.matchplay ? (
Match-status
f.eks. «2 opp», «AS»
} />
) : null}
{fields.strokes ? (
Mottatte slag: = ett slag,{" "}
= to slag (tall ved 3+).
) : null}
{sheet.players.map((p, i) => (
Spillerens signatur — {p.name}
))}
)
}
function ScaledSheet({ paper, orientation, children }: { paper: PaperKey; orientation: Orientation; children: React.ReactNode }) {
const containerRef = useRef(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 (
)
}
function PanelSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
)
}
function Segmented({
label,
value,
onChange,
options,
}: {
label: string
value: T
onChange: (v: T) => void
options: { value: T; label: string; disabled?: boolean }[]
}) {
return (
{options.map((o) => {
const active = o.value === value
return (
)
})}
)
}
function Toggle({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
)
}
// -------------------------- 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(null)
const [tournament, setTournament] = useState(null)
const [holes, setHoles] = useState([])
const [sheets, setSheets] = useState([])
const [contextLabel, setContextLabel] = useState("")
const [paper, setPaper] = useState(initialPaper)
const [orientation, setOrientation] = useState(initialOrientation)
const [perSheet, setPerSheet] = useState(1)
const [fields, setFields] = useState({
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(roundParticipants.map((rp) => [rp.id, rp]))
const tpById = new Map(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(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 (
{loading ? (
Laster scorekort …
) : sheets.length === 0 ? (
Ingen grupper eller matcher funnet for denne runden/økten ennå.
) : twoUp ? (
chunk(sheets, 2).map((pair, i) => (
))
) : (
sheets.map((sheet, i) => (
))
)}
)
}
function TwoUp({ orientation, children }: { orientation: Orientation; children: React.ReactNode[] | React.ReactNode }) {
const stackVertical = orientation === "portrait"
const kids = Array.isArray(children) ? children : [children]
return (
{kids[0]}
{"✂"} klipp/brett her
{kids[1]}
)
}
function chunk(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
}