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,
|
2026-07-19 08:17:12 +02:00
|
|
|
Check,
|
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
|
|
|
ChevronDown,
|
2026-07-19 11:41:57 +02:00
|
|
|
ChevronRight,
|
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
|
|
|
Clock,
|
|
|
|
|
Flag,
|
2026-07-19 08:17:12 +02:00
|
|
|
MapPin,
|
|
|
|
|
MoreVertical,
|
|
|
|
|
Pencil,
|
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
|
|
|
Plus,
|
2026-08-21 13:54:01 +02:00
|
|
|
Printer,
|
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
|
|
|
Sparkles,
|
2026-07-19 08:17:12 +02:00
|
|
|
Trash2,
|
2026-07-19 11:41:57 +02:00
|
|
|
Users,
|
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
|
|
|
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"
|
2026-07-19 08:17:12 +02:00
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from "@/components/ui/dropdown-menu"
|
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
|
|
|
import { cn } from "@/lib/utils"
|
2026-08-04 11:34:28 +02:00
|
|
|
import { CourseTemplatePicker } from "@/components/course-template-editor"
|
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
|
|
|
|
|
|
|
|
// --- 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).
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
// Fire nye org-lagturnering-formater (2026-07-30, se ADR/plan) -- flatt-
|
|
|
|
|
// felt-formatene (Københavner/BBB/Flag) hører ALDRI hjemme her (ADR-011s
|
|
|
|
|
// to-lags-modell passer strukturelt ikke), kun individuelle org-turneringer.
|
|
|
|
|
type SessionFormat =
|
|
|
|
|
| "foursome"
|
|
|
|
|
| "greensome"
|
|
|
|
|
| "scramble_2"
|
|
|
|
|
| "scramble_4"
|
|
|
|
|
| "fourball"
|
|
|
|
|
| "singles"
|
|
|
|
|
| "chapman"
|
|
|
|
|
| "shamble"
|
|
|
|
|
| "money_ball"
|
|
|
|
|
| "high_low_high"
|
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
|
|
|
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"
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
// eller "per_player" (se app/handicap.py sin _SIDE_IS_UNIT). Chapman er
|
|
|
|
|
// delt-ball (som foursome); shamble/money_ball/high_low_high er
|
|
|
|
|
// individuell-ball (som fourball) selv om alle fire er to-sidede formater.
|
|
|
|
|
const SIDE_IS_UNIT_FORMAT = new Set<SessionFormat>(["foursome", "greensome", "scramble_2", "scramble_4", "chapman"])
|
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
|
|
|
|
|
|
|
|
type ApiCourse = {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
source: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 11:34:28 +02:00
|
|
|
type ApiTee = {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Konkurranseklasser (2026-08-03) -- fritt navngitte, med et valgfritt
|
|
|
|
|
// standardutslag brukt til å forhåndsutfylle riktig utslag når en spiller
|
|
|
|
|
// legges til en match (session-blind-draw.tsx). Ren utslag-bekvemmelighet
|
|
|
|
|
// her -- INGEN egen resultatliste for lagturneringer (bekreftet med
|
|
|
|
|
// bruker: poeng er knyttet til hele kamper, ikke enkeltspillere). Flyttet
|
|
|
|
|
// hit fra tournament-detail.tsx (2026-08-04) -- gir ingen mening å velge
|
|
|
|
|
// et standardutslag før en bane faktisk finnes i programmet.
|
|
|
|
|
type ApiTournamentClass = {
|
|
|
|
|
id: string
|
|
|
|
|
name: string
|
|
|
|
|
default_tee_id: string | null
|
|
|
|
|
default_tee_name: string | null
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
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
|
2026-08-21 08:59:54 +02:00
|
|
|
// Shotgun-start (migrasjon 088, ADR-096) -- 'consecutive' (standard) er
|
|
|
|
|
// dagens oppførsel uendret. 'shotgun': alle matcher i økten går ut
|
|
|
|
|
// SAMTIDIG, fra hvert sitt starthull (match.start_hole, satt i
|
|
|
|
|
// trekning-skjermen) i stedet for staggert klokkeslett.
|
|
|
|
|
start_mode: "consecutive" | "shotgun"
|
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
|
|
|
locked_team_ids: string[]
|
|
|
|
|
revealed: boolean
|
2026-08-24 09:55:04 +02:00
|
|
|
// "Jakter Jesper"-visningsmodus (migrasjon 093, del 2 av ADR-106) -- kun
|
|
|
|
|
// meningsfullt for format IN ('singles','fourball') OG
|
|
|
|
|
// scoring_mode='stroke'.
|
|
|
|
|
net_display_style: "standard" | "hunt"
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 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",
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
chapman: "Chapman",
|
|
|
|
|
shamble: "Shamble",
|
|
|
|
|
money_ball: "Money Ball",
|
|
|
|
|
high_low_high: "High-low-high",
|
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 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[]>([])
|
2026-08-04 11:34:28 +02:00
|
|
|
const [classes, setClasses] = useState<ApiTournamentClass[]>([])
|
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 [loading, setLoading] = useState(true)
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false
|
|
|
|
|
async function load() {
|
|
|
|
|
try {
|
2026-08-04 11:34:28 +02:00
|
|
|
const [sessionsRes, coursesRes, classesRes] = await Promise.all([
|
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
|
|
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/sessions`, {
|
|
|
|
|
credentials: "include",
|
|
|
|
|
}),
|
|
|
|
|
fetch(`/orgs/${organizationId}/courses`, { credentials: "include" }),
|
2026-08-04 11:34:28 +02:00
|
|
|
fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
|
|
|
|
|
credentials: "include",
|
|
|
|
|
}),
|
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
|
|
|
])
|
|
|
|
|
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)
|
2026-08-04 11:34:28 +02:00
|
|
|
if (classesRes.ok) setClasses(await classesRes.json())
|
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
|
|
|
} 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])
|
|
|
|
|
|
2026-08-04 11:34:28 +02:00
|
|
|
async function createClass(name: string, defaultTeeId: string | null) {
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/tournaments/${tournamentId}/classes`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
credentials: "include",
|
|
|
|
|
body: JSON.stringify({ name, default_tee_id: defaultTeeId }),
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) throw new Error(`create class: ${res.status}`)
|
|
|
|
|
const created: ApiTournamentClass = await res.json()
|
|
|
|
|
setClasses((prev) => [...prev, created])
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Klarte ikke å opprette klassen. Prøv igjen.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteClass(classId: string) {
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`/orgs/${organizationId}/tournaments/${tournamentId}/classes/${classId}`,
|
|
|
|
|
{ method: "DELETE", credentials: "include" },
|
|
|
|
|
)
|
|
|
|
|
if (res.status !== 204) throw new Error(`delete class: ${res.status}`)
|
|
|
|
|
setClasses((prev) => prev.filter((c) => c.id !== classId))
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Klarte ikke å fjerne klassen. Prøv igjen.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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 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
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
shambleBestN: number | null
|
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
|
|
|
}) {
|
|
|
|
|
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,
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
shamble_best_n: input.format === "shamble" ? input.shambleBestN : null,
|
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
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
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.")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 08:17:12 +02:00
|
|
|
async function updateSession(sessionId: string, patch: Record<string, unknown>): Promise<boolean> {
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}`, {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
credentials: "include",
|
|
|
|
|
body: JSON.stringify(patch),
|
|
|
|
|
})
|
|
|
|
|
if (!res.ok) throw new Error(`update session: ${res.status}`)
|
|
|
|
|
const updated: ApiSession = await res.json()
|
|
|
|
|
setSessions((prev) => prev.map((s) => (s.id === sessionId ? updated : s)))
|
|
|
|
|
return true
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Klarte ikke å oppdatere økten. Prøv igjen.")
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteSession(sessionId: string): Promise<string | null> {
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/sessions/${sessionId}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
credentials: "include",
|
|
|
|
|
})
|
|
|
|
|
if (res.status === 204) {
|
|
|
|
|
setSessions((prev) => prev.filter((s) => s.id !== sessionId))
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
const body = await res.json().catch(() => null)
|
|
|
|
|
return body?.detail?.message ?? "Klarte ikke å slette økten. Prøv igjen."
|
|
|
|
|
} catch {
|
|
|
|
|
return "Klarte ikke å slette økten. Prøv igjen."
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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 detailHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
|
|
|
|
const programHref = `/tournaments/${tournamentId}/program?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
|
2026-07-18 18:22:10 +02:00
|
|
|
const leaderboardHref = `/tournaments/${tournamentId}/leaderboard?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
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.
To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).
Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00
|
|
|
<nav
|
|
|
|
|
className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto px-5 pb-3"
|
|
|
|
|
aria-label="Turneringsseksjoner"
|
|
|
|
|
>
|
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
|
|
|
<Link
|
|
|
|
|
href={detailHref}
|
Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.
To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).
Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00
|
|
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
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
|
|
|
>
|
|
|
|
|
Lag og spillere
|
|
|
|
|
</Link>
|
|
|
|
|
<Link
|
|
|
|
|
href={programHref}
|
|
|
|
|
aria-current="page"
|
Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.
To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).
Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00
|
|
|
className="shrink-0 rounded-full bg-primary px-4 py-2 text-sm font-bold text-primary-foreground"
|
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
|
|
|
>
|
|
|
|
|
Program
|
|
|
|
|
</Link>
|
2026-07-18 18:22:10 +02:00
|
|
|
<Link
|
|
|
|
|
href={leaderboardHref}
|
Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.
To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).
Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00
|
|
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
2026-07-18 18:22:10 +02:00
|
|
|
>
|
|
|
|
|
Leaderboard
|
|
|
|
|
</Link>
|
Legg til turnering-presentasjon: hero-bilde, sponsorer, synlighet, påmelding
Backend for dette (hero_image_key, sponsor-CRUD, visibility/description/
registrering) har vært klart og live siden ADR-018 (2026-07-18), men ingen
organisator-skjerm satte noensinne disse feltene - alt var 100% API-only.
Ny tournament-presentation.tsx dekker begge turneringstyper (delt
tournament-tabell, ikke format_type-spesifikt): full side for
lagturneringer (ny rute), embeddet som fjerde fane for individuelle
turneringer. Lagt til i alle fire eksisterende nav-rader.
To små backend-tillegg uten migrasjon: hero_image_url/logo_url som
beregnede felt (samme mønster som avatar_url), og en DELETE-endepunkt for
hero-bilde (fantes fra før kun opplasting).
Fant og rettet en ekte mobil-layoutbug under scratch-verifisering:
sponsor-raden klemte navnet til nesten ingenting på 390px viewport -
samme klasse trunkeringsdefekt som leaderboard-omskrivingen tidligere
denne uken, men på et annet sted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 19:03:53 +02:00
|
|
|
<Link
|
|
|
|
|
href={`/tournaments/${tournamentId}/presentation?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
|
|
|
|
className="shrink-0 rounded-full px-4 py-2 text-sm font-semibold text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
Presentasjon
|
|
|
|
|
</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
|
|
|
</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 />
|
|
|
|
|
) : (
|
2026-07-21 09:18:07 +02:00
|
|
|
<>
|
|
|
|
|
<DateCoverageSummary
|
|
|
|
|
total={sorted.length}
|
|
|
|
|
withDate={sorted.filter((s) => s.scheduled_at !== null).length}
|
|
|
|
|
/>
|
|
|
|
|
<ol className="flex flex-col gap-3">
|
|
|
|
|
{sorted.map((session, index) => (
|
|
|
|
|
<SessionCard
|
|
|
|
|
key={session.id}
|
|
|
|
|
session={session}
|
|
|
|
|
course={coursesById.get(session.course_id) ?? null}
|
|
|
|
|
courses={courses}
|
|
|
|
|
organizationId={organizationId}
|
2026-08-21 13:54:01 +02:00
|
|
|
tournamentId={tournamentId}
|
2026-07-21 09:18:07 +02:00
|
|
|
isLast={index === sorted.length - 1}
|
|
|
|
|
href={`/tournaments/${tournamentId}/sessions/${session.id}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`}
|
|
|
|
|
onCourseImported={(course) => setCourses((prev) => [...prev, course])}
|
|
|
|
|
onSave={(patch) => updateSession(session.id, patch)}
|
|
|
|
|
onDelete={() => deleteSession(session.id)}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</ol>
|
|
|
|
|
</>
|
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="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>
|
2026-08-04 11:34:28 +02:00
|
|
|
|
|
|
|
|
<div className="mt-6">
|
|
|
|
|
<ClassesCard
|
|
|
|
|
classes={classes}
|
|
|
|
|
courses={courses}
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
onCreate={createClass}
|
|
|
|
|
onDelete={deleteClass}
|
|
|
|
|
/>
|
|
|
|
|
</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
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-04 11:34:28 +02:00
|
|
|
// --- Konkurranseklasser (2026-08-03, flyttet hit 2026-08-04) ---------------
|
|
|
|
|
// Fritt navngitte klasser med et valgfritt standardutslag -- se
|
|
|
|
|
// tournament_class-tabellen (053_tournament_classes.sql). Kun utslag-
|
|
|
|
|
// bekvemmelighet i lagturneringer (ingen egen resultatliste her, bekreftet
|
|
|
|
|
// med bruker -- se AssignRoundParticipantControl/LeaderboardTab i
|
|
|
|
|
// individual-tournament-detail.tsx for der klasse OGSÅ deler leaderboardet).
|
|
|
|
|
// Plassert her, ETTER økt-/baneoppsettet over, siden standardutslaget er
|
|
|
|
|
// meningsløst å velge før man vet hvilke utslag banen faktisk har.
|
|
|
|
|
|
|
|
|
|
function ClassesCard({
|
|
|
|
|
classes,
|
|
|
|
|
courses,
|
|
|
|
|
organizationId,
|
|
|
|
|
onCreate,
|
|
|
|
|
onDelete,
|
|
|
|
|
}: {
|
|
|
|
|
classes: ApiTournamentClass[]
|
|
|
|
|
courses: ApiCourse[]
|
|
|
|
|
organizationId: string
|
|
|
|
|
onCreate: (name: string, defaultTeeId: string | null) => void
|
|
|
|
|
onDelete: (classId: string) => void
|
|
|
|
|
}) {
|
|
|
|
|
const [name, setName] = useState("")
|
|
|
|
|
const [courseId, setCourseId] = useState("")
|
|
|
|
|
const [teeId, setTeeId] = useState("")
|
|
|
|
|
const [tees, setTees] = useState<ApiTee[]>([])
|
|
|
|
|
const [adding, setAdding] = useState(false)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!courseId) {
|
|
|
|
|
setTees([])
|
|
|
|
|
setTeeId("")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
let cancelled = false
|
|
|
|
|
fetch(`/orgs/${organizationId}/courses/${courseId}/tees`, { credentials: "include" })
|
|
|
|
|
.then((res) => (res.ok ? res.json() : []))
|
|
|
|
|
.then((data: ApiTee[]) => {
|
|
|
|
|
if (!cancelled) {
|
|
|
|
|
setTees(data)
|
|
|
|
|
setTeeId(data[0]?.id ?? "")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true
|
|
|
|
|
}
|
|
|
|
|
}, [organizationId, courseId])
|
|
|
|
|
|
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
if (name.trim().length === 0 || adding) return
|
|
|
|
|
setAdding(true)
|
|
|
|
|
await onCreate(name.trim(), teeId || null)
|
|
|
|
|
setAdding(false)
|
|
|
|
|
setName("")
|
|
|
|
|
setCourseId("")
|
|
|
|
|
setTeeId("")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section className="flex flex-col gap-4 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8 sm:p-6">
|
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
|
|
|
<div className="flex size-10 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
<Users aria-hidden="true" className="size-5 text-muted-foreground" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<h3 className="text-sm font-bold text-foreground">Klasser</h3>
|
|
|
|
|
<p className="text-xs text-muted-foreground text-pretty">
|
|
|
|
|
Foreslår riktig utslag når en spiller legges til en kamp -- ingen egen resultatliste
|
|
|
|
|
her (poeng er knyttet til hele kamper, ikke enkeltspillere).
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{classes.length > 0 && (
|
|
|
|
|
<ul className="flex flex-col divide-y divide-border rounded-2xl border border-border">
|
|
|
|
|
{classes.map((c) => (
|
|
|
|
|
<li key={c.id} className="flex items-center gap-3 px-3 py-2.5">
|
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
|
|
|
<span className="truncate text-sm font-semibold text-foreground">{c.name}</span>
|
|
|
|
|
{c.default_tee_name && (
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
Standardutslag: {c.default_tee_name}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onDelete(c.id)}
|
|
|
|
|
aria-label={`Fjern klassen ${c.name}`}
|
|
|
|
|
className="flex size-11 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 aria-hidden="true" className="size-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleSubmit} className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
|
|
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
|
|
|
<Label htmlFor="class-name">Klassenavn</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="class-name"
|
|
|
|
|
value={name}
|
|
|
|
|
onChange={(e) => setName(e.target.value)}
|
|
|
|
|
placeholder="F.eks. Damer"
|
|
|
|
|
className="h-11"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
|
|
|
<Label htmlFor="class-course">Bane (for standardutslag)</Label>
|
|
|
|
|
<select
|
|
|
|
|
id="class-course"
|
|
|
|
|
value={courseId}
|
|
|
|
|
onChange={(e) => setCourseId(e.target.value)}
|
|
|
|
|
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
|
|
|
|
|
>
|
|
|
|
|
<option value="">Ingen standardutslag</option>
|
|
|
|
|
{courses.map((c) => (
|
|
|
|
|
<option key={c.id} value={c.id}>
|
|
|
|
|
{c.name}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
{tees.length > 0 && (
|
|
|
|
|
<div className="flex flex-1 flex-col gap-1.5">
|
|
|
|
|
<Label htmlFor="class-tee">Standardutslag</Label>
|
|
|
|
|
<select
|
|
|
|
|
id="class-tee"
|
|
|
|
|
value={teeId}
|
|
|
|
|
onChange={(e) => setTeeId(e.target.value)}
|
|
|
|
|
className="h-11 rounded-lg border border-border bg-card px-2.5 text-sm font-medium text-foreground outline-none"
|
|
|
|
|
>
|
|
|
|
|
{tees.map((t) => (
|
|
|
|
|
<option key={t.id} value={t.id}>
|
|
|
|
|
{t.name}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
variant="outline"
|
|
|
|
|
disabled={adding || name.trim().length === 0}
|
|
|
|
|
className="h-11 shrink-0 rounded-xl font-semibold"
|
|
|
|
|
>
|
|
|
|
|
<Plus aria-hidden="true" className="size-4" />
|
|
|
|
|
Legg til
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
</section>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
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 card (timeline item) ------------------------------------------
|
|
|
|
|
|
|
|
|
|
function SessionCard({
|
|
|
|
|
session,
|
|
|
|
|
course,
|
2026-07-19 08:17:12 +02:00
|
|
|
courses,
|
|
|
|
|
organizationId,
|
2026-08-21 13:54:01 +02:00
|
|
|
tournamentId,
|
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
|
|
|
isLast,
|
2026-07-18 17:15:50 +02:00
|
|
|
href,
|
2026-07-19 08:17:12 +02:00
|
|
|
onCourseImported,
|
|
|
|
|
onSave,
|
|
|
|
|
onDelete,
|
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
|
2026-07-19 08:17:12 +02:00
|
|
|
courses: ApiCourse[]
|
|
|
|
|
organizationId: string
|
2026-08-21 13:54:01 +02:00
|
|
|
tournamentId: 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
|
|
|
isLast: boolean
|
2026-07-18 17:15:50 +02:00
|
|
|
href: string
|
2026-07-19 08:17:12 +02:00
|
|
|
onCourseImported: (course: ApiCourse) => void
|
|
|
|
|
onSave: (patch: Record<string, unknown>) => Promise<boolean>
|
|
|
|
|
onDelete: () => Promise<string | null>
|
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
|
|
|
}) {
|
2026-07-19 08:17:12 +02:00
|
|
|
const [mode, setMode] = useState<"view" | "editing" | "confirmingDelete">("view")
|
|
|
|
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
|
|
|
|
const [deleting, setDeleting] = 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 title = session.name?.trim() || `Økt ${session.sequence}`
|
|
|
|
|
|
2026-07-19 08:17:12 +02:00
|
|
|
async function handleDelete() {
|
|
|
|
|
setDeleting(true)
|
|
|
|
|
const message = await onDelete()
|
|
|
|
|
setDeleting(false)
|
|
|
|
|
if (message) {
|
|
|
|
|
setDeleteError(message)
|
|
|
|
|
} else {
|
|
|
|
|
setMode("view")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
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-19 08:17:12 +02:00
|
|
|
{mode === "editing" ? (
|
|
|
|
|
<EditSessionForm
|
|
|
|
|
session={session}
|
|
|
|
|
course={course}
|
|
|
|
|
courses={courses}
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
onCourseImported={onCourseImported}
|
|
|
|
|
onCancel={() => setMode("view")}
|
|
|
|
|
onSave={async (patch) => {
|
|
|
|
|
const ok = await onSave(patch)
|
|
|
|
|
if (ok) setMode("view")
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
2026-07-29 12:44:51 +02:00
|
|
|
<div className="mb-1 flex flex-1 flex-col gap-3 rounded-3xl border border-border bg-card p-5 shadow-md shadow-black/8">
|
2026-07-19 08:17:12 +02:00
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
|
<Link href={href} className="flex min-w-0 flex-1 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>
|
|
|
|
|
</Link>
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger
|
|
|
|
|
aria-label={`Handlinger for ${title}`}
|
|
|
|
|
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
<MoreVertical aria-hidden="true" className="size-4" />
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end" className="w-44 rounded-2xl p-1.5">
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
onClick={() => setMode("editing")}
|
|
|
|
|
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
|
|
|
|
>
|
|
|
|
|
<Pencil aria-hidden="true" className="size-4" />
|
|
|
|
|
Rediger
|
|
|
|
|
</DropdownMenuItem>
|
2026-08-21 13:54:01 +02:00
|
|
|
<DropdownMenuItem
|
|
|
|
|
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
|
|
|
|
render={
|
|
|
|
|
<Link href={`/tournaments/${tournamentId}/print/startlist?org=${organizationId}&sessionId=${session.id}`}>
|
|
|
|
|
<Printer aria-hidden="true" className="size-4" />
|
|
|
|
|
Skriv ut startliste
|
|
|
|
|
</Link>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
|
|
|
|
render={
|
|
|
|
|
<Link href={`/tournaments/${tournamentId}/print/cart-tags?org=${organizationId}&sessionId=${session.id}`}>
|
|
|
|
|
<Printer aria-hidden="true" className="size-4" />
|
|
|
|
|
Skriv ut cart-tags
|
|
|
|
|
</Link>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold"
|
|
|
|
|
render={
|
|
|
|
|
<Link href={`/tournaments/${tournamentId}/print/scorecard?org=${organizationId}&sessionId=${session.id}`}>
|
|
|
|
|
<Printer aria-hidden="true" className="size-4" />
|
|
|
|
|
Skriv ut scorekort
|
|
|
|
|
</Link>
|
|
|
|
|
}
|
|
|
|
|
/>
|
2026-07-19 08:17:12 +02:00
|
|
|
<DropdownMenuItem
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setDeleteError(null)
|
|
|
|
|
setMode("confirmingDelete")
|
|
|
|
|
}}
|
|
|
|
|
className="flex cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm font-semibold text-destructive"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 aria-hidden="true" className="size-4" />
|
|
|
|
|
Slett
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
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>
|
|
|
|
|
|
2026-07-19 08:17:12 +02:00
|
|
|
<Link href={href} className="flex flex-col gap-1.5 border-t border-border pt-3">
|
2026-08-21 08:59:54 +02:00
|
|
|
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-full px-2 py-0.5",
|
|
|
|
|
session.start_mode === "shotgun" ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{session.start_mode === "shotgun" ? "Shotgun" : "Løpende start"}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-07-19 08:17:12 +02:00
|
|
|
{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">
|
2026-08-21 08:59:54 +02:00
|
|
|
{session.start_mode === "shotgun"
|
|
|
|
|
? `Felles utslag ${formatDateTime(session.scheduled_at)}`
|
|
|
|
|
: `Første utslag ${formatDateTime(session.scheduled_at)}${
|
|
|
|
|
session.tee_interval_minutes ? `, ${session.tee_interval_minutes} min mellom hver` : ""
|
|
|
|
|
}`}
|
2026-07-19 08:17:12 +02:00
|
|
|
</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>
|
|
|
|
|
)}
|
2026-08-21 08:59:54 +02:00
|
|
|
{session.start_mode === "consecutive" && session.start_hole !== 1 && (
|
2026-07-19 08:17:12 +02:00
|
|
|
<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>
|
|
|
|
|
)}
|
2026-07-19 11:41:57 +02:00
|
|
|
<div className="mt-1 flex items-center gap-1.5 text-sm font-semibold text-primary">
|
|
|
|
|
<Users aria-hidden="true" className="size-4 shrink-0" />
|
|
|
|
|
<span>Sett opp flights og lås oppstilling</span>
|
|
|
|
|
<ChevronRight aria-hidden="true" className="size-4 shrink-0" />
|
|
|
|
|
</div>
|
2026-07-19 08:17:12 +02:00
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
{mode === "confirmingDelete" &&
|
|
|
|
|
(deleteError ? (
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-muted/50 p-4">
|
|
|
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">{deleteError}</p>
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="h-9 rounded-xl"
|
|
|
|
|
onClick={() => setMode("view")}
|
|
|
|
|
>
|
|
|
|
|
Lukk
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-2xl border border-destructive/30 bg-destructive/5 p-4">
|
|
|
|
|
<p className="text-sm leading-relaxed text-foreground text-pretty">
|
|
|
|
|
Er du sikker på at du vil slette denne økten? Dette kan ikke angres.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="destructive"
|
|
|
|
|
className="h-9 rounded-xl"
|
|
|
|
|
disabled={deleting}
|
|
|
|
|
onClick={handleDelete}
|
|
|
|
|
>
|
|
|
|
|
{deleting ? "Sletter…" : "Slett"}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
className="h-9 rounded-xl"
|
|
|
|
|
onClick={() => setMode("view")}
|
|
|
|
|
>
|
|
|
|
|
Avbryt
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</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
|
|
|
</div>
|
2026-07-19 08:17:12 +02:00
|
|
|
)}
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-19 08:17:12 +02:00
|
|
|
// --- Edit session form -------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
function EditSessionForm({
|
|
|
|
|
session,
|
|
|
|
|
course,
|
|
|
|
|
courses,
|
|
|
|
|
organizationId,
|
|
|
|
|
onCourseImported,
|
|
|
|
|
onSave,
|
|
|
|
|
onCancel,
|
|
|
|
|
}: {
|
|
|
|
|
session: ApiSession
|
|
|
|
|
course: ApiCourse | null
|
|
|
|
|
courses: ApiCourse[]
|
|
|
|
|
organizationId: string
|
|
|
|
|
onCourseImported: (course: ApiCourse) => void
|
|
|
|
|
onSave: (patch: Record<string, unknown>) => Promise<void>
|
|
|
|
|
onCancel: () => void
|
|
|
|
|
}) {
|
|
|
|
|
const [name, setName] = useState(session.name ?? "")
|
|
|
|
|
const [points, setPoints] = useState(String(session.points_per_match))
|
|
|
|
|
const [startHole, setStartHole] = useState(String(session.start_hole))
|
|
|
|
|
const [scheduledAt, setScheduledAt] = useState(session.scheduled_at?.slice(0, 16) ?? "")
|
|
|
|
|
const [teeInterval, setTeeInterval] = useState(
|
|
|
|
|
session.tee_interval_minutes ? String(session.tee_interval_minutes) : "",
|
|
|
|
|
)
|
2026-08-21 08:59:54 +02:00
|
|
|
// Shotgun-start (migrasjon 088, ADR-096) -- bruker: "Det må komme frem
|
|
|
|
|
// om det er Shotgun eller løpende start... Sorteringen av startlisten
|
|
|
|
|
// gjøres etter utslagsform." 'shotgun': starthull settes per MATCH i
|
|
|
|
|
// trekning-skjermen i stedet (økten sitt eget start_hole blir da
|
|
|
|
|
// urelevant, skjules under).
|
|
|
|
|
const [startMode, setStartMode] = useState<"consecutive" | "shotgun">(session.start_mode)
|
2026-07-19 08:17:12 +02:00
|
|
|
const [courseId, setCourseId] = useState(session.course_id)
|
|
|
|
|
const [courseName, setCourseName] = useState(course?.name ?? "")
|
2026-08-24 09:55:04 +02:00
|
|
|
const [netDisplayStyle, setNetDisplayStyle] = useState(session.net_display_style)
|
2026-07-19 08:17:12 +02:00
|
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
|
|
|
|
|
|
const hasTime = scheduledAt.trim() !== ""
|
|
|
|
|
const uid = session.id
|
2026-08-24 09:55:04 +02:00
|
|
|
// "Jakter Jesper" (migrasjon 093) -- samme betingelse
|
|
|
|
|
// fetch_individual_leaderboard håndhever ved lesing (tournaments.py).
|
|
|
|
|
const supportsNetDisplayStyle =
|
|
|
|
|
(session.format === "singles" || session.format === "fourball") && session.scoring_mode === "stroke"
|
2026-07-19 08:17:12 +02:00
|
|
|
|
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
const pointsValue = Number(points.replace(",", ".")) || session.points_per_match
|
|
|
|
|
const startHoleValue = Math.min(18, Math.max(1, Number(startHole) || 1))
|
|
|
|
|
const intervalValue = hasTime && teeInterval.trim() !== "" ? Number(teeInterval) : undefined
|
|
|
|
|
|
|
|
|
|
setSubmitting(true)
|
|
|
|
|
await onSave({
|
|
|
|
|
name: name.trim() || null,
|
|
|
|
|
points_per_match: pointsValue,
|
|
|
|
|
start_hole: startHoleValue,
|
2026-08-21 08:59:54 +02:00
|
|
|
start_mode: startMode,
|
2026-07-19 08:17:12 +02:00
|
|
|
scheduled_at: hasTime ? scheduledAt : null,
|
2026-08-21 08:59:54 +02:00
|
|
|
tee_interval_minutes:
|
|
|
|
|
startMode === "consecutive" && intervalValue && !Number.isNaN(intervalValue) ? intervalValue : null,
|
2026-07-19 08:17:12 +02:00
|
|
|
course_id: courseId,
|
2026-08-24 09:55:04 +02:00
|
|
|
...(supportsNetDisplayStyle && netDisplayStyle !== session.net_display_style
|
|
|
|
|
? { net_display_style: netDisplayStyle }
|
|
|
|
|
: {}),
|
2026-07-19 08:17:12 +02:00
|
|
|
})
|
|
|
|
|
setSubmitting(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<form
|
|
|
|
|
onSubmit={handleSubmit}
|
|
|
|
|
className="mb-1 flex flex-1 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">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-11 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
<Pencil aria-hidden="true" className="size-5 text-muted-foreground" />
|
2026-07-19 08:17:12 +02:00
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
|
|
|
Rediger økt {session.sequence}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="text-base font-bold text-foreground">
|
|
|
|
|
{session.name?.trim() || `Økt ${session.sequence}`}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<Label htmlFor={`edit-name-${uid}`} className="text-sm font-semibold">
|
|
|
|
|
Navn <span className="font-normal text-muted-foreground">(valgfritt)</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id={`edit-name-${uid}`}
|
|
|
|
|
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">Bane</Label>
|
|
|
|
|
<EditCourseField
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
courses={courses}
|
|
|
|
|
currentCourseId={courseId}
|
|
|
|
|
currentCourseName={courseName}
|
|
|
|
|
onCourseImported={onCourseImported}
|
|
|
|
|
onChange={(id, name) => {
|
|
|
|
|
setCourseId(id)
|
|
|
|
|
setCourseName(name)
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-21 08:59:54 +02:00
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<Label className="text-sm font-semibold">Utslagsform</Label>
|
|
|
|
|
<div role="radiogroup" aria-label="Utslagsform" className="inline-flex w-fit rounded-2xl border border-border bg-background p-1">
|
|
|
|
|
{(
|
|
|
|
|
[
|
|
|
|
|
{ value: "consecutive" as const, label: "Løpende start" },
|
|
|
|
|
{ value: "shotgun" as const, label: "Shotgun" },
|
|
|
|
|
]
|
|
|
|
|
).map((opt) => (
|
|
|
|
|
<Button
|
|
|
|
|
key={opt.value}
|
|
|
|
|
type="button"
|
|
|
|
|
role="radio"
|
|
|
|
|
aria-checked={startMode === opt.value}
|
|
|
|
|
variant={startMode === opt.value ? "default" : "ghost"}
|
|
|
|
|
onClick={() => setStartMode(opt.value)}
|
|
|
|
|
className="h-10 rounded-xl px-4 text-sm font-bold"
|
|
|
|
|
>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</Button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
{startMode === "shotgun" && (
|
|
|
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
|
|
|
Alle matcher i økten går ut samtidig -- starthull settes per match i trekning-skjermen
|
|
|
|
|
i stedet for her.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-07-19 08:17:12 +02:00
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<Label htmlFor={`edit-points-${uid}`} className="text-sm font-semibold">
|
|
|
|
|
Poeng per match
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id={`edit-points-${uid}`}
|
|
|
|
|
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>
|
2026-08-21 08:59:54 +02:00
|
|
|
{startMode === "consecutive" && (
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<Label htmlFor={`edit-starthole-${uid}`} className="text-sm font-semibold">
|
|
|
|
|
Starthull
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id={`edit-starthole-${uid}`}
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
type="number"
|
|
|
|
|
min="1"
|
|
|
|
|
max="18"
|
|
|
|
|
value={startHole}
|
|
|
|
|
onChange={(e) => setStartHole(e.target.value)}
|
|
|
|
|
className="h-12 rounded-2xl text-base"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-07-19 08:17:12 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<Label htmlFor={`edit-time-${uid}`} className="text-sm font-semibold">
|
2026-08-21 08:59:54 +02:00
|
|
|
{startMode === "shotgun" ? "Felles utslagstidspunkt" : "Startdato og -klokkeslett"}{" "}
|
2026-07-19 08:17:12 +02:00
|
|
|
<span className="font-normal text-muted-foreground">(valgfritt)</span>
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id={`edit-time-${uid}`}
|
|
|
|
|
type="datetime-local"
|
|
|
|
|
value={scheduledAt}
|
|
|
|
|
onChange={(e) => setScheduledAt(e.target.value)}
|
|
|
|
|
className="h-12 rounded-2xl text-base"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-08-21 08:59:54 +02:00
|
|
|
{hasTime && startMode === "consecutive" && (
|
2026-07-19 08:17:12 +02:00
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<Label htmlFor={`edit-interval-${uid}`} className="text-sm font-semibold">
|
|
|
|
|
Minutter mellom hver flight
|
|
|
|
|
</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id={`edit-interval-${uid}`}
|
|
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-08-24 09:55:04 +02:00
|
|
|
{supportsNetDisplayStyle && (
|
|
|
|
|
<div className="flex flex-col gap-2">
|
|
|
|
|
<Label className="text-sm font-semibold">Visningsmodus for netto-score</Label>
|
|
|
|
|
<div role="radiogroup" aria-label="Visningsmodus for netto-score" className="inline-flex w-fit rounded-2xl border border-border bg-background p-1">
|
|
|
|
|
{(
|
|
|
|
|
[
|
|
|
|
|
{ value: "standard" as const, label: "Standard" },
|
|
|
|
|
{ value: "hunt" as const, label: "Jakter Jesper" },
|
|
|
|
|
]
|
|
|
|
|
).map((opt) => (
|
|
|
|
|
<Button
|
|
|
|
|
key={opt.value}
|
|
|
|
|
type="button"
|
|
|
|
|
role="radio"
|
|
|
|
|
aria-checked={netDisplayStyle === opt.value}
|
|
|
|
|
variant={netDisplayStyle === opt.value ? "default" : "ghost"}
|
|
|
|
|
onClick={() => setNetDisplayStyle(opt.value)}
|
|
|
|
|
className="h-10 rounded-xl px-4 text-sm font-bold"
|
|
|
|
|
>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</Button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-sm leading-relaxed text-muted-foreground text-pretty">
|
|
|
|
|
Jakter Jesper: alle starter på minus spillehandicapet sitt, og hullscore vises brutto mot
|
|
|
|
|
par løpende -- i stedet for at slag "tjenes inn" gradvis gjennom økten. Sluttresultatet
|
|
|
|
|
blir identisk uansett. Standard som standard.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-19 08:17:12 +02:00
|
|
|
<div className="flex gap-3">
|
|
|
|
|
<Button type="submit" disabled={submitting} className="h-12 flex-1 rounded-2xl text-base font-bold shadow-sm">
|
|
|
|
|
{submitting ? "Lagrer…" : "Lagre"}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={onCancel}
|
|
|
|
|
className="h-12 rounded-2xl px-6 text-base font-semibold"
|
|
|
|
|
>
|
|
|
|
|
Avbryt
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Bane-bytte i redigeringsskjemaet (kun eksisterende baner -- PATCH sin
|
|
|
|
|
// course_id må være en ekte, allerede opprettet bane, ikke en "opprett
|
|
|
|
|
// ny"-tekststreng slik opprett-skjemaet tillater) -------------------------
|
|
|
|
|
|
|
|
|
|
function EditCourseField({
|
|
|
|
|
organizationId,
|
|
|
|
|
courses,
|
|
|
|
|
currentCourseId,
|
|
|
|
|
currentCourseName,
|
|
|
|
|
onCourseImported,
|
|
|
|
|
onChange,
|
|
|
|
|
}: {
|
|
|
|
|
organizationId: string
|
|
|
|
|
courses: ApiCourse[]
|
|
|
|
|
currentCourseId: string
|
|
|
|
|
currentCourseName: string
|
|
|
|
|
onCourseImported: (course: ApiCourse) => void
|
|
|
|
|
onChange: (courseId: string, courseName: string) => void
|
|
|
|
|
}) {
|
|
|
|
|
const [open, setOpen] = useState(false)
|
|
|
|
|
const [query, setQuery] = useState("")
|
|
|
|
|
const [officialSearchOpen, setOfficialSearchOpen] = useState(false)
|
2026-08-12 21:39:13 +02:00
|
|
|
const [internationalSearchOpen, setInternationalSearchOpen] = useState(false)
|
2026-08-04 11:34:28 +02:00
|
|
|
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
2026-07-19 08:17:12 +02:00
|
|
|
|
|
|
|
|
if (!open) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<span className="inline-flex items-center gap-1.5 rounded-full bg-secondary px-3 py-1.5 text-sm font-semibold text-secondary-foreground">
|
|
|
|
|
<MapPin aria-hidden="true" className="size-4 text-primary" />
|
|
|
|
|
{currentCourseName || "Ukjent bane"}
|
|
|
|
|
</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="h-9 rounded-xl"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setQuery("")
|
|
|
|
|
setOpen(true)
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Bytt bane
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const q = query.trim().toLowerCase()
|
|
|
|
|
const matches = courses.filter((c) => c.name.toLowerCase().includes(q))
|
|
|
|
|
|
|
|
|
|
function select(c: ApiCourse) {
|
|
|
|
|
onChange(c.id, c.name)
|
|
|
|
|
setOpen(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col gap-3 rounded-2xl border border-border bg-background/50 p-3">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
autoFocus
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
|
|
|
placeholder="Søk blant organisasjonens baner…"
|
|
|
|
|
className="h-11 flex-1 rounded-xl text-base"
|
|
|
|
|
/>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
aria-label="Lukk banesøk"
|
|
|
|
|
className="flex size-9 shrink-0 items-center justify-center rounded-xl text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
<X aria-hidden="true" className="size-4" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<ul className="flex max-h-48 flex-col overflow-auto rounded-xl border border-border">
|
|
|
|
|
{matches.map((c) => (
|
|
|
|
|
<li key={c.id} className="border-b border-border last:border-b-0">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => select(c)}
|
|
|
|
|
className="flex w-full items-center justify-between gap-2 px-3 py-2.5 text-left text-sm font-semibold text-foreground transition-colors hover:bg-accent/60"
|
|
|
|
|
>
|
|
|
|
|
<span className="flex items-center gap-2">
|
|
|
|
|
<MapPin aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
|
|
|
{c.name}
|
|
|
|
|
</span>
|
|
|
|
|
{c.id === currentCourseId && <Check aria-hidden="true" className="size-4 text-primary" />}
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
{matches.length === 0 && (
|
|
|
|
|
<li className="px-3 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
|
|
|
|
)}
|
|
|
|
|
</ul>
|
|
|
|
|
|
2026-08-12 21:39:13 +02:00
|
|
|
{!officialSearchOpen && !internationalSearchOpen && !templatePickerOpen ? (
|
2026-08-04 11:34:28 +02:00
|
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
|
|
|
|
<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>
|
2026-08-12 21:39:13 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setInternationalSearchOpen(true)}
|
|
|
|
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Hent bane fra GolfAPI i stedet
|
|
|
|
|
</button>
|
2026-08-04 11:34:28 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setTemplatePickerOpen(true)}
|
|
|
|
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Opprett bane med hull/utslag i stedet
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : officialSearchOpen ? (
|
2026-07-19 08:17:12 +02:00
|
|
|
<OfficialCourseSearch
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
onClose={() => setOfficialSearchOpen(false)}
|
|
|
|
|
onImported={(newCourse) => {
|
|
|
|
|
onCourseImported(newCourse)
|
|
|
|
|
select(newCourse)
|
|
|
|
|
setOfficialSearchOpen(false)
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-08-12 21:39:13 +02:00
|
|
|
) : internationalSearchOpen ? (
|
|
|
|
|
<InternationalCourseSearch
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
onClose={() => setInternationalSearchOpen(false)}
|
|
|
|
|
onImported={(newCourse) => {
|
|
|
|
|
onCourseImported(newCourse)
|
|
|
|
|
select(newCourse)
|
|
|
|
|
setInternationalSearchOpen(false)
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-08-04 11:34:28 +02:00
|
|
|
) : (
|
|
|
|
|
<CourseTemplatePicker
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
onCreated={(created) => {
|
|
|
|
|
const course = { id: created.id, name: created.name, source: "custom" }
|
|
|
|
|
onCourseImported(course)
|
|
|
|
|
select(course)
|
|
|
|
|
setTemplatePickerOpen(false)
|
|
|
|
|
}}
|
|
|
|
|
onCancel={() => setTemplatePickerOpen(false)}
|
|
|
|
|
/>
|
2026-07-19 08:17:12 +02:00
|
|
|
)}
|
|
|
|
|
</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
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-21 09:18:07 +02:00
|
|
|
// --- Date coverage summary ---------------------------------------------------
|
|
|
|
|
// Ingen sammendrag fantes tidligere for "har alle runder fått dato" -- måtte
|
|
|
|
|
// leses manuelt per øktkort. All nødvendig data finnes allerede i sessions.
|
|
|
|
|
|
|
|
|
|
function DateCoverageSummary({ total, withDate }: { total: number; withDate: number }) {
|
|
|
|
|
const allSet = withDate === total
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"mb-3 flex items-center gap-2 rounded-2xl border px-4 py-3 text-sm font-semibold",
|
|
|
|
|
allSet
|
|
|
|
|
? "border-primary/30 bg-primary/5 text-primary"
|
|
|
|
|
: "border-border bg-muted/50 text-muted-foreground",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{allSet ? (
|
|
|
|
|
<Check aria-hidden="true" className="size-4 shrink-0" />
|
|
|
|
|
) : (
|
|
|
|
|
<Clock aria-hidden="true" className="size-4 shrink-0" />
|
|
|
|
|
)}
|
|
|
|
|
<span>
|
|
|
|
|
{allSet
|
|
|
|
|
? `Alle ${total} runder har fått dato og klokkeslett.`
|
|
|
|
|
: `${withDate} av ${total} runder har fått dato og klokkeslett.`}
|
|
|
|
|
</span>
|
|
|
|
|
</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
|
|
|
// --- 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
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
shambleBestN: number | null
|
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
|
|
|
}) => Promise<void>
|
|
|
|
|
}) {
|
|
|
|
|
const [name, setName] = useState("")
|
|
|
|
|
const [format, setFormat] = useState<SessionFormat>("foursome")
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
const [shambleBestN, setShambleBestN] = useState(1)
|
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 [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)
|
2026-08-12 21:39:13 +02:00
|
|
|
const [internationalSearchOpen, setInternationalSearchOpen] = useState(false)
|
2026-08-04 11:34:28 +02:00
|
|
|
const [templatePickerOpen, setTemplatePickerOpen] = 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(),
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
shambleBestN: format === "shamble" ? shambleBestN : null,
|
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
|
|
|
})
|
|
|
|
|
setSubmitting(false)
|
|
|
|
|
|
|
|
|
|
setName("")
|
|
|
|
|
setFormat("foursome")
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
setShambleBestN(1)
|
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
|
|
|
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">
|
2026-08-02 16:34:54 +02:00
|
|
|
<div className="flex size-11 items-center justify-center rounded-xl bg-muted">
|
|
|
|
|
<Plus aria-hidden="true" className="size-5 text-muted-foreground" />
|
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 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>
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
{format === "shamble" && (
|
|
|
|
|
<div className="flex flex-col gap-2 rounded-2xl border border-border bg-background p-3">
|
|
|
|
|
<span className="text-sm font-semibold text-foreground">Beste N av laget teller per hull</span>
|
|
|
|
|
<div className="grid grid-cols-4 gap-2">
|
|
|
|
|
{[1, 2, 3, 4].map((n) => (
|
|
|
|
|
<SegmentButton key={n} selected={shambleBestN === n} onClick={() => setShambleBestN(n)}>
|
|
|
|
|
{n}
|
|
|
|
|
</SegmentButton>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Kan ikke endres etter at økten er opprettet.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{format === "money_ball" && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Nøyaktig 4 spillere per side -- rekkefølgen (hvem som er "money ball" på hvilket hull)
|
|
|
|
|
tildeles når spillerne legges til matchen.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
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 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>
|
|
|
|
|
)}
|
2026-08-04 11:34:28 +02:00
|
|
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
|
|
|
|
<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>
|
2026-08-12 21:39:13 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setInternationalSearchOpen(true)}
|
|
|
|
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Hent bane fra GolfAPI i stedet
|
|
|
|
|
</button>
|
2026-08-04 11:34:28 +02:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setTemplatePickerOpen(true)}
|
|
|
|
|
className="self-start text-sm font-semibold text-primary underline-offset-2 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
Opprett bane med hull/utslag i stedet
|
|
|
|
|
</button>
|
|
|
|
|
</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
|
|
|
{officialSearchOpen && (
|
|
|
|
|
<OfficialCourseSearch
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
onClose={() => setOfficialSearchOpen(false)}
|
|
|
|
|
onImported={(course) => {
|
|
|
|
|
onCourseImported(course)
|
|
|
|
|
setSelectedCourseId(course.id)
|
|
|
|
|
setOfficialSearchOpen(false)
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-08-12 21:39:13 +02:00
|
|
|
{internationalSearchOpen && (
|
|
|
|
|
<InternationalCourseSearch
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
onClose={() => setInternationalSearchOpen(false)}
|
|
|
|
|
onImported={(course) => {
|
|
|
|
|
onCourseImported(course)
|
|
|
|
|
setSelectedCourseId(course.id)
|
|
|
|
|
setInternationalSearchOpen(false)
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2026-08-04 11:34:28 +02:00
|
|
|
{templatePickerOpen && (
|
|
|
|
|
<CourseTemplatePicker
|
|
|
|
|
organizationId={organizationId}
|
|
|
|
|
onCreated={(course) => {
|
|
|
|
|
onCourseImported({ id: course.id, name: course.name, source: "custom" })
|
|
|
|
|
setSelectedCourseId(course.id)
|
|
|
|
|
setTemplatePickerOpen(false)
|
|
|
|
|
}}
|
|
|
|
|
onCancel={() => setTemplatePickerOpen(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} />
|
2026-08-03 06:24:57 +02:00
|
|
|
{useHandicap && (
|
|
|
|
|
<>
|
|
|
|
|
<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>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
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>
|
|
|
|
|
|
|
|
|
|
<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å.")
|
2026-08-21 17:11:41 +02:00
|
|
|
} else if (code === "EXTERNAL_DATA_INVALID") {
|
|
|
|
|
setError(
|
|
|
|
|
body?.detail?.message ??
|
|
|
|
|
"Banedataen fra teeoff er ugyldig (f.eks. duplisert hull-indeks) -- dette må rettes hos teeoff før banen kan importeres.",
|
|
|
|
|
)
|
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
|
|
|
} 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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-12 21:39:13 +02:00
|
|
|
// --- Internasjonal bane fra GolfAPI.io (ADR-064) ----------------------------
|
|
|
|
|
//
|
|
|
|
|
// For baner utenfor teeoffs dekning (f.eks. Tjøme Golfklubb, bekreftet
|
|
|
|
|
// IKKE i teeoff 2026-08-12). Speiler OfficialCourseSearch over i STRUKTUR,
|
|
|
|
|
// men GolfAPIs /clubs-søk returnerer klubbens baner INLINE i samme svar
|
|
|
|
|
// (ingen eget facility-detalj-kall trengs, ulikt teeoff).
|
|
|
|
|
|
|
|
|
|
type ApiInternationalCourseOption = {
|
|
|
|
|
course_id: string
|
|
|
|
|
course_name: string
|
|
|
|
|
num_holes: number
|
|
|
|
|
has_gps: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ApiInternationalClub = {
|
|
|
|
|
club_id: string
|
|
|
|
|
club_name: string
|
|
|
|
|
city: string | null
|
|
|
|
|
country: string | null
|
|
|
|
|
courses: ApiInternationalCourseOption[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function InternationalCourseSearch({
|
|
|
|
|
organizationId,
|
|
|
|
|
onClose,
|
|
|
|
|
onImported,
|
|
|
|
|
}: {
|
|
|
|
|
organizationId: string
|
|
|
|
|
onClose: () => void
|
|
|
|
|
onImported: (course: ApiCourse) => void
|
|
|
|
|
}) {
|
|
|
|
|
const [query, setQuery] = useState("")
|
|
|
|
|
const [clubs, setClubs] = useState<ApiInternationalClub[] | null>(null)
|
|
|
|
|
const [selectedClub, setSelectedClub] = useState<ApiInternationalClub | null>(null)
|
|
|
|
|
const [searching, setSearching] = useState(false)
|
|
|
|
|
const [importing, setImporting] = useState(false)
|
|
|
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
|
|
|
|
|
|
async function runSearch() {
|
|
|
|
|
if (!query.trim()) return
|
|
|
|
|
setSearching(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(
|
|
|
|
|
`/orgs/${organizationId}/courses/international-search?q=${encodeURIComponent(query.trim())}`,
|
|
|
|
|
{ credentials: "include" },
|
|
|
|
|
)
|
|
|
|
|
if (!res.ok) throw new Error(`search: ${res.status}`)
|
|
|
|
|
setClubs(await res.json())
|
|
|
|
|
} catch {
|
|
|
|
|
setError("Klarte ikke å søke hos GolfAPI akkurat nå.")
|
|
|
|
|
setClubs([])
|
|
|
|
|
} finally {
|
|
|
|
|
setSearching(false)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function importCourse(course: ApiInternationalCourseOption) {
|
|
|
|
|
setImporting(true)
|
|
|
|
|
setError(null)
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/orgs/${organizationId}/courses/international-import`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
credentials: "include",
|
|
|
|
|
body: JSON.stringify({ golfapi_course_id: course.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 hos GolfAPI til å importeres ennå.")
|
2026-08-21 17:11:41 +02:00
|
|
|
} else if (code === "EXTERNAL_DATA_INVALID") {
|
|
|
|
|
setError(
|
|
|
|
|
body?.detail?.message ??
|
|
|
|
|
"Banedataen fra GolfAPI er ugyldig (f.eks. duplisert hull-indeks) -- dette må rettes hos GolfAPI før banen kan importeres.",
|
|
|
|
|
)
|
2026-08-12 21:39:13 +02:00
|
|
|
} else if (code === "NOT_CONFIGURED") {
|
|
|
|
|
setError("GolfAPI er ikke konfigurert ennå.")
|
|
|
|
|
} 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 GolfAPI</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>}
|
|
|
|
|
|
|
|
|
|
{!selectedClub ? (
|
|
|
|
|
<>
|
|
|
|
|
{/* Bevisst IKKE et eget <form> her -- samme nesting-fellgruve som
|
|
|
|
|
OfficialCourseSearch over (nestede <form>-er er ugyldig HTML). */}
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Input
|
|
|
|
|
autoFocus
|
|
|
|
|
placeholder="Søk klubbnavn…"
|
|
|
|
|
value={query}
|
|
|
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
|
|
|
onKeyDown={(e) => {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
runSearch()
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="h-11 flex-1 rounded-xl text-base"
|
|
|
|
|
/>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => runSearch()}
|
|
|
|
|
disabled={searching}
|
|
|
|
|
className="h-11 shrink-0 rounded-xl font-bold"
|
|
|
|
|
>
|
|
|
|
|
{searching ? "Søker…" : "Søk"}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
{clubs && (
|
|
|
|
|
<ul className="flex max-h-56 flex-col overflow-auto rounded-xl border border-border">
|
|
|
|
|
{clubs.map((c) => (
|
|
|
|
|
<li key={c.club_id} className="border-b border-border last:border-b-0">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setSelectedClub(c)}
|
|
|
|
|
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">{c.club_name}</span>
|
|
|
|
|
{(c.city || c.country) && (
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{[c.city, c.country].filter(Boolean).join(", ")}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
{clubs.length === 0 && (
|
|
|
|
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">Ingen treff.</li>
|
|
|
|
|
)}
|
|
|
|
|
</ul>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setSelectedClub(null)}
|
|
|
|
|
className="self-start text-sm font-semibold text-muted-foreground hover:text-foreground"
|
|
|
|
|
>
|
|
|
|
|
← {selectedClub.club_name}
|
|
|
|
|
</button>
|
|
|
|
|
<ul className="flex flex-col overflow-hidden rounded-xl border border-border">
|
|
|
|
|
{selectedClub.courses.map((c) => (
|
|
|
|
|
<li key={c.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"
|
|
|
|
|
>
|
|
|
|
|
<span className="flex flex-col">
|
|
|
|
|
{c.course_name}
|
|
|
|
|
<span className="text-xs font-normal text-muted-foreground">
|
|
|
|
|
{c.num_holes} hull{c.has_gps ? " · avstandsdata tilgjengelig" : ""}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
{importing && <span className="text-xs font-normal text-muted-foreground">Importerer…</span>}
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
{selectedClub.courses.length === 0 && (
|
|
|
|
|
<li className="px-4 py-3 text-center text-sm text-muted-foreground">
|
|
|
|
|
Ingen baner registrert hos denne klubben hos GolfAPI 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",
|
|
|
|
|
})
|
|
|
|
|
}
|