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