780 lines
29 KiB
TypeScript
780 lines
29 KiB
TypeScript
|
|
"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
|
|||
|
|
}
|