704 lines
30 KiB
TypeScript
704 lines
30 KiB
TypeScript
"use client"
|
||
|
||
import type React from "react"
|
||
import { useEffect, useMemo, useRef, useState } from "react"
|
||
import { Download, Printer, Crown } from "lucide-react"
|
||
import { cn } from "@/lib/utils"
|
||
import {
|
||
fetchCupLeaderboard,
|
||
fetchIndividualLeaderboard,
|
||
fetchMatches,
|
||
fetchPlayers,
|
||
fetchSessions,
|
||
fetchTeams,
|
||
fetchTournament,
|
||
fetchTournamentParticipants,
|
||
type ApiLeaderboardEntry,
|
||
type ApiMatch,
|
||
type ApiPlayer,
|
||
type ApiSession,
|
||
type ApiTeam,
|
||
type ApiTournament,
|
||
type ApiTournamentParticipant,
|
||
} from "@/lib/print-data"
|
||
|
||
/**
|
||
* Print-friendly RESULTATLISTE -- ekte data (ADR-097). Videreført fra
|
||
* V0-mockupen: individuell rankert leaderboard, eller Cup-sluttresultat
|
||
* (poeng per økt + match for match). Resultater vises med FORM (sirkel =
|
||
* under par, firkant = over par) så arket er lesbart i sort/hvitt.
|
||
*/
|
||
|
||
type SizeKey = "A4" | "Letter"
|
||
type Orientation = "portrait" | "landscape"
|
||
const PAPER: Record<SizeKey, { label: string; sub: string; w: number; h: number }> = {
|
||
A4: { label: "A4", sub: "210 × 297 mm", w: 210, h: 297 },
|
||
Letter: { label: "Letter", sub: "216 × 279 mm", w: 216, h: 279 },
|
||
}
|
||
const MM_TO_PX = 96 / 25.4
|
||
|
||
const INK = "#171717"
|
||
const INK_MUTED = "#5b5b5b"
|
||
const HAIRLINE = "#d9d9d9"
|
||
const HAIRLINE_STRONG = "#a3a3a3"
|
||
const BAND = "#f2f2f0"
|
||
|
||
function Segmented<T extends string>({
|
||
options,
|
||
value,
|
||
onChange,
|
||
ariaLabel,
|
||
}: {
|
||
options: { key: T; label: string; sub?: string }[]
|
||
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}
|
||
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",
|
||
active ? "border-primary bg-primary/10 text-foreground" : "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-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>
|
||
)
|
||
}
|
||
|
||
// Form bærer betydning (sirkel/firkant/tekst), ikke farge -- leselig i sort/hvitt.
|
||
function Shape({ label, tone, size = "md" }: { label: string; tone: "under" | "even" | "over" | null; size?: "md" | "sm" }) {
|
||
const dim = size === "md" ? "h-[24px] min-w-[32px] text-[13px]" : "h-[19px] min-w-[26px] text-[11px]"
|
||
if (tone === "under") {
|
||
return (
|
||
<span style={{ borderColor: INK, color: INK }} className={cn("inline-flex items-center justify-center rounded-full border-[1.5px] px-1 font-bold tabular-nums", dim)}>
|
||
{label}
|
||
</span>
|
||
)
|
||
}
|
||
if (tone === "over") {
|
||
return (
|
||
<span style={{ borderColor: INK, color: INK }} className={cn("inline-flex items-center justify-center rounded-[4px] border-[1.5px] px-1 font-bold tabular-nums", dim)}>
|
||
{label}
|
||
</span>
|
||
)
|
||
}
|
||
return (
|
||
<span style={{ color: INK }} className={cn("inline-flex items-center justify-center px-1 font-bold tabular-nums", dim)}>
|
||
{label}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function StatusPill({ text }: { text: string }) {
|
||
return (
|
||
<span style={{ borderColor: HAIRLINE_STRONG, color: INK }} className="inline-flex h-[24px] items-center justify-center rounded-[3px] border border-dashed px-2 text-[11px] font-extrabold uppercase tracking-wide">
|
||
{text}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
const STATUS_LABEL: Record<string, string> = { dsq: "DSQ", dns: "DNS", rtd: "RTD", dnf: "DNF" }
|
||
|
||
type EnrichedEntry = ApiLeaderboardEntry & { club: string; hcpLabel: string }
|
||
|
||
function SheetIndividual({
|
||
tournamentName,
|
||
period,
|
||
method,
|
||
entries,
|
||
fields,
|
||
}: {
|
||
tournamentName: string
|
||
period: string
|
||
method: string
|
||
entries: EnrichedEntry[]
|
||
fields: { hcp: boolean; rounds: boolean; club: boolean; classes: boolean; cut: boolean }
|
||
}) {
|
||
const maxRounds = Math.max(0, ...entries.map((e) => e.rounds.length))
|
||
const firstCutIndex = entries.findIndex((e) => e.cut)
|
||
|
||
const gridCols = ["36px", "minmax(0,1fr)", fields.club ? "120px" : null, fields.hcp ? "48px" : null, ...(fields.rounds ? Array(maxRounds).fill("40px") : []), "56px"]
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
|
||
const totalCell = (e: EnrichedEntry) => {
|
||
if (e.status !== "active") return <StatusPill text={STATUS_LABEL[e.status] ?? e.status.toUpperCase()} />
|
||
if (e.cut) return <StatusPill text="Kuttet" />
|
||
const label = e.total_label ?? "–"
|
||
const tone = label.startsWith("-") || label.startsWith("−") ? "under" : label.startsWith("+") ? "over" : "even"
|
||
return <Shape label={label} tone={tone} />
|
||
}
|
||
|
||
const HeaderRow = () => (
|
||
<div style={{ gridTemplateColumns: gridCols, color: INK_MUTED, borderColor: HAIRLINE_STRONG }} className="grid items-end gap-x-2 border-b-2 px-2 pb-1 text-[10px] font-bold uppercase tracking-wide">
|
||
<span className="text-right">Plass</span>
|
||
<span>Spiller</span>
|
||
{fields.club && <span>Hjemmeklubb</span>}
|
||
{fields.hcp && <span className="text-right">HCP</span>}
|
||
{fields.rounds && Array.from({ length: maxRounds }).map((_, i) => (
|
||
<span key={i} className="text-center">
|
||
R{i + 1}
|
||
</span>
|
||
))}
|
||
<span className="text-center">Total</span>
|
||
</div>
|
||
)
|
||
|
||
const Row = ({ e }: { e: EnrichedEntry }) => (
|
||
<div style={{ gridTemplateColumns: gridCols, borderColor: HAIRLINE }} className="grid items-center gap-x-2 border-b px-2 py-[5px] text-[12px]">
|
||
<span className="text-right text-[13px] font-extrabold tabular-nums" style={{ color: e.position ? INK : INK_MUTED }}>
|
||
{e.position ?? "–"}
|
||
</span>
|
||
<span className="truncate font-bold" style={{ color: INK }}>
|
||
{e.player_name}
|
||
</span>
|
||
{fields.club && (
|
||
<span className="truncate" style={{ color: INK_MUTED }}>
|
||
{e.club}
|
||
</span>
|
||
)}
|
||
{fields.hcp && (
|
||
<span className="text-right tabular-nums" style={{ color: INK_MUTED }}>
|
||
{e.hcpLabel}
|
||
</span>
|
||
)}
|
||
{fields.rounds &&
|
||
Array.from({ length: maxRounds }).map((_, i) => {
|
||
const r = e.rounds[i]
|
||
return (
|
||
<span key={i} className="flex justify-center">
|
||
{r ? <Shape label={r.label ?? "–"} tone={r.tone} size="sm" /> : <span style={{ color: INK_MUTED }}>–</span>}
|
||
</span>
|
||
)
|
||
})}
|
||
<span className="flex justify-center">{totalCell(e)}</span>
|
||
</div>
|
||
)
|
||
|
||
let body: React.ReactNode
|
||
if (!fields.classes) {
|
||
body = (
|
||
<div className="flex flex-col">
|
||
<HeaderRow />
|
||
{entries.map((e, idx) => (
|
||
<div key={e.tournament_participant_id} className="contents">
|
||
{fields.cut && idx === firstCutIndex && firstCutIndex >= 0 && (
|
||
<div className="my-1 flex items-center gap-2 px-2">
|
||
<span style={{ backgroundColor: INK }} className="h-[2px] flex-1" />
|
||
<span style={{ color: INK }} className="text-[10px] font-extrabold uppercase tracking-wide">
|
||
Cut
|
||
</span>
|
||
<span style={{ backgroundColor: INK }} className="h-[2px] flex-1" />
|
||
</div>
|
||
)}
|
||
<Row e={e} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
} else {
|
||
const classNames = Array.from(new Set(entries.map((e) => e.class_name ?? "Ingen klasse")))
|
||
body = (
|
||
<div className="flex flex-col gap-2.5">
|
||
{classNames.map((cls) => (
|
||
<div key={cls} className="break-inside-avoid">
|
||
<div style={{ backgroundColor: BAND, borderColor: HAIRLINE, color: INK }} className="mb-0.5 rounded-[2px] border px-2 py-1 text-[12px] font-extrabold">
|
||
{cls}
|
||
</div>
|
||
<HeaderRow />
|
||
{entries.filter((e) => (e.class_name ?? "Ingen klasse") === cls).map((e) => (
|
||
<Row key={e.tournament_participant_id} e={e} />
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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">
|
||
Resultatliste
|
||
</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">
|
||
{method}
|
||
</span>
|
||
<span>{period}</span>
|
||
</div>
|
||
</header>
|
||
<div className="mt-3 flex-1">{body}</div>
|
||
<div style={{ color: INK_MUTED }} className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] font-medium">
|
||
<span className="flex items-center gap-1.5">
|
||
<Shape label="-1" tone="under" size="sm" /> Under par
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<Shape label="E" tone="even" size="sm" /> Par
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<Shape label="+1" tone="over" size="sm" /> Over par
|
||
</span>
|
||
<span className="flex items-center gap-1.5">
|
||
<StatusPill text="DSQ" /> Spesialstatus
|
||
</span>
|
||
</div>
|
||
<SheetFooter />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type CupMatchRow = { no: number; session: string; nord: string; sor: string; result: string; winner: "nord" | "sor" | null }
|
||
|
||
function SheetCup({
|
||
tournamentName,
|
||
meta,
|
||
teamNord,
|
||
teamSor,
|
||
sessions,
|
||
matches,
|
||
fields,
|
||
}: {
|
||
tournamentName: string
|
||
meta: string
|
||
teamNord: { name: string; color: string; points: number }
|
||
teamSor: { name: string; color: string; points: number }
|
||
sessions: { name: string; points: { nord: number; sor: number } }[]
|
||
matches: CupMatchRow[]
|
||
fields: { sessions: boolean; matches: boolean }
|
||
}) {
|
||
const nordLeads = teamNord.points > teamSor.points
|
||
const sorLeads = teamSor.points > teamNord.points
|
||
const fmt = (n: number) => n.toLocaleString("nb-NO", { minimumFractionDigits: 1, maximumFractionDigits: 1 })
|
||
|
||
const TeamScore = ({ name, color, pts, leads }: { name: string; color: string; pts: number; leads: boolean }) => (
|
||
<div style={{ borderColor: leads ? INK : HAIRLINE }} className={cn("relative flex flex-1 flex-col items-center rounded-[4px] border-2 px-4 py-3", leads && "border-[2.5px]")}>
|
||
{leads && (
|
||
<span style={{ borderColor: INK, color: INK }} className="absolute -top-2.5 flex items-center gap-1 rounded-full border bg-white px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wide">
|
||
<Crown aria-hidden className="size-3" fill="currentColor" />
|
||
Leder
|
||
</span>
|
||
)}
|
||
<span className="flex items-center gap-2">
|
||
<span aria-hidden style={{ backgroundColor: color, borderColor: "rgba(0,0,0,0.25)" }} className="h-3.5 w-3.5 rounded-[3px] border" />
|
||
<span className="text-[15px] font-extrabold">{name}</span>
|
||
</span>
|
||
<span className="mt-1 text-[40px] font-extrabold leading-none tabular-nums">{fmt(pts)}</span>
|
||
<span style={{ color: INK_MUTED }} className="text-[10px] font-bold uppercase tracking-wide">
|
||
poeng
|
||
</span>
|
||
</div>
|
||
)
|
||
|
||
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">
|
||
Sluttresultat
|
||
</span>
|
||
</div>
|
||
<div style={{ color: INK_MUTED }} className="mt-1 text-[12px] font-medium">
|
||
{meta}
|
||
</div>
|
||
</header>
|
||
|
||
<div className="mt-4 flex items-stretch gap-3">
|
||
<TeamScore name={teamNord.name} color={teamNord.color} pts={teamNord.points} leads={nordLeads} />
|
||
<div className="flex items-center">
|
||
<span style={{ color: INK_MUTED }} className="text-[15px] font-extrabold">
|
||
–
|
||
</span>
|
||
</div>
|
||
<TeamScore name={teamSor.name} color={teamSor.color} pts={teamSor.points} leads={sorLeads} />
|
||
</div>
|
||
|
||
{fields.sessions && (
|
||
<div className="mt-4">
|
||
<h2 style={{ color: INK }} className="mb-1 text-[12px] font-extrabold uppercase tracking-wide">
|
||
Poeng per økt
|
||
</h2>
|
||
<table className="w-full border-collapse text-[12px]">
|
||
<thead>
|
||
<tr style={{ color: INK_MUTED, borderColor: HAIRLINE_STRONG }} className="border-b">
|
||
<th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">Økt</th>
|
||
<th className="px-2 py-1 text-right font-semibold uppercase tracking-wide">{teamNord.name}</th>
|
||
<th className="px-2 py-1 text-right font-semibold uppercase tracking-wide">{teamSor.name}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sessions.map((s) => (
|
||
<tr key={s.name} style={{ borderColor: HAIRLINE }} className="border-b">
|
||
<td className="px-2 py-1 font-bold">{s.name}</td>
|
||
<td className="px-2 py-1 text-right font-bold tabular-nums">{fmt(s.points.nord)}</td>
|
||
<td className="px-2 py-1 text-right font-bold tabular-nums">{fmt(s.points.sor)}</td>
|
||
</tr>
|
||
))}
|
||
<tr style={{ borderColor: INK }} className="border-t-2">
|
||
<td className="px-2 py-1 font-extrabold">Totalt</td>
|
||
<td className="px-2 py-1 text-right font-extrabold tabular-nums">{fmt(teamNord.points)}</td>
|
||
<td className="px-2 py-1 text-right font-extrabold tabular-nums">{fmt(teamSor.points)}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{fields.matches && (
|
||
<div className="mt-4 flex-1">
|
||
<h2 style={{ color: INK }} className="mb-1 text-[12px] font-extrabold uppercase tracking-wide">
|
||
Match for match
|
||
</h2>
|
||
<table className="w-full border-collapse text-[12px]">
|
||
<thead>
|
||
<tr style={{ color: INK_MUTED, borderColor: HAIRLINE_STRONG }} className="border-b">
|
||
<th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">#</th>
|
||
<th className="px-2 py-1 text-left font-semibold uppercase tracking-wide">{teamNord.name}</th>
|
||
<th className="px-2 py-1 text-center font-semibold uppercase tracking-wide">Resultat</th>
|
||
<th className="px-2 py-1 text-right font-semibold uppercase tracking-wide">{teamSor.name}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{matches.map((m) => (
|
||
<tr key={m.no} style={{ borderColor: HAIRLINE }} className="border-b">
|
||
<td className="px-2 py-1 font-bold tabular-nums" style={{ color: INK_MUTED }}>
|
||
{m.no}
|
||
</td>
|
||
<td className={cn("px-2 py-1", m.winner === "nord" ? "font-extrabold" : "font-medium")}>{m.nord}</td>
|
||
<td className="px-2 py-1 text-center font-bold tabular-nums whitespace-nowrap">{m.result}</td>
|
||
<td className={cn("px-2 py-1 text-right", m.winner === "sor" ? "font-extrabold" : "font-medium")}>{m.sor}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
<SheetFooter />
|
||
</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>
|
||
)
|
||
}
|
||
|
||
const SCORING_METHOD_LABEL: Record<string, string> = {
|
||
stroke_gross: "Bruttoslagspill",
|
||
stroke_net: "Nettoslagspill",
|
||
stableford: "Stableford",
|
||
copenhagen: "Københavner",
|
||
bbb: "High-low-high",
|
||
}
|
||
|
||
export function PrintResultlist({
|
||
organizationId,
|
||
tournamentId,
|
||
initialSize = "A4",
|
||
initialOrientation = "portrait",
|
||
}: {
|
||
organizationId: string
|
||
tournamentId: string
|
||
initialSize?: SizeKey
|
||
initialOrientation?: Orientation
|
||
}) {
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [tournament, setTournament] = useState<ApiTournament | null>(null)
|
||
const [entries, setEntries] = useState<EnrichedEntry[]>([])
|
||
const [cupData, setCupData] = useState<{ teamNord: { name: string; color: string; points: number }; teamSor: { name: string; color: string; points: number }; sessions: { name: string; points: { nord: number; sor: number } }[]; matches: CupMatchRow[] } | null>(null)
|
||
const [downloading, setDownloading] = useState(false)
|
||
|
||
const [size, setSize] = useState<SizeKey>(initialSize)
|
||
const [orientation, setOrientation] = useState<Orientation>(initialOrientation)
|
||
const [aFields, setAFields] = useState({ hcp: false, rounds: true, club: true, classes: false, cut: true })
|
||
const [bFields, setBFields] = useState({ sessions: true, matches: true })
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
async function load() {
|
||
setLoading(true)
|
||
setError(null)
|
||
try {
|
||
const t = await fetchTournament(organizationId, tournamentId)
|
||
if (cancelled) return
|
||
setTournament(t)
|
||
if (!t) throw new Error("Fant ikke turneringen.")
|
||
|
||
if (t.format_type === "individual") {
|
||
const [leaderboard, tournamentParticipants, players] = await Promise.all([
|
||
fetchIndividualLeaderboard(organizationId, tournamentId),
|
||
fetchTournamentParticipants(organizationId, tournamentId),
|
||
fetchPlayers(organizationId),
|
||
])
|
||
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 enriched: EnrichedEntry[] = leaderboard.map((e) => {
|
||
const tp = tpById.get(e.tournament_participant_id)
|
||
const player = tp ? playerById.get(tp.player_id) : undefined
|
||
return {
|
||
...e,
|
||
club: player?.club ?? "—",
|
||
hcpLabel: tp?.handicap_index_snapshot != null ? tp.handicap_index_snapshot.toFixed(1).replace(".", ",") : "–",
|
||
}
|
||
})
|
||
if (cancelled) return
|
||
setEntries(enriched)
|
||
} else {
|
||
const [cup, sessions, teams] = await Promise.all([
|
||
fetchCupLeaderboard(organizationId, tournamentId),
|
||
fetchSessions(organizationId, tournamentId),
|
||
fetchTeams(organizationId, tournamentId),
|
||
])
|
||
const [teamA, teamB] = cup.teams
|
||
const teamById = new Map<string, ApiTeam>(teams.map((tm) => [tm.id, tm]))
|
||
const allMatches = (
|
||
await Promise.all(sessions.map(async (s: ApiSession) => ({ session: s, matches: await fetchMatches(organizationId, s.id) })))
|
||
).flatMap(({ session, matches }) =>
|
||
matches.map((m: ApiMatch) => ({ session, m })),
|
||
)
|
||
const matchRows: CupMatchRow[] = allMatches.map(({ session, m }, i) => {
|
||
const nordNames = m.participants.filter((p) => p.team_side === "a").map((p) => p.player_name).join(" / ")
|
||
const sorNames = m.participants.filter((p) => p.team_side === "b").map((p) => p.player_name).join(" / ")
|
||
return {
|
||
no: i + 1,
|
||
session: session.name,
|
||
nord: nordNames || (teamById.get(m.team_a_id)?.name ?? "Lag A"),
|
||
sor: sorNames || (teamById.get(m.team_b_id)?.name ?? "Lag B"),
|
||
result: m.status_text ?? "Ikke spilt",
|
||
winner: m.leading_side === "a" ? "nord" : m.leading_side === "b" ? "sor" : null,
|
||
}
|
||
})
|
||
if (cancelled) return
|
||
setCupData({
|
||
teamNord: { name: teamA?.team_name ?? "Lag A", color: teamA?.color ?? "#1f5fbf", points: teamA?.points ?? 0 },
|
||
teamSor: { name: teamB?.team_name ?? "Lag B", color: teamB?.color ?? "#d1561b", points: teamB?.points ?? 0 },
|
||
sessions: cup.sessions.map((s) => ({
|
||
name: s.name ?? `Økt ${s.sequence}`,
|
||
points: { nord: teamA ? (s.points_by_team[teamA.team_id] ?? 0) : 0, sor: teamB ? (s.points_by_team[teamB.team_id] ?? 0) : 0 },
|
||
})),
|
||
matches: matchRows,
|
||
})
|
||
}
|
||
} catch {
|
||
if (!cancelled) setError("Klarte ikke å laste resultatlisten. Prøv igjen.")
|
||
} finally {
|
||
if (!cancelled) setLoading(false)
|
||
}
|
||
}
|
||
void load()
|
||
return () => {
|
||
cancelled = true
|
||
}
|
||
}, [organizationId, tournamentId])
|
||
|
||
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 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],
|
||
)
|
||
|
||
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: "resultlist", tournament_id: tournamentId, paper: 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-resultatliste.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="flex min-h-screen flex-col bg-background text-foreground" data-print-ready={ready ? "true" : undefined}>
|
||
<style>{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 resultatliste</h1>
|
||
<p className="text-[12px] text-muted-foreground">{error ?? (loading ? "Laster …" : "Klar")}</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={(Object.keys(PAPER) as SizeKey[]).map((k) => ({ key: k, label: PAPER[k].label, sub: PAPER[k].sub }))}
|
||
value={size}
|
||
onChange={setSize}
|
||
/>
|
||
</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={tournament?.format_type === "individual" ? "Valgfrie felt" : "Vis på arket"}>
|
||
{tournament?.format_type === "individual" ? (
|
||
<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="Delresultat per runde" checked={aFields.rounds} onCheckedChange={(v) => setAFields((s) => ({ ...s, rounds: v }))} />
|
||
<ToggleRow label="Hjemmeklubb" checked={aFields.club} onCheckedChange={(v) => setAFields((s) => ({ ...s, club: v }))} />
|
||
<ToggleRow label="Del inn i klasser" checked={aFields.classes} onCheckedChange={(v) => setAFields((s) => ({ ...s, classes: v }))} />
|
||
<ToggleRow label="Vis cut-grense" checked={aFields.cut} onCheckedChange={(v) => setAFields((s) => ({ ...s, cut: v }))} />
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col divide-y divide-border/70">
|
||
<ToggleRow label="Poeng per økt" checked={bFields.sessions} onCheckedChange={(v) => setBFields((s) => ({ ...s, sessions: v }))} />
|
||
<ToggleRow label="Match for match" checked={bFields.matches} onCheckedChange={(v) => setBFields((s) => ({ ...s, matches: 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 resultatliste …</p>
|
||
) : tournament?.format_type === "individual" ? (
|
||
<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={tournament.name}
|
||
period=""
|
||
method={SCORING_METHOD_LABEL[tournament.scoring_method ?? ""] ?? tournament.scoring_method ?? ""}
|
||
entries={entries}
|
||
fields={aFields}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : cupData && tournament ? (
|
||
<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={tournament.name} meta="" teamNord={cupData.teamNord} teamSor={cupData.teamSor} sessions={cupData.sessions} matches={cupData.matches} fields={bFields} />
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">{error ?? "Ingen data funnet."}</p>
|
||
)}
|
||
</main>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|