616 lines
26 KiB
TypeScript
616 lines
26 KiB
TypeScript
|
|
"use client"
|
|||
|
|
|
|||
|
|
import type React from "react"
|
|||
|
|
import { useEffect, useId, useMemo, useRef, useState } from "react"
|
|||
|
|
import { Download, Printer, Star } from "lucide-react"
|
|||
|
|
import { cn } from "@/lib/utils"
|
|||
|
|
import {
|
|||
|
|
fetchMatches,
|
|||
|
|
fetchPlayers,
|
|||
|
|
fetchRoundGroups,
|
|||
|
|
fetchRoundParticipants,
|
|||
|
|
fetchRounds,
|
|||
|
|
fetchSessions,
|
|||
|
|
fetchTeams,
|
|||
|
|
fetchTournament,
|
|||
|
|
fetchTournamentParticipants,
|
|||
|
|
formatDate,
|
|||
|
|
formatTeeTime,
|
|||
|
|
type ApiMatch,
|
|||
|
|
type ApiPlayer,
|
|||
|
|
type ApiRound,
|
|||
|
|
type ApiRoundParticipant,
|
|||
|
|
type ApiSession,
|
|||
|
|
type ApiTeam,
|
|||
|
|
type ApiTournamentParticipant,
|
|||
|
|
} from "@/lib/print-data"
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Print-friendly START-LISTE -- ekte data (ADR-097). Videreført fra
|
|||
|
|
* V0-mockupen: Variant A (individuell, gruppevis) / Variant B (Cup,
|
|||
|
|
* match-oppstilling m/ lagfarger). A3/A2-plakatformat kun for Cup.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
type SizeKey = "A4" | "Letter" | "A3" | "A2"
|
|||
|
|
type Orientation = "portrait" | "landscape"
|
|||
|
|
const PAPER: Record<SizeKey, { label: string; sub: string; w: number; h: number; large: boolean }> = {
|
|||
|
|
A4: { label: "A4", sub: "210 × 297 mm", w: 210, h: 297, large: false },
|
|||
|
|
Letter: { label: "Letter", sub: "216 × 279 mm", w: 216, h: 279, large: false },
|
|||
|
|
A3: { label: "A3 / Tabloid", sub: "297 × 420 mm", w: 297, h: 420, large: true },
|
|||
|
|
A2: { label: "A2 / ANSI C", sub: "420 × 594 mm", w: 420, h: 594, large: true },
|
|||
|
|
}
|
|||
|
|
const MM_TO_PX = 96 / 25.4
|
|||
|
|
|
|||
|
|
const INK = "#171717"
|
|||
|
|
const INK_MUTED = "#5b5b5b"
|
|||
|
|
const HAIRLINE = "#d9d9d9"
|
|||
|
|
const HAIRLINE_STRONG = "#a3a3a3"
|
|||
|
|
|
|||
|
|
type GroupRow = { seq: number; time: string; players: { name: string; tee: string; hcp: string; klasse: string; gender: string; club: string }[] }
|
|||
|
|
type MatchRow = {
|
|||
|
|
no: number
|
|||
|
|
time: string
|
|||
|
|
points: string
|
|||
|
|
nord: { name: string; tee: string; hcp: string; klasse: string; captain?: boolean }[]
|
|||
|
|
sor: { name: string; tee: string; hcp: string; klasse: string; captain?: boolean }[]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function Segmented<T extends string>({
|
|||
|
|
options,
|
|||
|
|
value,
|
|||
|
|
onChange,
|
|||
|
|
ariaLabel,
|
|||
|
|
}: {
|
|||
|
|
options: { key: T; label: string; sub?: string; disabled?: boolean }[]
|
|||
|
|
value: T
|
|||
|
|
onChange: (v: T) => void
|
|||
|
|
ariaLabel: string
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div role="radiogroup" aria-label={ariaLabel} className="flex flex-wrap gap-1.5">
|
|||
|
|
{options.map((o) => {
|
|||
|
|
const active = o.key === value
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
key={o.key}
|
|||
|
|
type="button"
|
|||
|
|
role="radio"
|
|||
|
|
aria-checked={active}
|
|||
|
|
disabled={o.disabled}
|
|||
|
|
onClick={() => onChange(o.key)}
|
|||
|
|
className={cn(
|
|||
|
|
"flex min-h-11 flex-1 flex-col items-start justify-center rounded-xl border px-3 py-2 text-left 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 ? "border-primary bg-primary/10 text-foreground" : "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
|||
|
|
o.disabled && "cursor-not-allowed opacity-40 hover:bg-card hover:text-muted-foreground",
|
|||
|
|
)}
|
|||
|
|
>
|
|||
|
|
<span className="text-sm font-bold">{o.label}</span>
|
|||
|
|
{o.sub ? <span className="text-[11px] font-medium opacity-70">{o.sub}</span> : null}
|
|||
|
|
</button>
|
|||
|
|
)
|
|||
|
|
})}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ToggleRow({ label, hint, checked, onCheckedChange }: { label: string; hint?: string; checked: boolean; onCheckedChange: (v: boolean) => void }) {
|
|||
|
|
return (
|
|||
|
|
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-xl px-1 py-1.5">
|
|||
|
|
<span className="flex flex-col">
|
|||
|
|
<span className="text-sm font-semibold text-foreground">{label}</span>
|
|||
|
|
{hint ? <span className="text-[11px] text-muted-foreground">{hint}</span> : null}
|
|||
|
|
</span>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
role="switch"
|
|||
|
|
aria-checked={checked}
|
|||
|
|
onClick={() => onCheckedChange(!checked)}
|
|||
|
|
className={cn("relative h-6 w-11 shrink-0 rounded-full transition-colors", checked ? "bg-primary" : "bg-muted-foreground/30")}
|
|||
|
|
>
|
|||
|
|
<span className={cn("absolute top-0.5 size-5 rounded-full bg-white shadow transition-transform", checked ? "translate-x-[22px]" : "translate-x-0.5")} />
|
|||
|
|
</button>
|
|||
|
|
</label>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function PanelSection({ title, children }: { title: string; children: React.ReactNode }) {
|
|||
|
|
return (
|
|||
|
|
<section className="flex flex-col gap-2">
|
|||
|
|
<h3 className="text-[11px] font-bold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
|||
|
|
{children}
|
|||
|
|
</section>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function TeamChip({ color, name }: { color: string; name: string }) {
|
|||
|
|
return (
|
|||
|
|
<span className="inline-flex items-center gap-2">
|
|||
|
|
<span aria-hidden style={{ backgroundColor: color, borderColor: "rgba(0,0,0,0.25)" }} className="inline-block h-3 w-3 shrink-0 rounded-[3px] border" />
|
|||
|
|
<span style={{ color: INK }} className="font-bold">
|
|||
|
|
{name}
|
|||
|
|
</span>
|
|||
|
|
</span>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function SheetIndividual({
|
|||
|
|
tournamentName,
|
|||
|
|
round,
|
|||
|
|
groups,
|
|||
|
|
fields,
|
|||
|
|
}: {
|
|||
|
|
tournamentName: string
|
|||
|
|
round: ApiRound
|
|||
|
|
groups: GroupRow[]
|
|||
|
|
fields: { hcp: boolean; klasse: boolean; gender: boolean; club: boolean }
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div style={{ color: INK }} className="flex h-full flex-col">
|
|||
|
|
<header style={{ borderColor: HAIRLINE_STRONG }} className="border-b-2 pb-3">
|
|||
|
|
<div className="flex items-baseline justify-between gap-4">
|
|||
|
|
<h1 className="text-[22px] font-extrabold leading-tight">{tournamentName}</h1>
|
|||
|
|
<span style={{ color: INK_MUTED }} className="text-[12px] font-semibold">
|
|||
|
|
{round.start_mode === "shotgun" ? "Startliste — shotgun" : "Startliste"}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ color: INK_MUTED }} className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] font-medium">
|
|||
|
|
<span style={{ color: INK }} className="font-bold">
|
|||
|
|
{round.name}
|
|||
|
|
</span>
|
|||
|
|
<span>{round.course_name}</span>
|
|||
|
|
<span>{formatDate(round.scheduled_at)}</span>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<div className="mt-3 flex flex-1 flex-col gap-3">
|
|||
|
|
{groups.map((g) => (
|
|||
|
|
<div key={g.seq} style={{ borderColor: HAIRLINE }} className="break-inside-avoid rounded-[2px] border">
|
|||
|
|
<div style={{ backgroundColor: "#f2f2f0", borderColor: HAIRLINE }} className="flex items-center justify-between border-b px-2.5 py-1.5">
|
|||
|
|
<span className="text-[13px] font-extrabold">Gruppe {g.seq}</span>
|
|||
|
|
<span className="text-[13px] font-bold tabular-nums">
|
|||
|
|
{round.start_mode === "shotgun" ? `Starthull ${g.time}` : `Utslag ${g.time}`}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<table className="w-full border-collapse text-[12px]">
|
|||
|
|
<thead>
|
|||
|
|
<tr style={{ color: INK_MUTED, borderColor: HAIRLINE }} className="border-b">
|
|||
|
|
<th className="px-2.5 py-1 text-left font-semibold uppercase tracking-wide">Spiller</th>
|
|||
|
|
<th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">Tee</th>
|
|||
|
|
{fields.hcp && <th className="px-2 py-1 text-right font-semibold uppercase tracking-wide">HCP</th>}
|
|||
|
|
{fields.klasse && <th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">Klasse</th>}
|
|||
|
|
{fields.gender && <th className="px-2 py-1 text-center font-semibold uppercase tracking-wide">Kjønn</th>}
|
|||
|
|
{fields.club && <th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">Hjemmeklubb</th>}
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{g.players.map((p, i) => (
|
|||
|
|
<tr key={p.name + i} style={{ borderColor: HAIRLINE }} className={cn(i < g.players.length - 1 && "border-b")}>
|
|||
|
|
<td className="px-2.5 py-1 font-bold">{p.name}</td>
|
|||
|
|
<td className="px-2 py-1">{p.tee}</td>
|
|||
|
|
{fields.hcp && <td className="px-2 py-1 text-right tabular-nums">{p.hcp}</td>}
|
|||
|
|
{fields.klasse && <td className="px-2 py-1">{p.klasse}</td>}
|
|||
|
|
{fields.gender && <td className="px-2 py-1 text-center">{p.gender}</td>}
|
|||
|
|
{fields.club && <td className="px-2 py-1">{p.club}</td>}
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
<SheetFooter />
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function SheetCup({
|
|||
|
|
tournamentName,
|
|||
|
|
session,
|
|||
|
|
teamNord,
|
|||
|
|
teamSor,
|
|||
|
|
matches,
|
|||
|
|
fields,
|
|||
|
|
poster,
|
|||
|
|
}: {
|
|||
|
|
tournamentName: string
|
|||
|
|
session: ApiSession
|
|||
|
|
teamNord: ApiTeam
|
|||
|
|
teamSor: ApiTeam
|
|||
|
|
matches: MatchRow[]
|
|||
|
|
fields: { tee: boolean; hcp: boolean; klasse: boolean; points: boolean; captain: boolean }
|
|||
|
|
poster: boolean
|
|||
|
|
}) {
|
|||
|
|
const meta = [`${session.name} — ${session.format}`, formatDate(session.scheduled_at)]
|
|||
|
|
return (
|
|||
|
|
<div style={{ color: INK }} className="flex h-full flex-col">
|
|||
|
|
<header style={{ borderColor: HAIRLINE_STRONG }} className="border-b-2 pb-3">
|
|||
|
|
<div className="flex items-baseline justify-between gap-4">
|
|||
|
|
<h1 className={cn("font-extrabold leading-tight", poster ? "text-[30px]" : "text-[22px]")}>{tournamentName}</h1>
|
|||
|
|
<span style={{ color: INK_MUTED }} className="text-[12px] font-semibold">
|
|||
|
|
Oppstilling
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div style={{ color: INK_MUTED }} className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] font-medium">
|
|||
|
|
{meta.map((m, i) => (
|
|||
|
|
<span key={m} style={i === 0 ? { color: INK } : undefined} className={i === 0 ? "font-bold" : undefined}>
|
|||
|
|
{m}
|
|||
|
|
</span>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
<div className="mt-2 flex flex-wrap items-center gap-x-6 gap-y-1 text-[13px]">
|
|||
|
|
<TeamChip color={teamNord.color ?? "#1f5fbf"} name={teamNord.name} />
|
|||
|
|
<span style={{ color: INK_MUTED }} className="font-bold">
|
|||
|
|
vs
|
|||
|
|
</span>
|
|||
|
|
<TeamChip color={teamSor.color ?? "#d1561b"} name={teamSor.name} />
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<div className={cn("mt-3 flex flex-1 flex-col", poster ? "gap-4" : "gap-2.5")}>
|
|||
|
|
{matches.map((m) => (
|
|||
|
|
<div key={m.no} style={{ borderColor: HAIRLINE_STRONG }} className="grid break-inside-avoid grid-cols-[auto_1fr_auto_1fr] items-stretch overflow-hidden rounded-[3px] border">
|
|||
|
|
<div style={{ backgroundColor: "#f2f2f0", borderColor: HAIRLINE }} className={cn("flex flex-col items-center justify-center border-r px-3", poster ? "min-w-[92px]" : "min-w-[64px]")}>
|
|||
|
|
<span style={{ color: INK_MUTED }} className="text-[10px] font-bold uppercase tracking-wide">
|
|||
|
|
Match
|
|||
|
|
</span>
|
|||
|
|
<span className={cn("font-extrabold leading-none", poster ? "text-[34px]" : "text-[22px]")}>{m.no}</span>
|
|||
|
|
<span className="mt-0.5 text-[12px] font-bold tabular-nums">{m.time}</span>
|
|||
|
|
{fields.points && (
|
|||
|
|
<span style={{ color: INK_MUTED }} className="mt-0.5 text-[10px] font-semibold">
|
|||
|
|
{m.points}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
<MatchSide accent={teamNord.color ?? "#1f5fbf"} players={m.nord} fields={fields} poster={poster} align="left" />
|
|||
|
|
<div style={{ borderColor: HAIRLINE }} className="flex items-center justify-center border-x px-2">
|
|||
|
|
<span style={{ color: INK_MUTED }} className={cn("font-extrabold", poster ? "text-[16px]" : "text-[13px]")}>
|
|||
|
|
vs
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<MatchSide accent={teamSor.color ?? "#d1561b"} players={m.sor} fields={fields} poster={poster} align="right" />
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
<SheetFooter />
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function MatchSide({
|
|||
|
|
accent,
|
|||
|
|
players,
|
|||
|
|
fields,
|
|||
|
|
poster,
|
|||
|
|
align,
|
|||
|
|
}: {
|
|||
|
|
accent: string
|
|||
|
|
players: MatchRow["nord"]
|
|||
|
|
fields: { tee: boolean; hcp: boolean; klasse: boolean; points: boolean; captain: boolean }
|
|||
|
|
poster: boolean
|
|||
|
|
align: "left" | "right"
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<div className={cn("flex flex-col justify-center gap-1 px-3 py-2", align === "right" && "items-end text-right")}>
|
|||
|
|
<span aria-hidden style={{ backgroundColor: accent }} className="h-1 w-8 rounded-full" />
|
|||
|
|
{players.map((p, i) => (
|
|||
|
|
<div key={p.name + i} className={cn("flex flex-col", align === "right" && "items-end")}>
|
|||
|
|
<span className={cn("font-bold leading-tight", poster ? "text-[16px]" : "text-[13px]")}>
|
|||
|
|
{p.name}
|
|||
|
|
{fields.captain && p.captain && (
|
|||
|
|
<span style={{ color: INK_MUTED }} className="ml-1 inline-flex items-center align-middle text-[11px] font-semibold">
|
|||
|
|
<Star aria-hidden className="mr-0.5 size-3" fill="currentColor" />
|
|||
|
|
<span>Kaptein</span>
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</span>
|
|||
|
|
{(fields.tee || fields.hcp || fields.klasse) && (
|
|||
|
|
<span style={{ color: INK_MUTED }} className="text-[11px] font-medium">
|
|||
|
|
{[fields.tee && p.tee, fields.hcp && `HCP ${p.hcp}`, fields.klasse && `Kl. ${p.klasse}`].filter(Boolean).join(" · ")}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function SheetFooter() {
|
|||
|
|
return (
|
|||
|
|
<footer style={{ borderColor: HAIRLINE, color: INK_MUTED }} className="mt-3 flex items-center justify-between border-t pt-2 text-[10px] font-medium">
|
|||
|
|
<span>Side 1 av 1</span>
|
|||
|
|
<span>Generert {new Date().toLocaleString("no-NO")}</span>
|
|||
|
|
<span className="font-bold" style={{ color: INK }}>
|
|||
|
|
TeeCup
|
|||
|
|
</span>
|
|||
|
|
</footer>
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function PrintStartlist({
|
|||
|
|
organizationId,
|
|||
|
|
tournamentId,
|
|||
|
|
roundId,
|
|||
|
|
sessionId,
|
|||
|
|
initialSize = "A4",
|
|||
|
|
initialOrientation = "portrait",
|
|||
|
|
}: {
|
|||
|
|
organizationId: string
|
|||
|
|
tournamentId: string
|
|||
|
|
roundId?: string
|
|||
|
|
sessionId?: string
|
|||
|
|
initialSize?: SizeKey
|
|||
|
|
initialOrientation?: Orientation
|
|||
|
|
}) {
|
|||
|
|
const [loading, setLoading] = useState(true)
|
|||
|
|
const [error, setError] = useState<string | null>(null)
|
|||
|
|
const [tournamentName, setTournamentName] = useState("Turnering")
|
|||
|
|
const [round, setRound] = useState<ApiRound | null>(null)
|
|||
|
|
const [groups, setGroups] = useState<GroupRow[]>([])
|
|||
|
|
const [session, setSession] = useState<ApiSession | null>(null)
|
|||
|
|
const [matches, setMatches] = useState<MatchRow[]>([])
|
|||
|
|
const [teamNord, setTeamNord] = useState<ApiTeam | null>(null)
|
|||
|
|
const [teamSor, setTeamSor] = useState<ApiTeam | null>(null)
|
|||
|
|
const [downloading, setDownloading] = useState(false)
|
|||
|
|
|
|||
|
|
const [size, setSize] = useState<SizeKey>(initialSize)
|
|||
|
|
const [orientation, setOrientation] = useState<Orientation>(initialOrientation)
|
|||
|
|
const [aFields, setAFields] = useState({ hcp: true, klasse: true, gender: false, club: true })
|
|||
|
|
const [bFields, setBFields] = useState({ tee: true, hcp: false, klasse: false, points: true, captain: true })
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
let cancelled = false
|
|||
|
|
async function load() {
|
|||
|
|
setLoading(true)
|
|||
|
|
setError(null)
|
|||
|
|
try {
|
|||
|
|
const t = await fetchTournament(organizationId, tournamentId)
|
|||
|
|
if (cancelled) return
|
|||
|
|
setTournamentName(t?.name ?? "Turnering")
|
|||
|
|
|
|||
|
|
if (roundId) {
|
|||
|
|
const [rounds, groupsRes, roundParticipants, tournamentParticipants, players] = await Promise.all([
|
|||
|
|
fetchRounds(organizationId, tournamentId),
|
|||
|
|
fetchRoundGroups(organizationId, tournamentId, roundId),
|
|||
|
|
fetchRoundParticipants(organizationId, tournamentId, roundId),
|
|||
|
|
fetchTournamentParticipants(organizationId, tournamentId),
|
|||
|
|
fetchPlayers(organizationId),
|
|||
|
|
])
|
|||
|
|
const r = rounds.find((x: ApiRound) => x.id === roundId) ?? null
|
|||
|
|
if (!r) throw new Error("Fant ikke runden.")
|
|||
|
|
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 playerById = new Map<string, ApiPlayer>(players.map((p) => [p.id, p]))
|
|||
|
|
|
|||
|
|
const genderLabel = (g: ApiPlayer["gender"]) => (g === "m" ? "M" : g === "f" ? "K" : g === "x" ? "A" : "–")
|
|||
|
|
|
|||
|
|
const built: GroupRow[] = groupsRes.groups.map((g, i) => ({
|
|||
|
|
seq: g.sequence ?? i + 1,
|
|||
|
|
time: r.start_mode === "shotgun" ? String(g.start_hole ?? "–") : formatTeeTime(g.tee_time),
|
|||
|
|
players: g.participants.map((gp) => {
|
|||
|
|
const rp = rpById.get(gp.round_participant_id)
|
|||
|
|
const tp = rp ? tpById.get(rp.tournament_participant_id) : undefined
|
|||
|
|
const player = tp ? playerById.get(tp.player_id) : undefined
|
|||
|
|
return {
|
|||
|
|
name: gp.player_name,
|
|||
|
|
tee: rp?.tee_name ?? "—",
|
|||
|
|
hcp: tp?.handicap_index_snapshot != null ? tp.handicap_index_snapshot.toFixed(1).replace(".", ",") : "–",
|
|||
|
|
klasse: tp?.class_name ?? "—",
|
|||
|
|
gender: genderLabel(player?.gender ?? null),
|
|||
|
|
club: player?.club ?? "—",
|
|||
|
|
}
|
|||
|
|
}),
|
|||
|
|
}))
|
|||
|
|
|
|||
|
|
if (cancelled) return
|
|||
|
|
setRound(r)
|
|||
|
|
setGroups(built)
|
|||
|
|
} else if (sessionId) {
|
|||
|
|
const [sessions, matchesRaw, teams] = await Promise.all([
|
|||
|
|
fetchSessions(organizationId, tournamentId),
|
|||
|
|
fetchMatches(organizationId, sessionId),
|
|||
|
|
fetchTeams(organizationId, tournamentId),
|
|||
|
|
])
|
|||
|
|
const s = sessions.find((x: ApiSession) => x.id === sessionId) ?? null
|
|||
|
|
if (!s) throw new Error("Fant ikke økten.")
|
|||
|
|
const [teamA, teamB] = teams
|
|||
|
|
const built: MatchRow[] = matchesRaw.map((m: ApiMatch) => {
|
|||
|
|
const side = (sideKey: "a" | "b") =>
|
|||
|
|
m.participants
|
|||
|
|
.filter((p) => p.team_side === sideKey)
|
|||
|
|
.map((p) => ({ name: p.player_name, tee: p.tee_name ?? "—", hcp: p.playing_handicap != null ? String(p.playing_handicap) : "–", klasse: "—" }))
|
|||
|
|
return {
|
|||
|
|
no: m.sequence,
|
|||
|
|
time: s.start_mode === "shotgun" ? String(m.start_hole ?? "–") : formatTeeTime(m.tee_time),
|
|||
|
|
points: `${s.points_per_match.toLocaleString("nb-NO", { maximumFractionDigits: 1 })} poeng`,
|
|||
|
|
nord: side("a"),
|
|||
|
|
sor: side("b"),
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
if (cancelled) return
|
|||
|
|
setSession(s)
|
|||
|
|
setTeamNord(teamA ?? { id: "a", name: "Lag A", color: "#1f5fbf" })
|
|||
|
|
setTeamSor(teamB ?? { id: "b", name: "Lag B", color: "#d1561b" })
|
|||
|
|
setMatches(built)
|
|||
|
|
} else {
|
|||
|
|
throw new Error("Mangler runde- eller økt-ID.")
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
if (!cancelled) setError("Klarte ikke å laste data for startlisten. Prøv igjen.")
|
|||
|
|
} finally {
|
|||
|
|
if (!cancelled) setLoading(false)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
void load()
|
|||
|
|
return () => {
|
|||
|
|
cancelled = true
|
|||
|
|
}
|
|||
|
|
}, [organizationId, tournamentId, roundId, sessionId])
|
|||
|
|
|
|||
|
|
const paper = PAPER[size]
|
|||
|
|
const natW = (orientation === "portrait" ? paper.w : paper.h) * MM_TO_PX
|
|||
|
|
const natH = (orientation === "portrait" ? paper.h : paper.w) * MM_TO_PX
|
|||
|
|
|
|||
|
|
const areaRef = useRef<HTMLDivElement>(null)
|
|||
|
|
const [scale, setScale] = useState(0.4)
|
|||
|
|
useEffect(() => {
|
|||
|
|
const el = areaRef.current
|
|||
|
|
if (!el) return
|
|||
|
|
const compute = () => {
|
|||
|
|
const pad = 56
|
|||
|
|
const availW = el.clientWidth - pad
|
|||
|
|
const availH = el.clientHeight - pad
|
|||
|
|
if (availW <= 0 || availH <= 0) return
|
|||
|
|
setScale(Math.max(0.12, Math.min(availW / natW, availH / natH, 1.6)))
|
|||
|
|
}
|
|||
|
|
compute()
|
|||
|
|
const ro = new ResizeObserver(compute)
|
|||
|
|
ro.observe(el)
|
|||
|
|
return () => ro.disconnect()
|
|||
|
|
}, [natW, natH])
|
|||
|
|
|
|||
|
|
const poster = sessionId != null && paper.large
|
|||
|
|
|
|||
|
|
const pageCss = useMemo(
|
|||
|
|
() => `@page { size: ${orientation === "portrait" ? `${paper.w}mm ${paper.h}mm` : `${paper.h}mm ${paper.w}mm`}; margin: 0; }`,
|
|||
|
|
[paper.w, paper.h, orientation],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const sizeOptions = (Object.keys(PAPER) as SizeKey[]).map((k) => ({
|
|||
|
|
key: k,
|
|||
|
|
label: PAPER[k].label,
|
|||
|
|
sub: PAPER[k].sub,
|
|||
|
|
disabled: PAPER[k].large && roundId != null,
|
|||
|
|
}))
|
|||
|
|
|
|||
|
|
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: "startlist",
|
|||
|
|
tournament_id: tournamentId,
|
|||
|
|
round_id: roundId ?? null,
|
|||
|
|
session_id: sessionId ?? null,
|
|||
|
|
paper: size === "A3" || size === "A2" ? "A4" : size,
|
|||
|
|
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-startliste.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
|
|||
|
|
const styleId = useId()
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="flex min-h-screen flex-col bg-background text-foreground" data-print-ready={ready ? "true" : undefined}>
|
|||
|
|
<style key={styleId}>{pageCss}</style>
|
|||
|
|
|
|||
|
|
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-border px-4 py-3 lg:px-6 print:hidden">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/15 text-primary">
|
|||
|
|
<Printer className="size-5" aria-hidden />
|
|||
|
|
</div>
|
|||
|
|
<div>
|
|||
|
|
<h1 className="text-base font-extrabold leading-tight">Skriv ut startliste</h1>
|
|||
|
|
<p className="text-[12px] text-muted-foreground">{error ?? (loading ? "Laster …" : `${groups.length + matches.length} rad(er) klare`)}</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={downloadPdf}
|
|||
|
|
disabled={downloading || loading}
|
|||
|
|
className="inline-flex min-h-10 items-center gap-2 rounded-xl bg-primary px-4 text-sm font-extrabold text-primary-foreground shadow-sm transition-transform hover:brightness-105 active:scale-[0.99] disabled:opacity-60"
|
|||
|
|
>
|
|||
|
|
<Download className="size-4" aria-hidden />
|
|||
|
|
{downloading ? "Genererer …" : "Last ned PDF"}
|
|||
|
|
</button>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<div className="flex flex-1 flex-col lg:flex-row">
|
|||
|
|
<aside className="w-full shrink-0 border-b border-border bg-card p-4 lg:w-80 lg:border-b-0 lg:border-r lg:p-5 print:hidden">
|
|||
|
|
<div className="flex flex-col gap-6">
|
|||
|
|
<PanelSection title="Papirstørrelse">
|
|||
|
|
<Segmented ariaLabel="Papirstørrelse" options={sizeOptions} value={size} onChange={setSize} />
|
|||
|
|
{sessionId && <p className="text-[11px] leading-relaxed text-muted-foreground">A3 og A2 gir en plakat for oppslagstavla.</p>}
|
|||
|
|
</PanelSection>
|
|||
|
|
|
|||
|
|
<PanelSection title="Orientering">
|
|||
|
|
<Segmented
|
|||
|
|
ariaLabel="Orientering"
|
|||
|
|
options={[
|
|||
|
|
{ key: "portrait" as Orientation, label: "Portrett" },
|
|||
|
|
{ key: "landscape" as Orientation, label: "Landskap" },
|
|||
|
|
]}
|
|||
|
|
value={orientation}
|
|||
|
|
onChange={setOrientation}
|
|||
|
|
/>
|
|||
|
|
</PanelSection>
|
|||
|
|
|
|||
|
|
<PanelSection title="Valgfrie felt">
|
|||
|
|
{roundId ? (
|
|||
|
|
<div className="flex flex-col divide-y divide-border/70">
|
|||
|
|
<ToggleRow label="HCP" checked={aFields.hcp} onCheckedChange={(v) => setAFields((s) => ({ ...s, hcp: v }))} />
|
|||
|
|
<ToggleRow label="Klasse" checked={aFields.klasse} onCheckedChange={(v) => setAFields((s) => ({ ...s, klasse: v }))} />
|
|||
|
|
<ToggleRow label="Kjønn" checked={aFields.gender} onCheckedChange={(v) => setAFields((s) => ({ ...s, gender: v }))} />
|
|||
|
|
<ToggleRow label="Hjemmeklubb" checked={aFields.club} onCheckedChange={(v) => setAFields((s) => ({ ...s, club: v }))} />
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<div className="flex flex-col divide-y divide-border/70">
|
|||
|
|
<ToggleRow label="Utslag / tee" checked={bFields.tee} onCheckedChange={(v) => setBFields((s) => ({ ...s, tee: v }))} />
|
|||
|
|
<ToggleRow label="HCP" checked={bFields.hcp} onCheckedChange={(v) => setBFields((s) => ({ ...s, hcp: v }))} />
|
|||
|
|
<ToggleRow label="Poeng for matchen" checked={bFields.points} onCheckedChange={(v) => setBFields((s) => ({ ...s, points: v }))} />
|
|||
|
|
<ToggleRow label="Kapteinmerking" checked={bFields.captain} onCheckedChange={(v) => setBFields((s) => ({ ...s, captain: v }))} />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</PanelSection>
|
|||
|
|
</div>
|
|||
|
|
</aside>
|
|||
|
|
|
|||
|
|
<main ref={areaRef} className="grid flex-1 place-items-center overflow-auto bg-[oklch(0.9_0.01_130)] p-7 dark:bg-[oklch(0.15_0.01_150)] print:block print:bg-transparent print:p-0">
|
|||
|
|
{loading ? (
|
|||
|
|
<p className="text-sm text-muted-foreground">Laster startliste …</p>
|
|||
|
|
) : round ? (
|
|||
|
|
<div style={{ width: natW * scale, height: natH * scale }} className="shrink-0 print:h-auto print:w-auto">
|
|||
|
|
<div
|
|||
|
|
style={{ width: natW, height: natH, transform: `scale(${scale})`, transformOrigin: "top left", backgroundColor: "#fff", boxShadow: "0 10px 30px rgba(0,0,0,0.28)", padding: `${12 * MM_TO_PX}px` }}
|
|||
|
|
className="overflow-hidden print:!scale-100 print:shadow-none"
|
|||
|
|
>
|
|||
|
|
<SheetIndividual tournamentName={tournamentName} round={round} groups={groups} fields={aFields} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : session && teamNord && teamSor ? (
|
|||
|
|
<div style={{ width: natW * scale, height: natH * scale }} className="shrink-0 print:h-auto print:w-auto">
|
|||
|
|
<div
|
|||
|
|
style={{ width: natW, height: natH, transform: `scale(${scale})`, transformOrigin: "top left", backgroundColor: "#fff", boxShadow: "0 10px 30px rgba(0,0,0,0.28)", padding: `${12 * MM_TO_PX}px` }}
|
|||
|
|
className="overflow-hidden print:!scale-100 print:shadow-none"
|
|||
|
|
>
|
|||
|
|
<SheetCup tournamentName={tournamentName} session={session} teamNord={teamNord} teamSor={teamSor} matches={matches} fields={bFields} poster={poster} />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
) : (
|
|||
|
|
<p className="text-sm text-muted-foreground">{error ?? "Ingen data funnet."}</p>
|
|||
|
|
)}
|
|||
|
|
</main>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)
|
|||
|
|
}
|