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).
2026-07-18 12:20:45 +02:00
|
|
|
"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}
|
2026-07-18 17:15:50 +02:00
|
|
|
href={`/tournaments/${tournamentId}/sessions/${session.id}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</ol>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="mt-6">
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
<CreateSessionCard
|
|
|
|
|
nextSequence={nextSequence}
|
|
|
|
|
courses={courses}
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
onCourseImported={(course) => setCourses((prev) => [...prev, course])}
|
|
|
|
|
onCreate={addSession}
|
|
|
|
|
/>
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Session card (timeline item) ------------------------------------------
|
|
|
|
|
|
|
|
|
|
function SessionCard({
|
|
|
|
|
session,
|
|
|
|
|
course,
|
|
|
|
|
isLast,
|
2026-07-18 17:15:50 +02:00
|
|
|
href,
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
}: {
|
|
|
|
|
session: ApiSession
|
|
|
|
|
course: ApiCourse | null
|
|
|
|
|
isLast: boolean
|
2026-07-18 17:15:50 +02:00
|
|
|
href: string
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
}) {
|
|
|
|
|
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>
|
|
|
|
|
|
2026-07-18 17:15:50 +02:00
|
|
|
<Link
|
|
|
|
|
href={href}
|
|
|
|
|
className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-sm shadow-black/5 transition-colors hover:border-primary/50 hover:bg-accent/50"
|
|
|
|
|
>
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
<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>
|
2026-07-18 17:15:50 +02:00
|
|
|
</Link>
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
</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,
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
organizationId,
|
|
|
|
|
onCourseImported,
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
onCreate,
|
|
|
|
|
}: {
|
|
|
|
|
nextSequence: number
|
|
|
|
|
courses: ApiCourse[]
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
organizationId: string
|
|
|
|
|
onCourseImported: (course: ApiCourse) => void
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
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)
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
|
|
|
|
|
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)
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
setOfficialSearchOpen(false)
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
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>
|
|
|
|
|
)}
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setOfficialSearchOpen(true)}
|
|
|
|
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Hent bane fra teeoff i stedet
|
|
|
|
|
</button>
|
|
|
|
|
{officialSearchOpen && (
|
|
|
|
|
<OfficialCourseSearch
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
onClose={() => setOfficialSearchOpen(false)}
|
|
|
|
|
onImported={(course) => {
|
|
|
|
|
onCourseImported(course)
|
|
|
|
|
setSelectedCourseId(course.id)
|
|
|
|
|
setOfficialSearchOpen(false)
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
// --- Offisiell bane fra teeoff (ADR-019) ------------------------------------
|
|
|
|
|
|
|
|
|
|
type ApiOfficialFacility = {
|
|
|
|
|
slug: string
|
|
|
|
|
name: string
|
|
|
|
|
city: string | null
|
|
|
|
|
county: string | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ApiOfficialCourseOption = {
|
|
|
|
|
teeoff_course_id: number
|
|
|
|
|
name: string
|
|
|
|
|
is_main_course: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function OfficialCourseSearch({
|
|
|
|
|
organizationId,
|
|
|
|
|
onClose,
|
|
|
|
|
onImported,
|
|
|
|
|
}: {
|
|
|
|
|
organizationId: string
|
|
|
|
|
onClose: () => void
|
|
|
|
|
onImported: (course: ApiCourse) => void
|
|
|
|
|
}) {
|
|
|
|
|
const [query, setQuery] = useState("")
|
|
|
|
|
const [facilities, setFacilities] = useState<ApiOfficialFacility[] | null>(null)
|
|
|
|
|
const [selectedFacility, setSelectedFacility] = useState<ApiOfficialFacility | null>(null)
|
|
|
|
|
const [courseOptions, setCourseOptions] = useState<ApiOfficialCourseOption[] | null>(null)
|
|
|
|
|
const [searching, setSearching] = useState(false)
|
|
|
|
|
const [importing, setImporting] = useState(false)
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
2026-07-18 15:54:40 +02:00
|
|
|
async function runSearch() {
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
setSearching(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`/orgs/${organizationId}/courses/official-search?q=${encodeURIComponent(query.trim())}`,
|
|
|
|
|
{ credentials: "include" },
|
|
|
|
|
)
|
|
|
|
|
if (!res.ok) throw new Error(`search: ${res.status}`)
|
|
|
|
|
setFacilities(await res.json())
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Klarte ikke å søke i teeoff sine baner akkurat nå.")
|
|
|
|
|
setFacilities([])
|
|
|
|
|
} finally {
|
|
|
|
|
setSearching(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pickFacility(facility: ApiOfficialFacility) {
|
|
|
|
|
setSelectedFacility(facility)
|
|
|
|
|
setError(null)
|
|
|
|
|
setCourseOptions(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/courses/official-search/${facility.slug}`, {
|
|
|
|
|
credentials: "include",
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) throw new Error(`facility detail: ${res.status}`)
|
|
|
|
|
const detail: { courses: ApiOfficialCourseOption[] } = await res.json()
|
|
|
|
|
setCourseOptions(detail.courses)
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Klarte ikke å hente baner for dette anlegget.")
|
|
|
|
|
setCourseOptions([])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function importCourse(course: ApiOfficialCourseOption) {
|
|
|
|
|
if (!selectedFacility) return
|
|
|
|
|
setImporting(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/courses/official-import`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
credentials: "include",
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
facility_slug: selectedFacility.slug,
|
|
|
|
|
teeoff_course_id: course.teeoff_course_id,
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const body = await res.json().catch(() => null)
|
|
|
|
|
const code = body?.detail?.code
|
|
|
|
|
if (code === "EXTERNAL_DATA_INCOMPLETE") {
|
|
|
|
|
setError("Denne banen mangler nok data i teeoff til å importeres ennå.")
|
|
|
|
|
} else if (code === "DUPLICATE") {
|
|
|
|
|
setError("Denne banen er allerede importert til organisasjonen.")
|
|
|
|
|
} else {
|
|
|
|
|
setError("Klarte ikke å importere banen. Prøv igjen.")
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
onImported(await res.json())
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Klarte ikke å importere banen. Prøv igjen.")
|
|
|
|
|
} finally {
|
|
|
|
|
setImporting(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background p-4">
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
|
|
|
|
<span className="text-sm font-bold text-foreground">Hent bane fra teeoff</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
className="size-8 rounded-lg text-muted-foreground"
|
|
|
|
|
aria-label="Lukk"
|
|
|
|
|
>
|
|
|
|
|
<X aria-hidden="true" className="size-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error && <p className="text-sm font-medium text-destructive">{error}</p>}
|
|
|
|
|
|
|
|
|
|
{!selectedFacility ? (
|
|
|
|
|
<>
|
2026-07-18 15:54:40 +02:00
|
|
|
{/* Bevisst IKKE et eget <form> her -- denne komponenten rendres
|
|
|
|
|
inne i CreateSessionCard sitt <form onSubmit={handleSubmit}>,
|
|
|
|
|
og nestede <form>-elementer er ugyldig HTML. Nettleseren
|
|
|
|
|
slår da sammen de to skjemaene i den faktiske DOM-en, som
|
|
|
|
|
gjorde at "Søk"-knappen i praksis submittet det YTRE
|
|
|
|
|
økt-skjemaet i stedet -- en ekte native side-navigasjon som
|
|
|
|
|
vasket bort ?org=...-parameteren fra URL-en (funnet ved en
|
|
|
|
|
reell feilrapport fra bruker mot produksjon). */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
<Input
|
|
|
|
|
autoFocus
|
|
|
|
|
placeholder="Søk anleggsnavn…"
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
2026-07-18 15:54:40 +02:00
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
runSearch()
|
|
|
|
|
}
|
|
|
|
|
}}
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
className="h-11 flex-1 rounded-xl text-base"
|
|
|
|
|
/>
|
2026-07-18 15:54:40 +02:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => runSearch()}
|
|
|
|
|
disabled={searching}
|
|
|
|
|
className="h-11 shrink-0 rounded-xl font-bold"
|
|
|
|
|
>
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
{searching ? "Søker…" : "Søk"}
|
|
|
|
|
</Button>
|
2026-07-18 15:54:40 +02:00
|
|
|
</div>
|
Bygget, og verifisert grundig mot ekte infrastruktur — inkludert et ekte kall mot teeoff_api (søkte opp «Borregaard», importerte Borregaard Golfklubb sin 18-hulls hovedbane med alle hull, 4 tee-farger × kjønn, ratinger, og opprettet faktisk en økt med den importerte banen). Duplikat-import ble korrekt avvist (409), kryss-org-isolasjon holder, test_isolation.sql 12/12.
To ting gjenstår, begge mot ekte infrastruktur — vil du bekrefte at jeg går videre?
Migrasjon 010_official_course_unique_ref.sql mot ekte teecup_db — kun én ny partiell unik-indeks (organization_id, external_course_ref), rører ingen eksisterende rader (alle er source='custom' med external_course_ref IS NULL i dag)
docker compose up -d --build teecup_api teecup_frontend — ny backend-kode (courses.py, teeoff_client.py, httpx-avhengighet) + ny frontend-kode (bane-søk mot teeoff i program-skjemaet)
2026-07-18 12:44:05 +02:00
|
|
|
{facilities && (
|
|
|
|
|
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
|
|
|
|
{facilities.map((f) => (
|
|
|
|
|
<li key={f.slug} className="border-b border-border last:border-b-0">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => pickFacility(f)}
|
|
|
|
|
className="flex w-full flex-col px-4 py-2.5 text-left transition-colors hover:bg-accent/60"
|
|
|
|
|
>
|
|
|
|
|
<span className="text-sm font-semibold text-foreground">{f.name}</span>
|
|
|
|
|
{(f.city || f.county) && (
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{[f.city, f.county].filter(Boolean).join(", ")}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
{facilities.length === 0 && (
|
|
|
|
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
|
|
|
|
)}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setSelectedFacility(null)
|
|
|
|
|
setCourseOptions(null)
|
|
|
|
|
}}
|
|
|
|
|
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
← {selectedFacility.name}
|
|
|
|
|
</button>
|
|
|
|
|
{courseOptions === null ? (
|
|
|
|
|
<p className="text-sm text-muted-foreground">Laster baner…</p>
|
|
|
|
|
) : (
|
|
|
|
|
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
|
|
|
|
|
{courseOptions.map((c) => (
|
|
|
|
|
<li key={c.teeoff_course_id} className="border-b border-border last:border-b-0">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={importing}
|
|
|
|
|
onClick={() => importCourse(c)}
|
|
|
|
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60 disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
{c.name}
|
|
|
|
|
{importing && <span className="text-xs font-normal text-muted-foreground">Importerer…</span>}
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
{courseOptions.length === 0 && (
|
|
|
|
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
|
|
|
|
|
Ingen 18-hulls baner å importere hos dette anlegget ennå.
|
|
|
|
|
</li>
|
|
|
|
|
)}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
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).
2026-07-18 12:20:45 +02:00
|
|
|
// --- 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",
|
|
|
|
|
})
|
|
|
|
|
}
|