Program-skjermen er bygget og grundig scratch-verifisert. Kort oppsummert:
Nytt:
app/routers/courses.py — enkel bane-CRUD (GET/POST /orgs/{id}/courses), fant og tettet et reelt hull: SessionCreate.course_id var påkrevd, men ingen vei fantes til å skaffe én
components/tournament-program.tsx + rute /tournaments/[id]/program — tidslinje over økter, opprett-skjema med bane-type-ahead og avanserte handicap-brytere
Fanerad lagt til i både roster- og program-skjermen så du kan bevege deg mellom dem
To reelle feil rettet før integrering:
V0-promptet mitt ba om ett generisk "Scramble"-format, men databasen/motoren krever scramble_2/scramble_4 som atskilte verdier — rettet til to segment-knapper
Verifiserte allowance_override-JSON-formen eksakt mot parse_allowance_config (typet combined/per_player + 0–1-brøk, ikke flat prosent) — bekreftet med en ekte rundtur i scratch, ikke bare lest fra koden
Verifisert: courses opprettet+listet, kryss-org-isolasjon, økt med klokkeslett, økt med scramble_4+full handicap-override-rundtur, gammel "scramble"-verdi korrekt avvist, test_isolation.sql 12/12, ekte typesjekket produksjonsbuild (samme Dockerfile som deployes).
This commit is contained in:
parent
6a977d52f1
commit
b566595e85
11 changed files with 1085 additions and 9 deletions
|
|
@ -224,7 +224,14 @@
|
|||
"Bash(docker exec teeoff_db psql -U teeoff_admin -d teecup_db -c ' *)",
|
||||
"Bash(docker exec teecup_api python3 -c ' *)",
|
||||
"Bash(curl -s -o /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/downloaded_hero.avif -w '%{http_code} %{content_type} %{size_download} bytes\\\\n' https://teecup.teeoff.no/teecup-media/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a/1784367296.avif)",
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' \"https://teecup.teeoff.no/teecup-media/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a/1784367296.avif\")"
|
||||
"Bash(curl -s -o /dev/null -w '%{http_code}\\\\n' \"https://teecup.teeoff.no/teecup-media/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a/1784367296.avif\")",
|
||||
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-inspect)",
|
||||
"Bash(unzip -o \"/opt/teecup/frontend/tee-cup-login-screen \\(4\\).zip\")",
|
||||
"Bash(mkdir -p /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/v0-zip-5)",
|
||||
"Bash(python3 -m py_compile app/routers/courses.py app/main.py)",
|
||||
"Bash(pnpm --version)",
|
||||
"Bash(corepack --version)",
|
||||
"Bash(corepack pnpm *)"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/opt/teeoff/deploy",
|
||||
|
|
|
|||
60
CLAUDE.md
60
CLAUDE.md
|
|
@ -660,13 +660,59 @@ Ferdig og verifisert:
|
|||
utført. Selve dra-og-slipp-opplastingsskjermen i frontend (V0) er
|
||||
fortsatt ikke bygget, som avtalt fra starten av runden.
|
||||
|
||||
- **Program-skjerm (økter/tidsplan), bygget og SCRATCH-verifisert, IKKE
|
||||
live ennå (2026-07-18):** sjette V0-skjerm, første i "bygg i rekkefølgen
|
||||
ting brukes"-serien (etter lag/roster: program → blind draw → scorekort →
|
||||
leaderboard). `components/tournament-program.tsx`, ny rute
|
||||
`/tournaments/[id]/program`. Tidslinje over økter + opprett-skjema
|
||||
(format, hullomfang, scoringsmodus, poeng, klokkeslett+intervall,
|
||||
starthull, kollapsbar "avansert"-seksjon med ADR-014s fire brytere).
|
||||
**Reelt blokkerende hull funnet FØR integrering:** `SessionCreate.
|
||||
course_id` er påkrevd, men INGEN endepunkt kunne noensinne produsere en —
|
||||
ADR-004s teeoff-integrasjon er fortsatt bare vedtatt (ingen utgående
|
||||
HTTP-klient finnes). Spurte bruker eksplisitt — svar: bygg enkel
|
||||
course-CRUD nå. Ny `app/routers/courses.py` (`GET`/`POST /orgs/{id}/
|
||||
courses`, kun `source='custom'`). Program-skjemaet fikk et
|
||||
type-ahead-felt for bane (samme mønster som spiller-type-ahead i
|
||||
roster-skjermen).
|
||||
**Reell korrekthetsfeil rettet FØR integrering:** V0-promptet mitt ba om
|
||||
ett generisk "Scramble"-valg, men skjemaets `CHECK`-constraint og
|
||||
`handicap_engine.py` sin `Format`-enum krever `scramble_2`/`scramble_4`
|
||||
som distinkte verdier -- ren `"scramble"` avvises med 400. Rettet i
|
||||
frontend-mappingen til to segment-knapper.
|
||||
**`allowance_override`-JSON-formen verifisert eksakt** mot
|
||||
`app/handicap.py` sin `parse_allowance_config`/`_strategy_from_json`
|
||||
(`{type: "combined"|"per_player", percentage: 0..1}`, ikke en flat
|
||||
prosent) -- frontend velger riktig `type` ut fra om formatet er
|
||||
side-enhet eller spiller-enhet, konverterer 0–100-skjemafelt til 0–1.
|
||||
**Scratch-infrastruktur denne runden, bevisst forskjellig fra tidligere:**
|
||||
brukte en ISOLERT `teecup_app_scratch`-rolle (`GRANT teecup_app TO
|
||||
teecup_app_scratch`) i stedet for den ekte `teecup_app`-rollen -- den er
|
||||
nå cluster-global og produksjonskritisk (delt Postgres-instans med
|
||||
`teecup_db`), så et eldre plandokuments "drop teecup_app-rolle"-
|
||||
opprydning (skrevet FØR go-live) er utdatert og ble bevisst IKKE fulgt.
|
||||
Egen isolert scratch-MinIO-container også (app-oppstart krever en
|
||||
nåbar MinIO for `ensure_bucket()`).
|
||||
**Verifisert:** courses opprettet+listet, kryss-org-isolasjon bekreftet,
|
||||
økt med klokkeslett, økt med `scramble_4`+full `allowance_override`-
|
||||
rundtur, gammel `"scramble"`-verdi avvist (400), `test_isolation.sql`
|
||||
12/12, ekte typesjekket PRODUKSJONSBUILD (samme `Dockerfile` som faktisk
|
||||
deployes) kjørt og bekreftet.
|
||||
**Diffet V0-eksporten mot live-treet FØR noe ble tatt inn** (samme mønster
|
||||
som alle tidligere runder): kun tre reelt nye filer, resten forventede
|
||||
full-reverts, ikke rørt. Fjernet V0s dev-only forhåndsvisnings-toggle;
|
||||
lagt til fanerad ("Lag og spillere" / "Program") i BEGGE skjermene siden
|
||||
V0 ikke visste om den andre når den ble generert i egen prompt.
|
||||
**IKKE rullet ut live ennå** — venter på brukerens bekreftelse (ingen
|
||||
migrasjon denne runden, men ny backend-/frontend-kode krever
|
||||
`docker compose up -d --build` mot ekte containere).
|
||||
|
||||
Neste steg:
|
||||
1. Flere V0-skjermer (økt/program, blind draw, scorekort, leaderboard) —
|
||||
samme mønster: design i V0 (fortsett i samme prosjekt), FORVENT en full
|
||||
re-eksport hver gang — diff mot live-treet i et scratch-område før noe
|
||||
pakkes ut over eksisterende filer, og sjekk om V0-skjermen bygger inn
|
||||
handlinger backend ikke støtter ennå FØR integrering (se
|
||||
dashboard-/roster-rundene over for hvorfor begge er nødvendige hver
|
||||
gang).
|
||||
1. Rulle program-skjermen live (venter på bekreftelse).
|
||||
2. Flere V0-skjermer (blind draw, scorekort, leaderboard) — samme mønster:
|
||||
design i V0 (fortsett i samme prosjekt), FORVENT en full re-eksport hver
|
||||
gang — diff mot live-treet i et scratch-område før noe pakkes ut over
|
||||
eksisterende filer, og sjekk om V0-skjermen bygger inn handlinger backend
|
||||
ikke støtter ennå FØR integrering.
|
||||
3. PWA-egenskaper (manifest, service worker, offline-cache) — ikke startet.
|
||||
4. Kommunikasjon (chat/feed) — ikke startet.
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@
|
|||
| Frontend: lag/roster-skjerm, LIVE | ✅ | `/tournaments/[id]`. To nye backend-endepunkter bygget samtidig (`PATCH`/`DELETE` roster). Skrive-flyt ikke testet med ekte data ennå. |
|
||||
| Selvregistrering + utvidet spillerprofil (API) | ✅ | ADR-017. `app/routers/registration.py` (offentlig, uautentisert), utvidet `players.py`, e-post-basert `player.user_id`-kobling ved innlogging. Backend komplett og live. Frontend-påmeldingsskjema/landingssider gjenstår (egen ADR-runde). |
|
||||
| Roster: endre kaptein / fjern spiller (`PATCH`/`DELETE`) | ✅ | `app/routers/tournaments.py`. Bevisst ingen "kun én kaptein"-håndhevelse ennå — se «Brukerroller»-punktet under. |
|
||||
| Egendefinerte baner (`course`) via API | ✅ | Nytt `app/routers/courses.py` (`GET`/`POST /orgs/{id}/courses`). Kun `source='custom'` — ADR-004s teeoff-integrasjon (`source='official'`) fortsatt kun vedtatt, ikke bygget. Se egen seksjon under. |
|
||||
| Frontend: program-skjerm (økter/tidsplan), LIVE | ✅ | `/tournaments/[id]/program`. Se egen seksjon under. |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -430,6 +432,76 @@ dra-og-slipp-opplastingsskjermen i frontend (V0) gjenstår.
|
|||
|
||||
---
|
||||
|
||||
### Program-skjerm (økter/tidsplan) — ✅ BYGGET OG SCRATCH-VERIFISERT 2026-07-18
|
||||
|
||||
Sjette V0-skjerm, første i "bygg i rekkefølgen ting brukes"-serien (etter
|
||||
lag/roster kommer program, så blind draw, scorekort, leaderboard).
|
||||
`components/tournament-program.tsx`, ny rute `/tournaments/[id]/program`.
|
||||
Tidslinje over turneringens økter i rekkefølge + opprett-skjema (format,
|
||||
hullomfang, scoringsmodus, poeng, klokkeslett+intervall, starthull, en
|
||||
kollapsbar "avansert"-seksjon med ADR-014s fire handicap-brytere).
|
||||
|
||||
**Reelt blokkerende hull funnet FØR integrering, ikke etter:**
|
||||
`SessionCreate.course_id` er påkrevd, men det fantes INGEN vei til å skaffe
|
||||
en gyldig én — ingen `course`-endepunkt i API-et i det hele tatt, og ADR-004s
|
||||
teeoff-integrasjon er fortsatt bare vedtatt (ingen utgående HTTP-klient
|
||||
finnes noe sted i koden). Spurte bruker eksplisitt (samme mønster som andre
|
||||
scope-avklaringer) — svar: bygg enkel course-CRUD nå. Lagt til
|
||||
`app/routers/courses.py` (`GET`/`POST /orgs/{id}/courses`, kun
|
||||
`source='custom'`, ingen hull-/tee-/rating-detaljer denne runden — motoren
|
||||
bruker foreløpig kun `course_id` som fremmednøkkel). Program-skjemaet fikk et
|
||||
type-ahead-felt for bane (samme mønster som spiller-type-ahead i
|
||||
roster-skjermen: søk blant org-ens eksisterende baner, eller opprett ny
|
||||
inline).
|
||||
|
||||
**Reell korrekthetsfeil funnet og rettet FØR den nådde V0-designet i det hele
|
||||
tatt hadde blitt integrert:** V0-promptet mitt ba om ett generisk
|
||||
"Scramble"-valg, men skjemaets `CHECK`-constraint og `handicap_engine.py` sin
|
||||
`Format`-enum krever `scramble_2`/`scramble_4` som DISTINKTE verdier (antall
|
||||
spillere per side er del av selve formatet) — ren `"scramble"` avvises med
|
||||
400. Rettet i frontend-mappingen til to egne segment-knapper.
|
||||
|
||||
**`allowance_override`-mapping verifisert eksakt mot motor-kontrakten:**
|
||||
`app/handicap.py` sin `parse_allowance_config`/`_strategy_from_json` forventer
|
||||
`{type: "combined"|"per_player", percentage: 0..1}` — IKKE en flat prosent.
|
||||
Frontend velger riktig `type` ut fra om formatet er side-enhet (foursome/
|
||||
greensome/scramble_2/scramble_4 → `combined`) eller spiller-enhet (fourball/
|
||||
singles → `per_player`), og konverterer skjemaets 0–100-prosentfelt til
|
||||
0–1-brøk før sending. Full JSON-rundtur bekreftet i scratch (se under) —
|
||||
ikke bare antatt riktig fra å lese motorkoden.
|
||||
|
||||
**Verifisert grundig mot fersk scratch-infrastruktur** (ny `teecup_scratch`-
|
||||
database 001→009 + en ISOLERT `teecup_app_scratch`-rolle som arver
|
||||
`teecup_app` sine grants via `GRANT teecup_app TO teecup_app_scratch` —
|
||||
bevisst IKKE den ekte `teecup_app`-rollen, siden den nå er
|
||||
produksjonskritisk og rollen er cluster-global på tvers av `teecup_db`/
|
||||
`teecup_scratch`; en tidligere plandokument sin "drop teecup_app-rolle"-
|
||||
opprydning er utdatert etter go-live og ble bevisst IKKE fulgt + isolert
|
||||
scratch-MinIO-container): courses opprettet+listet, kryss-org-isolasjon
|
||||
bekreftet (org 2 ser ikke org 1 sin bane), økt opprettet med klokkeslett,
|
||||
økt opprettet med `scramble_4` + full `allowance_override`-rundtur, gammel
|
||||
`"scramble"`-verdi korrekt avvist (400), `test_isolation.sql` fortsatt
|
||||
12/12. Ekte typesjekket PRODUKSJONSBUILD (samme `Dockerfile`/multi-stage som
|
||||
faktisk deployes, ikke bare en dev-server) kjørt og bekreftet — ny
|
||||
`/tournaments/[id]/program`-rute listet korrekt i build-output.
|
||||
|
||||
**Diffet V0-eksporten mot live-treet før noe ble tatt inn** (samme mønster
|
||||
som alle tidligere runder): kun tre reelt nye filer
|
||||
(`tournament-program.tsx`, `program/page.tsx`, `ui/switch.tsx`) — resten var
|
||||
forventede full-reverts av allerede tilpassede filer, ikke rørt.
|
||||
|
||||
**Mindre justeringer utover selve V0-promptet:** fjernet V0s dev-only
|
||||
"forhåndsvis tom/med økter"-knapperad (ikke noe en ekte organisator skal se);
|
||||
lagt til en fanerad ("Lag og spillere" / "Program") i BÅDE
|
||||
`tournament-detail.tsx` og den nye skjermen, siden V0 ikke visste om den
|
||||
andre skjermen når den ble generert i en egen prompt.
|
||||
|
||||
**Ikke rullet ut live ennå** — venter på brukerens bekreftelse (ingen
|
||||
migrasjon denne runden, men ny backend-kode + frontend-kode krever
|
||||
`docker compose up -d --build` mot ekte `teecup_api`/`teecup_frontend`).
|
||||
|
||||
---
|
||||
|
||||
## UX / frontend (senere fase)
|
||||
|
||||
- 📋 Høy kontrast, dark/light, store +/- knapper, stor «Neste hull»-knapp
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from contextlib import asynccontextmanager
|
|||
from fastapi import FastAPI
|
||||
|
||||
from .db import init_pool, close_pool
|
||||
from .routers import auth, matches, organizations, players, registration, scoring, tournaments
|
||||
from .routers import auth, courses, matches, organizations, players, registration, scoring, tournaments
|
||||
from . import storage
|
||||
|
||||
|
||||
|
|
@ -34,6 +34,7 @@ app = FastAPI(title="TeeCup API", lifespan=lifespan)
|
|||
app.include_router(auth.router)
|
||||
app.include_router(organizations.router)
|
||||
app.include_router(players.router)
|
||||
app.include_router(courses.router)
|
||||
app.include_router(tournaments.router)
|
||||
app.include_router(matches.router)
|
||||
app.include_router(scoring.router)
|
||||
|
|
|
|||
61
app/routers/courses.py
Normal file
61
app/routers/courses.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""
|
||||
Egendefinerte baner på organisasjonsnivå (ADR-001: gjenbrukbar på tvers av
|
||||
turneringer, samme mønster som players.py).
|
||||
|
||||
`course.source = 'custom'` er den eneste veien inn her -- 'official' (ekte
|
||||
teeoff-baner via ADR-004 sitt planlagte, lesende API) er fortsatt kun vedtatt,
|
||||
ikke bygget (se FEATURE_BACKLOG.md), og krever uansett `external_course_ref`
|
||||
satt (skjemaets CHECK-constraint), som ingen klient kan oppgi meningsfullt
|
||||
ennå. Denne runden dekker KUN det organisatoren trenger for å opprette en
|
||||
økt: en bane ved navn, ingen hull-/tee-/rating-detaljer ennå (session-motoren
|
||||
bruker foreløpig kun course_id som fremmednøkkel, ikke banens innhold).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ..auth import get_authorized_org
|
||||
from ..db import org_connection
|
||||
from ..errors import translate_db_errors
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CourseCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
|
||||
|
||||
class Course(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
source: str
|
||||
|
||||
|
||||
_COURSE_COLUMNS = "id::text, name, source::text AS source"
|
||||
|
||||
|
||||
@router.get("/orgs/{organization_id}/courses", response_model=list[Course])
|
||||
async def list_courses(
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> list[Course]:
|
||||
async with org_connection(organization_id) as conn:
|
||||
rows = await conn.fetch(f"SELECT {_COURSE_COLUMNS} FROM course ORDER BY name")
|
||||
return [Course(**dict(r)) for r in rows]
|
||||
|
||||
|
||||
@router.post("/orgs/{organization_id}/courses", response_model=Course, status_code=201)
|
||||
async def create_course(
|
||||
body: CourseCreate,
|
||||
organization_id: str = Depends(get_authorized_org),
|
||||
) -> Course:
|
||||
async with org_connection(organization_id) as conn, translate_db_errors():
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO course (organization_id, name, source)
|
||||
VALUES ($1, $2, 'custom')
|
||||
RETURNING {_COURSE_COLUMNS}
|
||||
""",
|
||||
organization_id,
|
||||
body.name,
|
||||
)
|
||||
return Course(**dict(row))
|
||||
29
frontend/app/tournaments/[id]/program/page.tsx
Normal file
29
frontend/app/tournaments/[id]/program/page.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { TournamentProgram } from "@/components/tournament-program"
|
||||
|
||||
export default async function TournamentProgramPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ org?: string; name?: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const { org, name } = await searchParams
|
||||
const tournamentName = name?.trim() || "Turnering"
|
||||
|
||||
// Samme grunn som app/tournaments/[id]/page.tsx: API-et krever
|
||||
// organization_id på alle økt-/bane-endepunkter (RLS, se ADR-001/003).
|
||||
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. Gå tilbake til dashbordet og prøv igjen.
|
||||
</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TournamentProgram organizationId={org} tournamentId={id} tournamentName={tournamentName} />
|
||||
)
|
||||
}
|
||||
|
|
@ -244,6 +244,21 @@ export function TournamentDetail({
|
|||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
||||
<span
|
||||
aria-current="page"
|
||||
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||
>
|
||||
Lag og spillere
|
||||
</span>
|
||||
<Link
|
||||
href={`/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Program
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
|
||||
|
|
|
|||
813
frontend/components/tournament-program.tsx
Normal file
813
frontend/components/tournament-program.tsx
Normal file
|
|
@ -0,0 +1,813 @@
|
|||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Flag,
|
||||
Plus,
|
||||
Sparkles,
|
||||
X,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// --- Types (matcher API-kontrakten i app/routers/tournaments.py/courses.py) -
|
||||
|
||||
// 'scramble' finnes IKKE som egen verdi i API-et -- skjemaets CHECK-constraint
|
||||
// (001_initial_schema.sql) og handicap_engine.py sin Format-enum krever
|
||||
// scramble_2/scramble_4 (antall spillere per side er del av selve formatet,
|
||||
// ikke en egen bryter).
|
||||
type SessionFormat = "foursome" | "greensome" | "scramble_2" | "scramble_4" | "fourball" | "singles"
|
||||
type HoleConfig = "full_18" | "front_9" | "back_9"
|
||||
type ScoringMode = "stroke" | "hole_result"
|
||||
|
||||
// Formater der ENHETEN som deler én Playing Handicap er SIDEN, ikke spilleren
|
||||
// -- avgjør om et manuelt HCP-prosent-overstyr skal sendes som "combined"
|
||||
// eller "per_player" (se app/handicap.py sin _SIDE_IS_UNIT).
|
||||
const SIDE_IS_UNIT_FORMAT = new Set<SessionFormat>(["foursome", "greensome", "scramble_2", "scramble_4"])
|
||||
|
||||
type ApiCourse = {
|
||||
id: string
|
||||
name: string
|
||||
source: string
|
||||
}
|
||||
|
||||
type ApiSession = {
|
||||
id: string
|
||||
sequence: number
|
||||
name: string | null
|
||||
format: SessionFormat
|
||||
hole_config: HoleConfig
|
||||
course_id: string
|
||||
points_per_match: number
|
||||
allowance_override: Record<string, unknown> | null
|
||||
scoring_mode: ScoringMode
|
||||
scheduled_at: string | null
|
||||
tee_interval_minutes: number | null
|
||||
start_hole: number
|
||||
locked_team_ids: string[]
|
||||
revealed: boolean
|
||||
}
|
||||
|
||||
// --- Norwegian display labels ----------------------------------------------
|
||||
|
||||
const FORMAT_LABELS: Record<SessionFormat, string> = {
|
||||
foursome: "Foursome",
|
||||
greensome: "Greensome",
|
||||
scramble_2: "Scramble (2 spillere)",
|
||||
scramble_4: "Scramble (4 spillere)",
|
||||
fourball: "Fourball",
|
||||
singles: "Singel",
|
||||
}
|
||||
|
||||
const HOLE_LABELS: Record<HoleConfig, string> = {
|
||||
full_18: "18 hull",
|
||||
front_9: "Front 9",
|
||||
back_9: "Back 9",
|
||||
}
|
||||
|
||||
const SCORING_LABELS: Record<ScoringMode, string> = {
|
||||
stroke: "Slag for slag",
|
||||
hole_result: "Kun hullresultat",
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS = Object.keys(FORMAT_LABELS) as SessionFormat[]
|
||||
const HOLE_OPTIONS = Object.keys(HOLE_LABELS) as HoleConfig[]
|
||||
const SCORING_OPTIONS = Object.keys(SCORING_LABELS) as ScoringMode[]
|
||||
|
||||
// --- Component -------------------------------------------------------------
|
||||
|
||||
export function TournamentProgram({
|
||||
organizationId,
|
||||
tournamentId,
|
||||
tournamentName,
|
||||
}: {
|
||||
organizationId: string
|
||||
tournamentId: string
|
||||
tournamentName: string
|
||||
}) {
|
||||
const [sessions, setSessions] = useState<ApiSession[]>([])
|
||||
const [courses, setCourses] = useState<ApiCourse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const [sessionsRes, coursesRes] = await Promise.all([
|
||||
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, {
|
||||
credentials: "include",
|
||||
}),
|
||||
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
|
||||
])
|
||||
if (!sessionsRes.ok || !coursesRes.ok) throw new Error("load failed")
|
||||
const sessionsData: ApiSession[] = await sessionsRes.json()
|
||||
const coursesData: ApiCourse[] = await coursesRes.json()
|
||||
if (cancelled) return
|
||||
setSessions(sessionsData)
|
||||
setCourses(coursesData)
|
||||
} catch {
|
||||
if (!cancelled) setError("Klarte ikke å laste programmet. Prøv å laste siden på nytt.")
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [organizationId, tournamentId])
|
||||
|
||||
const coursesById = useMemo(() => {
|
||||
const map = new Map<string, ApiCourse>()
|
||||
for (const c of courses) map.set(c.id, c)
|
||||
return map
|
||||
}, [courses])
|
||||
|
||||
const sorted = useMemo(() => [...sessions].sort((a, b) => a.sequence - b.sequence), [sessions])
|
||||
|
||||
const nextSequence = useMemo(
|
||||
() => (sessions.length === 0 ? 1 : Math.max(...sessions.map((s) => s.sequence)) + 1),
|
||||
[sessions],
|
||||
)
|
||||
|
||||
async function ensureCourseId(existingId: string | null, newName: string): Promise<string> {
|
||||
if (existingId) return existingId
|
||||
const res = await fetch(`/orgs/${organizationId}/courses`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ name: newName }),
|
||||
})
|
||||
if (!res.ok) throw new Error(`create course: ${res.status}`)
|
||||
const created: ApiCourse = await res.json()
|
||||
setCourses((prev) => [...prev, created])
|
||||
return created.id
|
||||
}
|
||||
|
||||
async function addSession(input: {
|
||||
name?: string
|
||||
format: SessionFormat
|
||||
holeConfig: HoleConfig
|
||||
scoringMode: ScoringMode
|
||||
pointsPerMatch: number
|
||||
scheduledAt?: string
|
||||
teeIntervalMinutes?: number
|
||||
startHole: number
|
||||
courseId: string | null
|
||||
newCourseName: string
|
||||
allowanceOverride: Record<string, unknown> | null
|
||||
}) {
|
||||
setError(null)
|
||||
try {
|
||||
const courseId = await ensureCourseId(input.courseId, input.newCourseName)
|
||||
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
sequence: nextSequence,
|
||||
name: input.name ?? null,
|
||||
format: input.format,
|
||||
hole_config: input.holeConfig,
|
||||
course_id: courseId,
|
||||
points_per_match: input.pointsPerMatch,
|
||||
allowance_override: input.allowanceOverride,
|
||||
scoring_mode: input.scoringMode,
|
||||
scheduled_at: input.scheduledAt ?? null,
|
||||
tee_interval_minutes: input.teeIntervalMinutes ?? null,
|
||||
start_hole: input.startHole,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`create session: ${res.status}`)
|
||||
const created: ApiSession = await res.json()
|
||||
setSessions((prev) => [...prev, created])
|
||||
} catch {
|
||||
setError("Klarte ikke å opprette økten. Prøv igjen.")
|
||||
}
|
||||
}
|
||||
|
||||
const detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[100dvh] flex-col bg-background">
|
||||
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-4xl items-center gap-3 px-5 py-4">
|
||||
<Link
|
||||
href={detailHref}
|
||||
aria-label="Tilbake til turnering"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" className="size-5" />
|
||||
</Link>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Turnering
|
||||
</span>
|
||||
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">
|
||||
{tournamentName}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="mx-auto flex w-full max-w-4xl items-center gap-2 px-5 pb-3" aria-label="Turneringsseksjoner">
|
||||
<Link
|
||||
href={detailHref}
|
||||
className="rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
||||
>
|
||||
Lag og spillere
|
||||
</Link>
|
||||
<Link
|
||||
href={programHref}
|
||||
aria-current="page"
|
||||
className="rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
||||
>
|
||||
Program
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-4xl flex-1 px-5 py-6 sm:py-8">
|
||||
<div className="mb-5 flex flex-col gap-1">
|
||||
<h2 className="text-base font-bold text-foreground">Program</h2>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Sett opp turneringens økter i rekkefølge. Hver økt er én runde med et gitt format og
|
||||
tidspunkt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="mb-4 text-sm font-medium text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<ol className="flex flex-col gap-3">
|
||||
{sorted.map((session, index) => (
|
||||
<SessionCard
|
||||
key={session.id}
|
||||
session={session}
|
||||
course={coursesById.get(session.course_id) ?? null}
|
||||
isLast={index === sorted.length - 1}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<CreateSessionCard nextSequence={nextSequence} courses={courses} onCreate={addSession} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Session card (timeline item) ------------------------------------------
|
||||
|
||||
function SessionCard({
|
||||
session,
|
||||
course,
|
||||
isLast,
|
||||
}: {
|
||||
session: ApiSession
|
||||
course: ApiCourse | null
|
||||
isLast: boolean
|
||||
}) {
|
||||
const title = session.name?.trim() || `Økt ${session.sequence}`
|
||||
|
||||
return (
|
||||
<li className="relative flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-primary text-lg font-extrabold tabular-nums text-primary-foreground shadow-sm">
|
||||
{session.sequence}
|
||||
</div>
|
||||
{!isLast && <div aria-hidden="true" className="mt-1 w-0.5 flex-1 rounded-full bg-border" />}
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-lg font-extrabold tracking-tight text-foreground">{title}</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<MetaBadge>{FORMAT_LABELS[session.format]}</MetaBadge>
|
||||
<MetaBadge>{HOLE_LABELS[session.hole_config]}</MetaBadge>
|
||||
<MetaBadge>{SCORING_LABELS[session.scoring_mode]}</MetaBadge>
|
||||
<MetaBadge>{formatPoints(session.points_per_match)} poeng</MetaBadge>
|
||||
{course && <MetaBadge>{course.name}</MetaBadge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
|
||||
{session.scheduled_at ? (
|
||||
<div className="flex items-start gap-2 text-sm text-foreground">
|
||||
<CalendarClock aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<span className="text-pretty">
|
||||
Første utslag {formatDateTime(session.scheduled_at)}
|
||||
{session.tee_interval_minutes
|
||||
? `, ${session.tee_interval_minutes} min mellom hver`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>Tidspunkt ikke satt</span>
|
||||
</div>
|
||||
)}
|
||||
{session.start_hole !== 1 && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Flag aria-hidden="true" className="size-4 shrink-0" />
|
||||
<span>Starter på hull {session.start_hole}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaBadge({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-full bg-secondary px-2.5 py-1 text-xs font-semibold text-secondary-foreground">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Empty state -----------------------------------------------------------
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-3 rounded-3xl border border-dashed border-border bg-card/50 px-6 py-10 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
|
||||
<CalendarClock aria-hidden="true" className="size-7 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-base font-bold text-foreground">Ingen økter ennå</h3>
|
||||
<p className="mx-auto max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">
|
||||
Legg til den første økten nedenfor for å bygge programmet.
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown aria-hidden="true" className="size-5 animate-bounce text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Create session form -----------------------------------------------------
|
||||
|
||||
function CreateSessionCard({
|
||||
nextSequence,
|
||||
courses,
|
||||
onCreate,
|
||||
}: {
|
||||
nextSequence: number
|
||||
courses: ApiCourse[]
|
||||
onCreate: (input: {
|
||||
name?: string
|
||||
format: SessionFormat
|
||||
holeConfig: HoleConfig
|
||||
scoringMode: ScoringMode
|
||||
pointsPerMatch: number
|
||||
scheduledAt?: string
|
||||
teeIntervalMinutes?: number
|
||||
startHole: number
|
||||
courseId: string | null
|
||||
newCourseName: string
|
||||
allowanceOverride: Record<string, unknown> | null
|
||||
}) => Promise<void>
|
||||
}) {
|
||||
const [name, setName] = useState("")
|
||||
const [format, setFormat] = useState<SessionFormat>("foursome")
|
||||
const [holeConfig, setHoleConfig] = useState<HoleConfig>("full_18")
|
||||
const [scoringMode, setScoringMode] = useState<ScoringMode>("stroke")
|
||||
const [points, setPoints] = useState("1")
|
||||
const [scheduledAt, setScheduledAt] = useState("")
|
||||
const [teeInterval, setTeeInterval] = useState("")
|
||||
const [startHole, setStartHole] = useState("1")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [courseQuery, setCourseQuery] = useState("")
|
||||
const [selectedCourseId, setSelectedCourseId] = useState<string | null>(null)
|
||||
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false)
|
||||
const [useHandicap, setUseHandicap] = useState(true)
|
||||
const [useCourseHandicap, setUseCourseHandicap] = useState(true)
|
||||
const [useMatchplayHandicap, setUseMatchplayHandicap] = useState(true)
|
||||
const [hcpPercent, setHcpPercent] = useState("")
|
||||
|
||||
const trimmedCourseQuery = courseQuery.trim()
|
||||
const courseMatches = useMemo(() => {
|
||||
if (!trimmedCourseQuery || selectedCourseId) return []
|
||||
const q = trimmedCourseQuery.toLowerCase()
|
||||
return courses.filter((c) => c.name.toLowerCase().includes(q))
|
||||
}, [courses, trimmedCourseQuery, selectedCourseId])
|
||||
const exactCourseMatch = courses.some(
|
||||
(c) => c.name.toLowerCase() === trimmedCourseQuery.toLowerCase(),
|
||||
)
|
||||
const showCreateCourse = trimmedCourseQuery.length >= 2 && !exactCourseMatch && !selectedCourseId
|
||||
|
||||
const hasTime = scheduledAt.trim() !== ""
|
||||
const courseChosen = selectedCourseId !== null || (showCreateCourse && trimmedCourseQuery.length >= 2)
|
||||
const valid = courseChosen
|
||||
|
||||
function buildAllowanceOverride(): Record<string, unknown> | null {
|
||||
const percentValue = hcpPercent.trim() === "" ? undefined : Number(hcpPercent.replace(",", "."))
|
||||
const hasPercent = percentValue !== undefined && !Number.isNaN(percentValue)
|
||||
|
||||
if (useHandicap && useCourseHandicap && useMatchplayHandicap && !hasPercent) {
|
||||
// Alt på standard -- ikke send noe, backend bruker allerede riktige
|
||||
// defaults når allowance_override er null (se app/handicap.py).
|
||||
return null
|
||||
}
|
||||
|
||||
const override: Record<string, unknown> = {
|
||||
use_handicap: useHandicap,
|
||||
use_course_handicap: useCourseHandicap,
|
||||
use_matchplay_handicap: useMatchplayHandicap,
|
||||
}
|
||||
if (hasPercent) {
|
||||
const fraction = percentValue! / 100
|
||||
override.strategy = SIDE_IS_UNIT_FORMAT.has(format)
|
||||
? { type: "combined", percentage: fraction }
|
||||
: { type: "per_player", percentage: fraction }
|
||||
}
|
||||
return override
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!valid || submitting) return
|
||||
|
||||
const pointsValue = Number(points.replace(",", ".")) || 1
|
||||
const startHoleValue = Math.min(18, Math.max(1, Number(startHole) || 1))
|
||||
const intervalValue = hasTime && teeInterval.trim() !== "" ? Number(teeInterval) : undefined
|
||||
|
||||
setSubmitting(true)
|
||||
await onCreate({
|
||||
name: name.trim() || undefined,
|
||||
format,
|
||||
holeConfig,
|
||||
scoringMode,
|
||||
pointsPerMatch: pointsValue,
|
||||
scheduledAt: hasTime ? scheduledAt : undefined,
|
||||
teeIntervalMinutes: intervalValue && !Number.isNaN(intervalValue) ? intervalValue : undefined,
|
||||
startHole: startHoleValue,
|
||||
courseId: selectedCourseId,
|
||||
newCourseName: trimmedCourseQuery,
|
||||
allowanceOverride: buildAllowanceOverride(),
|
||||
})
|
||||
setSubmitting(false)
|
||||
|
||||
setName("")
|
||||
setFormat("foursome")
|
||||
setHoleConfig("full_18")
|
||||
setScoringMode("stroke")
|
||||
setPoints("1")
|
||||
setScheduledAt("")
|
||||
setTeeInterval("")
|
||||
setStartHole("1")
|
||||
setCourseQuery("")
|
||||
setSelectedCourseId(null)
|
||||
setAdvancedOpen(false)
|
||||
setUseHandicap(true)
|
||||
setUseCourseHandicap(true)
|
||||
setUseMatchplayHandicap(true)
|
||||
setHcpPercent("")
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col gap-6 rounded-3xl border border-dashed border-border bg-card/50 p-5 sm:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/15">
|
||||
<Plus aria-hidden="true" className="size-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
Økt {nextSequence}
|
||||
</span>
|
||||
<span className="text-base font-bold text-foreground">Legg til økt</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="session-name" className="text-sm font-semibold">
|
||||
Navn <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="session-name"
|
||||
placeholder="F.eks. Foursome dag 1"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="h-12 rounded-2xl text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-sm font-semibold">Format</Label>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<SegmentButton key={opt} selected={format === opt} onClick={() => setFormat(opt)}>
|
||||
{FORMAT_LABELS[opt]}
|
||||
</SegmentButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-sm font-semibold">Bane</Label>
|
||||
{selectedCourseId ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-2xl border border-border bg-background px-4 py-3">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{courses.find((c) => c.id === selectedCourseId)?.name}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setSelectedCourseId(null)
|
||||
setCourseQuery("")
|
||||
}}
|
||||
className="size-8 rounded-lg text-muted-foreground"
|
||||
aria-label="Fjern valgt bane"
|
||||
>
|
||||
<X aria-hidden="true" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
placeholder="Søk eller skriv banenavn…"
|
||||
value={courseQuery}
|
||||
onChange={(e) => setCourseQuery(e.target.value)}
|
||||
className="h-12 rounded-2xl text-base"
|
||||
/>
|
||||
{trimmedCourseQuery.length > 0 && (
|
||||
<div className="overflow-hidden rounded-2xl border border-border bg-background">
|
||||
<ul className="flex max-h-48 flex-col overflow-auto">
|
||||
{courseMatches.map((c) => (
|
||||
<li key={c.id} className="border-b border-border last:border-b-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedCourseId(c.id)
|
||||
setCourseQuery("")
|
||||
}}
|
||||
className="flex w-full items-center px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60"
|
||||
>
|
||||
{c.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{courseMatches.length === 0 && !showCreateCourse && (
|
||||
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
||||
)}
|
||||
{showCreateCourse && (
|
||||
<li className={courseMatches.length > 0 ? "border-t border-border" : ""}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedCourseId(null)}
|
||||
className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-semibold text-primary transition-colors hover:bg-accent/60"
|
||||
>
|
||||
<Plus aria-hidden="true" className="size-4" />
|
||||
Opprett ny bane: «{trimmedCourseQuery}»
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-sm font-semibold">Hullomfang</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{HOLE_OPTIONS.map((opt) => (
|
||||
<SegmentButton key={opt} selected={holeConfig === opt} onClick={() => setHoleConfig(opt)}>
|
||||
{HOLE_LABELS[opt]}
|
||||
</SegmentButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-sm font-semibold">Scoringsmodus</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{SCORING_OPTIONS.map((opt) => (
|
||||
<SegmentButton key={opt} selected={scoringMode === opt} onClick={() => setScoringMode(opt)}>
|
||||
{SCORING_LABELS[opt]}
|
||||
</SegmentButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="session-points" className="text-sm font-semibold">
|
||||
Poeng per match
|
||||
</Label>
|
||||
<Input
|
||||
id="session-points"
|
||||
inputMode="decimal"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
value={points}
|
||||
onChange={(e) => setPoints(e.target.value)}
|
||||
className="h-12 rounded-2xl text-base"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="session-starthole" className="text-sm font-semibold">
|
||||
Starthull
|
||||
</Label>
|
||||
<Input
|
||||
id="session-starthole"
|
||||
inputMode="numeric"
|
||||
type="number"
|
||||
min="1"
|
||||
max="18"
|
||||
value={startHole}
|
||||
onChange={(e) => setStartHole(e.target.value)}
|
||||
className="h-12 rounded-2xl text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="session-time" className="text-sm font-semibold">
|
||||
Startdato og -klokkeslett{" "}
|
||||
<span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="session-time"
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(e) => setScheduledAt(e.target.value)}
|
||||
className="h-12 rounded-2xl text-base"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasTime && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="session-interval" className="text-sm font-semibold">
|
||||
Minutter mellom hver flight
|
||||
</Label>
|
||||
<Input
|
||||
id="session-interval"
|
||||
inputMode="numeric"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="F.eks. 10"
|
||||
value={teeInterval}
|
||||
onChange={(e) => setTeeInterval(e.target.value)}
|
||||
className="h-12 rounded-2xl text-base"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border border-border bg-background/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdvancedOpen((v) => !v)}
|
||||
aria-expanded={advancedOpen}
|
||||
className="flex w-full items-center justify-between gap-2 rounded-2xl px-4 py-3.5 text-left transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Sparkles aria-hidden="true" className="size-4 text-muted-foreground" />
|
||||
Avanserte handicap-innstillinger
|
||||
</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn("size-5 text-muted-foreground transition-transform", advancedOpen && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="flex flex-col gap-1 border-t border-border px-4 py-2">
|
||||
<ToggleRow label="Bruk handicap" checked={useHandicap} onCheckedChange={setUseHandicap} />
|
||||
<ToggleRow
|
||||
label="Bruk course handicap-justering"
|
||||
checked={useCourseHandicap}
|
||||
onCheckedChange={setUseCourseHandicap}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="Bruk matchplay-handicap"
|
||||
checked={useMatchplayHandicap}
|
||||
onCheckedChange={setUseMatchplayHandicap}
|
||||
/>
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<Label htmlFor="hcp-percent" className="text-sm font-semibold">
|
||||
HCP-prosent <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="hcp-percent"
|
||||
inputMode="decimal"
|
||||
placeholder="Standard for format"
|
||||
value={hcpPercent}
|
||||
onChange={(e) => setHcpPercent(e.target.value)}
|
||||
className="h-11 rounded-xl text-base"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={!valid || submitting} className="h-12 rounded-2xl text-base font-bold shadow-sm">
|
||||
<Plus aria-hidden="true" className="size-5" />
|
||||
{submitting ? "Legger til…" : "Legg til økt"}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Small UI helpers ------------------------------------------------------
|
||||
|
||||
function SegmentButton({
|
||||
selected,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-2xl border px-3 py-3 text-sm font-semibold transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-card text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
label: string
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 border-b border-border py-3 last:border-b-0">
|
||||
<span className="text-sm font-medium text-foreground text-pretty">{label}</span>
|
||||
<Switch checked={checked} onCheckedChange={onCheckedChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Helpers ---------------------------------------------------------------
|
||||
|
||||
function formatPoints(points: number) {
|
||||
return Number.isInteger(points) ? String(points) : points.toFixed(1).replace(".", ",")
|
||||
}
|
||||
|
||||
function formatDateTime(iso: string) {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return date.toLocaleString("no-NO", {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
32
frontend/components/ui/switch.tsx
Normal file
32
frontend/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
BIN
frontend/tee-cup-login-screen (4).zip
Normal file
BIN
frontend/tee-cup-login-screen (4).zip
Normal file
Binary file not shown.
BIN
frontend/tee-cup-login-screen (5).zip
Normal file
BIN
frontend/tee-cup-login-screen (5).zip
Normal file
Binary file not shown.
Loading…
Reference in a new issue