Turneringsoppsett som steg-wizard (ADR-092)
Oppsett-fanen var én lang stablet side med alle kort etter hverandre. Omgjort til 6 klikkbare steg med fri navigasjon, strukturelt inspirert av Golf GameBooks steg-navigasjon (ikke visuelt -- egne clubhouse- tokens). Grupper og startliste er det eneste genuint nye steget, løfter Utslagsgrupper ut av å være gjemt inni hver enkelt runde. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
6f25b57f90
commit
1867c65143
5 changed files with 425 additions and 128 deletions
|
|
@ -9319,6 +9319,89 @@ allerede fungerer andre steder i appen).
|
||||||
ekte `teecup_db`, deretter `docker compose build teecup_api
|
ekte `teecup_db`, deretter `docker compose build teecup_api
|
||||||
teecup_frontend && up -d` for begge. Rene logger, 200 OK.
|
teecup_frontend && up -d` for begge. Rene logger, 200 OK.
|
||||||
|
|
||||||
|
## ADR-092: Turneringsoppsett som steg-wizard, strukturelt inspirert av Golf GameBook (2026-08-18)
|
||||||
|
|
||||||
|
Bruker viste skjermdumper av Golf GameBooks turneringsoppsett (steg-
|
||||||
|
navigasjon: Tournament Setup → Round Setup → Tournament Site → Branding
|
||||||
|
→ Entries → Players → Groups and Start List → Results and Options) og
|
||||||
|
spurte: "Jeg synes denne delen av TeeCup er veldig lite brukervennlig.
|
||||||
|
Kan du hjelpe meg å tenke gjennom dette?" -- TeeCups "Oppsett"-fane
|
||||||
|
(`SetupTab` i `individual-tournament-detail.tsx`) var én lang stablet
|
||||||
|
side med alle kort etter hverandre (`TournamentSettingsCard` →
|
||||||
|
Scoringsmetode → `RoundsCard` → `CutCard`/`TiebreakCard` →
|
||||||
|
`ClassesCard` → `ParticipantsCard`), i byggerekkefølge, ikke i en
|
||||||
|
rekkefølge en arrangør faktisk tenker gjennom oppsettet i. Bruker
|
||||||
|
svarte "Full wizard. Bring gjerne V0 inn", med ett eksplisitt forbehold
|
||||||
|
underveis: "Yes, men husk at vi ikke skal ende opp med et plagiat av
|
||||||
|
Golf GameBook."
|
||||||
|
|
||||||
|
**Struktur, ikke visuell kopi.** GameBook-skjermdumpene ble brukt
|
||||||
|
UTELUKKENDE som referanse for selve IA-mønsteret (steg i stedet for én
|
||||||
|
lang side), ikke som visuell mal -- V0-prompten instruerte eksplisitt
|
||||||
|
om å bruke TeeCups egne clubhouse-fargetoken/`rounded-2xl`-kortstil fra
|
||||||
|
`DESIGN_SYSTEM.md`, ingen grønnfarge/ikonsett/spacing hentet fra
|
||||||
|
GameBook. Dette var et rent IA-/navigasjonsproblem, ikke et manglende-
|
||||||
|
funksjonalitet-problem -- praktisk talt alle felt GameBook viser fantes
|
||||||
|
allerede og fungerte. Ingen backend-endring, ingen migrasjon.
|
||||||
|
|
||||||
|
**6 steg** (ikke GameBooks 8 -- "Tournament Site"/"Branding" dekkes
|
||||||
|
allerede av TeeCups egen frittstående Presentasjon-fane, "Entries" er
|
||||||
|
allerede en velfungerende inne-i-kortet-seksjon i `TournamentSettings
|
||||||
|
Card` som ikke var verdt å splitte ut av et nylig ferdigstilt kort),
|
||||||
|
hver drevet av et kort som allerede fantes og fungerte -- ingen av dem
|
||||||
|
trengte V0-rework: Turneringsoppsett (`TournamentSettingsCard`), Runder
|
||||||
|
og baner (`RoundsCard`), Klasser (`ClassesCard`), Spillere
|
||||||
|
(`ParticipantsCard`), Resultat og opsjoner (Scoringsmetode-seksjonen,
|
||||||
|
flyttet hit fra å stå løst øverst siden det er et resultatvalg ikke
|
||||||
|
identitet, + `CutCard` + `TiebreakCard`, samme betingede visning som
|
||||||
|
før).
|
||||||
|
|
||||||
|
**Grupper og startliste er det eneste genuint nye steget.**
|
||||||
|
`RoundGroupsPanel` var tidligere gjemt bak en "Utslagsgrupper"-knapp
|
||||||
|
inni hver enkelt `RoundCard` (måtte åpne runden først) -- direkte i
|
||||||
|
ånden til brukerens opprinnelige klage om at rundene har mye å ta tak i
|
||||||
|
mens org-oppsettet har lite. Løftet til et eget toppnivå-steg: ny
|
||||||
|
`RoundGroupsStepList`-komponent (V0-generert) lister alle runder
|
||||||
|
(sammenhengende liste-mønster, `divide-y`, ikke separate kort) med én
|
||||||
|
"Rediger grupper"-knapp per runde som åpner samme `RoundGroupsPanel`
|
||||||
|
uendret. Den gamle knappen inni `RoundCard` ble FJERNET (ikke bare
|
||||||
|
duplisert) -- to veier til samme handling ville vært akkurat den typen
|
||||||
|
duplisering wizarden var ment å rydde opp i.
|
||||||
|
|
||||||
|
**Fri navigasjon, ingen låsing.** GameBooks egne skjermdumper viser
|
||||||
|
faktisk grønne haker på ALLE 8 steg samtidig som "Tournament name"
|
||||||
|
sto tomt i opprettelses-flyten -- hakene er altså ikke ekte fullført-
|
||||||
|
validering, bare statisk wizard-pynt. Ny `TournamentSetupNav`
|
||||||
|
(V0-generert): alle 6 steg alltid klikkbare i vilkårlig rekkefølge,
|
||||||
|
ingen fullført-per-steg-logikk bygget (TeeCup er allerede fleksibelt på
|
||||||
|
oppsett-rekkefølge, og en slik validering ble aldri etterspurt). Steget
|
||||||
|
er ren `useState` inni `SetupTab`, ikke URL-drevet -- samme mønster som
|
||||||
|
de ytre fanene (Oppsett/Scorekort/Leaderboard/Presentasjon) allerede
|
||||||
|
bruker.
|
||||||
|
|
||||||
|
**Bredde-regresjon funnet og rettet under verifisering:** Oppsett-fanen
|
||||||
|
delte tidligere en `max-w-4xl` (896px) sidewrapper med Scorekort/
|
||||||
|
Presentasjon-fanene. Ved en lokal scratch-visning på 1440px viste det
|
||||||
|
seg at 6 steg-piller IKKE fikk plass side ved side i 896px -- akkurat
|
||||||
|
den "stor skjerm først"-egenskapen wizarden skulle innfri (jf. samme
|
||||||
|
mønster som leaderboardets `max-w-[110rem]`-unntak fra tidligere denne
|
||||||
|
dagen). Fikset med en egen `max-w-6xl`-unntaksbredde for `tab ===
|
||||||
|
"setup"` i den delte ternæren i `individual-tournament-detail.tsx`
|
||||||
|
sin `<main>`.
|
||||||
|
|
||||||
|
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Lokal scratch-
|
||||||
|
visning (`next dev` på port 3100, midlertidig `/setupwizardpreviewtmp`-
|
||||||
|
side, ALDRI committet, fjernet etter bruk): alle 6 steg klikkbare og
|
||||||
|
viser riktig innhold, Grupper-steget bekreftet å sende riktig runde-id
|
||||||
|
videre til panelet (tre varianter testet: dato+bane, kun bane, ingen av
|
||||||
|
delene), lys+mørk tema, smal skjerm (390px -- steg-raden scroller
|
||||||
|
sidelengs i stedet for å wrappe, som spesifisert, i stedet for feilende
|
||||||
|
layout).
|
||||||
|
|
||||||
|
**Rullet ut 2026-08-18** -- bruker bekreftet. Ingen migrasjon (ren
|
||||||
|
frontend). `docker compose build teecup_frontend && up -d
|
||||||
|
teecup_frontend`, rene logger, 200 OK.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Utviklingsplan (rekkefølge)
|
## Utviklingsplan (rekkefølge)
|
||||||
|
|
|
||||||
36
CHANGELOG.md
36
CHANGELOG.md
|
|
@ -12878,3 +12878,39 @@ Neste steg:
|
||||||
**Rullet ut 2026-08-18** -- bruker bekreftet. Migrasjon 087 kjørt mot
|
**Rullet ut 2026-08-18** -- bruker bekreftet. Migrasjon 087 kjørt mot
|
||||||
ekte `teecup_db`, deretter `docker compose build teecup_api
|
ekte `teecup_db`, deretter `docker compose build teecup_api
|
||||||
teecup_frontend && up -d` for begge, rene logger, 200 OK.
|
teecup_frontend && up -d` for begge, rene logger, 200 OK.
|
||||||
|
|
||||||
|
123. **Turneringsoppsett som steg-wizard -- 2026-08-18 (ADR-092).**
|
||||||
|
Bruker viste GameBook-skjermdumper: "Jeg synes denne delen av
|
||||||
|
TeeCup er veldig lite brukervennlig ... Full wizard. Bring gjerne
|
||||||
|
V0 inn", med presisering underveis: "husk at vi ikke skal ende opp
|
||||||
|
med et plagiat av Golf GameBook." Oppsett-fanen (`SetupTab`) var én
|
||||||
|
lang stablet side -- omgjort til 6 klikkbare steg (Turnerings-
|
||||||
|
oppsett/Runder og baner/Klasser/Spillere/Grupper og startliste/
|
||||||
|
Resultat og opsjoner), fri navigasjon uten låsing eller fullført-
|
||||||
|
haker (GameBooks egne skjermdumper viste haker på alle steg selv
|
||||||
|
med tomt navnefelt -- ikke ekte validering). Kun to nye V0-
|
||||||
|
komponenter, GameBook brukt UTELUKKENDE som strukturelt forbilde
|
||||||
|
(steg-mønsteret), ikke visuelt -- begge bruker TeeCups egne
|
||||||
|
clubhouse-tokens fra `DESIGN_SYSTEM.md`: `tournament-setup-nav.tsx`
|
||||||
|
(steg-raden) og `round-groups-step-list.tsx` (ny topplassering for
|
||||||
|
"Utslagsgrupper", tidligere gjemt bak en knapp inni hver enkelt
|
||||||
|
runde -- den gamle knappen fjernet, ikke duplisert). Alle 6 stegs
|
||||||
|
faktiske innhold er allerede-eksisterende kort (TournamentSettings
|
||||||
|
Card/RoundsCard/ClassesCard/ParticipantsCard/CutCard/TiebreakCard),
|
||||||
|
kun flyttet til riktig steg -- ingen ny logikk, ingen backend-
|
||||||
|
endring, ingen migrasjon. Fant og fikset en bredde-regresjon
|
||||||
|
underveis: Oppsett delte en `max-w-4xl`-wrapper med Scorekort/
|
||||||
|
Presentasjon som gjorde at 6 steg-piller ikke fikk plass ved siden
|
||||||
|
av hverandre selv på en bred skjerm -- egen `max-w-6xl`-unntaksbredde
|
||||||
|
lagt til (samme mønster som leaderboardets `max-w-[110rem]`-unntak).
|
||||||
|
|
||||||
|
**Verifisert:** `tsc --noEmit` rent, `vitest run` 55/55. Lokal
|
||||||
|
scratch-visning (`next dev`, midlertidig previewside, ALDRI
|
||||||
|
committet, fjernet etter bruk): alle 6 steg klikkbare med riktig
|
||||||
|
innhold, Grupper-steget sender riktig runde-id videre (tre
|
||||||
|
varianter: dato+bane/kun bane/ingen av delene), lys+mørk, smal
|
||||||
|
skjerm (steg-raden scroller sidelengs i stedet for å wrappe/feile).
|
||||||
|
|
||||||
|
**Rullet ut 2026-08-18** -- bruker bekreftet. Ingen migrasjon (ren
|
||||||
|
frontend). `docker compose build teecup_frontend && up -d
|
||||||
|
teecup_frontend`, rene logger, 200 OK.
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,11 @@ import {
|
||||||
Crosshair,
|
Crosshair,
|
||||||
Flag,
|
Flag,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Layers,
|
||||||
MapPin,
|
MapPin,
|
||||||
Medal,
|
Medal,
|
||||||
Plus,
|
Plus,
|
||||||
|
Settings2,
|
||||||
Trash2,
|
Trash2,
|
||||||
Trophy,
|
Trophy,
|
||||||
Upload,
|
Upload,
|
||||||
|
|
@ -44,7 +46,9 @@ import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { PlayerImportPanel } from "@/components/player-import-panel"
|
import { PlayerImportPanel } from "@/components/player-import-panel"
|
||||||
import { RoundGroupsPanel } from "@/components/round-groups-panel"
|
import { RoundGroupsPanel } from "@/components/round-groups-panel"
|
||||||
|
import { RoundGroupsStepList, type RoundSummary } from "@/components/round-groups-step-list"
|
||||||
import { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card"
|
import { TournamentSettingsCard, type TournamentSettingsValues } from "@/components/tournament-settings-card"
|
||||||
|
import { TournamentSetupNav, type SetupStep } from "@/components/tournament-setup-nav"
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
|
@ -991,11 +995,17 @@ export function IndividualTournamentDetail({
|
||||||
egen kommentar om "storskjerm uten skrolling"), men satt inni denne
|
egen kommentar om "storskjerm uten skrolling"), men satt inni denne
|
||||||
delte 4xl-wrapperen (896px, samme wrapper som skjema-tunge Oppsett/
|
delte 4xl-wrapperen (896px, samme wrapper som skjema-tunge Oppsett/
|
||||||
Presentasjon-fanene bruker) fikk den aldri sjansen til det -- felles
|
Presentasjon-fanene bruker) fikk den aldri sjansen til det -- felles
|
||||||
med resten av siden siden komponenten ble bygget/testet isolert. */}
|
med resten av siden siden komponenten ble bygget/testet isolert.
|
||||||
|
|
||||||
|
Oppsett-fanen fikk samme problem 2026-08-18 (ADR-092): 6 steg i
|
||||||
|
TournamentSetupNav får ikke plass side ved side i 896px, selv på en
|
||||||
|
bred skjerm -- akkurat den "stor skjerm først"-regelen wizarden
|
||||||
|
skulle innfri. Egen, litt smalere unntaksbredde enn leaderboardets
|
||||||
|
110rem (dette er skjemainnhold, ikke en datatabell). */}
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-auto w-full flex-1 px-5 py-6 sm:py-8",
|
"mx-auto w-full flex-1 px-5 py-6 sm:py-8",
|
||||||
tab === "leaderboard" ? "max-w-[110rem]" : "max-w-4xl",
|
tab === "leaderboard" ? "max-w-[110rem]" : tab === "setup" ? "max-w-6xl" : "max-w-4xl",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -1104,6 +1114,21 @@ function fromDatetimeLocalValue(value: string | null): string | null {
|
||||||
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wizard-steg (ADR-092) -- GameBook-skjermdumper var strukturelt forbilde
|
||||||
|
// (steg i stedet for én lang stablet side), IKKE visuelt forbilde.
|
||||||
|
// Fri navigasjon -- ingen låsing/fullført-haker, se ADR-092: GameBooks
|
||||||
|
// egne skjermdumper viser haker på alle steg selv med tomt navnefelt,
|
||||||
|
// altså ikke ekte validering, og TeeCup er allerede fleksibelt på
|
||||||
|
// oppsett-rekkefølge.
|
||||||
|
const SETUP_STEPS: SetupStep[] = [
|
||||||
|
{ key: "basics", label: "Turneringsoppsett", icon: Settings2 },
|
||||||
|
{ key: "rounds", label: "Runder og baner", icon: Flag },
|
||||||
|
{ key: "classes", label: "Klasser", icon: Layers },
|
||||||
|
{ key: "players", label: "Spillere", icon: Users },
|
||||||
|
{ key: "groups", label: "Grupper og startliste", icon: Clock },
|
||||||
|
{ key: "results", label: "Resultat og opsjoner", icon: Trophy },
|
||||||
|
]
|
||||||
|
|
||||||
function SetupTab({
|
function SetupTab({
|
||||||
base,
|
base,
|
||||||
organizationId,
|
organizationId,
|
||||||
|
|
@ -1171,6 +1196,8 @@ function SetupTab({
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
}) {
|
}) {
|
||||||
|
const [step, setStep] = useState<string>("basics")
|
||||||
|
const [groupsOpenFor, setGroupsOpenFor] = useState<string | null>(null)
|
||||||
const [pool, setPool] = useState<ApiPlayer[]>([])
|
const [pool, setPool] = useState<ApiPlayer[]>([])
|
||||||
|
|
||||||
async function refreshPool() {
|
async function refreshPool() {
|
||||||
|
|
@ -1202,116 +1229,151 @@ function SetupTab({
|
||||||
registrationRequiresApproval: tournament.registration_requires_approval,
|
registrationRequiresApproval: tournament.registration_requires_approval,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const roundSummaries: RoundSummary[] = rounds.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name || `Runde ${r.sequence}`,
|
||||||
|
sequence: r.sequence,
|
||||||
|
courseName: r.course_name || null,
|
||||||
|
dateLabel: r.scheduled_at
|
||||||
|
? new Date(r.scheduled_at).toLocaleDateString("nb-NO", { day: "numeric", month: "short", year: "numeric" })
|
||||||
|
: null,
|
||||||
|
}))
|
||||||
|
const groupsOpenRound = rounds.find((r) => r.id === groupsOpenFor) ?? null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<TournamentSetupNav steps={SETUP_STEPS} activeStep={step} onChange={setStep}>
|
||||||
<TournamentSettingsCard values={settingsValues} onSave={onUpdateSettings} />
|
{step === "basics" && <TournamentSettingsCard values={settingsValues} onSave={onUpdateSettings} />}
|
||||||
|
|
||||||
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
{step === "rounds" && (
|
||||||
<h2 className="text-base font-bold text-foreground">Scoringsmetode</h2>
|
<RoundsCard
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
base={base}
|
||||||
Avgjør hvordan totalen på leaderboardet regnes ut, og om deltakere trenger
|
organizationId={organizationId}
|
||||||
registrert handicap og kjønn.
|
rounds={rounds}
|
||||||
</p>
|
courses={courses}
|
||||||
<DropdownMenu>
|
participants={participants}
|
||||||
<DropdownMenuTrigger className="flex min-h-11 w-fit items-center gap-2 rounded-xl border border-border bg-background px-4 text-sm font-bold text-foreground transition-colors hover:bg-accent/50">
|
classes={classes}
|
||||||
{tournament.scoring_method ? SCORING_METHOD_LABELS[tournament.scoring_method] : "Ikke satt"}
|
scoringMethod={tournament.scoring_method}
|
||||||
<ChevronDown aria-hidden="true" className="size-4 text-muted-foreground" />
|
onAddRound={onAddRound}
|
||||||
</DropdownMenuTrigger>
|
onDeleteRound={onDeleteRound}
|
||||||
<DropdownMenuContent align="start" className="w-56 rounded-2xl p-1.5">
|
onAddCourse={onAddCourse}
|
||||||
{Object.entries(SCORING_METHOD_LABELS).map(([value, label]) => (
|
onCourseImported={onCourseImported}
|
||||||
<DropdownMenuItem
|
onError={onError}
|
||||||
key={value}
|
/>
|
||||||
onClick={() => onUpdateScoringMethod(value)}
|
)}
|
||||||
className="cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold"
|
|
||||||
>
|
{step === "classes" && (
|
||||||
{label}
|
<ClassesCard
|
||||||
</DropdownMenuItem>
|
classes={classes}
|
||||||
))}
|
courses={courses}
|
||||||
</DropdownMenuContent>
|
organizationId={organizationId}
|
||||||
</DropdownMenu>
|
onCreate={onCreateClass}
|
||||||
{tournament.scoring_method === "bingo_bango_bongo" && (
|
onDelete={onDeleteClass}
|
||||||
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
|
/>
|
||||||
<input
|
)}
|
||||||
type="checkbox"
|
|
||||||
checked={tournament.bbb_sweep_bonus_enabled}
|
{step === "players" && (
|
||||||
onChange={(e) => onUpdateBbbSweepBonus(e.target.checked)}
|
<ParticipantsCard
|
||||||
className="mt-1 size-5 shrink-0 accent-primary"
|
organizationId={organizationId}
|
||||||
|
tournamentId={tournament.id}
|
||||||
|
participants={participants}
|
||||||
|
pool={pool}
|
||||||
|
classes={classes}
|
||||||
|
onAddParticipant={onAddParticipant}
|
||||||
|
onAddNewPlayer={onAddNewPlayer}
|
||||||
|
onImportComplete={async () => {
|
||||||
|
await Promise.all([refreshPool(), onImportComplete()])
|
||||||
|
}}
|
||||||
|
onRemoveParticipant={onRemoveParticipant}
|
||||||
|
onSetClass={onSetParticipantClass}
|
||||||
|
onSetStatLevel={onSetParticipantStatLevel}
|
||||||
|
onSetHandicap={onSetParticipantHandicap}
|
||||||
|
onSetStatus={onSetParticipantStatus}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === "groups" && (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<RoundGroupsStepList rounds={roundSummaries} onManageGroups={setGroupsOpenFor} />
|
||||||
|
{groupsOpenRound && (
|
||||||
|
<RoundGroupsPanel
|
||||||
|
base={base}
|
||||||
|
roundId={groupsOpenRound.id}
|
||||||
|
roundLabel={groupsOpenRound.name || `Runde ${groupsOpenRound.sequence}`}
|
||||||
|
onClose={() => setGroupsOpenFor(null)}
|
||||||
/>
|
/>
|
||||||
<span className="flex flex-col gap-0.5">
|
)}
|
||||||
<span className="text-sm font-bold text-foreground">Bonuspoeng ved "sveip"</span>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">
|
)}
|
||||||
Ekstra poeng til en spiller som vinner alle tre kategoriene på samme hull.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
{tournament.scoring_method === "flag" && (
|
|
||||||
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={tournament.flag_map_visible}
|
|
||||||
onChange={(e) => onUpdateFlagMapVisible(e.target.checked)}
|
|
||||||
className="mt-1 size-5 shrink-0 accent-primary"
|
|
||||||
/>
|
|
||||||
<span className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-sm font-bold text-foreground">Kartoversikt over flagg</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Når dette er PÅ, kan alle deltakere se hvor alle andre har plantet flagget. Av som standard.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<RoundsCard
|
{step === "results" && (
|
||||||
base={base}
|
<div className="flex flex-col gap-6">
|
||||||
organizationId={organizationId}
|
<section className="flex flex-col gap-3 rounded-2xl border border-border bg-card p-4 shadow-md shadow-black/8">
|
||||||
rounds={rounds}
|
<h2 className="text-base font-bold text-foreground">Scoringsmetode</h2>
|
||||||
courses={courses}
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||||
participants={participants}
|
Avgjør hvordan totalen på leaderboardet regnes ut, og om deltakere trenger
|
||||||
classes={classes}
|
registrert handicap og kjønn.
|
||||||
scoringMethod={tournament.scoring_method}
|
</p>
|
||||||
onAddRound={onAddRound}
|
<DropdownMenu>
|
||||||
onDeleteRound={onDeleteRound}
|
<DropdownMenuTrigger className="flex min-h-11 w-fit items-center gap-2 rounded-xl border border-border bg-background px-4 text-sm font-bold text-foreground transition-colors hover:bg-accent/50">
|
||||||
onAddCourse={onAddCourse}
|
{tournament.scoring_method ? SCORING_METHOD_LABELS[tournament.scoring_method] : "Ikke satt"}
|
||||||
onCourseImported={onCourseImported}
|
<ChevronDown aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||||
onError={onError}
|
</DropdownMenuTrigger>
|
||||||
/>
|
<DropdownMenuContent align="start" className="w-56 rounded-2xl p-1.5">
|
||||||
|
{Object.entries(SCORING_METHOD_LABELS).map(([value, label]) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={value}
|
||||||
|
onClick={() => onUpdateScoringMethod(value)}
|
||||||
|
className="cursor-pointer rounded-xl px-3 py-2.5 text-sm font-semibold"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{tournament.scoring_method === "bingo_bango_bongo" && (
|
||||||
|
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tournament.bbb_sweep_bonus_enabled}
|
||||||
|
onChange={(e) => onUpdateBbbSweepBonus(e.target.checked)}
|
||||||
|
className="mt-1 size-5 shrink-0 accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Bonuspoeng ved "sveip"</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Ekstra poeng til en spiller som vinner alle tre kategoriene på samme hull.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{tournament.scoring_method === "flag" && (
|
||||||
|
<label className="flex min-h-11 items-start gap-3 rounded-xl border border-border bg-background px-4 py-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={tournament.flag_map_visible}
|
||||||
|
onChange={(e) => onUpdateFlagMapVisible(e.target.checked)}
|
||||||
|
className="mt-1 size-5 shrink-0 accent-primary"
|
||||||
|
/>
|
||||||
|
<span className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-bold text-foreground">Kartoversikt over flagg</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Når dette er PÅ, kan alle deltakere se hvor alle andre har plantet flagget. Av som standard.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{tournament.scoring_method != null &&
|
{tournament.scoring_method != null &&
|
||||||
["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && (
|
["stroke_gross", "stroke_net", "stableford"].includes(tournament.scoring_method) && (
|
||||||
<>
|
<>
|
||||||
<CutCard tournament={tournament} rounds={rounds} onUpdateConfig={onUpdateCutConfig} onApplyCut={onApplyCut} />
|
<CutCard tournament={tournament} rounds={rounds} onUpdateConfig={onUpdateCutConfig} onApplyCut={onApplyCut} />
|
||||||
<TiebreakCard tournament={tournament} onUpdateConfig={onUpdateTiebreakConfig} />
|
<TiebreakCard tournament={tournament} onUpdateConfig={onUpdateTiebreakConfig} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<ClassesCard
|
)}
|
||||||
classes={classes}
|
</TournamentSetupNav>
|
||||||
courses={courses}
|
|
||||||
organizationId={organizationId}
|
|
||||||
onCreate={onCreateClass}
|
|
||||||
onDelete={onDeleteClass}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ParticipantsCard
|
|
||||||
organizationId={organizationId}
|
|
||||||
tournamentId={tournament.id}
|
|
||||||
participants={participants}
|
|
||||||
pool={pool}
|
|
||||||
classes={classes}
|
|
||||||
onAddParticipant={onAddParticipant}
|
|
||||||
onAddNewPlayer={onAddNewPlayer}
|
|
||||||
onImportComplete={async () => {
|
|
||||||
await Promise.all([refreshPool(), onImportComplete()])
|
|
||||||
}}
|
|
||||||
onRemoveParticipant={onRemoveParticipant}
|
|
||||||
onSetClass={onSetParticipantClass}
|
|
||||||
onSetStatLevel={onSetParticipantStatLevel}
|
|
||||||
onSetHandicap={onSetParticipantHandicap}
|
|
||||||
onSetStatus={onSetParticipantStatus}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2603,7 +2665,6 @@ function RoundCard({
|
||||||
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null)
|
const [roundParticipants, setRoundParticipants] = useState<ApiRoundParticipant[] | null>(null)
|
||||||
const [tees, setTees] = useState<ApiTee[]>([])
|
const [tees, setTees] = useState<ApiTee[]>([])
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||||
const [groupsOpen, setGroupsOpen] = useState(false)
|
|
||||||
|
|
||||||
async function loadRoundParticipants() {
|
async function loadRoundParticipants() {
|
||||||
const [rpData, teeData] = await Promise.all([
|
const [rpData, teeData] = await Promise.all([
|
||||||
|
|
@ -2758,30 +2819,10 @@ function RoundCard({
|
||||||
deltakere før det er på plass.
|
deltakere før det er på plass.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{roundParticipants.length > 0 && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setGroupsOpen(true)}
|
|
||||||
className="h-10 w-full rounded-xl text-sm font-bold"
|
|
||||||
>
|
|
||||||
<Clock aria-hidden="true" className="size-4" />
|
|
||||||
Utslagsgrupper -- hvem spiller med hvem, klokka når
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groupsOpen && (
|
|
||||||
<RoundGroupsPanel
|
|
||||||
base={base}
|
|
||||||
roundId={round.id}
|
|
||||||
roundLabel={round.name || `Runde ${round.sequence}`}
|
|
||||||
onClose={() => setGroupsOpen(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
frontend/components/round-groups-step-list.tsx
Normal file
66
frontend/components/round-groups-step-list.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Clock } from "lucide-react"
|
||||||
|
|
||||||
|
export type RoundSummary = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
sequence: number
|
||||||
|
courseName: string | null
|
||||||
|
dateLabel: string | null // already formatted, e.g. "20. sep 2026", or null if unscheduled
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RoundGroupsStepListProps = {
|
||||||
|
rounds: RoundSummary[]
|
||||||
|
onManageGroups: (roundId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundGroupsStepList({ rounds, onManageGroups }: RoundGroupsStepListProps) {
|
||||||
|
if (rounds.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-border bg-card p-6 shadow-md shadow-black/8">
|
||||||
|
<p className="text-pretty text-center text-sm leading-relaxed text-muted-foreground">
|
||||||
|
Ingen runder er lagt til ennå — legg til runder under «Runder og baner» først.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Connected list: one container, rows divided by hairlines — not separate
|
||||||
|
// shadowed cards with gaps between them.
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border bg-card shadow-md shadow-black/8">
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{rounds.map((round) => {
|
||||||
|
// Build the secondary "course · date" line from whatever is set.
|
||||||
|
const meta = [round.courseName, round.dateLabel].filter(Boolean).join(" · ")
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={round.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 p-4 sm:p-5"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||||
|
Runde {round.sequence}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-base font-bold text-foreground">{round.name}</span>
|
||||||
|
<span className="truncate text-sm text-muted-foreground tabular-nums">
|
||||||
|
{meta === "" ? "Ingen bane eller dato satt ennå" : meta}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onManageGroups(round.id)}
|
||||||
|
className="inline-flex min-h-11 shrink-0 items-center gap-2 rounded-xl border border-border bg-card px-4 py-2.5 text-sm font-bold text-foreground transition-all duration-200 ease-in-out hover:bg-accent/50 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
||||||
|
>
|
||||||
|
<Clock aria-hidden="true" className="size-5 shrink-0" />
|
||||||
|
<span>Rediger grupper</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
frontend/components/tournament-setup-nav.tsx
Normal file
71
frontend/components/tournament-setup-nav.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { LucideIcon } from "lucide-react"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type SetupStep = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
icon: LucideIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TournamentSetupNavProps = {
|
||||||
|
steps: SetupStep[]
|
||||||
|
activeStep: string
|
||||||
|
onChange: (key: string) => void
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TournamentSetupNav({
|
||||||
|
steps,
|
||||||
|
activeStep,
|
||||||
|
onChange,
|
||||||
|
children,
|
||||||
|
}: TournamentSetupNavProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<nav aria-label="Turneringsoppsett-steg">
|
||||||
|
{/*
|
||||||
|
Every step is always selectable in any order — no gating, no completion
|
||||||
|
checkmarks, no step numbers. On wide screens all 6 pills share the row
|
||||||
|
equally; on narrow screens the row scrolls sideways rather than wrapping
|
||||||
|
or collapsing, so every labelled step stays reachable.
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
role="tablist"
|
||||||
|
aria-orientation="horizontal"
|
||||||
|
className="flex snap-x snap-mandatory gap-2 overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{steps.map((step) => {
|
||||||
|
const Icon = step.icon
|
||||||
|
const isActive = step.key === activeStep
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={step.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
onClick={() => onChange(step.key)}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-11 flex-1 shrink-0 snap-start items-center justify-center gap-2 rounded-2xl px-4 py-2.5",
|
||||||
|
"whitespace-nowrap text-sm font-bold transition-all duration-200 ease-in-out",
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
||||||
|
"active:scale-[0.98]",
|
||||||
|
isActive
|
||||||
|
? "bg-primary text-primary-foreground shadow-md shadow-black/8"
|
||||||
|
: "border border-border bg-card text-foreground hover:bg-accent/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden="true" className="size-5 shrink-0" />
|
||||||
|
<span>{step.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue