Før endringer i turneringsoppsettet
Some checks failed
Backend-tester / test (push) Successful in 1m8s
Frontend-tester / test (push) Failing after 20s

This commit is contained in:
Erol Haagenrud 2026-08-21 13:54:01 +02:00
parent 64d57f56ba
commit 546900c2d6
24 changed files with 3470 additions and 10 deletions

View file

@ -466,7 +466,80 @@
"Bash(echo \"EXIT CODE: $?\")", "Bash(echo \"EXIT CODE: $?\")",
"Bash(python3 -m json.tool /tmp/adapted.json)", "Bash(python3 -m json.tool /tmp/adapted.json)",
"Bash(curl -s -o /dev/null -w \"teecup.golf: %{http_code}\\\\n\" https://teecup.golf/)", "Bash(curl -s -o /dev/null -w \"teecup.golf: %{http_code}\\\\n\" https://teecup.golf/)",
"Bash(docker rm -f teecup_api_dr5 teecup-scratch-dr5-minio 2>&1 *)" "Bash(docker rm -f teecup_api_dr5 teecup-scratch-dr5-minio 2>&1 *)",
"Bash(./scripts/run_backend_tests.sh)",
"Bash(.venv/bin/python test_handicap_engine.py)",
"Bash(curl -s https://forgejo.jegvil.no/api/v1/repos/envide/teecup/actions/runs)",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('default_branch:', d.get\\('default_branch'\\)\\)\")",
"Bash(curl -s -o /tmp/runs.txt -w \"HTTP %{http_code}\\\\n\" https://forgejo.jegvil.no/api/v1/repos/envide/teecup/actions/runs)",
"Bash(curl -s -o /tmp/actions_page.html -w \"HTTP %{http_code}\\\\n\" \"https://forgejo.jegvil.no/envide/teecup/actions\")",
"Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions/runs/1\" -o /tmp/run1.html -w \"HTTP %{http_code}\\\\n\")",
"Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_page2.html -w \"HTTP %{http_code}\\\\n\")",
"Bash(docker pull *)",
"Bash(chmod 600 /opt/teecup/.forgejo-runner/.runner)",
"Bash(stat -c \"%G %g\" /var/run/docker.sock *)",
"Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_page3.html)",
"Bash(python3 -m py_compile app/routers/scoring.py)",
"Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_final.html)",
"Bash(npm --version)",
"Bash(./node_modules/.bin/vitest run *)",
"Bash(git -C /opt/teecup diff frontend/package.json)",
"Bash(git -C /opt/teecup diff --stat frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml)",
"Bash(git mv *)",
"Bash(git -C /opt/teecup log --oneline -3 -- frontend/.gitignore)",
"Bash(curl -s \"https://forgejo.jegvil.no/envide/teecup/actions\" -o /tmp/actions_frontend.html)",
"Bash(git log *)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18300/)",
"Bash(curl -s -X POST http://localhost:18311/auth/verify-link -H 'Content-Type: application/json' -d '{\"token\":\"g02xhESalHSdbGV6BuTahriCMScuVsB_UGqXVqY84iQ\"}' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)",
"Bash(curl -s -X PATCH http://localhost:18311/auth/profile -H 'Content-Type: application/json' -d '{\"first_name\":\"Erol\",\"last_name\":\"Test\",\"gender\":\"m\",\"handicap_index\":18.4}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -o /dev/null -w 'profile: %{http_code}\\\\n')",
"Bash(curl -s -X POST http://localhost:18311/personal-courses -H 'Content-Type: application/json' -d @/tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/sf_course.json -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)",
"Bash(curl -s -X POST http://localhost:18311/rounds -H 'Content-Type: application/json' -d '{\"course_source\":\"custom\",\"personal_course_id\":\"64f0eec3-6da2-4b76-b41a-836ffba2316b\",\"tee_name\":\"Gul\",\"played_at\":\"2026-08-11\",\"holes_planned\":18,\"play_format\":\"stroke\"}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)",
"Bash(curl -s -X PATCH http://localhost:18311/rounds/0fd25550-f0f6-4c22-a2b1-c1e4eeb3da7d/participants/16044c55-dd0e-4142-b5c1-ed6a70768b4c -H 'Content-Type: application/json' -d '{\"stat_level\":\"full\"}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt)",
"Bash(curl -s -X PATCH http://localhost:18311/auth/profile -H 'Content-Type: application/json' -d '{\"bag_clubs\":[\"Driver\",\"3-wood\",\"3-hybrid\",\"4-iron\",\"5-iron\",\"6-iron\",\"7-iron\",\"8-iron\",\"9-iron\",\"Pitching wedge\",\"Gap wedge\",\"Sand wedge\",\"Lob wedge\",\"Putter\"]}' -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies6/a.txt -o /dev/null -w '%{http_code}\\\\n')",
"Bash(grep -n \"Committet \\(\\\\`ee91d03\\\\`\\), \\\\*\\\\*ikke rullet ut ennå\\\\*\\\\*\" /opt/teecup/CHANGELOG.md)",
"Bash(python3 -m py_compile app/golfapi_client.py app/golfapi_cache.py app/config.py app/routers/courses.py app/routers/rounds.py)",
"Bash(unzip -o \"Temp-uploads/tee-cup \\(10\\).zip\" -d /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/zip10)",
"Bash(grep -n \"Hull \\\\${\" frontend/components/round-detail.tsx)",
"Bash(grep -c \"^[a-f0-9]\\\\{64\\\\}$\")",
"Bash(curl -sS -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:18734/logg-inn)",
"Bash(curl -sS -X POST http://localhost:18734/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"tjome-verify@example.com\",\"locale\":\"nb\"}' -c /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt)",
"Bash(curl -sS -X POST http://localhost:18735/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"tjome-verify@example.com\",\"locale\":\"nb\"}')",
"Bash(curl -sS -X POST http://localhost:18735/auth/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"tjome-verify@example.com\",\"locale\":\"nb\"}')",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X POST http://localhost:18735/personal-courses/international-import -H 'Content-Type: application/json' -d '{\"golfapi_course_id\":\"0121250146602173\"}')",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X POST http://localhost:18735/rounds -H 'Content-Type: application/json' -d '{\"course_source\":\"custom\",\"personal_course_id\":\"cac41997-77f3-44a5-b395-9bb6b3c3c075\",\"tee_name\":\"55\",\"played_at\":\"2026-08-12\",\"start_hole\":1,\"holes_planned\":18,\"play_format\":\"stroke\"}')",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X PATCH http://localhost:18735/auth/profile -H 'Content-Type: application/json' -d '{\"gender\":\"m\",\"handicap_index\":18.0,\"birth_date\":\"1990-01-01\"}')",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X PATCH http://localhost:18735/auth/profile -H 'Content-Type: application/json' -d '{\"first_name\":\"Test\",\"last_name\":\"Verify\"}')",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\('profile_complete:', d['profile_complete']\\)\")",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt -X PATCH http://localhost:18735/auth/profile -H 'Content-Type: application/json' -d '{\"home_club\":\"Tjøme Golfklubb\",\"country\":\"Norge\"}')",
"Bash(curl -sS -b /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/cookies.txt http://localhost:18735/rounds/99978ceb-55e5-42a6-a0a8-60496bb97e96/holes/1/target-points)",
"Bash(python3 -c \"import json,sys; print\\(json.load\\(sys.stdin\\)['id']\\)\")",
"Bash(curl -sS -X POST http://localhost:18738/auth/request-link -H \"Content-Type: application/json\" -d '{\"email\":\"layout-verify@example.com\",\"locale\":\"nb\"}')",
"Bash(python3 -m py_compile app/routers/rounds.py app/email.py)",
"Bash(python3 -m py_compile app/golfapi_cache.py)",
"Bash(python3 -m py_compile app/golfapi_cache.py app/routers/courses.py app/routers/rounds.py)",
"Bash(python3 -m py_compile /opt/teecup/app/email.py)",
"Bash(pkill -f \"next dev -p 13002\")",
"Bash(python3 -m py_compile app/routers/players.py)",
"Bash(pkill -f \"next dev.*13003\")",
"Bash(pkill -9 -f \"next dev.*13003\")",
"Bash(npx --yes ffmpeg-static --version)",
"Bash(node -e \"console.log\\(require\\('ffmpeg-static'\\)\\)\")",
"Bash(/tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/ffmpeg_tool/node_modules/ffmpeg-static/ffmpeg -i /opt/teecup/Temp-uploads/Video_2026-08-15_070115.mp4)",
"Bash(/tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/ffmpeg_tool/node_modules/ffmpeg-static/ffmpeg -i /opt/teecup/Temp-uploads/Video_2026-08-15_070115.mp4 -vf fps=1,scale=800:-1 /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/video_frames/frame_%03d.png -y)",
"Bash(pkill -f \"next dev -p 13008\")",
"Bash(rm -rf /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/migrations_oom*)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/0de08b71-4922-441e-a975-840b2c94e23c/scratchpad/oom_*.txt)",
"Bash(curl -s -o /dev/null -w \"public OOM \\(should be 200\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/d3916ae4-c14f-4e64-a964-25c26d456732)",
"Bash(curl -s http://localhost:18103/public/order-of-merits/d3916ae4-c14f-4e64-a964-25c26d456732)",
"Bash(curl -s -o /dev/null -w \"private OOM \\(should be 404\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/b5eb2b39-58e5-4e92-ac3c-266abb70e17d)",
"Bash(curl -s -o /dev/null -w \"nonexistent \\(should be 404\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/00000000-0000-0000-0000-000000000000)",
"Bash(curl -s -o /dev/null -w \"public OOM after toggling off \\(should be 404\\): %{http_code}\\\\n\" http://localhost:18103/public/order-of-merits/d3916ae4-c14f-4e64-a964-25c26d456732)",
"Bash(TEECUP_API_ORIGIN=http://localhost:18104 NEXT_PUBLIC_MAPBOX_TOKEN=unused nohup npx next dev -p 13104)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://localhost:13104/)",
"Bash(python3 -m py_compile tests/test_account_merge.py)",
"Bash(mkdir -p \"/opt/teecup/frontend/app/kontosammenslaing/[token]\")",
"Bash(grep -n 'href={`/tournaments/\\\\|Link.*tournaments/\\\\${' components/tournament-detail.tsx)",
"Bash(grep -n \"fetch\\(\\\\|getJson\\(\\\\|baseUrl}\\\\`\\\\|\\\\`/rounds/\\\\${roundId}\" /opt/teecup/frontend/components/round-detail.tsx)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -9808,6 +9808,110 @@ Scratch-ruten slettet etterpå, bekreftet med `git status --short`.
--- ---
## ADR-097: Utskriftsklare turneringsdokumenter -- server-PDF via headless Chromium, ingen ny malmotor (2026-08-21)
**Kontekst:** oppfølging av ADR-096 (shotgun-start ble bygget nettopp FOR
dette behovet). Bruker ba om V0-prompter for fire utskriftsflater --
scorekort, startliste, cart-tags, resultatliste -- dekket individuell OG
Cup-format, se FEATURE_BACKLOG.md "Funksjonelle hull funnet under
'utskrift av turneringsdokumenter'-planlegging" for de tre bevisst
utsatte hullene (hullengde, turneringslogo, bracket-format) som ble
avklart FØR promptene ble skrevet. V0-eksporten kom tilbake som fire
rene visuelle mockups (`components/print-scorecard.tsx` m.fl. i
eksporten) -- ingen ekte API-kall, "Last ned PDF" var en blindvei.
Denne runden kobler alle fire til ekte data og gjør nedlastingen ekte.
**Beslutning A -- PDF-mekanisme: server-generert via headless Chromium
(Playwright), IKKE nettleserens egen print-dialog.** Bruker spurte
eksplisitt etter en anbefaling ut fra SLUTTRESULTAT, ikke byggekostnad.
Begrunnelse: cart-tags har stiplede klippelinjer som må treffe eksakt på
tvers av alle brukeres nettlesere/OS/skrivere -- en `window.print()`-vei
er avhengig av det, mens Playwright gir samme deterministiske output
uansett hvem som klikker. Playwright rendrer de EKTE Next.js-
utskriftssidene (samme React-komponent brukeren ser i forhåndsvisningen)
i stedet for en egen server-side malmotor (Jinja2/WeasyPrint) -- én
kilde til sannhet for layout, unngår at de to visningene glir fra
hverandre over tid. Eksplisitt akseptert kostnad: Playwright + Chromium
lagt til `Dockerfile` (`playwright install --with-deps chromium`),
merkbart større API-image (~300-500MB), ny "headless nettleser som
PDF-motor"-arkitekturmønster i backend-en.
**Beslutning B -- ny rute, ingen ny malduplisering:**
`POST /orgs/{organization_id}/print/pdf` (`app/routers/print_pdf.py`).
Tar `{kind, tournament_id, round_id|session_id, paper, orientation}` --
ALDRI en rå URL fra klienten (SSRF-hensyn); ruten bygger selv den
interne stien til riktig `/tournaments/{id}/print/{kind}`-side ut fra en
hardkodet mal per `kind`. Sesjonscookien fra selve PDF-forespørselen
videreføres til Playwright sin nettleserkontekst før navigering, slik at
siden gjør sine egne fetch()-kall til API-et akkurat som en ekte
innlogget nettleser -- ingen egen auth-vei for renderingen. Hver
utskriftsside setter `data-print-ready="true"` på rot-elementet FØRST
når all ekte data er hentet og lagt inn i DOM-en; ruten venter på dette
attributtet (15s timeout) før `page.pdf(prefer_css_page_size=True)`
kalles, slik at PDF-en aldri fanger en "Laster..."-tilstand. Siden setter
selv `@page`-CSS-regelen ut fra valgt papirstørrelse/retning (styrt av
URL-parametrene ruten konstruerer) -- `prefer_css_page_size=True` bruker
den regelen direkte, ingen egen mm-utregning dupliseres i Python.
`_verify_scope()` sjekker runde-/økt-IDen faktisk hører til den oppgitte
turneringen FØR rendering (RLS via `org_connection` gjør at dette stille
feiler for en annen organisasjons rad, samme mønster som resten av
API-et).
**Beslutning C -- ruteplassering, frontend:** fire nye sider under
`app/tournaments/[id]/print/{scorecard,startlist,cart-tags,resultlist}/
page.tsx` (org via query-param, samme mønster som
`app/tournaments/[id]/program/page.tsx`). IKKE V0-eksportens flate
`app/print-scorecard/page.tsx`-struktur (kun et forhåndsvisnings-
prosjektoppsett, ikke ment som endelig ruteform). `/tournaments`-prefikset
er verifisert IKKE i konflikt med noen `next.config.mjs`-rewrite (se den
filens egen kommentar om denne fellen, ADR-033/FEATURE_BACKLOG.md
2026-07-23) -- API-ets turneringsendepunkter ligger uansett under
`/orgs/{id}/tournaments/...`, aldri et topp-nivå `/tournaments`-prefiks.
**Beslutning D -- datakobling, ekte join-mønster kartlagt før bygging**
(Explore-agent brukt til å lese samtlige relevante routere FØR
komponentene ble skrevet, for å unngå å gjette feltnavn): ingen enkelt
endepunkt gir en komplett startliste/scorekort for noen av de to
formatene -- alltid 3-4 kall som joines klient-side (runde/økt-metadata
→ grupper/matcher → runde-/kamp-deltakere → turnering-/rosterdeltakere
→ spillerdata for kjønn/hjemmeklubb). Delt i `lib/print-data.ts` (typer
+ fetch-funksjoner) -- ekte gjenbruk på tvers av alle fire sider, ikke en
tidlig abstraksjon. To reelle datamodell-hull bekreftet under kartlegging
(utover de tre allerede kjente fra planleggingsrunden): ingen `color`-
kolonne på `tee` (kun `name` -- norsk konvensjon er at navnet ER fargen,
løst med en liten navn→hex-oppslagstabell, ikke en ekte kilde) og ingen
enkelt "hent én turnering"-endepunkt (løst som resten av appen allerede
gjør: hent listen, filtrer klient-side på id).
**Scorekortet skriver ETT kort PER UTSLAGSGRUPPE (individuell) / PER
MATCH (Cup), for HELE feltet i én utskrift** -- ikke ett enkelt
eksempelkort slik V0-mockupen viste det. En organisator skriver ut
scorekort for alle spillere på én gang før runden starter, ikke ett om
gangen. Cart-tags og startlisten fulgte allerede dette mønsteret i
mockupen (alle grupper/matcher samtidig), kun scorekortet trengte denne
generaliseringen fra ett hardkodet eksempel til en løkke over ekte
grupper/matcher.
**Inngangspunkter lagt til (ikke en egen isolert flate):**
`round-groups-panel.tsx` fikk en delt `PrintMenu`-komponent
(`components/print-menu.tsx`) i sitt sticky-headerpar ved siden av
lukkeknappen -- organisatoren står allerede på riktig runde der.
`tournament-program.tsx` sin økt-handlingsmeny (samme "⋯"-meny som
Rediger/Slett) fikk tre nye "Skriv ut ..."-oppføringer. Leaderboard-
visningene (`tournament-leaderboard.tsx` sin fanerad, og en liten lenke
øverst i `individual-tournament-detail.tsx` sin `LeaderboardTab`) fikk
hver én "Skriv ut resultatliste"-lenke, siden resultatlisten er
turneringsomfattende (ingen runde-/økt-scope).
**Ikke gjort denne runden, bevisst:** selve Docker-bygget/utrullingen
(ny Chromium-avhengighet i produksjons-API-imaget er en reell nok endring
til å vise frem og bekrefte FØR den kjøres, samme disiplin som alltid).
Ekte hullengde/turneringslogo forblir mocket (uendret fra planleggings-
rundens beslutning) -- egen, senere oppgave når/hvis de datamodell-
utvidelsene faktisk bygges.
---
## Utviklingsplan (rekkefølge) ## Utviklingsplan (rekkefølge)
1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003) 1. ✅ Land tenant-modell → **Organisasjon** (ADR-001/002/003)

View file

@ -13975,3 +13975,48 @@ Neste steg:
med `information_schema`-spørring FØR og ETTER), deretter med `information_schema`-spørring FØR og ETTER), deretter
`docker compose build teecup_api teecup_frontend && up -d`, begge `docker compose build teecup_api teecup_frontend && up -d`, begge
containere friske, rene logger. containere friske, rene logger.
150. **Utskriftsklare turneringsdokumenter koblet til ekte data, 2026-08-21
(ADR-097).** Oppfølging av #149 (shotgun-start ble bygget nettopp for
dette behovet). Bruker lastet opp V0-eksporten for de fire
utskriftsprompt-flatene (scorekort/startliste/cart-tags/
resultatliste, planlagt tidligere samme dag -- se FEATURE_BACKLOG.md
"utskrift av turneringsdokumenter"-planlegging). Eksporten var rene
visuelle mockups (mock-data, "Last ned PDF" var en blindvei) --
denne runden kobler alle fire til ekte API-data for begge
turneringsformer og gjør PDF-nedlastingen ekte.
Bruker spurte eksplisitt om en anbefaling for PDF-mekanisme ut fra
sluttresultat, ikke byggekostnad: valgte server-generert PDF via
headless Chromium (Playwright), som rendrer de EKTE Next.js-
utskriftssidene (samme React-komponent som forhåndsvisningen) i
stedet for nettleserens egen print-dialog eller en duplisert
server-side malmotor -- se ADR-097 for full begrunnelse. Ny
`POST /orgs/{organization_id}/print/pdf` (`app/routers/print_pdf.py`),
Playwright + Chromium lagt til `Dockerfile`/`requirements.txt`.
Fire nye sider (`app/tournaments/[id]/print/{scorecard,startlist,
cart-tags,resultlist}/page.tsx`) + fire komponenter, alle bygget mot
ekte join-mønstre kartlagt av en Explore-agent FØR koding (ingen
endepunkt gir en komplett startliste/scorekort alene -- 3-4 kall
joines klient-side). Delt `lib/print-data.ts` (typer + fetchere,
ekte gjenbruk på tvers av alle fire). Scorekortet generalisert fra
V0-mockupens ETT eksempelkort til én sheet PER UTSLAGSGRUPPE
(individuell) / PER MATCH (Cup) -- hele feltet i én utskrift, ikke
ett kort om gangen.
Inngangspunkter: `PrintMenu`-komponent i `round-groups-panel.tsx` sin
header (individuell), tre nye "Skriv ut ..."-oppføringer i
`tournament-program.tsx` sin økt-handlingsmeny (Cup), og en
"Skriv ut resultatliste"-lenke i begge leaderboard-visningene.
**Verifisert så langt:** `tsc --noEmit` rent på hele frontend-
prosjektet, hele `vitest`-suiten grønn (55 tester, ingen regresjon).
`python3 -m py_compile` rent på de nye backend-filene.
**⏳ IKKE rullet ut ennå.** Ny Chromium-avhengighet i produksjons-
API-imaget (~300-500MB større) venter på eksplisitt bekreftelse før
`docker compose build && up -d` kjøres. Ekte scratch-verifisering
(klikk gjennom alle fire med ekte turneringsdata, bekreft faktisk
PDF-nedlasting) gjenstår også -- kun kodenivå-verifisering gjort
denne runden.

View file

@ -4,6 +4,10 @@ WORKDIR /srv
COPY app/requirements.txt app/requirements.txt COPY app/requirements.txt app/requirements.txt
RUN pip install --no-cache-dir -r app/requirements.txt RUN pip install --no-cache-dir -r app/requirements.txt
# Chromium for print_pdf.py (ADR-097) -- --with-deps henter apt-avhengighetene
# Chromium trenger (fonter/libnss3/libatk osv.), python:3.12-slim har dem ikke.
# Merkbart større image (~300-500MB) -- akseptert kostnad, se ADR-097.
RUN playwright install --with-deps chromium
COPY app/ app/ COPY app/ app/
COPY handicap_engine.py handicap_engine.py COPY handicap_engine.py handicap_engine.py

View file

@ -4829,6 +4829,19 @@ funnet; bruker har eksplisitt tatt stilling til alle tre (se hver):
2026-08-21** (migrasjon 088, ADR-096) -- se ARCHITECTURE_DECISIONS.md 2026-08-21** (migrasjon 088, ADR-096) -- se ARCHITECTURE_DECISIONS.md
for full detalj, ikke gjentatt her. for full detalj, ikke gjentatt her.
**Oppfølging 2026-08-21 -- V0-eksporten koblet til ekte data (ADR-097):**
alle fire utskriftsflater (scorekort/startliste/cart-tags/resultatliste)
er nå kodet mot ekte API-endepunkter for begge turneringsformer, med
server-generert PDF via headless Chromium (Playwright) -- se ADR-097 for
full detalj (PDF-mekanisme-valget, rutestruktur, datajoin-mønster,
inngangspunkter). `tsc --noEmit` og hele `vitest`-suiten grønn.
**⏳ Ikke bygget/utrullet ennå** -- ny Chromium-avhengighet i
produksjons-API-imaget venter på eksplisitt bekreftelse før
`docker compose build && up -d` kjøres (samme disiplin som alltid for
noe som endrer produksjonscontaineren). Ekte scratch-verifisering (klikk
gjennom alle fire med ekte turneringsdata, bekreft PDF-nedlasting) gjenstår
også -- kun kodenivå-verifisering (typecheck/tester) er gjort så langt.
## Idé, ikke spesifisert: påminnelser + tilbakemeldings-"kartotek" under en runde (2026-08-21) — 📋 fanget, ikke designet ## Idé, ikke spesifisert: påminnelser + tilbakemeldings-"kartotek" under en runde (2026-08-21) — 📋 fanget, ikke designet
Bruker: "Jeg vil brukeren skal få spørsmål om han vil bli påminnet Bruker: "Jeg vil brukeren skal få spørsmål om han vil bli påminnet

View file

@ -27,6 +27,7 @@ from .routers import (
order_of_merit, order_of_merit,
organizations, organizations,
players, players,
print_pdf,
registration, registration,
round_messages, round_messages,
rounds, rounds,
@ -67,6 +68,7 @@ app.include_router(rounds.router)
app.include_router(round_messages.router) app.include_router(round_messages.router)
app.include_router(friends.router) app.include_router(friends.router)
app.include_router(notifications.router) app.include_router(notifications.router)
app.include_router(print_pdf.router)
@app.get("/health") @app.get("/health")

View file

@ -12,3 +12,4 @@ argon2-cffi>=23.1
pyotp>=2.9 pyotp>=2.9
qrcode>=7.4 qrcode>=7.4
pywebpush>=2.0 pywebpush>=2.0
playwright>=1.45

152
app/routers/print_pdf.py Normal file
View file

@ -0,0 +1,152 @@
"""
Server-generert PDF for utskriftsklare turneringsdokumenter (scorekort,
startliste, cart-tags, resultatliste). Se ARCHITECTURE_DECISIONS.md ADR-097.
Rendrer den EKTE Next.js-utskriftssiden i en headless Chromium (Playwright)
i stedet for å duplisere layouten i en egen server-side malmotor -- én kilde
til sannhet for visuell layout (samme React-komponent brukeren allerede ser
i forhåndsvisningen). Siden selv setter @page-CSS-regelen ut fra valgt
papirstørrelse/retning (styrt av URL-parametrene denne ruten konstruerer);
Playwright sin prefer_css_page_size=True bruker den regelen direkte -- ingen
egen mm-utregning her, dupliseres ikke.
Sesjonscookien fra DENNE forespørselen videreføres til Playwright sin
nettleserkontekst før navigering, slik at siden gjør sine egne fetch()-kall
til API-et akkurat som om en ekte innlogget nettleser lastet den -- ingen
egen auth-vei for renderingen.
`path` konstrueres ALDRI fra klient-input direkte (SSRF-hensyn) -- kun
`kind` + verifiserte IDer, malen for hver `kind` er hardkodet her.
"""
from typing import Literal
from fastapi import APIRouter, Depends, Request, Response
from playwright.async_api import async_playwright
from pydantic import BaseModel
from ..auth import SESSION_COOKIE_NAME, get_authorized_org
from ..db import org_connection
from ..errors import app_error
router = APIRouter()
# Samme Docker-nettverk som teecup_api (docker-compose.yml) -- internt
# tjenestenavn, aldri en offentlig URL.
_FRONTEND_ORIGIN = "http://teecup_frontend:3000"
_FRONTEND_HOST = "teecup_frontend"
PrintKind = Literal["scorecard", "startlist", "cart-tags", "resultlist"]
Paper = Literal["A4", "Letter", "A5", "A3", "A2"]
Orientation = Literal["portrait", "landscape"]
# resultlist er turneringsomfattende (leaderboard-endepunktene), trenger
# verken runde eller økt. De tre andre trenger ETT av (runde ELLER økt) --
# hvilket avhenger av om turneringen er individuell eller Cup-format, ikke
# noe denne ruten selv vet -- frontend sender riktig ID.
_SCOPED_KINDS: set[PrintKind] = {"scorecard", "startlist", "cart-tags"}
class PrintPdfRequest(BaseModel):
kind: PrintKind
tournament_id: str
round_id: str | None = None
session_id: str | None = None
paper: Paper = "A4"
orientation: Orientation = "portrait"
async def _verify_scope(conn, tournament_id: str, round_id: str | None, session_id: str | None) -> None:
# org_connection har allerede satt app.current_org -- RLS gjør at disse
# EXISTS-sjekkene stille returnerer usann for en rad i en ANNEN
# organisasjon, selv om raden faktisk finnes. Samme mønster som resten
# av API-et stoler på (ADR-001/003), ikke en egen sjekk her.
tournament_exists = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM tournament WHERE id = $1)", tournament_id
)
if not tournament_exists:
raise app_error(404, "NOT_FOUND", "Fant ingen turnering med denne IDen i organisasjonen.")
if round_id is not None:
exists = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM tournament_round WHERE id = $1 AND tournament_id = $2)",
round_id,
tournament_id,
)
if not exists:
raise app_error(404, "NOT_FOUND", "Fant ingen runde med denne IDen i turneringen.")
if session_id is not None:
exists = await conn.fetchval(
'SELECT EXISTS (SELECT 1 FROM session WHERE id = $1 AND tournament_id = $2)',
session_id,
tournament_id,
)
if not exists:
raise app_error(404, "NOT_FOUND", "Fant ingen økt med denne IDen i turneringen.")
def _build_path(organization_id: str, body: PrintPdfRequest) -> str:
params = [f"org={organization_id}", f"paper={body.paper}", f"orientation={body.orientation}"]
if body.round_id:
params.append(f"roundId={body.round_id}")
if body.session_id:
params.append(f"sessionId={body.session_id}")
return f"/tournaments/{body.tournament_id}/print/{body.kind}?{'&'.join(params)}"
@router.post("/orgs/{organization_id}/print/pdf")
async def render_print_pdf(
body: PrintPdfRequest,
request: Request,
organization_id: str = Depends(get_authorized_org),
) -> Response:
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
raise app_error(401, "NOT_AUTHENTICATED", "Ikke innlogget.")
if body.kind in _SCOPED_KINDS and not body.round_id and not body.session_id:
raise app_error(400, "MISSING_SCOPE", "Mangler runde- eller økt-ID for denne utskriftstypen.")
async with org_connection(organization_id) as conn:
await _verify_scope(conn, body.tournament_id, body.round_id, body.session_id)
url = f"{_FRONTEND_ORIGIN}{_build_path(organization_id, body)}"
async with async_playwright() as pw:
browser = await pw.chromium.launch()
try:
context = await browser.new_context()
await context.add_cookies(
[
{
"name": SESSION_COOKIE_NAME,
"value": token,
"domain": _FRONTEND_HOST,
"path": "/",
"httpOnly": True,
"secure": False,
}
]
)
page = await context.new_page()
await page.goto(url, wait_until="networkidle")
try:
# Hver utskriftsside setter dette attributtet på <body> først
# NÅR all data er hentet OG lagt inn i DOM-en -- printer vi
# før det, får vi enten "Laster..."-tilstanden eller mock-
# dataene siden startet med, ikke ekte innhold.
await page.wait_for_selector("[data-print-ready='true']", timeout=15000)
except Exception:
raise app_error(
504,
"PRINT_RENDER_TIMEOUT",
"Utskriftssiden ble ikke klar i tide. Prøv igjen.",
)
pdf_bytes = await page.pdf(print_background=True, prefer_css_page_size=True)
finally:
await browser.close()
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="teecup-{body.kind}.pdf"'},
)

View file

@ -0,0 +1,33 @@
import { PrintCartTags } from "@/components/print-cart-tags"
export default async function PrintCartTagsPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ org?: string; roundId?: string; sessionId?: string; paper?: string; orientation?: string }>
}) {
const { id } = await params
const { org, roundId, sessionId, paper, orientation } = await searchParams
if (!org) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">
Mangler organisasjon i lenken. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
return (
<PrintCartTags
organizationId={org}
tournamentId={id}
roundId={roundId}
sessionId={sessionId}
initialPaper={paper === "Letter" ? "letter" : "a4"}
initialOrientation={orientation === "landscape" ? "landscape" : "portrait"}
/>
)
}

View file

@ -0,0 +1,31 @@
import { PrintResultlist } from "@/components/print-resultlist"
export default async function PrintResultlistPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ org?: string; paper?: string; orientation?: string }>
}) {
const { id } = await params
const { org, paper, orientation } = await searchParams
if (!org) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">
Mangler organisasjon i lenken. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
return (
<PrintResultlist
organizationId={org}
tournamentId={id}
initialSize={paper === "Letter" ? "Letter" : "A4"}
initialOrientation={orientation === "landscape" ? "landscape" : "portrait"}
/>
)
}

View file

@ -0,0 +1,33 @@
import { PrintScorecard } from "@/components/print-scorecard"
export default async function PrintScorecardPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ org?: string; roundId?: string; sessionId?: string; paper?: string; orientation?: string }>
}) {
const { id } = await params
const { org, roundId, sessionId, paper, orientation } = await searchParams
if (!org) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">
Mangler organisasjon i lenken. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
return (
<PrintScorecard
organizationId={org}
tournamentId={id}
roundId={roundId}
sessionId={sessionId}
initialPaper={paper === "Letter" || paper === "A5" ? paper : "A4"}
initialOrientation={orientation === "portrait" ? "portrait" : "landscape"}
/>
)
}

View file

@ -0,0 +1,36 @@
import { PrintStartlist } from "@/components/print-startlist"
export default async function PrintStartlistPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ org?: string; roundId?: string; sessionId?: string; paper?: string; orientation?: string }>
}) {
const { id } = await params
const { org, roundId, sessionId, paper, orientation } = await searchParams
if (!org) {
return (
<main className="flex min-h-[100dvh] flex-col items-center justify-center gap-2 bg-background px-5 text-center">
<p className="text-sm font-medium text-destructive">
Mangler organisasjon i lenken. tilbake til dashbordet og prøv igjen.
</p>
</main>
)
}
const validSizes = ["A4", "Letter", "A3", "A2"] as const
const initialSize = (validSizes as readonly string[]).includes(paper ?? "") ? (paper as (typeof validSizes)[number]) : "A4"
return (
<PrintStartlist
organizationId={org}
tournamentId={id}
roundId={roundId}
sessionId={sessionId}
initialSize={initialSize}
initialOrientation={orientation === "landscape" ? "landscape" : "portrait"}
/>
)
}

View file

@ -1671,6 +1671,8 @@ function SetupTab({
{groupsOpenRound && ( {groupsOpenRound && (
<RoundGroupsPanel <RoundGroupsPanel
base={base} base={base}
organizationId={organizationId}
tournamentId={tournament.id}
roundId={groupsOpenRound.id} roundId={groupsOpenRound.id}
roundLabel={groupsOpenRound.name || `Runde ${groupsOpenRound.sequence}`} roundLabel={groupsOpenRound.name || `Runde ${groupsOpenRound.sequence}`}
onClose={() => setGroupsOpenFor(null)} onClose={() => setGroupsOpenFor(null)}
@ -4743,8 +4745,20 @@ function LeaderboardTab({ base, tournament }: { base: string; tournament: ApiTou
? tournament.tiebreak_playoff_rule ? tournament.tiebreak_playoff_rule
: null : null
// base = /orgs/{organizationId}/tournaments/{tournamentId} -- utledet i
// stedet for å utvide LeaderboardTab sitt propsett kun for utskrifts-lenken.
const [, , printOrgId, , printTournamentId] = base.split("/")
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex justify-end">
<Link
href={`/tournaments/${printTournamentId}/print/resultlist?org=${printOrgId}`}
className="inline-flex min-h-9 items-center gap-1.5 rounded-full border border-border px-3 text-xs font-bold text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
Skriv ut resultatliste
</Link>
</div>
{playoffNote && ( {playoffNote && (
<div className="flex items-start gap-3 rounded-2xl border border-gold bg-gold/10 px-4 py-3"> <div className="flex items-start gap-3 rounded-2xl border border-gold bg-gold/10 px-4 py-3">
<Trophy aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-gold-foreground" /> <Trophy aria-hidden="true" className="mt-0.5 size-5 shrink-0 text-gold-foreground" />

View file

@ -0,0 +1,459 @@
"use client"
import type React from "react"
import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"
import { Download, Printer, Scissors } from "lucide-react"
import { cn } from "@/lib/utils"
import {
fetchMatches,
fetchRoundGroups,
fetchRoundParticipants,
fetchRounds,
fetchSessions,
fetchTeams,
fetchTournament,
fetchTournamentParticipants,
type ApiMatch,
type ApiRound,
type ApiRoundParticipant,
type ApiSession,
type ApiTeam,
type ApiTournamentParticipant,
} from "@/lib/print-data"
/**
* Print-friendly CART TAGS -- ekte data (ADR-097). Videreført fra
* V0-mockupen: 6 tags per ark (2×3 portrett / 3×2 landskap), stiplede
* klippelinjer. Individuell: én tag per utslagsgruppe. Cup: én tag per
* match (begge lags spillere samme tag, lagfarge som markør).
*/
type PaperSize = "a4" | "letter"
type Orientation = "portrait" | "landscape"
const PAPER_MM: Record<PaperSize, { w: number; h: number; label: string }> = {
a4: { w: 210, h: 297, label: "A4" },
letter: { w: 216, h: 279, label: "Letter" },
}
const PX_PER_MM = 3.7795
type TagPlayer = { name: string; hcp: string; className: string; teamName?: string; teamColor?: string }
type TagGroup = { cart: number; teeTime: string; startHole: number; players: TagPlayer[] }
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">{label}</p>
{children}
{hint && <p className="text-xs leading-relaxed text-muted-foreground">{hint}</p>}
</div>
)
}
function Segmented({
ariaLabel,
value,
onChange,
options,
}: {
ariaLabel: string
value: string
onChange: (v: string) => void
options: { value: string; label: string; disabled?: boolean }[]
}) {
return (
<div role="radiogroup" aria-label={ariaLabel} className="flex rounded-xl border border-border bg-muted/40 p-1">
{options.map((opt) => {
const active = opt.value === value
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={active}
disabled={opt.disabled}
onClick={() => onChange(opt.value)}
className={cn(
"flex-1 rounded-lg px-2 py-1.5 text-sm font-bold transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active ? "bg-primary text-primary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
opt.disabled && "cursor-not-allowed opacity-40 hover:text-muted-foreground",
)}
>
{opt.label}
</button>
)
})}
</div>
)
}
function Toggle({ label, hint, checked, onChange, disabled }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
return (
<label className={cn("flex cursor-pointer items-center justify-between gap-3 py-3", disabled && "cursor-not-allowed opacity-50")}>
<span className="flex flex-col">
<span className="text-sm font-semibold text-foreground">{label}</span>
{hint && <span className="text-xs leading-relaxed text-muted-foreground">{hint}</span>}
</span>
<button
type="button"
role="switch"
aria-checked={checked && !disabled}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn("relative h-6 w-11 shrink-0 rounded-full transition-colors", checked && !disabled ? "bg-primary" : "bg-muted-foreground/30")}
>
<span className={cn("absolute top-0.5 size-5 rounded-full bg-white shadow transition-transform", checked && !disabled ? "translate-x-[22px]" : "translate-x-0.5")} />
</button>
</label>
)
}
function CartTag({
group,
tournamentName,
showHcp,
showStartHole,
showClass,
showTeams,
showHeader,
}: {
group: TagGroup
tournamentName: string
showHcp: boolean
showStartHole: boolean
showClass: boolean
showTeams: boolean
showHeader: boolean
}) {
const startElsewhere = showStartHole && group.startHole !== 1
return (
<div className="flex h-full flex-col" style={{ padding: 6 * PX_PER_MM }}>
{showHeader && (
<p className="truncate font-semibold uppercase tracking-wide text-neutral-500" style={{ fontSize: 11 }}>
{tournamentName}
</p>
)}
<div className="flex items-start justify-between gap-2" style={{ marginTop: showHeader ? 2 : 0 }}>
<div className="flex items-baseline gap-2 leading-none">
<span className="font-semibold uppercase text-neutral-500" style={{ fontSize: 13 }}>
Cart
</span>
<span className="font-black leading-none" style={{ fontSize: 68 }}>
{group.cart}
</span>
</div>
<div className="flex flex-col items-end leading-none">
<span className="font-semibold uppercase text-neutral-500" style={{ fontSize: 11 }}>
Utslag
</span>
<span className="font-black tabular-nums" style={{ fontSize: 26 }}>
{group.teeTime}
</span>
{startElsewhere && (
<span className="mt-1 inline-flex items-center rounded font-bold uppercase" style={{ fontSize: 11, padding: "1px 6px", border: "2px solid #111", letterSpacing: "0.02em" }}>
Start hull {group.startHole}
</span>
)}
</div>
</div>
<div style={{ height: 2, background: "#111", margin: "8px 0" }} />
<ul className="flex flex-1 flex-col justify-center" style={{ gap: 6 }}>
{group.players.map((p, i) => (
<li key={p.name + i} className="flex items-center gap-2">
{showTeams && p.teamColor && <span aria-hidden="true" className="shrink-0 rounded-sm" style={{ width: 12, height: 22, background: p.teamColor }} />}
<div className="min-w-0 flex-1">
<p className="truncate font-bold leading-tight" style={{ fontSize: 19 }}>
{p.name}
</p>
{(showTeams || showClass) && (
<p className="truncate font-medium leading-tight text-neutral-500" style={{ fontSize: 12 }}>
{[showTeams && p.teamName, showClass && p.className && `Klasse ${p.className}`].filter(Boolean).join(" · ")}
</p>
)}
</div>
{showHcp && (
<span className="shrink-0 rounded font-bold tabular-nums" style={{ fontSize: 14, padding: "1px 7px", background: "#f3f4f6", border: "1px solid #d1d5db" }}>
{p.hcp}
</span>
)}
</li>
))}
</ul>
</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
}
export function PrintCartTags({
organizationId,
tournamentId,
roundId,
sessionId,
initialPaper = "a4",
initialOrientation = "portrait",
}: {
organizationId: string
tournamentId: string
roundId?: string
sessionId?: string
initialPaper?: PaperSize
initialOrientation?: Orientation
}) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [tournamentName, setTournamentName] = useState("Turnering")
const [groups, setGroups] = useState<TagGroup[]>([])
const [isCup, setIsCup] = useState(false)
const [paper, setPaper] = useState<PaperSize>(initialPaper)
const [orientation, setOrientation] = useState<Orientation>(initialOrientation)
const [showHcp, setShowHcp] = useState(true)
const [showStartHole, setShowStartHole] = useState(true)
const [showClass, setShowClass] = useState(false)
const [showTeams, setShowTeams] = useState(true)
const [showHeader, setShowHeader] = useState(true)
const [downloading, setDownloading] = useState(false)
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] = await Promise.all([
fetchRounds(organizationId, tournamentId),
fetchRoundGroups(organizationId, tournamentId, roundId),
fetchRoundParticipants(organizationId, tournamentId, roundId),
fetchTournamentParticipants(organizationId, tournamentId),
])
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 built: TagGroup[] = groupsRes.groups.map((g, i) => ({
cart: g.sequence ?? i + 1,
teeTime: r.start_mode === "shotgun" ? "Shotgun" : g.tee_time ? new Date(g.tee_time).toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" }) : "",
startHole: g.start_hole ?? r.start_hole,
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,
hcp: tp?.handicap_index_snapshot != null ? tp.handicap_index_snapshot.toFixed(1).replace(".", ",") : "",
className: tp?.class_name ?? "",
}
}),
}))
if (cancelled) return
setIsCup(false)
setGroups(built)
} else if (sessionId) {
const [sessions, matches, 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 teamById = new Map<string, ApiTeam>(teams.map((tm) => [tm.id, tm]))
const built: TagGroup[] = matches.map((m: ApiMatch) => ({
cart: m.sequence,
teeTime: s.start_mode === "shotgun" ? "Shotgun" : m.tee_time ? new Date(m.tee_time).toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" }) : "",
startHole: m.start_hole ?? s.start_hole,
players: m.participants.map((mp) => {
const team = teamById.get(mp.team_side === "a" ? m.team_a_id : m.team_b_id)
return {
name: mp.player_name,
hcp: mp.playing_handicap != null ? String(mp.playing_handicap) : "",
className: "",
teamName: team?.name,
teamColor: team?.color ?? undefined,
}
}),
}))
if (cancelled) return
setIsCup(true)
setGroups(built)
} else {
throw new Error("Mangler runde- eller økt-ID.")
}
} catch {
if (!cancelled) setError("Klarte ikke å laste data for cart-tagsene. Prøv igjen.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, tournamentId, roundId, sessionId])
const sheetMm = useMemo(() => {
const base = PAPER_MM[paper]
return orientation === "portrait" ? { w: base.w, h: base.h } : { w: base.h, h: base.w }
}, [paper, orientation])
const grid = orientation === "portrait" ? { cols: 2, rows: 3 } : { cols: 3, rows: 2 }
const perSheet = grid.cols * grid.rows
const frameRef = useRef<HTMLDivElement>(null)
const [scale, setScale] = useState(1)
useEffect(() => {
const el = frameRef.current
if (!el) return
const compute = () => {
const avail = el.clientWidth - 32
const target = sheetMm.w * PX_PER_MM
setScale(Math.min(1, avail / target))
}
compute()
const ro = new ResizeObserver(compute)
ro.observe(el)
return () => ro.disconnect()
}, [sheetMm.w])
const sheetStyle: CSSProperties = { width: sheetMm.w * PX_PER_MM, height: sheetMm.h * PX_PER_MM, transform: `scale(${scale})`, transformOrigin: "top center" }
const pages = chunk(groups, perSheet)
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: "cart-tags",
tournament_id: tournamentId,
round_id: roundId ?? null,
session_id: sessionId ?? null,
paper: paper === "a4" ? "A4" : "Letter",
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-cart-tags.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-dvh bg-background text-foreground" data-print-ready={ready ? "true" : undefined}>
<style>{`@page { size: ${PAPER_MM[paper].label} ${orientation}; margin: 8mm; }`}</style>
<div className="mx-auto flex max-w-7xl flex-col gap-6 p-4 lg:flex-row lg:p-6 print:block print:p-0">
<aside className="lg:w-80 lg:shrink-0 print:hidden">
<div className="flex flex-col gap-6 rounded-2xl border border-border bg-card p-5 lg:sticky lg:top-6">
<div className="flex flex-col gap-1">
<h1 className="flex items-center gap-2 text-lg font-extrabold">
<Printer aria-hidden="true" className="size-5 text-primary" />
Skriv ut cart-tags
</h1>
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">Flere tags per ark. Klipp langs de stiplede linjene etter utskrift.</p>
</div>
{error ? <p className="text-sm font-medium text-destructive">{error}</p> : null}
<Field label="Papirstørrelse">
<Segmented ariaLabel="Papirstørrelse" value={paper} onChange={(v) => setPaper(v as PaperSize)} options={[{ value: "a4", label: "A4" }, { value: "letter", label: "Letter" }]} />
</Field>
<Field label="Retning" hint="Styrer hvordan tag-ene plasseres på arket.">
<Segmented ariaLabel="Retning" value={orientation} onChange={(v) => setOrientation(v as Orientation)} options={[{ value: "portrait", label: "Portrett" }, { value: "landscape", label: "Landskap" }]} />
</Field>
<div className="flex flex-col gap-1">
<p className="text-xs font-bold uppercase tracking-wide text-muted-foreground">Valgfrie felt</p>
<div className="flex flex-col divide-y divide-border">
<Toggle label="HCP per spiller" checked={showHcp} onChange={setShowHcp} />
<Toggle label="Starthull" hint="Vises kun når gruppen ikke starter på hull 1." checked={showStartHole} onChange={setShowStartHole} />
<Toggle label="Klasse per spiller" checked={showClass} onChange={setShowClass} disabled={isCup} />
<Toggle label="Lagnavn + farge" hint={isCup ? undefined : "Kun for Cup-format."} checked={showTeams} onChange={setShowTeams} disabled={!isCup} />
<Toggle label="Turneringsnavn på hver tag" checked={showHeader} onChange={setShowHeader} />
</div>
</div>
<button
type="button"
onClick={downloadPdf}
disabled={downloading || loading || groups.length === 0}
className="flex 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 disabled:opacity-60"
>
<Download aria-hidden="true" className="size-4" />
{downloading ? "Genererer …" : "Last ned PDF"}
</button>
<p className="-mt-3 text-center text-xs text-muted-foreground">{groups.length > 0 ? `${groups.length} tags, ${pages.length} ark.` : loading ? "Laster …" : "Ingen data ennå."}</p>
</div>
</aside>
<main className="min-w-0 flex-1 print:w-full">
<div ref={frameRef} className="flex flex-col items-center gap-6 rounded-2xl bg-muted/40 p-4 print:gap-0 print:rounded-none print:bg-transparent print:p-0">
{loading ? (
<p className="p-8 text-sm text-muted-foreground">Laster cart-tags </p>
) : pages.length === 0 ? (
<p className="p-8 text-sm text-muted-foreground">Ingen grupper eller matcher funnet ennå.</p>
) : (
pages.map((pageGroups, pageIndex) => (
<div key={pageIndex} style={{ height: sheetMm.h * PX_PER_MM * scale }} className="print:h-auto print:break-after-page">
<div style={sheetStyle} className="flex flex-col overflow-hidden bg-white text-neutral-900 shadow-lg ring-1 ring-black/10 print:!scale-100 print:shadow-none print:ring-0">
<div
className="grid flex-1"
style={{ padding: 10 * PX_PER_MM, gap: 0, gridTemplateColumns: `repeat(${grid.cols}, 1fr)`, gridTemplateRows: `repeat(${grid.rows}, 1fr)` }}
>
{pageGroups.map((group, i) => {
const col = i % grid.cols
const row = Math.floor(i / grid.cols)
return (
<div
key={group.cart}
className="relative"
style={{
borderRight: col < grid.cols - 1 ? "1.5px dashed #9ca3af" : undefined,
borderBottom: row < grid.rows - 1 ? "1.5px dashed #9ca3af" : undefined,
}}
>
{col < grid.cols - 1 && (
<Scissors aria-hidden="true" className="absolute top-1/2 -translate-y-1/2 text-neutral-400" style={{ right: -8, width: 14, height: 14, background: "white" }} />
)}
{row < grid.rows - 1 && (
<Scissors aria-hidden="true" className="absolute left-1/2 -translate-x-1/2 rotate-90 text-neutral-400" style={{ bottom: -8, width: 14, height: 14, background: "white" }} />
)}
<CartTag group={group} tournamentName={tournamentName} showHcp={showHcp} showStartHole={showStartHole} showClass={showClass} showTeams={showTeams} showHeader={showHeader} />
</div>
)
})}
</div>
</div>
</div>
))
)}
</div>
</main>
</div>
</div>
)
}

View file

@ -0,0 +1,49 @@
"use client"
// Delt "Skriv ut"-meny -- lenker til de tre rundekoblede utskriftssidene
// (ADR-097). Brukt fra RoundGroupsPanel (individuell) og økt-visningene
// (Cup) -- ekte gjenbruk, ikke en tidlig abstraksjon.
import Link from "next/link"
import { Printer } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function PrintMenu({
organizationId,
tournamentId,
roundId,
sessionId,
includeCartTags = true,
}: {
organizationId: string
tournamentId: string
roundId?: string
sessionId?: string
includeCartTags?: boolean
}) {
const scope = roundId ? `roundId=${roundId}` : `sessionId=${sessionId}`
const href = (kind: "scorecard" | "startlist" | "cart-tags") =>
`/tournaments/${tournamentId}/print/${kind}?org=${organizationId}&${scope}`
return (
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Skriv ut"
className="inline-flex min-h-11 items-center gap-2 rounded-xl border border-border bg-card px-3 text-sm font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Printer aria-hidden="true" className="size-5" />
<span className="hidden sm:inline">Skriv ut</span>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem render={<Link href={href("scorecard")}>Scorekort</Link>} />
<DropdownMenuItem render={<Link href={href("startlist")}>Startliste</Link>} />
{includeCartTags && <DropdownMenuItem render={<Link href={href("cart-tags")}>Cart-tags</Link>} />}
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -0,0 +1,704 @@
"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) 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>
)
}

View file

@ -0,0 +1,779 @@
"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 é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
}

View file

@ -0,0 +1,615 @@
"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>
)
}

View file

@ -19,6 +19,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Clock, Plus, Trash2, X } from "lucide-react" import { Clock, Plus, Trash2, X } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PrintMenu } from "@/components/print-menu"
type ApiGroupParticipant = { type ApiGroupParticipant = {
round_participant_id: string round_participant_id: string
@ -71,11 +72,15 @@ const CONTROL =
export function RoundGroupsPanel({ export function RoundGroupsPanel({
base, base,
organizationId,
tournamentId,
roundId, roundId,
roundLabel, roundLabel,
onClose, onClose,
}: { }: {
base: string base: string
organizationId: string
tournamentId: string
roundId: string roundId: string
roundLabel: string roundLabel: string
onClose: () => void onClose: () => void
@ -295,14 +300,17 @@ export function RoundGroupsPanel({
<h1 className="text-lg font-black text-foreground">Utslagsgrupper</h1> <h1 className="text-lg font-black text-foreground">Utslagsgrupper</h1>
<p className="text-sm font-semibold text-muted-foreground">{roundLabel}</p> <p className="text-sm font-semibold text-muted-foreground">{roundLabel}</p>
</div> </div>
<button <div className="flex items-center gap-2">
type="button" <PrintMenu organizationId={organizationId} tournamentId={tournamentId} roundId={roundId} />
onClick={onClose} <button
aria-label="Lukk" type="button"
className="inline-flex size-11 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={onClose}
> aria-label="Lukk"
<X aria-hidden="true" className="size-5" /> className="inline-flex size-11 items-center justify-center rounded-xl border border-border bg-card text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
</button> >
<X aria-hidden="true" className="size-5" />
</button>
</div>
</div> </div>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6 sm:px-8"> <div className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6 sm:px-8">

View file

@ -134,6 +134,12 @@ export function TournamentLeaderboard({
> >
Presentasjon Presentasjon
</Link> </Link>
<Link
href={`/tournaments/${tournamentId}/print/resultlist?org=${organizationId}`}
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
Skriv ut
</Link>
</nav> </nav>
</header> </header>

View file

@ -15,6 +15,7 @@ import {
MoreVertical, MoreVertical,
Pencil, Pencil,
Plus, Plus,
Printer,
Sparkles, Sparkles,
Trash2, Trash2,
Users, Users,
@ -425,6 +426,7 @@ export function TournamentProgram({
course={coursesById.get(session.course_id) ?? null} course={coursesById.get(session.course_id) ?? null}
courses={courses} courses={courses}
organizationId={organizationId} organizationId={organizationId}
tournamentId={tournamentId}
isLast={index === sorted.length - 1} isLast={index === sorted.length - 1}
href={`/tournaments/${tournamentId}/sessions/${session.id}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`} href={`/tournaments/${tournamentId}/sessions/${session.id}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
onCourseImported={(course) => setCourses((prev) => [...prev, course])} onCourseImported={(course) => setCourses((prev) => [...prev, course])}
@ -626,6 +628,7 @@ function SessionCard({
course, course,
courses, courses,
organizationId, organizationId,
tournamentId,
isLast, isLast,
href, href,
onCourseImported, onCourseImported,
@ -636,6 +639,7 @@ function SessionCard({
course: ApiCourse | null course: ApiCourse | null
courses: ApiCourse[] courses: ApiCourse[]
organizationId: string organizationId: string
tournamentId: string
isLast: boolean isLast: boolean
href: string href: string
onCourseImported: (course: ApiCourse) => void onCourseImported: (course: ApiCourse) => void
@ -708,6 +712,33 @@ function SessionCard({
<Pencil aria-hidden="true" className="size-4" /> <Pencil aria-hidden="true" className="size-4" />
Rediger Rediger
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
render={
<Link href={`/tournaments/${tournamentId}/print/startlist?org=${organizationId}&sessionId=${session.id}`}>
<Printer aria-hidden="true" className="size-4" />
Skriv ut startliste
</Link>
}
/>
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
render={
<Link href={`/tournaments/${tournamentId}/print/cart-tags?org=${organizationId}&sessionId=${session.id}`}>
<Printer aria-hidden="true" className="size-4" />
Skriv ut cart-tags
</Link>
}
/>
<DropdownMenuItem
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
render={
<Link href={`/tournaments/${tournamentId}/print/scorecard?org=${organizationId}&sessionId=${session.id}`}>
<Printer aria-hidden="true" className="size-4" />
Skriv ut scorekort
</Link>
}
/>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setDeleteError(null) setDeleteError(null)

268
frontend/lib/print-data.ts Normal file
View file

@ -0,0 +1,268 @@
// Delte typer + fetch-funksjoner for de fire utskriftssidene
// (print-scorecard/print-startlist/print-cart-tags/print-resultlist).
// Ekte reell gjenbruk på tvers av alle fire (samme runde-/økt-/deltaker-
// endepunkter trengs flere steder), ikke en tidlig abstraksjon -- se
// ARCHITECTURE_DECISIONS.md ADR-097.
async function getJson<T>(url: string): Promise<T> {
const res = await fetch(url, { credentials: "include" })
if (!res.ok) throw new Error(`${url}: ${res.status}`)
return res.json() as Promise<T>
}
export function tournamentBase(org: string, tournamentId: string) {
return `/orgs/${org}/tournaments/${tournamentId}`
}
export type ApiTournament = {
id: string
name: string
status: string
format_type: "team" | "individual"
scoring_method: string | null
cut_after_round: number | null
cut_size: number | null
}
export type ApiRound = {
id: string
tournament_id: string
sequence: number
name: string
hole_config: "full_18" | "front_9" | "back_9"
course_id: string
course_name: string
scheduled_at: string | null
tee_interval_minutes: number | null
start_hole: number
start_mode: "consecutive" | "shotgun"
}
export type ApiRoundGroupParticipant = { round_participant_id: string; player_name: string }
export type ApiRoundGroup = {
id: string | null
sequence: number
tee_time: string | null
tee_time_override: string | null
start_hole: number | null
participants: ApiRoundGroupParticipant[]
}
export type ApiRoundGroups = { groups: ApiRoundGroup[]; ungrouped: ApiRoundGroupParticipant[] }
export type ApiRoundParticipant = {
id: string
tournament_participant_id: string
player_name: string
tee_id: string | null
tee_name: string | null
course_handicap: number | null
playing_handicap: number | null
stat_level: "strokes_only" | "strokes_and_putts" | "full"
}
export type ApiTournamentParticipant = {
id: string
player_id: string
player_name: string
handicap_index_snapshot: number | null
class_id: string | null
class_name: string | null
stat_level: string
status: "active" | "dsq" | "rtd" | "dnf" | "dns"
}
export type ApiCourseHole = { hole_number: number; par: number; stroke_index: number }
export type ApiCourse = { id: string; name: string; source: string }
export type ApiSession = {
id: string
sequence: number
name: string
format: string
hole_config: "full_18" | "front_9" | "back_9"
course_id: string
points_per_match: number
scoring_mode: "stroke" | "hole_result"
scheduled_at: string | null
tee_interval_minutes: number | null
start_hole: number
start_mode: "consecutive" | "shotgun"
locked_team_ids: string[]
revealed: boolean
}
export type ApiMatchParticipant = {
id: string
team_side: "a" | "b"
team_roster_id: string
player_name: string
tee_id: string | null
tee_name: string | null
playing_handicap: number | null
lineup_order: number | null
}
export type ApiMatch = {
id: string
sequence: number
team_a_id: string
team_b_id: string
status_text: string | null
points_side_a: number | null
points_side_b: number | null
leading_side: "a" | "b" | null
tee_time: string | null
start_hole: number | null
participants: ApiMatchParticipant[]
}
export type ApiTeam = { id: string; name: string; color: string | null }
export type ApiRosterEntry = {
id: string
player_id: string
display_name: string
handicap_index_snapshot: number | null
is_captain: boolean
class_id: string | null
class_name: string | null
}
export type ApiLeaderboardEntry = {
tournament_participant_id: string
player_id: string
player_name: string
rounds_played: number
gross_total: number | null
net_total: number | null
stableford_total: number | null
class_id: string | null
class_name: string | null
position: string | null
total_label: string | null
rank: number | null
cut: boolean
status: "active" | "dsq" | "rtd" | "dnf" | "dns"
rounds: { round_number: number; label: string | null; tone: "under" | "even" | "over" | null }[]
}
export type ApiCupLeaderboard = {
teams: { team_id: string; team_name: string; color: string | null; points: number; projected_points: number }[]
matches_total: number
matches_decided: number
sessions: {
session_id: string
sequence: number
name: string | null
points_by_team: Record<string, number>
projected_points_by_team: Record<string, number>
matches_total: number
matches_decided: number
}[]
}
export type ApiPlayer = {
id: string
display_name: string
handicap_index: number | null
gender: "m" | "f" | "x" | null
club: string | null
}
export async function fetchTournament(org: string, tournamentId: string): Promise<ApiTournament | null> {
const list = await getJson<ApiTournament[]>(`/orgs/${org}/tournaments`)
return list.find((t) => t.id === tournamentId) ?? null
}
export function fetchRounds(org: string, tournamentId: string) {
return getJson<ApiRound[]>(`${tournamentBase(org, tournamentId)}/rounds`)
}
export function fetchRoundGroups(org: string, tournamentId: string, roundId: string) {
return getJson<ApiRoundGroups>(`${tournamentBase(org, tournamentId)}/rounds/${roundId}/groups`)
}
export function fetchRoundParticipants(org: string, tournamentId: string, roundId: string) {
return getJson<ApiRoundParticipant[]>(`${tournamentBase(org, tournamentId)}/rounds/${roundId}/participants`)
}
export function fetchTournamentParticipants(org: string, tournamentId: string) {
return getJson<ApiTournamentParticipant[]>(`${tournamentBase(org, tournamentId)}/participants`)
}
export function fetchCourseHoles(org: string, courseId: string) {
return getJson<ApiCourseHole[]>(`/orgs/${org}/courses/${courseId}/holes`)
}
export function fetchCourses(org: string) {
return getJson<ApiCourse[]>(`/orgs/${org}/courses`)
}
export function fetchSessions(org: string, tournamentId: string) {
return getJson<ApiSession[]>(`${tournamentBase(org, tournamentId)}/sessions`)
}
export function fetchMatches(org: string, sessionId: string) {
return getJson<ApiMatch[]>(`/orgs/${org}/sessions/${sessionId}/matches`)
}
export function fetchTeams(org: string, tournamentId: string) {
return getJson<ApiTeam[]>(`${tournamentBase(org, tournamentId)}/teams`)
}
export function fetchRoster(org: string, teamId: string) {
return getJson<ApiRosterEntry[]>(`/orgs/${org}/teams/${teamId}/roster`)
}
export function fetchIndividualLeaderboard(org: string, tournamentId: string) {
return getJson<ApiLeaderboardEntry[]>(`${tournamentBase(org, tournamentId)}/individual-leaderboard`)
}
export function fetchCupLeaderboard(org: string, tournamentId: string) {
return getJson<ApiCupLeaderboard>(`${tournamentBase(org, tournamentId)}/leaderboard`)
}
export function fetchPlayers(org: string) {
return getJson<ApiPlayer[]>(`/orgs/${org}/players`)
}
// Ingen `color`-kolonne finnes på `tee` (kun `name`, verifisert mot skjema)
// -- norsk klubbkonvensjon er at navnet ER fargen. Kun til en liten
// fargeprikk i utskrift, faller tilbake til nøytral grå for ukjente navn.
const TEE_COLOR_BY_NAME: Record<string, string> = {
gul: "#eab308",
rød: "#dc2626",
hvit: "#f5f5f4",
blå: "#2563eb",
sølv: "#94a3b8",
oransje: "#f97316",
svart: "#171717",
grønn: "#16a34a",
}
export function teeColor(teeName: string | null | undefined): string {
if (!teeName) return "#a3a3a3"
const key = teeName.trim().toLowerCase().split(/\s+/)[0]
return TEE_COLOR_BY_NAME[key] ?? "#a3a3a3"
}
export function formatTeeTime(iso: string | null): string {
if (!iso) return ""
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ""
return d.toLocaleTimeString("no-NO", { hour: "2-digit", minute: "2-digit" })
}
export function formatDate(iso: string | null): string {
if (!iso) return ""
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return ""
return d.toLocaleDateString("no-NO", { day: "numeric", month: "long", year: "numeric" })
}
// Realistisk hull-lengde finnes ikke i datamodellen ennå (bevisst utsatt,
// se FEATURE_BACKLOG.md "utskrift av turneringsdokumenter"-planlegging
// 2026-08-21) -- mockes med plausible tall her, akkurat som i selve
// V0-utskriftspromptene. Deterministisk fra par + hullnummer, ikke
// tilfeldig, så tallet er stabilt mellom rendringer av samme hull.
export function mockHoleLength(par: number, holeNumber: number): number {
const base = par === 3 ? 150 : par === 5 ? 480 : 350
return base + ((holeNumber * 17) % 40) - 20
}

File diff suppressed because one or more lines are too long